@@ -97,10 +97,10 @@ use dropshot::{HttpErrorResponseBody, ResultsPage};
9797use nexus_test_utils:: identity_eq;
9898use 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} ;
102102use nexus_test_utils_macros:: nexus_test;
103- use nexus_types:: external_api:: shared:: SiloRole ;
103+ use nexus_types:: external_api:: shared:: { ProjectRole , SiloRole } ;
104104use omicron_test_utils:: dev:: poll;
105105use 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 ) ]
75137851async fn test_instance_v2p_mappings ( cptestctx : & ControlPlaneTestContext ) {
0 commit comments