Skip to content

Commit b148207

Browse files
committed
Add tests to verify that cross-subnet NIC creation is not a risk for limited collaborators
1 parent ceaeadc commit b148207

File tree

1 file changed

+340
-2
lines changed

1 file changed

+340
-2
lines changed

nexus/tests/integration_tests/instances.rs

Lines changed: 340 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,10 @@ use dropshot::{HttpErrorResponseBody, ResultsPage};
9797
use nexus_test_utils::identity_eq;
9898
use nexus_test_utils::resource_helpers::{
9999
create_instance, create_instance_with, create_instance_with_error,
100-
create_project,
100+
create_project, create_vpc, create_vpc_subnet,
101101
};
102102
use nexus_test_utils_macros::nexus_test;
103-
use nexus_types::external_api::shared::SiloRole;
103+
use nexus_types::external_api::shared::{ProjectRole, SiloRole};
104104
use omicron_test_utils::dev::poll;
105105
use omicron_test_utils::dev::poll::CondCheckError;
106106

@@ -7508,6 +7508,344 @@ async fn test_instance_create_in_silo(cptestctx: &ControlPlaneTestContext) {
75087508
.expect("Failed to delete the instance");
75097509
}
75107510

7511+
/// Test that limited-collaborators cannot create instances with NICs
7512+
/// referencing subnets in a different project (where they don't have access).
7513+
/// This validates cross-project isolation and protects against regressions.
7514+
#[nexus_test]
7515+
async fn test_instance_create_with_cross_project_subnet(
7516+
cptestctx: &ControlPlaneTestContext,
7517+
) {
7518+
let client = &cptestctx.external_client;
7519+
7520+
// Setup: Create IP pool and two projects
7521+
create_default_ip_pool(client).await;
7522+
let project_a_name = "project-a";
7523+
let project_b_name = "project-b";
7524+
create_project(&client, project_a_name).await;
7525+
create_project(&client, project_b_name).await;
7526+
7527+
// Create VPC and subnet in project A
7528+
let vpc_a_name = "vpc-a";
7529+
let subnet_a_name = "subnet-a";
7530+
create_vpc(&client, project_a_name, vpc_a_name).await;
7531+
create_vpc_subnet(
7532+
&client,
7533+
project_a_name,
7534+
vpc_a_name,
7535+
subnet_a_name,
7536+
"10.1.0.0/24".parse().unwrap(),
7537+
None,
7538+
None,
7539+
)
7540+
.await;
7541+
7542+
// Create VPC and subnet in project B
7543+
let vpc_b_name = "vpc-b";
7544+
let subnet_b_name = "subnet-b";
7545+
create_vpc(&client, project_b_name, vpc_b_name).await;
7546+
create_vpc_subnet(
7547+
&client,
7548+
project_b_name,
7549+
vpc_b_name,
7550+
subnet_b_name,
7551+
"10.2.0.0/24".parse().unwrap(),
7552+
None,
7553+
None,
7554+
)
7555+
.await;
7556+
7557+
// Get the default silo
7558+
let silo: views::Silo = NexusRequest::object_get(
7559+
client,
7560+
&format!("/v1/system/silos/{}", DEFAULT_SILO.name()),
7561+
)
7562+
.authn_as(AuthnMode::PrivilegedUser)
7563+
.execute_and_parse_unwrap()
7564+
.await;
7565+
7566+
// Create a limited collaborator who only has access to Project A
7567+
let limited_user = create_local_user(
7568+
client,
7569+
&silo,
7570+
&"limited-user".parse().unwrap(),
7571+
test_params::UserPassword::LoginDisallowed,
7572+
)
7573+
.await;
7574+
7575+
// Grant limited collaborator role on Project A only
7576+
let project_a_url = format!("/v1/projects/{}", project_a_name);
7577+
grant_iam(
7578+
client,
7579+
&project_a_url,
7580+
ProjectRole::LimitedCollaborator,
7581+
limited_user.id,
7582+
AuthnMode::PrivilegedUser,
7583+
)
7584+
.await;
7585+
7586+
// Test: Limited collaborator CANNOT create an instance in project A
7587+
// with a NIC that references a subnet from project B (where they have no access)
7588+
let if0_params = params::InstanceNetworkInterfaceCreate {
7589+
identity: IdentityMetadataCreateParams {
7590+
name: Name::try_from(String::from("cross-project-nic")).unwrap(),
7591+
description: String::from(
7592+
"NIC attempting to use project B's subnet",
7593+
),
7594+
},
7595+
vpc_name: vpc_b_name.parse().unwrap(),
7596+
subnet_name: subnet_b_name.parse().unwrap(),
7597+
ip: None,
7598+
transit_ips: vec![],
7599+
};
7600+
7601+
let instance_params = params::InstanceCreate {
7602+
identity: IdentityMetadataCreateParams {
7603+
name: Name::try_from(String::from("cross-project-instance"))
7604+
.unwrap(),
7605+
description: String::from(
7606+
"instance with cross-project subnet reference",
7607+
),
7608+
},
7609+
ncpus: InstanceCpuCount::try_from(2).unwrap(),
7610+
memory: ByteCount::from_gibibytes_u32(4),
7611+
hostname: "inst-cross".parse().unwrap(),
7612+
user_data: vec![],
7613+
ssh_public_keys: None,
7614+
network_interfaces: params::InstanceNetworkInterfaceAttachment::Create(
7615+
vec![if0_params],
7616+
),
7617+
external_ips: vec![],
7618+
disks: vec![],
7619+
boot_disk: None,
7620+
cpu_platform: None,
7621+
start: false,
7622+
auto_restart_policy: None,
7623+
anti_affinity_groups: Vec::new(),
7624+
};
7625+
7626+
let instances_url_a = format!("/v1/instances?project={}", project_a_name);
7627+
let error: HttpErrorResponseBody = NexusRequest::new(
7628+
RequestBuilder::new(client, Method::POST, &instances_url_a)
7629+
.body(Some(&instance_params))
7630+
.expect_status(Some(StatusCode::NOT_FOUND)),
7631+
)
7632+
.authn_as(AuthnMode::SiloUser(limited_user.id))
7633+
.execute()
7634+
.await
7635+
.expect("request should complete")
7636+
.parsed_body()
7637+
.unwrap();
7638+
7639+
// Should get 404 Not Found because the limited user can't see project B's
7640+
// VPC/subnet
7641+
assert!(
7642+
error.message.contains("not found") || error.message.contains("vpc"),
7643+
"Expected 'not found' error, got: {}",
7644+
error.message
7645+
);
7646+
}
7647+
7648+
/// Test that silo-level limited-collaborators (who have access to all projects
7649+
/// in a silo) can create instances with NICs in their own project using that
7650+
/// project's subnets, but CANNOT create NICs that reference subnets from a
7651+
/// different project. This validates that project networking boundaries are
7652+
/// enforced even when users have access to multiple projects.
7653+
#[nexus_test]
7654+
async fn test_silo_limited_collaborator_cross_project_subnet(
7655+
cptestctx: &ControlPlaneTestContext,
7656+
) {
7657+
let client = &cptestctx.external_client;
7658+
7659+
// Setup: Create IP pool and two projects
7660+
create_default_ip_pool(client).await;
7661+
let project_a_name = "project-a";
7662+
let project_b_name = "project-b";
7663+
create_project(&client, project_a_name).await;
7664+
create_project(&client, project_b_name).await;
7665+
7666+
// Create VPC and subnet in project A
7667+
let vpc_a_name = "vpc-a";
7668+
let subnet_a_name = "subnet-a";
7669+
create_vpc(&client, project_a_name, vpc_a_name).await;
7670+
create_vpc_subnet(
7671+
&client,
7672+
project_a_name,
7673+
vpc_a_name,
7674+
subnet_a_name,
7675+
"10.1.0.0/24".parse().unwrap(),
7676+
None,
7677+
None,
7678+
)
7679+
.await;
7680+
7681+
// Create VPC and subnet in project B
7682+
let vpc_b_name = "vpc-b";
7683+
let subnet_b_name = "subnet-b";
7684+
create_vpc(&client, project_b_name, vpc_b_name).await;
7685+
create_vpc_subnet(
7686+
&client,
7687+
project_b_name,
7688+
vpc_b_name,
7689+
subnet_b_name,
7690+
"10.2.0.0/24".parse().unwrap(),
7691+
None,
7692+
None,
7693+
)
7694+
.await;
7695+
7696+
// Get the default silo
7697+
let silo: views::Silo = NexusRequest::object_get(
7698+
client,
7699+
&format!("/v1/system/silos/{}", DEFAULT_SILO.name()),
7700+
)
7701+
.authn_as(AuthnMode::PrivilegedUser)
7702+
.execute_and_parse_unwrap()
7703+
.await;
7704+
7705+
// Create a silo-level limited collaborator (has access to all projects)
7706+
let limited_user = create_local_user(
7707+
client,
7708+
&silo,
7709+
&"silo-limited-user".parse().unwrap(),
7710+
test_params::UserPassword::LoginDisallowed,
7711+
)
7712+
.await;
7713+
7714+
// Grant silo-level limited collaborator role (inherits to all projects)
7715+
let silo_url = format!("/v1/system/silos/{}", DEFAULT_SILO.name());
7716+
grant_iam(
7717+
client,
7718+
&silo_url,
7719+
SiloRole::LimitedCollaborator,
7720+
limited_user.id,
7721+
AuthnMode::PrivilegedUser,
7722+
)
7723+
.await;
7724+
7725+
// Test 1: Silo limited collaborator CAN create an instance in project A
7726+
// with a NIC using project A's own subnet (success case)
7727+
let if_same_project = params::InstanceNetworkInterfaceCreate {
7728+
identity: IdentityMetadataCreateParams {
7729+
name: Name::try_from(String::from("nic-a")).unwrap(),
7730+
description: String::from("NIC using same project's subnet"),
7731+
},
7732+
vpc_name: vpc_a_name.parse().unwrap(),
7733+
subnet_name: subnet_a_name.parse().unwrap(),
7734+
ip: None,
7735+
transit_ips: vec![],
7736+
};
7737+
7738+
let instance_same_project = params::InstanceCreate {
7739+
identity: IdentityMetadataCreateParams {
7740+
name: Name::try_from(String::from("instance-same-project"))
7741+
.unwrap(),
7742+
description: String::from("instance with same-project subnet"),
7743+
},
7744+
ncpus: InstanceCpuCount::try_from(2).unwrap(),
7745+
memory: ByteCount::from_gibibytes_u32(4),
7746+
hostname: "inst-same".parse().unwrap(),
7747+
user_data: vec![],
7748+
ssh_public_keys: None,
7749+
network_interfaces: params::InstanceNetworkInterfaceAttachment::Create(
7750+
vec![if_same_project],
7751+
),
7752+
external_ips: vec![],
7753+
disks: vec![],
7754+
boot_disk: None,
7755+
cpu_platform: None,
7756+
start: false,
7757+
auto_restart_policy: None,
7758+
anti_affinity_groups: Vec::new(),
7759+
};
7760+
7761+
let instances_url_a = format!("/v1/instances?project={}", project_a_name);
7762+
let instance: Instance = NexusRequest::objects_post(
7763+
client,
7764+
&instances_url_a,
7765+
&instance_same_project,
7766+
)
7767+
.authn_as(AuthnMode::SiloUser(limited_user.id))
7768+
.execute()
7769+
.await
7770+
.expect("silo limited collaborator should be able to create instance with same-project subnet")
7771+
.parsed_body()
7772+
.unwrap();
7773+
7774+
assert_eq!(instance.identity.name, "instance-same-project");
7775+
7776+
// Clean up before next test
7777+
let instance_url = format!(
7778+
"/v1/instances/instance-same-project?project={}",
7779+
project_a_name
7780+
);
7781+
NexusRequest::object_delete(client, &instance_url)
7782+
.authn_as(AuthnMode::SiloUser(limited_user.id))
7783+
.execute()
7784+
.await
7785+
.expect("Failed to delete instance");
7786+
7787+
// Test 2: Silo limited collaborator CANNOT create an instance in project A
7788+
// with a NIC that references a subnet from project B (failure case)
7789+
let if_cross_project = params::InstanceNetworkInterfaceCreate {
7790+
identity: IdentityMetadataCreateParams {
7791+
name: Name::try_from(String::from("cross-project-nic")).unwrap(),
7792+
description: String::from(
7793+
"NIC attempting to use different project's subnet",
7794+
),
7795+
},
7796+
vpc_name: vpc_b_name.parse().unwrap(),
7797+
subnet_name: subnet_b_name.parse().unwrap(),
7798+
ip: None,
7799+
transit_ips: vec![],
7800+
};
7801+
7802+
let instance_cross_project = params::InstanceCreate {
7803+
identity: IdentityMetadataCreateParams {
7804+
name: Name::try_from(String::from("instance-cross-project"))
7805+
.unwrap(),
7806+
description: String::from(
7807+
"instance with cross-project subnet reference",
7808+
),
7809+
},
7810+
ncpus: InstanceCpuCount::try_from(2).unwrap(),
7811+
memory: ByteCount::from_gibibytes_u32(4),
7812+
hostname: "inst-cross".parse().unwrap(),
7813+
user_data: vec![],
7814+
ssh_public_keys: None,
7815+
network_interfaces: params::InstanceNetworkInterfaceAttachment::Create(
7816+
vec![if_cross_project],
7817+
),
7818+
external_ips: vec![],
7819+
disks: vec![],
7820+
boot_disk: None,
7821+
cpu_platform: None,
7822+
start: false,
7823+
auto_restart_policy: None,
7824+
anti_affinity_groups: Vec::new(),
7825+
};
7826+
7827+
let error: HttpErrorResponseBody = NexusRequest::new(
7828+
RequestBuilder::new(client, Method::POST, &instances_url_a)
7829+
.body(Some(&instance_cross_project))
7830+
.expect_status(Some(StatusCode::NOT_FOUND)),
7831+
)
7832+
.authn_as(AuthnMode::SiloUser(limited_user.id))
7833+
.execute()
7834+
.await
7835+
.expect("request should complete")
7836+
.parsed_body()
7837+
.unwrap();
7838+
7839+
// Should get 404 Not Found because VPC/subnet lookups are scoped to the
7840+
// project context (project A), and project B's VPC/subnet aren't visible
7841+
// in that context
7842+
assert!(
7843+
error.message.contains("not found") || error.message.contains("vpc"),
7844+
"Expected 'not found' error, got: {}",
7845+
error.message
7846+
);
7847+
}
7848+
75117849
/// Test that appropriate OPTE V2P mappings are created and deleted.
75127850
#[nexus_test(extra_sled_agents = 3)]
75137851
async fn test_instance_v2p_mappings(cptestctx: &ControlPlaneTestContext) {

0 commit comments

Comments
 (0)