diff --git a/nexus/auth/src/authn/mod.rs b/nexus/auth/src/authn/mod.rs index e9e9a25848c..854581f0cf7 100644 --- a/nexus/auth/src/authn/mod.rs +++ b/nexus/auth/src/authn/mod.rs @@ -276,7 +276,7 @@ impl Context { Details { actor: Actor::Scim { silo_id } }, // This should never be non-empty, we don't want the SCIM user // to ever have associated roles. - Some(SiloAuthnPolicy::new(BTreeMap::default())), + Some(SiloAuthnPolicy::new(BTreeMap::default(), false)), ), schemes_tried: Vec::new(), } @@ -289,13 +289,17 @@ pub struct SiloAuthnPolicy { /// Describes which fleet-level roles are automatically conferred by which /// silo-level roles. mapped_fleet_roles: BTreeMap>, + + /// When true, restricts networking actions to Silo Admins only + restrict_network_actions: bool, } impl SiloAuthnPolicy { pub fn new( mapped_fleet_roles: BTreeMap>, + restrict_network_actions: bool, ) -> SiloAuthnPolicy { - SiloAuthnPolicy { mapped_fleet_roles } + SiloAuthnPolicy { mapped_fleet_roles, restrict_network_actions } } pub fn mapped_fleet_roles( @@ -303,6 +307,10 @@ impl SiloAuthnPolicy { ) -> &BTreeMap> { &self.mapped_fleet_roles } + + pub fn restrict_network_actions(&self) -> bool { + self.restrict_network_actions + } } impl TryFrom<&nexus_db_model::Silo> for SiloAuthnPolicy { @@ -311,9 +319,10 @@ impl TryFrom<&nexus_db_model::Silo> for SiloAuthnPolicy { fn try_from( value: &nexus_db_model::Silo, ) -> Result { - value - .mapped_fleet_roles() - .map(|mapped_fleet_roles| SiloAuthnPolicy { mapped_fleet_roles }) + value.mapped_fleet_roles().map(|mapped_fleet_roles| SiloAuthnPolicy { + mapped_fleet_roles, + restrict_network_actions: value.restrict_network_actions, + }) } } diff --git a/nexus/auth/src/authz/actor.rs b/nexus/auth/src/authz/actor.rs index 440064532a6..5c1671db3dd 100644 --- a/nexus/auth/src/authz/actor.rs +++ b/nexus/auth/src/authz/actor.rs @@ -88,6 +88,15 @@ impl AuthenticatedActor { }) .collect() } + + /// Returns whether this actor's Silo restricts networking actions to Silo + /// Admins only + pub fn silo_restricts_networking(&self) -> bool { + self.silo_policy + .as_ref() + .map(|policy| policy.restrict_network_actions()) + .unwrap_or(false) + } } impl PartialEq for AuthenticatedActor { @@ -164,5 +173,9 @@ impl oso::PolarClass for AuthenticatedActor { authn::Actor::Scim { .. } => false, }, ) + .add_method( + "silo_restricts_networking", + |a: &AuthenticatedActor| a.silo_restricts_networking(), + ) } } diff --git a/nexus/auth/src/authz/api_resources.rs b/nexus/auth/src/authz/api_resources.rs index 415b5507ae6..36ea25d6a18 100644 --- a/nexus/auth/src/authz/api_resources.rs +++ b/nexus/auth/src/authz/api_resources.rs @@ -1142,7 +1142,7 @@ authz_resource! { parent = "Project", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProject, + polar_snippet = InProjectNetworking, } authz_resource! { @@ -1150,7 +1150,7 @@ authz_resource! { parent = "Vpc", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProject, + polar_snippet = InProjectNetworking, } authz_resource! { @@ -1158,7 +1158,7 @@ authz_resource! { parent = "VpcRouter", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProject, + polar_snippet = InProjectNetworking, } authz_resource! { @@ -1166,7 +1166,7 @@ authz_resource! { parent = "Vpc", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProject, + polar_snippet = InProjectNetworking, } authz_resource! { @@ -1174,7 +1174,7 @@ authz_resource! { parent = "Vpc", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProject, + polar_snippet = InProjectNetworking, } authz_resource! { @@ -1182,7 +1182,7 @@ authz_resource! { parent = "InternetGateway", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProject, + polar_snippet = InProjectNetworking, } authz_resource! { @@ -1190,7 +1190,7 @@ authz_resource! { parent = "InternetGateway", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProject, + polar_snippet = InProjectNetworking, } authz_resource! { diff --git a/nexus/auth/src/authz/context.rs b/nexus/auth/src/authz/context.rs index 20437b7f535..36684ec15d9 100644 --- a/nexus/auth/src/authz/context.rs +++ b/nexus/auth/src/authz/context.rs @@ -235,6 +235,61 @@ mod test { Context::new(Arc::new(authn), Arc::new(authz), datastore) } + #[tokio::test] + async fn test_networking_restrictions_structure() { + // This test verifies that our networking restrictions compile and can be instantiated + let logctx = + dev::test_setup_log("test_networking_restrictions_structure"); + + // Test that SiloAuthnPolicy with networking restrictions can be created + let restricted_policy = authn::SiloAuthnPolicy::new( + std::collections::BTreeMap::new(), + true, // restrict_network_actions + ); + + let normal_policy = authn::SiloAuthnPolicy::new( + std::collections::BTreeMap::new(), + false, // restrict_network_actions + ); + + // Verify that the restricts_networking method works + assert_eq!(restricted_policy.restrict_network_actions(), true); + assert_eq!(normal_policy.restrict_network_actions(), false); + + // Test that we can create auth contexts with these policies + let authn_restricted = authn::Context::for_test_user( + omicron_uuid_kinds::SiloUserUuid::new_v4(), + Uuid::new_v4(), + restricted_policy, + ); + let authn_normal = authn::Context::for_test_user( + omicron_uuid_kinds::SiloUserUuid::new_v4(), + Uuid::new_v4(), + normal_policy, + ); + + // Verify the policies are accessible + assert_eq!( + authn_restricted + .silo_authn_policy() + .unwrap() + .restrict_network_actions(), + true + ); + assert_eq!( + authn_normal + .silo_authn_policy() + .unwrap() + .restrict_network_actions(), + false + ); + + println!( + "Networking restrictions structure test completed successfully" + ); + logctx.cleanup_successful(); + } + #[tokio::test] async fn test_unregistered_resource() { let logctx = dev::test_setup_log("test_unregistered_resource"); diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index b1384587b16..dc2924c426a 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -748,3 +748,76 @@ has_relation(silo: Silo, "parent_silo", scim_client_bearer_token_list: ScimClien if scim_client_bearer_token_list.silo = silo; has_relation(fleet: Fleet, "parent_fleet", collection: ScimClientBearerTokenList) if collection.silo.fleet = fleet; + +# NETWORKING RESTRICTIONS BASED ON SILO SETTINGS +# +# These rules enforce networking restrictions when a silo has restrict_network_actions = true. +# For silos with this restriction, only Silo Admins can perform networking create/modify/delete actions, +# while read/list actions remain available to all project collaborators. + +# Determine if the actor has permissions to modify networking resources +can_modify_networking_resource(actor: AuthenticatedActor, project: Project) if + # Always allow silo admins to update networking resources + has_role(actor, "admin", project.silo) or + # Allow project collaborators to update networking resources if the actor's silo allows it + # Note that the restriction is checked on the actor's silo, not embedded in the project + (has_role(actor, "collaborator", project) and not actor.silo_restricts_networking()); + +# Helper predicates to reduce duplication across networking resources +networking_write_perm(actor: AuthenticatedActor, action: String, project: Project) if + action in ["create_child", "modify", "delete"] and + can_modify_networking_resource(actor, project); + +networking_read_perm(actor: AuthenticatedActor, action: String, project: Project) if + action in ["read", "list_children"] and + has_role(actor, "viewer", project); + +# Apply networking restrictions to all networking resources +# VPCs (project path: vpc.project) +has_permission(actor: AuthenticatedActor, action: String, vpc: Vpc) if + networking_write_perm(actor, action, vpc.project); + +has_permission(actor: AuthenticatedActor, action: String, vpc: Vpc) if + networking_read_perm(actor, action, vpc.project); + +# VPC Routers (project path: router.vpc.project) +has_permission(actor: AuthenticatedActor, action: String, router: VpcRouter) if + networking_write_perm(actor, action, router.vpc.project); + +has_permission(actor: AuthenticatedActor, action: String, router: VpcRouter) if + networking_read_perm(actor, action, router.vpc.project); + +# VPC Subnets (project path: subnet.vpc.project) +has_permission(actor: AuthenticatedActor, action: String, subnet: VpcSubnet) if + networking_write_perm(actor, action, subnet.vpc.project); + +has_permission(actor: AuthenticatedActor, action: String, subnet: VpcSubnet) if + networking_read_perm(actor, action, subnet.vpc.project); + +# Internet Gateways (project path: gateway.vpc.project) +has_permission(actor: AuthenticatedActor, action: String, gateway: InternetGateway) if + networking_write_perm(actor, action, gateway.vpc.project); + +has_permission(actor: AuthenticatedActor, action: String, gateway: InternetGateway) if + networking_read_perm(actor, action, gateway.vpc.project); + +# Router Routes (project path: route.vpc_router.vpc.project) +has_permission(actor: AuthenticatedActor, action: String, route: RouterRoute) if + networking_write_perm(actor, action, route.vpc_router.vpc.project); + +has_permission(actor: AuthenticatedActor, action: String, route: RouterRoute) if + networking_read_perm(actor, action, route.vpc_router.vpc.project); + +# Internet Gateway IP Pool attachments (project path: pool.internet_gateway.vpc.project) +has_permission(actor: AuthenticatedActor, action: String, pool: InternetGatewayIpPool) if + networking_write_perm(actor, action, pool.internet_gateway.vpc.project); + +has_permission(actor: AuthenticatedActor, action: String, pool: InternetGatewayIpPool) if + networking_read_perm(actor, action, pool.internet_gateway.vpc.project); + +# Internet Gateway IP Address attachments (project path: addr.internet_gateway.vpc.project) +has_permission(actor: AuthenticatedActor, action: String, addr: InternetGatewayIpAddress) if + networking_write_perm(actor, action, addr.internet_gateway.vpc.project); + +has_permission(actor: AuthenticatedActor, action: String, addr: InternetGatewayIpAddress) if + networking_read_perm(actor, action, addr.internet_gateway.vpc.project); diff --git a/nexus/authz-macros/src/lib.rs b/nexus/authz-macros/src/lib.rs index 59ae8d9a963..c98f64b3f60 100644 --- a/nexus/authz-macros/src/lib.rs +++ b/nexus/authz-macros/src/lib.rs @@ -265,6 +265,11 @@ enum PolarSnippet { /// Generate it as a resource nested within a Project (either directly or /// indirectly) InProject, + + /// Generate it as a networking resource nested within a Project + /// (like InProject, but without default permission rules - all rules + /// defined in omicron.polar for networking restrictions) + InProjectNetworking, } /// Implementation of [`authz_resource!`] @@ -433,6 +438,67 @@ fn do_authz_resource( resource_name, parent_as_snake, ), + + // InProjectNetworking: Like InProject, but NO default permission rules. + // All permission rules are defined in omicron.polar to enforce + // networking restrictions. Only defines resource structure + relations. + (PolarSnippet::InProjectNetworking, "Project") => format!( + r#" + resource {} {{ + permissions = [ + "list_children", + "modify", + "read", + "create_child", + "delete", + ]; + + relations = {{ containing_project: Project }}; + # NOTE: No permission rules defined here! + # All permissions controlled by custom networking restriction + # rules in omicron.polar (can_modify_networking_resource) + }} + + has_relation(parent: Project, "containing_project", child: {}) + if child.project = parent; + "#, + resource_name, resource_name, + ), + + (PolarSnippet::InProjectNetworking, _) => format!( + r#" + resource {} {{ + permissions = [ + "list_children", + "modify", + "read", + "create_child", + "delete", + ]; + + relations = {{ + containing_project: Project, + parent: {} + }}; + # NOTE: No permission rules defined here! + # All permissions controlled by custom networking restriction + # rules in omicron.polar (can_modify_networking_resource) + }} + + has_relation(project: Project, "containing_project", child: {}) + if has_relation(project, "containing_project", child.{}); + + has_relation(parent: {}, "parent", child: {}) + if child.{} = parent; + "#, + resource_name, + parent_resource_name, + resource_name, + parent_as_snake, + parent_resource_name, + resource_name, + parent_as_snake, + ), }; let doc_struct = format!( diff --git a/nexus/db-fixed-data/src/silo.rs b/nexus/db-fixed-data/src/silo.rs index b5dd6b41aad..a45de95d11e 100644 --- a/nexus/db-fixed-data/src/silo.rs +++ b/nexus/db-fixed-data/src/silo.rs @@ -33,6 +33,7 @@ pub static DEFAULT_SILO: LazyLock = LazyLock::new(|| { admin_group_name: None, tls_certificates: vec![], mapped_fleet_roles: Default::default(), + restrict_network_actions: None, }, ) .unwrap() @@ -55,6 +56,7 @@ pub static INTERNAL_SILO: LazyLock = LazyLock::new(|| { admin_group_name: None, tls_certificates: vec![], mapped_fleet_roles: Default::default(), + restrict_network_actions: None, }, ) .unwrap() diff --git a/nexus/db-model/src/schema_versions.rs b/nexus/db-model/src/schema_versions.rs index be8d77012f3..90442b02977 100644 --- a/nexus/db-model/src/schema_versions.rs +++ b/nexus/db-model/src/schema_versions.rs @@ -16,7 +16,7 @@ use std::{collections::BTreeMap, sync::LazyLock}; /// /// This must be updated when you change the database schema. Refer to /// schema/crdb/README.adoc in the root of this repository for details. -pub const SCHEMA_VERSION: Version = Version::new(201, 0, 0); +pub const SCHEMA_VERSION: Version = Version::new(202, 0, 0); /// List of all past database schema versions, in *reverse* order /// @@ -28,6 +28,7 @@ static KNOWN_VERSIONS: LazyLock> = LazyLock::new(|| { // | leaving the first copy as an example for the next person. // v // KnownVersion::new(next_int, "unique-dirname-with-the-sql-files"), + KnownVersion::new(202, "restrict-network-actions"), KnownVersion::new(201, "scim-client-bearer-token"), KnownVersion::new(200, "dual-stack-network-interfaces"), KnownVersion::new(199, "multicast-pool-support"), diff --git a/nexus/db-model/src/silo.rs b/nexus/db-model/src/silo.rs index e285c8f01e6..9bb96c62b9e 100644 --- a/nexus/db-model/src/silo.rs +++ b/nexus/db-model/src/silo.rs @@ -119,6 +119,10 @@ pub struct Silo { /// important to store this name so that when groups are created the same /// automatic policy can be created as well. pub admin_group_name: Option, + + /// When true, restricts networking actions (VPC, subnet, etc.) to Silo Admins only. + /// When false (default), Project Collaborators can perform networking actions. + pub restrict_network_actions: bool, } /// Form of mapped fleet roles used when serializing to the database @@ -200,6 +204,9 @@ impl Silo { rcgen: Generation::new(), mapped_fleet_roles, admin_group_name: params.admin_group_name, + restrict_network_actions: params + .restrict_network_actions + .unwrap_or(false), }) } @@ -251,10 +258,18 @@ impl TryFrom for views::Silo { identity_mode, mapped_fleet_roles, admin_group_name: silo.admin_group_name, + restrict_network_actions: silo.restrict_network_actions, }) } } +impl Silo { + /// Returns true if this silo restricts networking actions to Silo Admins only + pub fn restricts_networking(&self) -> bool { + self.restrict_network_actions + } +} + impl DatastoreCollectionConfig for Silo { type CollectionId = Uuid; type GenerationNumberColumn = silo::dsl::rcgen; diff --git a/nexus/db-queries/src/db/datastore/rack.rs b/nexus/db-queries/src/db/datastore/rack.rs index 35cf9cd8e9b..603c821fbe1 100644 --- a/nexus/db-queries/src/db/datastore/rack.rs +++ b/nexus/db-queries/src/db/datastore/rack.rs @@ -1172,6 +1172,7 @@ mod test { admin_group_name: None, tls_certificates: vec![], mapped_fleet_roles: Default::default(), + restrict_network_actions: None, }, recovery_silo_fq_dns_name: format!( "test-silo.sys.{}", diff --git a/nexus/db-queries/src/policy_test/mod.rs b/nexus/db-queries/src/policy_test/mod.rs index b99a653e37e..4a7d536b051 100644 --- a/nexus/db-queries/src/policy_test/mod.rs +++ b/nexus/db-queries/src/policy_test/mod.rs @@ -92,6 +92,7 @@ async fn test_iam_prep( admin_group_name: None, tls_certificates: vec![], mapped_fleet_roles: Default::default(), + restrict_network_actions: None, // Default: no restrictions }, &[], DnsVersionUpdateBuilder::new( @@ -482,7 +483,7 @@ async fn test_conferred_roles() { let mut out = StdoutTee::new(&mut buffer); for policy in policies { write!(out, "policy: {:?}\n", policy).unwrap(); - let policy = SiloAuthnPolicy::new(policy); + let policy = SiloAuthnPolicy::new(policy, false); let user_contexts: Vec> = silo_resources .users() diff --git a/nexus/db-schema/src/schema.rs b/nexus/db-schema/src/schema.rs index ae62b1a327d..e6c53fcb0fd 100644 --- a/nexus/db-schema/src/schema.rs +++ b/nexus/db-schema/src/schema.rs @@ -763,6 +763,7 @@ table! { rcgen -> Int8, admin_group_name -> Nullable, + restrict_network_actions -> Bool, } } diff --git a/nexus/reconfigurator/execution/src/dns.rs b/nexus/reconfigurator/execution/src/dns.rs index 69bb198ff3d..bc68f5da2de 100644 --- a/nexus/reconfigurator/execution/src/dns.rs +++ b/nexus/reconfigurator/execution/src/dns.rs @@ -1085,6 +1085,7 @@ mod test { admin_group_name: None, tls_certificates: vec![], mapped_fleet_roles: Default::default(), + restrict_network_actions: None, }) .unwrap(); diff --git a/nexus/src/app/external_endpoints.rs b/nexus/src/app/external_endpoints.rs index 891a43ac6df..8fdcef39f27 100644 --- a/nexus/src/app/external_endpoints.rs +++ b/nexus/src/app/external_endpoints.rs @@ -833,6 +833,7 @@ mod test { admin_group_name: None, tls_certificates: vec![], mapped_fleet_roles: Default::default(), + restrict_network_actions: None, }; if let Some(silo_id) = silo_id { diff --git a/nexus/src/app/project.rs b/nexus/src/app/project.rs index 0f994a8c58e..3cf7ff2b876 100644 --- a/nexus/src/app/project.rs +++ b/nexus/src/app/project.rs @@ -53,10 +53,31 @@ impl super::Nexus { .internal_context("creating a Project")?; opctx.authorize(authz::Action::CreateChild, &authz_silo).await?; + // Determine if we should create a default VPC. + // Skip VPC creation if networking is restricted and user is not a Silo Admin. + let create_default_vpc = + if let Some(policy) = opctx.authn.silo_authn_policy() { + if policy.restrict_network_actions() { + // Networking is restricted - only create VPC if user is Silo Admin + // (i.e., has Modify permission on the Silo) + opctx + .authorize(authz::Action::Modify, &authz_silo) + .await + .is_ok() + } else { + // No networking restrictions, create VPC + true + } + } else { + // No policy, create VPC + true + }; + let saga_params = sagas::project_create::Params { serialized_authn: authn::saga::Serialized::for_opctx(opctx), project_create: new_project.clone(), authz_silo, + create_default_vpc, }; let saga_outputs = self .sagas diff --git a/nexus/src/app/rack.rs b/nexus/src/app/rack.rs index 5d4f877392a..d71711bfd23 100644 --- a/nexus/src/app/rack.rs +++ b/nexus/src/app/rack.rs @@ -305,6 +305,7 @@ impl super::Nexus { admin_group_name: None, tls_certificates, mapped_fleet_roles, + restrict_network_actions: None, }; let rack_network_config = &request.rack_network_config; diff --git a/nexus/src/app/sagas/project_create.rs b/nexus/src/app/sagas/project_create.rs index 37434275c02..c1a316ad347 100644 --- a/nexus/src/app/sagas/project_create.rs +++ b/nexus/src/app/sagas/project_create.rs @@ -23,6 +23,10 @@ pub(crate) struct Params { pub serialized_authn: authn::saga::Serialized, pub project_create: params::ProjectCreate, pub authz_silo: authz::Silo, + /// Whether to create a default VPC for this project. + /// Set to false when networking actions are restricted and the actor + /// is not a Silo Admin. + pub create_default_vpc: bool, } // project create saga: actions @@ -51,20 +55,26 @@ impl NexusSaga for SagaProjectCreate { } fn make_saga_dag( - _params: &Self::Params, + params: &Self::Params, mut builder: steno::DagBuilder, ) -> Result { builder.append(project_create_record_action()); - builder.append(project_create_vpc_params_action()); - - let subsaga_builder = steno::DagBuilder::new(steno::SagaName::new( - sagas::vpc_create::SagaVpcCreate::NAME, - )); - builder.append(steno::Node::subsaga( - "vpc", - sagas::vpc_create::create_dag(subsaga_builder)?, - "vpc_create_params", - )); + + // Only create default VPC if allowed (i.e., networking is not restricted + // or the actor is a Silo Admin) + if params.create_default_vpc { + builder.append(project_create_vpc_params_action()); + + let subsaga_builder = steno::DagBuilder::new(steno::SagaName::new( + sagas::vpc_create::SagaVpcCreate::NAME, + )); + builder.append(steno::Node::subsaga( + "vpc", + sagas::vpc_create::create_dag(subsaga_builder)?, + "vpc_create_params", + )); + } + Ok(builder.build()?) } } @@ -182,6 +192,7 @@ mod test { }, }, authz_silo, + create_default_vpc: true, } } diff --git a/nexus/src/app/vpc.rs b/nexus/src/app/vpc.rs index 77751d358a9..ea155255a85 100644 --- a/nexus/src/app/vpc.rs +++ b/nexus/src/app/vpc.rs @@ -65,6 +65,42 @@ impl super::Nexus { } } + /// Check if the actor's silo restricts networking actions, and if so, + /// verify the actor has Silo Admin permissions. + /// + /// Only used at VPC creation time; other networking resources use Polar rules + /// + /// Returns Ok(()) if either: + /// - The silo does not restrict networking actions, or + /// - The silo restricts networking and the actor is a Silo Admin + /// + /// Returns Err if the silo restricts networking and the actor is not + /// a Silo Admin. + pub(crate) async fn check_networking_restrictions( + &self, + opctx: &OpContext, + ) -> Result<(), Error> { + if let Some(actor) = opctx.authn.actor() { + if let Some(silo_id) = actor.silo_id() { + let silo_policy = opctx.authn.silo_authn_policy(); + if let Some(policy) = silo_policy { + if policy.restrict_network_actions() { + // The silo restricts networking - verify the actor is a Silo Admin + let authz_silo = authz::Silo::new( + authz::FLEET, + silo_id, + LookupType::ById(silo_id), + ); + opctx + .authorize(authz::Action::Modify, &authz_silo) + .await?; + } + } + } + } + Ok(()) + } + pub(crate) async fn project_create_vpc( self: &Arc, opctx: &OpContext, @@ -74,7 +110,11 @@ impl super::Nexus { let (.., authz_project) = project_lookup.lookup_for(authz::Action::CreateChild).await?; - opctx.authorize(authz::Action::CreateChild, &authz_project).await?; + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can create VPCs. Other networking resources + // use Polar rules to determine authorization, but VPC creation is a special + // case because it creates the top-level networking container. + self.check_networking_restrictions(opctx).await?; let saga_params = sagas::vpc_create::Params { serialized_authn: authn::saga::Serialized::for_opctx(opctx), @@ -124,7 +164,8 @@ impl super::Nexus { opctx: &OpContext, vpc_lookup: &lookup::Vpc<'_>, ) -> DeleteResult { - let (.., authz_vpc, db_vpc) = vpc_lookup.fetch().await?; + let (.., authz_vpc, db_vpc) = + vpc_lookup.fetch_for(authz::Action::Delete).await?; let authz_vpc_router = authz::VpcRouter::new( authz_vpc.clone(), diff --git a/nexus/test-utils/src/resource_helpers.rs b/nexus/test-utils/src/resource_helpers.rs index 3c9044740bc..4623ce2ae38 100644 --- a/nexus/test-utils/src/resource_helpers.rs +++ b/nexus/test-utils/src/resource_helpers.rs @@ -408,6 +408,7 @@ pub async fn create_silo( identity_mode, admin_group_name: None, tls_certificates: vec![], + restrict_network_actions: None, mapped_fleet_roles: Default::default(), }, ) diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index dd2ebc92b33..213b2e63909 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -121,6 +121,7 @@ pub static DEMO_SILO_CREATE: LazyLock = admin_group_name: None, tls_certificates: vec![], mapped_fleet_roles: Default::default(), + restrict_network_actions: None, }); pub static DEMO_SILO_UTIL_URL: LazyLock = LazyLock::new(|| { diff --git a/nexus/tests/integration_tests/quotas.rs b/nexus/tests/integration_tests/quotas.rs index 53baee4ae34..1a2b538c9f8 100644 --- a/nexus/tests/integration_tests/quotas.rs +++ b/nexus/tests/integration_tests/quotas.rs @@ -212,6 +212,7 @@ async fn setup_silo_with_quota( admin_group_name: None, tls_certificates: vec![], mapped_fleet_roles: Default::default(), + restrict_network_actions: None, }, ) .await; @@ -431,6 +432,7 @@ async fn test_negative_quota(cptestctx: &ControlPlaneTestContext) { admin_group_name: None, tls_certificates: vec![], mapped_fleet_roles: Default::default(), + restrict_network_actions: None, }, http::StatusCode::BAD_REQUEST, ) diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index a2866c5f506..39009ea5dc6 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -80,6 +80,7 @@ async fn test_silos(cptestctx: &ControlPlaneTestContext) { admin_group_name: None, tls_certificates: vec![], mapped_fleet_roles: Default::default(), + restrict_network_actions: None, }, ) .authn_as(AuthnMode::PrivilegedUser) @@ -297,6 +298,7 @@ async fn test_silo_admin_group(cptestctx: &ControlPlaneTestContext) { admin_group_name: Some("administrator".into()), tls_certificates: vec![], mapped_fleet_roles: Default::default(), + restrict_network_actions: None, }, ) .await; @@ -2377,6 +2379,7 @@ async fn test_silo_authn_policy(cptestctx: &ControlPlaneTestContext) { admin_group_name: None, tls_certificates: vec![], mapped_fleet_roles: policy, + restrict_network_actions: None, }, ) .authn_as(AuthnMode::PrivilegedUser) @@ -2454,6 +2457,7 @@ async fn check_fleet_privileges( admin_group_name: None, tls_certificates: vec![], mapped_fleet_roles: BTreeMap::new(), + restrict_network_actions: None, }; let (do_delete, nexus_request) = match expected { ExpectedFleetPrivileges::None | ExpectedFleetPrivileges::ReadOnly => ( @@ -2483,6 +2487,7 @@ async fn check_fleet_privileges( admin_group_name: None, tls_certificates: vec![], mapped_fleet_roles: BTreeMap::new(), + restrict_network_actions: None, }, ), ), @@ -2514,6 +2519,7 @@ async fn check_fleet_privileges( SiloRole::Admin, BTreeSet::from([FleetRole::Viewer]), )]), + restrict_network_actions: None, }; let (do_delete, nexus_request) = match expected { ExpectedFleetPrivileges::None @@ -2544,6 +2550,7 @@ async fn check_fleet_privileges( admin_group_name: None, tls_certificates: vec![], mapped_fleet_roles: BTreeMap::new(), + restrict_network_actions: None, }, ), ), diff --git a/nexus/tests/integration_tests/vpc_routers.rs b/nexus/tests/integration_tests/vpc_routers.rs index ce72e605c56..9ae2d3c6ea6 100644 --- a/nexus/tests/integration_tests/vpc_routers.rs +++ b/nexus/tests/integration_tests/vpc_routers.rs @@ -714,3 +714,433 @@ fn routers_eq(sn1: &VpcRouter, sn2: &VpcRouter) { identity_eq(&sn1.identity, &sn2.identity); assert_eq!(sn1.vpc_id, sn2.vpc_id); } + +#[nexus_test] +async fn test_vpc_router_networking_restrictions( + cptestctx: &ControlPlaneTestContext, +) { + use nexus_test_utils::resource_helpers::{ + create_local_user, grant_iam, object_create, test_params, + }; + use nexus_types::external_api::shared::SiloRole; + use nexus_types::external_api::{params, shared, views}; + use omicron_common::api::external::RouterRoute; + + let client = &cptestctx.external_client; + + // Test Part 1: Create a restricted silo with networking restrictions enabled + let restricted_silo_name = "router-restricted-silo"; + let silo_url = "/v1/system/silos"; + let silo_params = params::SiloCreate { + identity: IdentityMetadataCreateParams { + name: restricted_silo_name.parse().unwrap(), + description: "Silo with router networking restrictions".to_string(), + }, + discoverable: false, + identity_mode: + nexus_types::external_api::shared::SiloIdentityMode::LocalOnly, + admin_group_name: None, + tls_certificates: Vec::new(), + mapped_fleet_roles: Default::default(), + restrict_network_actions: Some(true), // Enable networking restrictions + quotas: params::SiloQuotasCreate::empty(), + }; + + let restricted_silo: views::Silo = + object_create(&client, silo_url, &silo_params).await; + + // Test Part 2: Create a user with Admin role (needed to create project with default VPC) + let test_user = create_local_user( + client, + &restricted_silo, + &"router-test-user".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + + let silo_policy_url = + format!("/v1/system/silos/{}/policy", restricted_silo_name); + let silo_url = format!("/v1/system/silos/{}", restricted_silo_name); + + // Grant the user Admin role first so they can create a project + grant_iam( + client, + &silo_url, + SiloRole::Admin, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + // Create a project in the restricted silo AS THE SILO USER (who is currently Admin) + let restricted_project_name = "router-restricted-project"; + let project_params = params::ProjectCreate { + identity: IdentityMetadataCreateParams { + name: restricted_project_name.parse().unwrap(), + description: "Project in router restricted silo".to_string(), + }, + }; + + let _restricted_project: views::Project = + NexusRequest::objects_post(&client, "/v1/projects", &project_params) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + // Test Part 3: Demote to Collaborator + let silo_policy: shared::Policy = + NexusRequest::object_get(client, &silo_policy_url) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to fetch silo policy") + .parsed_body() + .expect("failed to parse silo policy"); + + let test_user_uuid = test_user.id.into_untyped_uuid(); + + // Add Collaborator and remove Admin + let mut new_assignments: Vec<_> = silo_policy + .role_assignments + .into_iter() + .filter(|ra| { + !matches!( + ra, + shared::RoleAssignment { + identity_type: shared::IdentityType::SiloUser, + identity_id, + role_name, + } if *identity_id == test_user_uuid && *role_name == SiloRole::Admin + ) + }) + .collect(); + + new_assignments.push(shared::RoleAssignment::for_silo_user( + test_user.id, + SiloRole::Collaborator, + )); + + let collaborator_policy = + shared::Policy { role_assignments: new_assignments }; + NexusRequest::object_put( + client, + &silo_policy_url, + Some(&collaborator_policy), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to update silo policy"); + + // Test Part 4: Test VPC Router operations as Collaborator - CREATE, UPDATE, DELETE should all FAIL + let restricted_routers_url = format!( + "/v1/vpc-routers?project={}&vpc=default", + restricted_project_name + ); + + // Try to CREATE a router as Collaborator - should FAIL + let collab_router_params = params::VpcRouterCreate { + identity: IdentityMetadataCreateParams { + name: "collab-router".parse().unwrap(), + description: "Collaborator creation attempt".to_string(), + }, + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::POST, &restricted_routers_url) + .body(Some(&collab_router_params)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Collaborator should not be able to create VPC router"); + + // Try to UPDATE the system router as Collaborator - should FAIL + // First, get the system router ID + let routers_list: dropshot::ResultsPage = + NexusRequest::object_get(client, &restricted_routers_url) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + let system_router = &routers_list.items[0]; + + let router_update_url = format!("/v1/vpc-routers/{}", system_router.id()); + let router_update_params_collab = params::VpcRouterUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some("Collaborator update attempt".to_string()), + }, + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::PUT, &router_update_url) + .body(Some(&router_update_params_collab)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Collaborator should not be able to update VPC router"); + + // Test Part 5: Test as Admin - CREATE and UPDATE should SUCCEED + // Grant the user Silo Admin role again + grant_iam( + client, + &silo_url, + SiloRole::Admin, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + // Try to CREATE a router as Admin - should SUCCEED + let admin_router_params = params::VpcRouterCreate { + identity: IdentityMetadataCreateParams { + name: "admin-router".parse().unwrap(), + description: "Router created by admin".to_string(), + }, + }; + + let created_router: VpcRouter = NexusRequest::objects_post( + &client, + &restricted_routers_url, + &admin_router_params, + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + assert_eq!(created_router.identity.name, "admin-router"); + assert_eq!(created_router.identity.description, "Router created by admin"); + + // Try to UPDATE the system router as Admin - should SUCCEED + let router_update_params_admin = params::VpcRouterUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some("Admin update successful".to_string()), + }, + }; + + let updated_router: VpcRouter = NexusRequest::object_put( + &client, + &router_update_url, + Some(&router_update_params_admin), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + assert_eq!(updated_router.identity.description, "Admin update successful"); + + // Try to DELETE the admin-created router as Admin - should SUCCEED + let admin_router_url = format!("/v1/vpc-routers/{}", created_router.id()); + NexusRequest::object_delete(&client, &admin_router_url) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Admin should be able to delete VPC router"); + + // Test Part 6: Test Router Routes - Collaborator should be blocked, Admin should succeed + // Demote back to Collaborator + NexusRequest::object_put( + client, + &silo_policy_url, + Some(&collaborator_policy), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to update silo policy"); + + // Try to CREATE a route as Collaborator - should FAIL (but will fail with system router error first) + // So we'll test this with a custom router instead + // First create a custom router as Admin + grant_iam( + client, + &silo_url, + SiloRole::Admin, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + let custom_router_params = params::VpcRouterCreate { + identity: IdentityMetadataCreateParams { + name: "custom-router".parse().unwrap(), + description: "Custom router for route testing".to_string(), + }, + }; + + let _custom_router: VpcRouter = NexusRequest::objects_post( + &client, + &restricted_routers_url, + &custom_router_params, + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + let custom_routes_url = format!( + "/v1/vpc-router-routes?project={}&vpc=default&router=custom-router", + restricted_project_name + ); + + // Create a route as Admin + let test_route_params = params::RouterRouteCreate { + identity: IdentityMetadataCreateParams { + name: "test-route".parse().unwrap(), + description: "Route for testing".to_string(), + }, + target: omicron_common::api::external::RouteTarget::Ip( + "10.0.0.1".parse().unwrap(), + ), + destination: omicron_common::api::external::RouteDestination::IpNet( + "192.168.0.0/24".parse().unwrap(), + ), + }; + + let created_route: RouterRoute = NexusRequest::objects_post( + &client, + &custom_routes_url, + &test_route_params, + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + // Demote back to Collaborator + NexusRequest::object_put( + client, + &silo_policy_url, + Some(&collaborator_policy), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to update silo policy"); + + // Try to CREATE a route as Collaborator - should FAIL + let collab_route_params = params::RouterRouteCreate { + identity: IdentityMetadataCreateParams { + name: "collab-route".parse().unwrap(), + description: "Collaborator route creation attempt".to_string(), + }, + target: omicron_common::api::external::RouteTarget::Ip( + "10.0.0.2".parse().unwrap(), + ), + destination: omicron_common::api::external::RouteDestination::IpNet( + "192.168.1.0/24".parse().unwrap(), + ), + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::POST, &custom_routes_url) + .body(Some(&collab_route_params)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Collaborator should not be able to create router route"); + + // Try to UPDATE a route as Collaborator - should FAIL + let route_update_url = format!( + "/v1/vpc-router-routes/test-route?project={}&vpc=default&router=custom-router", + restricted_project_name + ); + let route_update_params_collab = params::RouterRouteUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some("Collaborator route update attempt".to_string()), + }, + target: created_route.target.clone(), + destination: created_route.destination.clone(), + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::PUT, &route_update_url) + .body(Some(&route_update_params_collab)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Collaborator should not be able to update router route"); + + // Try to DELETE a route as Collaborator - should FAIL + NexusRequest::new( + RequestBuilder::new(client, Method::DELETE, &route_update_url) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Collaborator should not be able to delete router route"); + + // Test Part 7: Route operations as Admin - should all SUCCEED + grant_iam( + client, + &silo_url, + SiloRole::Admin, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + // CREATE route as Admin - should SUCCEED + let admin_route_params = params::RouterRouteCreate { + identity: IdentityMetadataCreateParams { + name: "admin-route".parse().unwrap(), + description: "Route created by admin".to_string(), + }, + target: omicron_common::api::external::RouteTarget::Ip( + "10.0.0.3".parse().unwrap(), + ), + destination: omicron_common::api::external::RouteDestination::IpNet( + "192.168.2.0/24".parse().unwrap(), + ), + }; + + let admin_created_route: RouterRoute = NexusRequest::objects_post( + &client, + &custom_routes_url, + &admin_route_params, + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + assert_eq!(admin_created_route.identity.name, "admin-route"); + + // UPDATE route as Admin - should SUCCEED + let route_update_params_admin = params::RouterRouteUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some("Admin route update successful".to_string()), + }, + target: created_route.target, + destination: created_route.destination, + }; + + let updated_route: RouterRoute = NexusRequest::object_put( + &client, + &route_update_url, + Some(&route_update_params_admin), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + assert_eq!( + updated_route.identity.description, + "Admin route update successful" + ); + + // DELETE route as Admin - should SUCCEED + NexusRequest::object_delete(&client, &route_update_url) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Admin should be able to delete router route"); +} diff --git a/nexus/tests/integration_tests/vpcs.rs b/nexus/tests/integration_tests/vpcs.rs index 054a28334e4..ad6d14612c7 100644 --- a/nexus/tests/integration_tests/vpcs.rs +++ b/nexus/tests/integration_tests/vpcs.rs @@ -3,6 +3,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. use dropshot::HttpErrorResponseBody; +use dropshot::ResultsPage; use dropshot::test_util::ClientTestContext; use http::StatusCode; use http::method::Method; @@ -10,14 +11,19 @@ use nexus_test_utils::http_testing::AuthnMode; use nexus_test_utils::http_testing::NexusRequest; use nexus_test_utils::http_testing::RequestBuilder; use nexus_test_utils::identity_eq; +use nexus_test_utils::resource_helpers::object_create; use nexus_test_utils::resource_helpers::objects_list_page_authz; use nexus_test_utils::resource_helpers::{ - create_project, create_vpc, create_vpc_with_error, + create_local_user, create_project, create_vpc, create_vpc_with_error, + grant_iam, test_params, }; use nexus_test_utils_macros::nexus_test; -use nexus_types::external_api::{params, views::Vpc}; +use nexus_types::external_api::views; +use nexus_types::external_api::{params, shared, views::Vpc}; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_common::api::external::IdentityMetadataUpdateParams; +use omicron_common::api::external::NameOrId; +use omicron_uuid_kinds::GenericUuid; type ControlPlaneTestContext = nexus_test_utils::ControlPlaneTestContext; @@ -226,11 +232,8 @@ async fn vpcs_list(client: &ClientTestContext, vpcs_url: &str) -> Vec { async fn vpc_get(client: &ClientTestContext, vpc_url: &str) -> Vpc { NexusRequest::object_get(client, vpc_url) .authn_as(AuthnMode::PrivilegedUser) - .execute() + .execute_and_parse_unwrap() .await - .unwrap() - .parsed_body() - .unwrap() } async fn vpc_put( @@ -240,14 +243,2218 @@ async fn vpc_put( ) -> Vpc { NexusRequest::object_put(client, vpc_url, Some(¶ms)) .authn_as(AuthnMode::PrivilegedUser) - .execute() + .execute_and_parse_unwrap() .await - .unwrap() - .parsed_body() - .unwrap() } fn vpcs_eq(vpc1: &Vpc, vpc2: &Vpc) { identity_eq(&vpc1.identity, &vpc2.identity); assert_eq!(vpc1.project_id, vpc2.project_id); } + +#[nexus_test] +async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { + use nexus_types::external_api::params; + + let client = &cptestctx.external_client; + + // STEP 1: Setup - Create restricted silo and admin user + let restricted_silo_name = "restricted-silo"; + let silo_url = "/v1/system/silos"; + let silo_params = params::SiloCreate { + identity: IdentityMetadataCreateParams { + name: restricted_silo_name.parse().unwrap(), + description: "Silo with networking restrictions".to_string(), + }, + discoverable: false, + identity_mode: + nexus_types::external_api::shared::SiloIdentityMode::LocalOnly, + admin_group_name: None, + tls_certificates: Vec::new(), + mapped_fleet_roles: Default::default(), + restrict_network_actions: Some(true), + quotas: params::SiloQuotasCreate::empty(), + }; + + let restricted_silo: views::Silo = + object_create(&client, silo_url, &silo_params).await; + + let test_user = create_local_user( + client, + &restricted_silo, + &"test-user".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + + let silo_policy_url = + format!("/v1/system/silos/{}/policy", restricted_silo_name); + let silo_url = format!("/v1/system/silos/{}", restricted_silo_name); + + grant_iam( + client, + &silo_url, + shared::SiloRole::Admin, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + let restricted_project_name = "restricted-project"; + let project_params = params::ProjectCreate { + identity: IdentityMetadataCreateParams { + name: restricted_project_name.parse().unwrap(), + description: "Project in restricted silo".to_string(), + }, + }; + + let _restricted_project: views::Project = + NexusRequest::objects_post(&client, "/v1/projects", &project_params) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + let restricted_vpcs_url = + format!("/v1/vpcs?project={}", restricted_project_name); + + // STEP 2: As Admin - Create VPC + let test_vpc_params = params::VpcCreate { + identity: IdentityMetadataCreateParams { + name: "test-vpc".parse().unwrap(), + description: "VPC for testing".to_string(), + }, + ipv6_prefix: None, + dns_name: "test".parse().unwrap(), + }; + + let created_vpc: Vpc = NexusRequest::objects_post( + &client, + &restricted_vpcs_url, + &test_vpc_params, + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + assert_eq!(created_vpc.identity.name, "test-vpc"); + assert_eq!(created_vpc.dns_name, "test"); + + // STEP 3: As Admin - Update VPC to verify it works + let vpc_update_url = + format!("/v1/vpcs/test-vpc?project={}", restricted_project_name); + let vpc_update_params = params::VpcUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some("Updated by admin".to_string()), + }, + dns_name: None, + }; + + let updated_vpc: Vpc = NexusRequest::object_put( + &client, + &vpc_update_url, + Some(&vpc_update_params), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + assert_eq!(updated_vpc.identity.description, "Updated by admin"); + + // STEP 4: Demote to Collaborator + let silo_policy: shared::Policy = + NexusRequest::object_get(client, &silo_policy_url) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to fetch silo policy") + .parsed_body() + .expect("failed to parse silo policy"); + + let test_user_uuid = test_user.id.into_untyped_uuid(); + + let mut new_assignments: Vec<_> = silo_policy + .role_assignments + .into_iter() + .filter(|ra| { + !matches!( + ra, + shared::RoleAssignment { + identity_type: shared::IdentityType::SiloUser, + identity_id, + role_name, + } if *identity_id == test_user_uuid && *role_name == shared::SiloRole::Admin + ) + }) + .collect(); + + new_assignments.push(shared::RoleAssignment::for_silo_user( + test_user.id, + shared::SiloRole::Collaborator, + )); + + let collaborator_policy = + shared::Policy { role_assignments: new_assignments }; + + NexusRequest::object_put( + client, + &silo_policy_url, + Some(&collaborator_policy), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to update silo policy"); + + // STEP 5: As Collaborator - Try to CREATE VPC (should fail) + let collab_vpc_params = params::VpcCreate { + identity: IdentityMetadataCreateParams { + name: "collab-vpc".parse().unwrap(), + description: "Collaborator creation attempt".to_string(), + }, + ipv6_prefix: None, + dns_name: "collab".parse().unwrap(), + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::POST, &restricted_vpcs_url) + .body(Some(&collab_vpc_params)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Collaborator should not be able to create VPC"); + + // STEP 6: As Collaborator - Try to UPDATE VPC (should fail) + let vpc_update_params_collab = params::VpcUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some("Collaborator update attempt".to_string()), + }, + dns_name: None, + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::PUT, &vpc_update_url) + .body(Some(&vpc_update_params_collab)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Collaborator should not be able to update VPC"); + + // STEP 7: As Collaborator - Try to DELETE VPC (should fail) + NexusRequest::new( + RequestBuilder::new(client, Method::DELETE, &vpc_update_url) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Collaborator should not be able to delete VPC"); + + // STEP 8: Promote back to Admin + grant_iam( + client, + &silo_url, + shared::SiloRole::Admin, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + // STEP 9: As Admin - Delete resources created in step 2 + // Delete the default subnet first (VPCs can't be deleted if they have subnets) + let default_subnet_url = format!( + "/v1/vpc-subnets/default?project={}&vpc=test-vpc", + restricted_project_name + ); + NexusRequest::object_delete(&client, &default_subnet_url) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Admin should be able to delete subnet"); + + // Delete the VPC + NexusRequest::object_delete(&client, &vpc_update_url) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Admin should be able to delete VPC"); +} + +#[nexus_test] +async fn test_vpc_subnet_networking_restrictions( + cptestctx: &ControlPlaneTestContext, +) { + use nexus_types::external_api::params; + + let client = &cptestctx.external_client; + + // STEP 1: Setup - Create restricted silo and admin user + let restricted_silo_name = "subnet-restricted-silo"; + let silo_url = "/v1/system/silos"; + let silo_params = params::SiloCreate { + identity: IdentityMetadataCreateParams { + name: restricted_silo_name.parse().unwrap(), + description: "Silo with subnet networking restrictions".to_string(), + }, + discoverable: false, + identity_mode: + nexus_types::external_api::shared::SiloIdentityMode::LocalOnly, + admin_group_name: None, + tls_certificates: Vec::new(), + mapped_fleet_roles: Default::default(), + restrict_network_actions: Some(true), + quotas: params::SiloQuotasCreate::empty(), + }; + + let restricted_silo: views::Silo = + object_create(&client, silo_url, &silo_params).await; + + let test_user = create_local_user( + client, + &restricted_silo, + &"subnet-test-user".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + + let silo_policy_url = + format!("/v1/system/silos/{}/policy", restricted_silo_name); + let silo_url = format!("/v1/system/silos/{}", restricted_silo_name); + + grant_iam( + client, + &silo_url, + shared::SiloRole::Admin, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + // Create a project (this will automatically create a default VPC with a default subnet) + let restricted_project_name = "subnet-restricted-project"; + let project_params = params::ProjectCreate { + identity: IdentityMetadataCreateParams { + name: restricted_project_name.parse().unwrap(), + description: "Project in subnet restricted silo".to_string(), + }, + }; + + let _restricted_project: views::Project = + NexusRequest::objects_post(&client, "/v1/projects", &project_params) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + let restricted_subnets_url = format!( + "/v1/vpc-subnets?project={}&vpc=default", + restricted_project_name + ); + + // STEP 2: As Admin - Create subnet + let test_subnet_params = params::VpcSubnetCreate { + identity: IdentityMetadataCreateParams { + name: "test-subnet".parse().unwrap(), + description: "Subnet for testing".to_string(), + }, + ipv4_block: "10.1.0.0/22".parse().unwrap(), + ipv6_block: None, + custom_router: None, + }; + + let created_subnet: views::VpcSubnet = NexusRequest::objects_post( + &client, + &restricted_subnets_url, + &test_subnet_params, + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + assert_eq!(created_subnet.identity.name, "test-subnet"); + assert_eq!(created_subnet.identity.description, "Subnet for testing"); + + // STEP 3: As Admin - Update subnet to verify it works + let subnet_update_url = format!( + "/v1/vpc-subnets/test-subnet?project={}&vpc=default", + restricted_project_name + ); + let subnet_update_params = params::VpcSubnetUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some("Updated by admin".to_string()), + }, + custom_router: None, + }; + + let updated_subnet: views::VpcSubnet = NexusRequest::object_put( + &client, + &subnet_update_url, + Some(&subnet_update_params), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + assert_eq!(updated_subnet.identity.description, "Updated by admin"); + + // STEP 4: Demote to Collaborator + let silo_policy: shared::Policy = + NexusRequest::object_get(client, &silo_policy_url) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to fetch silo policy") + .parsed_body() + .expect("failed to parse silo policy"); + + let test_user_uuid = test_user.id.into_untyped_uuid(); + + let mut new_assignments: Vec<_> = silo_policy + .role_assignments + .into_iter() + .filter(|ra| { + !matches!( + ra, + shared::RoleAssignment { + identity_type: shared::IdentityType::SiloUser, + identity_id, + role_name, + } if *identity_id == test_user_uuid && *role_name == shared::SiloRole::Admin + ) + }) + .collect(); + + new_assignments.push(shared::RoleAssignment::for_silo_user( + test_user.id, + shared::SiloRole::Collaborator, + )); + + let collaborator_policy = + shared::Policy { role_assignments: new_assignments }; + + NexusRequest::object_put( + client, + &silo_policy_url, + Some(&collaborator_policy), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to update silo policy"); + + // STEP 5: As Collaborator - Try to CREATE subnet (should fail) + let collab_subnet_params = params::VpcSubnetCreate { + identity: IdentityMetadataCreateParams { + name: "collab-subnet".parse().unwrap(), + description: "Collaborator creation attempt".to_string(), + }, + ipv4_block: "10.2.0.0/22".parse().unwrap(), + ipv6_block: None, + custom_router: None, + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::POST, &restricted_subnets_url) + .body(Some(&collab_subnet_params)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Collaborator should not be able to create VPC subnet"); + + // STEP 6: As Collaborator - Try to UPDATE subnet (should fail) + let subnet_update_params_collab = params::VpcSubnetUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some("Collaborator update attempt".to_string()), + }, + custom_router: None, + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::PUT, &subnet_update_url) + .body(Some(&subnet_update_params_collab)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Collaborator should not be able to update VPC subnet"); + + // STEP 7: As Collaborator - Try to DELETE subnet (should fail) + NexusRequest::new( + RequestBuilder::new(client, Method::DELETE, &subnet_update_url) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Collaborator should not be able to delete VPC subnet"); + + // STEP 8: Promote back to Admin + grant_iam( + client, + &silo_url, + shared::SiloRole::Admin, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + // STEP 9: As Admin - Delete subnet created in step 2 + NexusRequest::object_delete(&client, &subnet_update_url) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Admin should be able to delete VPC subnet"); +} + +#[nexus_test] +async fn test_internet_gateway_firewall_networking_restrictions( + cptestctx: &ControlPlaneTestContext, +) { + use nexus_test_utils::resource_helpers::{ + create_local_user, grant_iam, object_create, test_params, + }; + use nexus_types::external_api::shared::SiloRole; + use nexus_types::external_api::{params, shared, views}; + use omicron_common::api::external::{ + L4Port, L4PortRange, VpcFirewallRuleAction, VpcFirewallRuleDirection, + VpcFirewallRuleFilter, VpcFirewallRulePriority, + VpcFirewallRuleProtocol, VpcFirewallRuleStatus, VpcFirewallRuleUpdate, + VpcFirewallRuleUpdateParams, VpcFirewallRules, + }; + use std::convert::TryFrom; + + let client = &cptestctx.external_client; + + // STEP 1: Setup - Create restricted silo and admin user + let restricted_silo_name = "igw-restricted-silo"; + let silo_url = "/v1/system/silos"; + let silo_params = params::SiloCreate { + identity: IdentityMetadataCreateParams { + name: restricted_silo_name.parse().unwrap(), + description: "Silo with IGW networking restrictions".to_string(), + }, + discoverable: false, + identity_mode: + nexus_types::external_api::shared::SiloIdentityMode::LocalOnly, + admin_group_name: None, + tls_certificates: Vec::new(), + mapped_fleet_roles: Default::default(), + restrict_network_actions: Some(true), + quotas: params::SiloQuotasCreate::empty(), + }; + + let restricted_silo: views::Silo = + object_create(&client, silo_url, &silo_params).await; + + let test_user = create_local_user( + client, + &restricted_silo, + &"igw-test-user".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + + let silo_policy_url = + format!("/v1/system/silos/{}/policy", restricted_silo_name); + let silo_url = format!("/v1/system/silos/{}", restricted_silo_name); + + grant_iam( + client, + &silo_url, + SiloRole::Admin, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + let restricted_project_name = "igw-restricted-project"; + let project_params = params::ProjectCreate { + identity: IdentityMetadataCreateParams { + name: restricted_project_name.parse().unwrap(), + description: "Project in IGW restricted silo".to_string(), + }, + }; + + let _restricted_project: views::Project = + NexusRequest::objects_post(&client, "/v1/projects", &project_params) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + let restricted_igws_url = format!( + "/v1/internet-gateways?project={}&vpc=default", + restricted_project_name + ); + + // STEP 2: As Admin - Create internet gateway + let test_igw_params = params::InternetGatewayCreate { + identity: IdentityMetadataCreateParams { + name: "test-igw".parse().unwrap(), + description: "IGW for testing".to_string(), + }, + }; + + let created_igw: views::InternetGateway = NexusRequest::objects_post( + &client, + &restricted_igws_url, + &test_igw_params, + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + assert_eq!(created_igw.identity.name, "test-igw"); + assert_eq!(created_igw.identity.description, "IGW for testing"); + + // STEP 3: As Admin - Update firewall rules to verify it works + let firewall_rules_url = format!( + "/v1/vpc-firewall-rules?project={}&vpc=default", + restricted_project_name + ); + + let initial_firewall_params = VpcFirewallRuleUpdateParams { + rules: vec![VpcFirewallRuleUpdate { + name: "allow-ssh".parse().unwrap(), + description: "Allow SSH".to_string(), + action: VpcFirewallRuleAction::Allow, + direction: VpcFirewallRuleDirection::Inbound, + filters: VpcFirewallRuleFilter { + hosts: None, + ports: Some(vec![L4PortRange { + first: L4Port::try_from(22).unwrap(), + last: L4Port::try_from(22).unwrap(), + }]), + protocols: Some(vec![VpcFirewallRuleProtocol::Tcp]), + }, + priority: VpcFirewallRulePriority(100), + status: VpcFirewallRuleStatus::Enabled, + targets: vec![], + }], + }; + + let initial_rules: VpcFirewallRules = NexusRequest::object_put( + &client, + &firewall_rules_url, + Some(&initial_firewall_params), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + assert_eq!(initial_rules.rules.len(), 1); + assert_eq!(initial_rules.rules[0].identity.name, "allow-ssh"); + + // STEP 4: Demote to Collaborator + let silo_policy: shared::Policy = + NexusRequest::object_get(client, &silo_policy_url) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to fetch silo policy") + .parsed_body() + .expect("failed to parse silo policy"); + + let test_user_uuid = test_user.id.into_untyped_uuid(); + + let mut new_assignments: Vec<_> = silo_policy + .role_assignments + .into_iter() + .filter(|ra| { + !matches!( + ra, + shared::RoleAssignment { + identity_type: shared::IdentityType::SiloUser, + identity_id, + role_name, + } if *identity_id == test_user_uuid && *role_name == SiloRole::Admin + ) + }) + .collect(); + + new_assignments.push(shared::RoleAssignment::for_silo_user( + test_user.id, + SiloRole::Collaborator, + )); + + let collaborator_policy = + shared::Policy { role_assignments: new_assignments }; + + NexusRequest::object_put( + client, + &silo_policy_url, + Some(&collaborator_policy), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to update silo policy"); + + // STEP 5: As Collaborator - Try to CREATE internet gateway (should fail) + let collab_igw_params = params::InternetGatewayCreate { + identity: IdentityMetadataCreateParams { + name: "collab-igw".parse().unwrap(), + description: "Collaborator IGW creation attempt".to_string(), + }, + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::POST, &restricted_igws_url) + .body(Some(&collab_igw_params)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Collaborator should not be able to create internet gateway"); + + // STEP 6: As Collaborator - Try to UPDATE firewall rules (should fail) + let collab_firewall_params = VpcFirewallRuleUpdateParams { + rules: vec![VpcFirewallRuleUpdate { + name: "allow-icmp".parse().unwrap(), + description: "Allow ICMP".to_string(), + action: VpcFirewallRuleAction::Allow, + direction: VpcFirewallRuleDirection::Inbound, + filters: VpcFirewallRuleFilter { + hosts: None, + ports: None, + protocols: Some(vec![VpcFirewallRuleProtocol::Icmp(None)]), + }, + priority: VpcFirewallRulePriority(100), + status: VpcFirewallRuleStatus::Enabled, + targets: vec![], + }], + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::PUT, &firewall_rules_url) + .body(Some(&collab_firewall_params)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Collaborator should not be able to update firewall rules"); + + // STEP 7: As Collaborator - Try to DELETE internet gateway (should fail) + let igw_delete_url = format!( + "/v1/internet-gateways/test-igw?project={}&vpc=default", + restricted_project_name + ); + + NexusRequest::new( + RequestBuilder::new(client, Method::DELETE, &igw_delete_url) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Collaborator should not be able to delete internet gateway"); + + // STEP 8: Promote back to Admin + grant_iam( + client, + &silo_url, + SiloRole::Admin, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + // STEP 9: As Admin - Delete internet gateway created in step 2 + NexusRequest::object_delete(&client, &igw_delete_url) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Admin should be able to delete internet gateway"); +} + +#[nexus_test] +async fn test_igw_ip_pool_address_attach_detach_restrictions( + cptestctx: &ControlPlaneTestContext, +) { + use nexus_test_utils::resource_helpers::{ + create_local_user, grant_iam, object_create, test_params, + }; + use nexus_types::external_api::shared::SiloRole; + use nexus_types::external_api::{params, shared}; + use omicron_common::address::IpRange; + use std::net::Ipv4Addr; + + let client = &cptestctx.external_client; + + // STEP 1: Setup - Create restricted silo and admin user + let restricted_silo_name = "igw-pool-addr-restricted-silo"; + let silo_url_base = "/v1/system/silos"; + let silo_params = params::SiloCreate { + identity: IdentityMetadataCreateParams { + name: restricted_silo_name.parse().unwrap(), + description: + "Silo with IGW IP pool/address networking restrictions" + .to_string(), + }, + discoverable: false, + identity_mode: + nexus_types::external_api::shared::SiloIdentityMode::LocalOnly, + admin_group_name: None, + tls_certificates: Vec::new(), + mapped_fleet_roles: Default::default(), + restrict_network_actions: Some(true), + quotas: params::SiloQuotasCreate::empty(), + }; + + let restricted_silo: views::Silo = + object_create(&client, silo_url_base, &silo_params).await; + + let test_user = create_local_user( + client, + &restricted_silo, + &"igw-pool-addr-test-user".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + + let silo_policy_url = + format!("/v1/system/silos/{}/policy", restricted_silo_name); + let silo_url = format!("/v1/system/silos/{}", restricted_silo_name); + + grant_iam( + client, + &silo_url, + SiloRole::Admin, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + let restricted_project_name = "igw-pool-addr-restricted-project"; + let project_params = params::ProjectCreate { + identity: IdentityMetadataCreateParams { + name: restricted_project_name.parse().unwrap(), + description: "Project in IGW pool/addr restricted silo".to_string(), + }, + }; + + let _restricted_project: views::Project = + NexusRequest::objects_post(&client, "/v1/projects", &project_params) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + // STEP 2: As Admin - Create internet gateway + let restricted_igws_url = format!( + "/v1/internet-gateways?project={}&vpc=default", + restricted_project_name + ); + + let test_igw_params = params::InternetGatewayCreate { + identity: IdentityMetadataCreateParams { + name: "test-igw-pools".parse().unwrap(), + description: "IGW for pool/address testing".to_string(), + }, + }; + + let created_igw: views::InternetGateway = NexusRequest::objects_post( + &client, + &restricted_igws_url, + &test_igw_params, + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + assert_eq!(created_igw.identity.name, "test-igw-pools"); + + // STEP 3: As Admin (privileged) - Create IP pool and link it to the silo + let pool_name = "test-pool-igw"; + let pool_params = params::IpPoolCreate::new( + IdentityMetadataCreateParams { + name: pool_name.parse().unwrap(), + description: String::from("IP pool for IGW testing"), + }, + views::IpVersion::v4(), + ); + let _pool: views::IpPool = + object_create(client, "/v1/system/ip-pools", &pool_params).await; + + // Add IP range to the pool + let ip_range = IpRange::try_from(( + Ipv4Addr::new(198, 51, 100, 1), + Ipv4Addr::new(198, 51, 100, 254), + )) + .unwrap(); + let url = format!("/v1/system/ip-pools/{}/ranges/add", pool_name); + let _range: views::IpPoolRange = + object_create(client, &url, &ip_range).await; + + // Link pool to silo + let link = params::IpPoolLinkSilo { + silo: NameOrId::Id(restricted_silo.identity.id), + is_default: true, + }; + let url = format!("/v1/system/ip-pools/{}/silos", pool_name); + let _link: views::IpPoolSiloLink = object_create(client, &url, &link).await; + + // STEP 4: As Admin - Attach IP pool to internet gateway + let attach_pool_url = format!( + "/v1/internet-gateway-ip-pools?project={}&vpc=default&gateway=test-igw-pools", + restricted_project_name + ); + + let attach_pool_params = params::InternetGatewayIpPoolCreate { + identity: IdentityMetadataCreateParams { + name: "pool-attachment-1".parse().unwrap(), + description: "Initial pool attachment".to_string(), + }, + ip_pool: NameOrId::Name(pool_name.parse().unwrap()), + }; + + let attached_pool: views::InternetGatewayIpPool = + NexusRequest::objects_post( + &client, + &attach_pool_url, + &attach_pool_params, + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + assert_eq!(attached_pool.identity.name, "pool-attachment-1"); + + // STEP 5: As Admin - Attach IP address to internet gateway + let attach_address_url = format!( + "/v1/internet-gateway-ip-addresses?project={}&vpc=default&gateway=test-igw-pools", + restricted_project_name + ); + + let test_ip = Ipv4Addr::new(198, 51, 100, 42); + let attach_address_params = params::InternetGatewayIpAddressCreate { + identity: IdentityMetadataCreateParams { + name: "address-attachment-1".parse().unwrap(), + description: "Initial address attachment".to_string(), + }, + address: test_ip.into(), + }; + + let attached_address: views::InternetGatewayIpAddress = + NexusRequest::objects_post( + &client, + &attach_address_url, + &attach_address_params, + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + assert_eq!(attached_address.identity.name, "address-attachment-1"); + + // STEP 6: Demote to Collaborator + let silo_policy: shared::Policy = + NexusRequest::object_get(client, &silo_policy_url) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to fetch silo policy") + .parsed_body() + .expect("failed to parse silo policy"); + + let test_user_uuid = test_user.id.into_untyped_uuid(); + + let mut new_assignments: Vec<_> = silo_policy + .role_assignments + .into_iter() + .filter(|ra| { + !matches!( + ra, + shared::RoleAssignment { + identity_type: shared::IdentityType::SiloUser, + identity_id, + role_name, + } if *identity_id == test_user_uuid && *role_name == SiloRole::Admin + ) + }) + .collect(); + + new_assignments.push(shared::RoleAssignment::for_silo_user( + test_user.id, + SiloRole::Collaborator, + )); + + let collaborator_policy = + shared::Policy { role_assignments: new_assignments }; + + NexusRequest::object_put( + client, + &silo_policy_url, + Some(&collaborator_policy), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to update silo policy"); + + // STEP 7: As Collaborator - Try to ATTACH IP pool (should fail) + let collab_pool_params = params::InternetGatewayIpPoolCreate { + identity: IdentityMetadataCreateParams { + name: "collab-pool-attachment".parse().unwrap(), + description: "Collaborator pool attachment attempt".to_string(), + }, + ip_pool: NameOrId::Name(pool_name.parse().unwrap()), + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::POST, &attach_pool_url) + .body(Some(&collab_pool_params)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Collaborator should not be able to attach IP pool"); + + // STEP 8: As Collaborator - Try to DETACH IP pool (should fail) + let detach_pool_url = format!( + "/v1/internet-gateway-ip-pools/pool-attachment-1?project={}&vpc=default&gateway=test-igw-pools&cascade=false", + restricted_project_name + ); + + NexusRequest::new( + RequestBuilder::new(client, Method::DELETE, &detach_pool_url) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Collaborator should not be able to detach IP pool"); + + // STEP 9: As Collaborator - Try to ATTACH IP address (should fail) + let another_test_ip = Ipv4Addr::new(198, 51, 100, 99); + let collab_address_params = params::InternetGatewayIpAddressCreate { + identity: IdentityMetadataCreateParams { + name: "collab-address-attachment".parse().unwrap(), + description: "Collaborator address attachment attempt".to_string(), + }, + address: another_test_ip.into(), + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::POST, &attach_address_url) + .body(Some(&collab_address_params)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Collaborator should not be able to attach IP address"); + + // STEP 10: As Collaborator - Try to DETACH IP address (should fail) + let detach_address_url = format!( + "/v1/internet-gateway-ip-addresses/address-attachment-1?project={}&vpc=default&gateway=test-igw-pools&cascade=false", + restricted_project_name + ); + + NexusRequest::new( + RequestBuilder::new(client, Method::DELETE, &detach_address_url) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Collaborator should not be able to detach IP address"); + + // STEP 11: Promote back to Admin + grant_iam( + client, + &silo_url, + SiloRole::Admin, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + // STEP 12: As Admin - Detach IP address + NexusRequest::object_delete(&client, &detach_address_url) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Admin should be able to detach IP address"); + + // STEP 13: As Admin - Detach IP pool + NexusRequest::object_delete(&client, &detach_pool_url) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Admin should be able to detach IP pool"); + + // STEP 14: As Admin - Delete internet gateway (cleanup) + let igw_delete_url = format!( + "/v1/internet-gateways/test-igw-pools?project={}&vpc=default", + restricted_project_name + ); + + NexusRequest::object_delete(&client, &igw_delete_url) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Admin should be able to delete internet gateway"); +} + +/// Test that project creation respects networking restrictions: +/// - Silo admins can create projects with default VPCs +/// - Non-admins in restricted silos create projects WITHOUT default VPCs +#[nexus_test] +async fn test_project_create_networking_restrictions( + cptestctx: &ControlPlaneTestContext, +) { + use nexus_types::external_api::params; + + let client = &cptestctx.external_client; + + // STEP 1: Setup - Create restricted silo and admin user + let restricted_silo_name = "restricted-silo"; + let silo_url_path = "/v1/system/silos"; + let silo_params = params::SiloCreate { + identity: IdentityMetadataCreateParams { + name: restricted_silo_name.parse().unwrap(), + description: "Silo with networking restrictions".to_string(), + }, + discoverable: false, + identity_mode: + nexus_types::external_api::shared::SiloIdentityMode::LocalOnly, + admin_group_name: None, + tls_certificates: Vec::new(), + mapped_fleet_roles: Default::default(), + restrict_network_actions: Some(true), + quotas: params::SiloQuotasCreate::empty(), + }; + + let restricted_silo: views::Silo = + object_create(&client, silo_url_path, &silo_params).await; + + let test_user = create_local_user( + client, + &restricted_silo, + &"test-user".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + + let silo_policy_url = + format!("/v1/system/silos/{}/policy", restricted_silo_name); + let silo_url = format!("/v1/system/silos/{}", restricted_silo_name); + + // Grant Silo Admin role + grant_iam( + client, + &silo_url, + shared::SiloRole::Admin, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + // STEP 2: As Admin - Create project and verify it has default VPC + let admin_project_name = "admin-project"; + let admin_project_params = params::ProjectCreate { + identity: IdentityMetadataCreateParams { + name: admin_project_name.parse().unwrap(), + description: "Project created by admin".to_string(), + }, + }; + + let _admin_project: views::Project = NexusRequest::objects_post( + &client, + "/v1/projects", + &admin_project_params, + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + // Verify default VPC was created + let admin_vpcs_url = format!("/v1/vpcs?project={}", admin_project_name); + let admin_vpcs_result = NexusRequest::object_get(&client, &admin_vpcs_url) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + let admin_vpcs: ResultsPage = admin_vpcs_result; + + assert_eq!( + admin_vpcs.items.len(), + 1, + "Admin project should have default VPC" + ); + assert_eq!(admin_vpcs.items[0].identity.name, "default"); + + // STEP 3: Demote to Collaborator + let silo_policy: shared::Policy = + NexusRequest::object_get(client, &silo_policy_url) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to fetch silo policy") + .parsed_body() + .expect("failed to parse silo policy"); + + let test_user_uuid = test_user.id.into_untyped_uuid(); + let updated_role_assignments = silo_policy + .role_assignments + .into_iter() + .map(|assignment| { + if assignment.identity_id == test_user_uuid { + shared::RoleAssignment { + identity_type: assignment.identity_type, + identity_id: assignment.identity_id, + role_name: shared::SiloRole::Collaborator, + } + } else { + assignment + } + }) + .collect(); + + let updated_policy = + shared::Policy { role_assignments: updated_role_assignments }; + + NexusRequest::object_put(client, &silo_policy_url, Some(&updated_policy)) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("Failed to update silo policy"); + + // STEP 4: As Collaborator - Create project and verify NO default VPC + let collab_project_name = "collab-project"; + let collab_project_params = params::ProjectCreate { + identity: IdentityMetadataCreateParams { + name: collab_project_name.parse().unwrap(), + description: "Project created by collaborator".to_string(), + }, + }; + + let _collab_project: views::Project = NexusRequest::objects_post( + &client, + "/v1/projects", + &collab_project_params, + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + // Verify NO default VPC was created + let collab_vpcs_url = format!("/v1/vpcs?project={}", collab_project_name); + let collab_vpcs_result = + NexusRequest::object_get(&client, &collab_vpcs_url) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + let collab_vpcs: ResultsPage = collab_vpcs_result; + + assert_eq!( + collab_vpcs.items.len(), + 0, + "Collaborator project should NOT have default VPC" + ); + + // STEP 5: Promote back to Admin + grant_iam( + client, + &silo_url, + shared::SiloRole::Admin, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + // STEP 6: As Admin - Create another project and verify it has default VPC + let admin_project_name_2 = "admin-project-2"; + let admin_project_params_2 = params::ProjectCreate { + identity: IdentityMetadataCreateParams { + name: admin_project_name_2.parse().unwrap(), + description: "Second project created by admin".to_string(), + }, + }; + + let _admin_project_2: views::Project = NexusRequest::objects_post( + &client, + "/v1/projects", + &admin_project_params_2, + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + // Verify default VPC was created + let admin_vpcs_url_2 = format!("/v1/vpcs?project={}", admin_project_name_2); + let admin_vpcs_2_result = + NexusRequest::object_get(&client, &admin_vpcs_url_2) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + let admin_vpcs_2: ResultsPage = admin_vpcs_2_result; + + assert_eq!( + admin_vpcs_2.items.len(), + 1, + "Admin project should have default VPC" + ); + assert_eq!(admin_vpcs_2.items[0].identity.name, "default"); +} + +// Helper struct to track permission test results for table display +#[derive(Debug)] +struct PermissionTest { + role: &'static str, + create: bool, + read: bool, + modify: bool, + delete: bool, +} + +impl PermissionTest { + fn new(role: &'static str) -> Self { + Self { role, create: false, read: false, modify: false, delete: false } + } + + fn print_table(tests: &[PermissionTest]) { + println!( + "\n┌─────────────────────────┬────────┬──────┬────────┬────────┐" + ); + println!( + "│ Role │ CREATE │ READ │ MODIFY │ DELETE │" + ); + println!( + "├─────────────────────────┼────────┼──────┼────────┼────────┤" + ); + for test in tests { + println!( + "│ {:<23} │ {} │ {} │ {} │ {} │", + test.role, + if test.create { "✓" } else { "✗" }, + if test.read { "✓" } else { "✗" }, + if test.modify { "✓" } else { "✗" }, + if test.delete { "✓" } else { "✗" }, + ); + } + println!( + "└─────────────────────────┴────────┴──────┴────────┴────────┘" + ); + } +} + +/// Test VPC networking permissions when restrict_network_actions = FALSE +/// +/// In unrestricted silos: +/// - Silo Admins can: create, read, modify, delete VPCs +/// - Silo Collaborators can: create, read, modify, delete VPCs +/// - Project Collaborators can: create, read, modify, delete VPCs in their project +/// - Project Viewers can: read VPCs (but not create/modify/delete) +#[nexus_test] +async fn test_vpc_networking_permissions_unrestricted( + cptestctx: &ControlPlaneTestContext, +) { + use nexus_types::external_api::params; + + let client = &cptestctx.external_client; + + // Track results for table display + let mut results = vec![ + PermissionTest::new("Silo Admin"), + PermissionTest::new("Silo Collaborator"), + PermissionTest::new("Project Collaborator"), + PermissionTest::new("Project Viewer"), + ]; + + // ======================================================================== + // SETUP: Create unrestricted silo with users at different privilege levels + // ======================================================================== + + let silo_name = "unrestricted-silo"; + let silo_url = "/v1/system/silos"; + let silo_params = params::SiloCreate { + identity: IdentityMetadataCreateParams { + name: silo_name.parse().unwrap(), + description: "Silo without networking restrictions".to_string(), + }, + discoverable: false, + identity_mode: shared::SiloIdentityMode::LocalOnly, + admin_group_name: None, + tls_certificates: Vec::new(), + mapped_fleet_roles: Default::default(), + restrict_network_actions: Some(false), // NO RESTRICTIONS + quotas: params::SiloQuotasCreate::empty(), + }; + + let silo: views::Silo = + object_create(&client, silo_url, &silo_params).await; + + // Create users with different roles + let silo_admin = create_local_user( + client, + &silo, + &"silo-admin".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + + let silo_collaborator = create_local_user( + client, + &silo, + &"silo-collaborator".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + + let project_collaborator = create_local_user( + client, + &silo, + &"project-collaborator".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + + let project_viewer = create_local_user( + client, + &silo, + &"project-viewer".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + + // Grant silo-level roles + let _silo_policy_url = format!("/v1/system/silos/{}/policy", silo_name); + let silo_resource_url = format!("/v1/system/silos/{}", silo_name); + + grant_iam( + client, + &silo_resource_url, + shared::SiloRole::Admin, + silo_admin.id, + AuthnMode::PrivilegedUser, + ) + .await; + + grant_iam( + client, + &silo_resource_url, + shared::SiloRole::Collaborator, + silo_collaborator.id, + AuthnMode::PrivilegedUser, + ) + .await; + + // Create a project + let project_name = "test-project"; + let project_params = params::ProjectCreate { + identity: IdentityMetadataCreateParams { + name: project_name.parse().unwrap(), + description: "Test project".to_string(), + }, + }; + + let _project: views::Project = + NexusRequest::objects_post(&client, "/v1/projects", &project_params) + .authn_as(AuthnMode::SiloUser(silo_admin.id)) + .execute_and_parse_unwrap() + .await; + + // Grant project-level roles + let project_url = format!("/v1/projects/{}", project_name); + + grant_iam( + client, + &project_url, + shared::ProjectRole::Collaborator, + project_collaborator.id, + AuthnMode::SiloUser(silo_admin.id), + ) + .await; + + grant_iam( + client, + &project_url, + shared::ProjectRole::Viewer, + project_viewer.id, + AuthnMode::SiloUser(silo_admin.id), + ) + .await; + + let vpcs_url = format!("/v1/vpcs?project={}", project_name); + + // ======================================================================== + // TEST: Silo Admin can CREATE VPCs + // ======================================================================== + + let vpc_params = params::VpcCreate { + identity: IdentityMetadataCreateParams { + name: "admin-vpc".parse().unwrap(), + description: "VPC created by silo admin".to_string(), + }, + ipv6_prefix: None, + dns_name: "admin".parse().unwrap(), + }; + + let _admin_vpc: Vpc = + NexusRequest::objects_post(&client, &vpcs_url, &vpc_params) + .authn_as(AuthnMode::SiloUser(silo_admin.id)) + .execute_and_parse_unwrap() + .await; + results[0].create = true; + + // ======================================================================== + // TEST: Silo Admin can MODIFY VPCs + // ======================================================================== + + let admin_vpc_url = format!("/v1/vpcs/admin-vpc?project={}", project_name); + let vpc_update = params::VpcUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some("Modified by admin".to_string()), + }, + dns_name: None, + }; + + let _: Vpc = + NexusRequest::object_put(&client, &admin_vpc_url, Some(&vpc_update)) + .authn_as(AuthnMode::SiloUser(silo_admin.id)) + .execute_and_parse_unwrap() + .await; + results[0].modify = true; + + // ======================================================================== + // TEST: Silo Collaborator can CREATE VPCs + // ======================================================================== + + let collab_vpc_params = params::VpcCreate { + identity: IdentityMetadataCreateParams { + name: "collab-vpc".parse().unwrap(), + description: "VPC created by silo collaborator".to_string(), + }, + ipv6_prefix: None, + dns_name: "collab".parse().unwrap(), + }; + + let _: Vpc = + NexusRequest::objects_post(&client, &vpcs_url, &collab_vpc_params) + .authn_as(AuthnMode::SiloUser(silo_collaborator.id)) + .execute_and_parse_unwrap() + .await; + results[1].create = true; + + // ======================================================================== + // TEST: Silo Collaborator can MODIFY VPCs + // ======================================================================== + + let collab_vpc_url = + format!("/v1/vpcs/collab-vpc?project={}", project_name); + let vpc_update = params::VpcUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some("Modified by collaborator".to_string()), + }, + dns_name: None, + }; + + let _: Vpc = + NexusRequest::object_put(&client, &collab_vpc_url, Some(&vpc_update)) + .authn_as(AuthnMode::SiloUser(silo_collaborator.id)) + .execute_and_parse_unwrap() + .await; + results[1].modify = true; + + // ======================================================================== + // TEST: Project Collaborator can CREATE VPCs + // ======================================================================== + + let proj_collab_vpc_params = params::VpcCreate { + identity: IdentityMetadataCreateParams { + name: "proj-collab-vpc".parse().unwrap(), + description: "VPC created by project collaborator".to_string(), + }, + ipv6_prefix: None, + dns_name: "proj-collab".parse().unwrap(), + }; + + let _: Vpc = + NexusRequest::objects_post(&client, &vpcs_url, &proj_collab_vpc_params) + .authn_as(AuthnMode::SiloUser(project_collaborator.id)) + .execute_and_parse_unwrap() + .await; + results[2].create = true; + + // ======================================================================== + // TEST: Project Collaborator can MODIFY VPCs + // ======================================================================== + + let proj_collab_vpc_url = + format!("/v1/vpcs/proj-collab-vpc?project={}", project_name); + let vpc_update = params::VpcUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some("Modified by project collaborator".to_string()), + }, + dns_name: None, + }; + + let _: Vpc = NexusRequest::object_put( + &client, + &proj_collab_vpc_url, + Some(&vpc_update), + ) + .authn_as(AuthnMode::SiloUser(project_collaborator.id)) + .execute_and_parse_unwrap() + .await; + results[2].modify = true; + + // ======================================================================== + // TEST: Project Viewer can READ VPCs + // ======================================================================== + + let _: Vpc = NexusRequest::object_get(&client, &admin_vpc_url) + .authn_as(AuthnMode::SiloUser(project_viewer.id)) + .execute_and_parse_unwrap() + .await; + results[3].read = true; + + // ======================================================================== + // TEST: Project Viewer CANNOT CREATE VPCs + // ======================================================================== + + let viewer_vpc_params = params::VpcCreate { + identity: IdentityMetadataCreateParams { + name: "viewer-vpc".parse().unwrap(), + description: "Should fail".to_string(), + }, + ipv6_prefix: None, + dns_name: "viewer".parse().unwrap(), + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::POST, &vpcs_url) + .body(Some(&viewer_vpc_params)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(project_viewer.id)) + .execute() + .await + .expect("Project Viewer should NOT be able to CREATE VPCs"); + // results[3].create stays false + + // ======================================================================== + // TEST: Project Viewer CANNOT MODIFY VPCs + // ======================================================================== + + let vpc_update = params::VpcUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some("Should fail".to_string()), + }, + dns_name: None, + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::PUT, &admin_vpc_url) + .body(Some(&vpc_update)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(project_viewer.id)) + .execute() + .await + .expect("Project Viewer should NOT be able to MODIFY VPCs"); + // results[3].modify stays false + + // ======================================================================== + // TEST: Project Viewer CANNOT DELETE VPCs + // ======================================================================== + + NexusRequest::new( + RequestBuilder::new(client, Method::DELETE, &admin_vpc_url) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(project_viewer.id)) + .execute() + .await + .expect("Project Viewer should NOT be able to DELETE VPCs"); + // results[3].delete stays false + + // ======================================================================== + // TEST: All privileged roles can READ and DELETE + // ======================================================================== + + // Silo Admin READ + let _: Vpc = NexusRequest::object_get(&client, &admin_vpc_url) + .authn_as(AuthnMode::SiloUser(silo_admin.id)) + .execute_and_parse_unwrap() + .await; + results[0].read = true; + + // Silo Collaborator READ + let _: Vpc = NexusRequest::object_get(&client, &collab_vpc_url) + .authn_as(AuthnMode::SiloUser(silo_collaborator.id)) + .execute_and_parse_unwrap() + .await; + results[1].read = true; + + // Project Collaborator READ + let _: Vpc = NexusRequest::object_get(&client, &proj_collab_vpc_url) + .authn_as(AuthnMode::SiloUser(project_collaborator.id)) + .execute_and_parse_unwrap() + .await; + results[2].read = true; + + // ======================================================================== + // CLEANUP (which also tests DELETE permissions) + // ======================================================================== + + // Delete all VPCs (must delete subnets first) + for (vpc_name, user_id, result_idx) in [ + ("admin-vpc", silo_admin.id, 0), + ("collab-vpc", silo_collaborator.id, 1), + ("proj-collab-vpc", project_collaborator.id, 2), + ] { + let subnet_url = format!( + "/v1/vpc-subnets/default?project={}&vpc={}", + project_name, vpc_name + ); + NexusRequest::object_delete(&client, &subnet_url) + .authn_as(AuthnMode::SiloUser(user_id)) + .execute() + .await + .unwrap(); + + let vpc_url = format!("/v1/vpcs/{}?project={}", vpc_name, project_name); + NexusRequest::object_delete(&client, &vpc_url) + .authn_as(AuthnMode::SiloUser(user_id)) + .execute() + .await + .unwrap(); + + results[result_idx].delete = true; + } + + // ======================================================================== + // DISPLAY RESULTS + // ======================================================================== + + println!("\n=== Unrestricted Silo VPC Permissions ==="); + PermissionTest::print_table(&results); + + // Verify expected results + assert!( + results[0].create + && results[0].read + && results[0].modify + && results[0].delete, + "Silo Admin should have all permissions" + ); + assert!( + results[1].create + && results[1].read + && results[1].modify + && results[1].delete, + "Silo Collaborator should have all permissions" + ); + assert!( + results[2].create + && results[2].read + && results[2].modify + && results[2].delete, + "Project Collaborator should have all permissions" + ); + assert!( + !results[3].create + && results[3].read + && !results[3].modify + && !results[3].delete, + "Project Viewer should only have read permission" + ); + + println!("\n✅ All unrestricted silo tests passed!"); +} + +/// Test VPC networking permissions when restrict_network_actions = TRUE +/// +/// In restricted silos: +/// - Silo Admins can: create, read, modify, delete VPCs (unrestricted) +/// - Silo Collaborators CANNOT: create, modify, delete VPCs (read-only) +/// - Project Collaborators CANNOT: create, modify, delete VPCs (read-only) +/// - Project Viewers can: read VPCs (but not create/modify/delete) +#[nexus_test] +async fn test_vpc_networking_permissions_restricted( + cptestctx: &ControlPlaneTestContext, +) { + use nexus_types::external_api::params; + + let client = &cptestctx.external_client; + + // Track results for table display + let mut results = vec![ + PermissionTest::new("Silo Admin"), + PermissionTest::new("Silo Collaborator"), + PermissionTest::new("Project Collaborator"), + PermissionTest::new("Project Viewer"), + ]; + + // ======================================================================== + // SETUP: Create restricted silo with users at different privilege levels + // ======================================================================== + + let silo_name = "restricted-silo-test"; + let silo_url = "/v1/system/silos"; + let silo_params = params::SiloCreate { + identity: IdentityMetadataCreateParams { + name: silo_name.parse().unwrap(), + description: "Silo WITH networking restrictions".to_string(), + }, + discoverable: false, + identity_mode: shared::SiloIdentityMode::LocalOnly, + admin_group_name: None, + tls_certificates: Vec::new(), + mapped_fleet_roles: Default::default(), + restrict_network_actions: Some(true), // RESTRICTIONS ENABLED + quotas: params::SiloQuotasCreate::empty(), + }; + + let silo: views::Silo = + object_create(&client, silo_url, &silo_params).await; + + // Create users with different roles + let silo_admin = create_local_user( + client, + &silo, + &"silo-admin".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + + let silo_collaborator = create_local_user( + client, + &silo, + &"silo-collaborator".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + + let project_collaborator = create_local_user( + client, + &silo, + &"project-collaborator".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + + let project_viewer = create_local_user( + client, + &silo, + &"project-viewer".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + + // Grant silo-level roles + let _silo_policy_url = format!("/v1/system/silos/{}/policy", silo_name); + let silo_resource_url = format!("/v1/system/silos/{}", silo_name); + + grant_iam( + client, + &silo_resource_url, + shared::SiloRole::Admin, + silo_admin.id, + AuthnMode::PrivilegedUser, + ) + .await; + + grant_iam( + client, + &silo_resource_url, + shared::SiloRole::Collaborator, + silo_collaborator.id, + AuthnMode::PrivilegedUser, + ) + .await; + + // Create a project + let project_name = "restricted-test-project"; + let project_params = params::ProjectCreate { + identity: IdentityMetadataCreateParams { + name: project_name.parse().unwrap(), + description: "Test project in restricted silo".to_string(), + }, + }; + + let _project: views::Project = + NexusRequest::objects_post(&client, "/v1/projects", &project_params) + .authn_as(AuthnMode::SiloUser(silo_admin.id)) + .execute_and_parse_unwrap() + .await; + + // Grant project-level roles + let project_url = format!("/v1/projects/{}", project_name); + + grant_iam( + client, + &project_url, + shared::ProjectRole::Collaborator, + project_collaborator.id, + AuthnMode::SiloUser(silo_admin.id), + ) + .await; + + grant_iam( + client, + &project_url, + shared::ProjectRole::Viewer, + project_viewer.id, + AuthnMode::SiloUser(silo_admin.id), + ) + .await; + + let vpcs_url = format!("/v1/vpcs?project={}", project_name); + + // ======================================================================== + // TEST: Silo Admin CAN CREATE VPCs (even with restrictions) + // ======================================================================== + + let vpc_params = params::VpcCreate { + identity: IdentityMetadataCreateParams { + name: "admin-vpc".parse().unwrap(), + description: "VPC created by silo admin".to_string(), + }, + ipv6_prefix: None, + dns_name: "admin".parse().unwrap(), + }; + + let _admin_vpc: Vpc = + NexusRequest::objects_post(&client, &vpcs_url, &vpc_params) + .authn_as(AuthnMode::SiloUser(silo_admin.id)) + .execute_and_parse_unwrap() + .await; + results[0].create = true; + + // ======================================================================== + // TEST: Silo Admin CAN MODIFY VPCs (even with restrictions) + // ======================================================================== + + let admin_vpc_url = format!("/v1/vpcs/admin-vpc?project={}", project_name); + let vpc_update = params::VpcUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some("Modified by admin".to_string()), + }, + dns_name: None, + }; + + let _: Vpc = + NexusRequest::object_put(&client, &admin_vpc_url, Some(&vpc_update)) + .authn_as(AuthnMode::SiloUser(silo_admin.id)) + .execute_and_parse_unwrap() + .await; + results[0].modify = true; + + // ======================================================================== + // TEST: Silo Collaborator CANNOT CREATE VPCs (restricted) + // ======================================================================== + + let collab_vpc_params = params::VpcCreate { + identity: IdentityMetadataCreateParams { + name: "collab-vpc".parse().unwrap(), + description: "Should fail".to_string(), + }, + ipv6_prefix: None, + dns_name: "collab".parse().unwrap(), + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::POST, &vpcs_url) + .body(Some(&collab_vpc_params)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(silo_collaborator.id)) + .execute() + .await + .expect("Silo Collaborator should NOT be able to CREATE VPCs in restricted silo"); + + println!("✓ Silo Collaborator CANNOT CREATE VPCs (restricted by policy)"); + + // ======================================================================== + // TEST: Silo Collaborator CAN READ VPCs (read is allowed) + // ======================================================================== + + let _: Vpc = NexusRequest::object_get(&client, &admin_vpc_url) + .authn_as(AuthnMode::SiloUser(silo_collaborator.id)) + .execute_and_parse_unwrap() + .await; + results[1].read = true; + + // ======================================================================== + // TEST: Silo Collaborator CANNOT MODIFY VPCs (restricted) + // ======================================================================== + + let vpc_update = params::VpcUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some("Should fail".to_string()), + }, + dns_name: None, + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::PUT, &admin_vpc_url) + .body(Some(&vpc_update)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(silo_collaborator.id)) + .execute() + .await + .expect("Silo Collaborator should NOT be able to MODIFY VPCs in restricted silo"); + + println!("✓ Silo Collaborator CANNOT MODIFY VPCs (restricted by policy)"); + + // ======================================================================== + // TEST: Silo Collaborator CANNOT DELETE VPCs (restricted) + // ======================================================================== + + NexusRequest::new( + RequestBuilder::new(client, Method::DELETE, &admin_vpc_url) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(silo_collaborator.id)) + .execute() + .await + .expect("Silo Collaborator should NOT be able to DELETE VPCs in restricted silo"); + + println!("✓ Silo Collaborator CANNOT DELETE VPCs (restricted by policy)"); + + // ======================================================================== + // TEST: Project Collaborator CANNOT CREATE VPCs (restricted) + // ======================================================================== + + let proj_collab_vpc_params = params::VpcCreate { + identity: IdentityMetadataCreateParams { + name: "proj-collab-vpc".parse().unwrap(), + description: "Should fail".to_string(), + }, + ipv6_prefix: None, + dns_name: "proj-collab".parse().unwrap(), + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::POST, &vpcs_url) + .body(Some(&proj_collab_vpc_params)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(project_collaborator.id)) + .execute() + .await + .expect("Project Collaborator should NOT be able to CREATE VPCs in restricted silo"); + + println!( + "✓ Project Collaborator CANNOT CREATE VPCs (restricted by policy)" + ); + + // ======================================================================== + // TEST: Project Collaborator CAN READ VPCs (read is allowed) + // ======================================================================== + + let _: Vpc = NexusRequest::object_get(&client, &admin_vpc_url) + .authn_as(AuthnMode::SiloUser(project_collaborator.id)) + .execute_and_parse_unwrap() + .await; + results[2].read = true; + + // ======================================================================== + // TEST: Project Collaborator CANNOT MODIFY VPCs (restricted) + // ======================================================================== + + let vpc_update = params::VpcUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some("Should fail".to_string()), + }, + dns_name: None, + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::PUT, &admin_vpc_url) + .body(Some(&vpc_update)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(project_collaborator.id)) + .execute() + .await + .expect("Project Collaborator should NOT be able to MODIFY VPCs in restricted silo"); + + println!( + "✓ Project Collaborator CANNOT MODIFY VPCs (restricted by policy)" + ); + + // ======================================================================== + // TEST: Project Collaborator CANNOT DELETE VPCs (restricted) + // ======================================================================== + + NexusRequest::new( + RequestBuilder::new(client, Method::DELETE, &admin_vpc_url) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(project_collaborator.id)) + .execute() + .await + .expect("Project Collaborator should NOT be able to DELETE VPCs in restricted silo"); + + println!( + "✓ Project Collaborator CANNOT DELETE VPCs (restricted by policy)" + ); + + // ======================================================================== + // TEST: Project Viewer CAN READ VPCs + // ======================================================================== + + let _: Vpc = NexusRequest::object_get(&client, &admin_vpc_url) + .authn_as(AuthnMode::SiloUser(project_viewer.id)) + .execute_and_parse_unwrap() + .await; + results[3].read = true; + + // ======================================================================== + // TEST: Project Viewer CANNOT CREATE VPCs + // ======================================================================== + + let viewer_vpc_params = params::VpcCreate { + identity: IdentityMetadataCreateParams { + name: "viewer-vpc".parse().unwrap(), + description: "Should fail".to_string(), + }, + ipv6_prefix: None, + dns_name: "viewer".parse().unwrap(), + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::POST, &vpcs_url) + .body(Some(&viewer_vpc_params)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(project_viewer.id)) + .execute() + .await + .expect("Project Viewer should NOT be able to CREATE VPCs"); + + println!("✓ Project Viewer CANNOT CREATE VPCs"); + + // ======================================================================== + // TEST: Project Viewer CANNOT MODIFY VPCs + // ======================================================================== + + let vpc_update = params::VpcUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some("Should fail".to_string()), + }, + dns_name: None, + }; + + NexusRequest::new( + RequestBuilder::new(client, Method::PUT, &admin_vpc_url) + .body(Some(&vpc_update)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(project_viewer.id)) + .execute() + .await + .expect("Project Viewer should NOT be able to MODIFY VPCs"); + + println!("✓ Project Viewer CANNOT MODIFY VPCs"); + + // ======================================================================== + // TEST: Project Viewer CANNOT DELETE VPCs + // ======================================================================== + + NexusRequest::new( + RequestBuilder::new(client, Method::DELETE, &admin_vpc_url) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(project_viewer.id)) + .execute() + .await + .expect("Project Viewer should NOT be able to DELETE VPCs"); + + println!("✓ Project Viewer CANNOT DELETE VPCs"); + + // ======================================================================== + // TEST: Silo Admin can READ and DELETE (unrestricted) + // ======================================================================== + + // Silo Admin READ + let _: Vpc = NexusRequest::object_get(&client, &admin_vpc_url) + .authn_as(AuthnMode::SiloUser(silo_admin.id)) + .execute_and_parse_unwrap() + .await; + results[0].read = true; + + // ======================================================================== + // CLEANUP (which also tests Silo Admin DELETE permission) + // ======================================================================== + + // Delete VPC (must delete subnet first) + let subnet_url = format!( + "/v1/vpc-subnets/default?project={}&vpc=admin-vpc", + project_name + ); + NexusRequest::object_delete(&client, &subnet_url) + .authn_as(AuthnMode::SiloUser(silo_admin.id)) + .execute() + .await + .unwrap(); + + NexusRequest::object_delete(&client, &admin_vpc_url) + .authn_as(AuthnMode::SiloUser(silo_admin.id)) + .execute() + .await + .unwrap(); + results[0].delete = true; + + // ======================================================================== + // DISPLAY RESULTS + // ======================================================================== + + println!("\n=== Restricted Silo VPC Permissions ==="); + PermissionTest::print_table(&results); + + // Verify expected results + assert!( + results[0].create + && results[0].read + && results[0].modify + && results[0].delete, + "Silo Admin should have all permissions (unrestricted by policy)" + ); + assert!( + !results[1].create + && results[1].read + && !results[1].modify + && !results[1].delete, + "Silo Collaborator should only have read permission (restricted by policy)" + ); + assert!( + !results[2].create + && results[2].read + && !results[2].modify + && !results[2].delete, + "Project Collaborator should only have read permission (restricted by policy)" + ); + assert!( + !results[3].create + && results[3].read + && !results[3].modify + && !results[3].delete, + "Project Viewer should only have read permission" + ); + + println!("\n✅ All restricted silo tests passed!"); +} diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index a285d863d05..93e3f64027b 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -448,6 +448,11 @@ pub struct SiloCreate { #[serde(default)] pub mapped_fleet_roles: BTreeMap>, + + /// When set to true, restricts networking actions (VPC, subnet, etc.) to Silo Admins only. + /// When false or unset (default), Project Collaborators can perform networking actions. + #[serde(default)] + pub restrict_network_actions: Option, } /// The amount of provisionable resources for a Silo diff --git a/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index fb1c5ac1402..511ec22332b 100644 --- a/nexus/types/src/external_api/views.rs +++ b/nexus/types/src/external_api/views.rs @@ -63,6 +63,10 @@ pub struct Silo { /// Optionally, silos can have a group name that is automatically granted /// the silo admin role. pub admin_group_name: Option, + + /// When true, restricts networking actions (VPC, subnet, etc.) to Silo Admins only. + /// When false (default), Project Collaborators can perform networking actions. + pub restrict_network_actions: bool, } /// A collection of resource counts used to describe capacity and utilization diff --git a/openapi/nexus.json b/openapi/nexus.json index 1428001ec44..165afe39d8e 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -23945,6 +23945,10 @@ } ] }, + "restrict_network_actions": { + "description": "When true, restricts networking actions (VPC, subnet, etc.) to Silo Admins only. When false (default), Project Collaborators can perform networking actions.", + "type": "boolean" + }, "time_created": { "description": "timestamp when this resource was created", "type": "string", @@ -23963,6 +23967,7 @@ "identity_mode", "mapped_fleet_roles", "name", + "restrict_network_actions", "time_created", "time_modified" ] @@ -24044,6 +24049,12 @@ } ] }, + "restrict_network_actions": { + "nullable": true, + "description": "When set to true, restricts networking actions (VPC, subnet, etc.) to Silo Admins only. When false or unset (default), Project Collaborators can perform networking actions.", + "default": null, + "type": "boolean" + }, "tls_certificates": { "description": "Initial TLS certificates to be used for the new Silo's console and API endpoints. These should be valid for the Silo's DNS name(s).", "type": "array", diff --git a/schema/crdb/dbinit.sql b/schema/crdb/dbinit.sql index ca22be8fd9d..8cf274fc956 100644 --- a/schema/crdb/dbinit.sql +++ b/schema/crdb/dbinit.sql @@ -878,7 +878,9 @@ CREATE TABLE IF NOT EXISTS omicron.public.silo ( /* child resource generation number, per RFD 192 */ rcgen INT NOT NULL, - admin_group_name TEXT + admin_group_name TEXT, + + restrict_network_actions BOOL NOT NULL DEFAULT FALSE ); CREATE UNIQUE INDEX IF NOT EXISTS lookup_silo_by_name ON omicron.public.silo ( @@ -6854,7 +6856,7 @@ INSERT INTO omicron.public.db_metadata ( version, target_version ) VALUES - (TRUE, NOW(), NOW(), '201.0.0', NULL) + (TRUE, NOW(), NOW(), '202.0.0', NULL) ON CONFLICT DO NOTHING; COMMIT; diff --git a/schema/crdb/restrict-network-actions/up.sql b/schema/crdb/restrict-network-actions/up.sql new file mode 100644 index 00000000000..63429ad2701 --- /dev/null +++ b/schema/crdb/restrict-network-actions/up.sql @@ -0,0 +1,4 @@ +ALTER TABLE omicron.public.silo + ADD COLUMN IF NOT EXISTS restrict_network_actions BOOL + NOT NULL + DEFAULT FALSE;