From 3752d575c24adf4e0cb5dca36c7cdc0051c9c177 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 11 Sep 2025 17:10:58 -0700 Subject: [PATCH 01/48] add restrict_networking_actions code --- nexus/auth/src/authn/mod.rs | 17 +- nexus/auth/src/authz/actor.rs | 11 + nexus/auth/src/authz/api_resources.rs | 43 +- nexus/auth/src/authz/omicron.polar | 70 + nexus/auth/src/authz/oso_generic.rs | 2 +- nexus/authz-macros/src/lib.rs | 85 + nexus/db-fixed-data/src/silo.rs | 2 + nexus/db-model/src/silo.rs | 15 + nexus/db-queries/src/policy_test/mod.rs | 2 +- nexus/db-schema/src/schema.rs | 1 + nexus/src/app/external_endpoints.rs | 1 + nexus/src/app/rack.rs | 1 + nexus/test-utils/src/resource_helpers.rs | 1 + nexus/tests/integration_tests/endpoints.rs | 1 + nexus/tests/integration_tests/quotas.rs | 2 + nexus/tests/integration_tests/quotas.rs.bak | 475 +++ nexus/tests/integration_tests/silos.rs | 3 + nexus/tests/integration_tests/silos.rs.bak | 2632 +++++++++++++++++ nexus/types/src/external_api/params.rs | 5 + nexus/types/src/external_api/views.rs | 4 + schema/crdb/13.0.0/up01.sql | 7 + .../crdb/restrict-network-actions/down01.sql | 2 + schema/crdb/restrict-network-actions/up01.sql | 3 + 23 files changed, 3374 insertions(+), 11 deletions(-) create mode 100644 nexus/tests/integration_tests/quotas.rs.bak create mode 100644 nexus/tests/integration_tests/silos.rs.bak create mode 100644 schema/crdb/13.0.0/up01.sql create mode 100644 schema/crdb/restrict-network-actions/down01.sql create mode 100644 schema/crdb/restrict-network-actions/up01.sql diff --git a/nexus/auth/src/authn/mod.rs b/nexus/auth/src/authn/mod.rs index c1469df0f7d..5c588b50a4f 100644 --- a/nexus/auth/src/authn/mod.rs +++ b/nexus/auth/src/authn/mod.rs @@ -268,13 +268,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 + pub 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( @@ -282,6 +286,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 { @@ -290,9 +298,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 26f7458b3b8..6702d9b48a8 100644 --- a/nexus/auth/src/authz/actor.rs +++ b/nexus/auth/src/authz/actor.rs @@ -88,6 +88,14 @@ impl AuthenticatedActor { }) .collect() } + + /// Returns true if this actor's Silo restricts networking actions to Silo Admins only + pub fn restricts_networking(&self) -> bool { + self.silo_policy + .as_ref() + .map(|policy| policy.restrict_network_actions()) + .unwrap_or(false) + } } impl PartialEq for AuthenticatedActor { @@ -151,5 +159,8 @@ impl oso::PolarClass for AuthenticatedActor { authn::Actor::UserBuiltin { .. } => false, }, ) + .add_method("restricts_networking", |a: &AuthenticatedActor| { + a.restricts_networking() + }) } } diff --git a/nexus/auth/src/authz/api_resources.rs b/nexus/auth/src/authz/api_resources.rs index 94d0ee32231..b8cc1bc2ae3 100644 --- a/nexus/auth/src/authz/api_resources.rs +++ b/nexus/auth/src/authz/api_resources.rs @@ -40,6 +40,7 @@ use authz_macros::authz_resource; use futures::FutureExt; use futures::future::BoxFuture; use nexus_db_fixed_data::FLEET_ID; +use nexus_db_model; use nexus_types::external_api::shared::{FleetRole, ProjectRole, SiloRole}; use omicron_common::api::external::{Error, LookupType, ResourceType}; use oso::PolarClass; @@ -1090,7 +1091,7 @@ authz_resource! { parent = "Project", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProject, + polar_snippet = InProjectNetworking, } authz_resource! { @@ -1098,7 +1099,7 @@ authz_resource! { parent = "Vpc", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProject, + polar_snippet = InProjectNetworking, } authz_resource! { @@ -1106,7 +1107,7 @@ authz_resource! { parent = "VpcRouter", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProject, + polar_snippet = InProjectNetworking, } authz_resource! { @@ -1114,7 +1115,7 @@ authz_resource! { parent = "Vpc", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProject, + polar_snippet = InProjectNetworking, } authz_resource! { @@ -1122,7 +1123,7 @@ authz_resource! { parent = "Vpc", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProject, + polar_snippet = InProjectNetworking, } authz_resource! { @@ -1253,6 +1254,38 @@ impl ApiResourceWithRolesType for Silo { type AllowedRoles = SiloRole; } +// Add methods that can be called from Polar +impl Silo { + pub fn restricts_networking(&self) -> bool { + // This method should not be called if project.silo refers to the database silo + // Returning false to maintain existing behavior + false + } + + /// Custom init method that adds restricts_networking to the Polar class + pub(super) fn init_with_networking() -> Init { + // Create a custom class builder that includes the restricts_networking method + let class = oso::Class::builder() + .with_equality_check() + .add_method( + "has_role", + |r: &Silo, actor: AuthenticatedActor, role: String| { + actor.has_role_resource(ResourceType::Silo, r.key, &role) + }, + ) + .add_attribute_getter("fleet", |r: &Silo| r.parent) + .add_method("restricts_networking", |silo: &Silo| { + silo.restricts_networking() + }) + .build(); + + Init { + polar_snippet: "", // Custom snippet defined in omicron.polar + polar_class: class, + } + } +} + authz_resource! { name = "SiloUser", parent = "Silo", diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index 2aa0284c1be..d6432d16075 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -703,3 +703,73 @@ resource AlertClassList { has_relation(fleet: Fleet, "parent_fleet", collection: AlertClassList) if collection.fleet = fleet; + +# +# NETWORKING RESTRICTIONS BASED ON SILO SETTINGS +# + +# For networking resources, when a silo restricts networking, only silo admins can modify/create +# This applies to resources that use the InProjectNetworking polar snippet + +# Networking modify permissions with silo restriction logic +# Allow silo admins always (override any restrictions) +has_permission(actor: AuthenticatedActor, "modify", vpc: Vpc) if + has_role(actor, "admin", vpc.project.parent_silo); + +has_permission(actor: AuthenticatedActor, "modify", router: VpcRouter) if + has_role(actor, "admin", router.vpc.project.parent_silo); + +has_permission(actor: AuthenticatedActor, "modify", subnet: VpcSubnet) if + has_role(actor, "admin", subnet.vpc.project.parent_silo); + +has_permission(actor: AuthenticatedActor, "modify", gateway: InternetGateway) if + has_role(actor, "admin", gateway.vpc.project.parent_silo); + +# Allow project collaborators only if silo doesn't restrict networking +has_permission(actor: AuthenticatedActor, "modify", vpc: Vpc) if + has_role(actor, "collaborator", vpc.project) and + not vpc.project.silo.restricts_networking(); + +has_permission(actor: AuthenticatedActor, "modify", router: VpcRouter) if + has_role(actor, "collaborator", router.vpc.project) and + not router.vpc.project.silo.restricts_networking(); + +has_permission(actor: AuthenticatedActor, "modify", subnet: VpcSubnet) if + has_role(actor, "collaborator", subnet.vpc.project) and + not subnet.vpc.project.silo.restricts_networking(); + +has_permission(actor: AuthenticatedActor, "modify", gateway: InternetGateway) if + has_role(actor, "collaborator", gateway.vpc.project) and + not gateway.vpc.project.silo.restricts_networking(); + +# Networking create_child permissions with silo restriction logic +# Allow silo admins always (override any restrictions) +has_permission(actor: AuthenticatedActor, "create_child", vpc: Vpc) if + has_role(actor, "admin", vpc.project.parent_silo); + +has_permission(actor: AuthenticatedActor, "create_child", router: VpcRouter) if + has_role(actor, "admin", router.vpc.project.parent_silo); + +has_permission(actor: AuthenticatedActor, "create_child", subnet: VpcSubnet) if + has_role(actor, "admin", subnet.vpc.project.parent_silo); + +has_permission(actor: AuthenticatedActor, "create_child", gateway: InternetGateway) if + has_role(actor, "admin", gateway.vpc.project.parent_silo); + +# Allow project collaborators only if silo doesn't restrict networking +has_permission(actor: AuthenticatedActor, "create_child", vpc: Vpc) if + has_role(actor, "collaborator", vpc.project) and + not vpc.project.silo.restricts_networking(); + +has_permission(actor: AuthenticatedActor, "create_child", router: VpcRouter) if + has_role(actor, "collaborator", router.vpc.project) and + not router.vpc.project.silo.restricts_networking(); + +has_permission(actor: AuthenticatedActor, "create_child", subnet: VpcSubnet) if + has_role(actor, "collaborator", subnet.vpc.project) and + not subnet.vpc.project.silo.restricts_networking(); + +has_permission(actor: AuthenticatedActor, "create_child", gateway: InternetGateway) if + has_role(actor, "collaborator", gateway.vpc.project) and + not gateway.vpc.project.silo.restricts_networking(); + diff --git a/nexus/auth/src/authz/oso_generic.rs b/nexus/auth/src/authz/oso_generic.rs index 1278b24382c..e848c59b0ec 100644 --- a/nexus/auth/src/authz/oso_generic.rs +++ b/nexus/auth/src/authz/oso_generic.rs @@ -159,7 +159,7 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result { PhysicalDisk::init(), Rack::init(), SshKey::init(), - Silo::init(), + Silo::init_with_networking(), SiloUser::init(), SiloGroup::init(), SupportBundle::init(), diff --git a/nexus/authz-macros/src/lib.rs b/nexus/authz-macros/src/lib.rs index 59ae8d9a963..f00c6bcb4c2 100644 --- a/nexus/authz-macros/src/lib.rs +++ b/nexus/authz-macros/src/lib.rs @@ -265,6 +265,10 @@ enum PolarSnippet { /// Generate it as a resource nested within a Project (either directly or /// indirectly) InProject, + + /// Generate it as a resource nested within a Project with networking restrictions + /// When the Silo's restrict_network_actions is true, only Silo Admins can perform networking actions + InProjectNetworking, } /// Implementation of [`authz_resource!`] @@ -433,6 +437,87 @@ fn do_authz_resource( resource_name, parent_as_snake, ), + + // If this networking resource is directly inside a Project, we need to + // check if the Silo restricts networking actions to Silo admins only. + // Read/list actions are always allowed for Project Collaborators. + (PolarSnippet::InProjectNetworking, "Project") => format!( + r#" + resource {} {{ + permissions = [ + "list_children", + "modify", + "read", + "create_child", + ]; + + relations = {{ containing_project: Project }}; + + # Read/list actions are always allowed for Project viewers/collaborators + "list_children" if "viewer" on "containing_project"; + "read" if "viewer" on "containing_project"; + + # Silo admins can always perform networking actions (override restrictions) + "modify" if "admin" on "containing_project".parent_silo; + "create_child" if "admin" on "containing_project".parent_silo; + + # Project collaborators can perform networking actions (restriction logic via has_permission rules) + "modify" if "collaborator" on "containing_project"; + "create_child" if "collaborator" on "containing_project"; + }} + + has_relation(parent: Project, "containing_project", child: {}) + if child.project = parent; + "#, + resource_name, resource_name, + ), + + // If this networking resource is nested under something else within the Project, + // we need to define both the "parent" relationship and the (indirect) + // relationship to the containing Project, with networking restrictions. + // Read/list actions are always allowed for Project Collaborators. + (PolarSnippet::InProjectNetworking, _) => format!( + r#" + resource {} {{ + permissions = [ + "list_children", + "modify", + "read", + "create_child", + ]; + + relations = {{ + containing_project: Project, + parent: {} + }}; + + # Read/list actions are always allowed for Project viewers/collaborators + "list_children" if "viewer" on "containing_project"; + "read" if "viewer" on "containing_project"; + + # Silo admins can always perform networking actions (override restrictions) + "modify" if "admin" on "containing_project".parent_silo; + "create_child" if "admin" on "containing_project".parent_silo; + + # Project collaborators can perform networking actions (restriction logic via has_permission rules) + "modify" if "collaborator" on "containing_project"; + "create_child" if "collaborator" on "containing_project"; + }} + + 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/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/policy_test/mod.rs b/nexus/db-queries/src/policy_test/mod.rs index 67879f61877..86bf65e89e7 100644 --- a/nexus/db-queries/src/policy_test/mod.rs +++ b/nexus/db-queries/src/policy_test/mod.rs @@ -466,7 +466,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 69222da53a8..4d0bef4bb46 100644 --- a/nexus/db-schema/src/schema.rs +++ b/nexus/db-schema/src/schema.rs @@ -756,6 +756,7 @@ table! { rcgen -> Int8, admin_group_name -> Nullable, + restrict_network_actions -> Bool, } } 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/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/test-utils/src/resource_helpers.rs b/nexus/test-utils/src/resource_helpers.rs index 71871e932c8..05b740997c6 100644 --- a/nexus/test-utils/src/resource_helpers.rs +++ b/nexus/test-utils/src/resource_helpers.rs @@ -393,6 +393,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 b88b41a143e..dd54f42f236 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/quotas.rs.bak b/nexus/tests/integration_tests/quotas.rs.bak new file mode 100644 index 00000000000..c11ce97695c --- /dev/null +++ b/nexus/tests/integration_tests/quotas.rs.bak @@ -0,0 +1,475 @@ +use anyhow::Error; +use dropshot::HttpErrorResponseBody; +use dropshot::test_util::ClientTestContext; +use http::Method; +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::http_testing::TestResponse; +use nexus_test_utils::resource_helpers::DiskTest; +use nexus_test_utils::resource_helpers::create_ip_pool; +use nexus_test_utils::resource_helpers::create_local_user; +use nexus_test_utils::resource_helpers::grant_iam; +use nexus_test_utils::resource_helpers::link_ip_pool; +use nexus_test_utils::resource_helpers::object_create; +use nexus_test_utils::resource_helpers::object_create_error; +use nexus_test_utils::resource_helpers::test_params; +use nexus_test_utils_macros::nexus_test; +use nexus_types::external_api::params; +use nexus_types::external_api::shared; +use nexus_types::external_api::shared::SiloRole; +use nexus_types::external_api::views::{Silo, SiloQuotas}; +use omicron_common::api::external::ByteCount; +use omicron_common::api::external::IdentityMetadataCreateParams; +use omicron_common::api::external::InstanceCpuCount; +use serde_json::json; + +type ControlPlaneTestContext = + nexus_test_utils::ControlPlaneTestContext; + +struct ResourceAllocator { + auth: AuthnMode, +} + +impl ResourceAllocator { + fn new(auth: AuthnMode) -> Self { + Self { auth } + } + + async fn set_quotas( + &self, + client: &ClientTestContext, + quotas: params::SiloQuotasUpdate, + ) -> Result { + NexusRequest::object_put( + client, + "/v1/system/silos/quota-test-silo/quotas", + Some("as), + ) + .authn_as(self.auth.clone()) + .execute() + .await + } + + async fn set_quotas_expect_error( + &self, + client: &ClientTestContext, + quotas: params::SiloQuotasUpdate, + code: http::StatusCode, + ) -> HttpErrorResponseBody { + NexusRequest::expect_failure_with_body( + client, + code, + http::Method::PUT, + "/v1/system/silos/quota-test-silo/quotas", + &Some("as), + ) + .authn_as(self.auth.clone()) + .execute() + .await + .expect("Expected failure updating quotas") + .parsed_body::() + .expect("Failed to read response after setting quotas") + } + + async fn get_quotas(&self, client: &ClientTestContext) -> SiloQuotas { + NexusRequest::object_get( + client, + "/v1/system/silos/quota-test-silo/quotas", + ) + .authn_as(self.auth.clone()) + .execute() + .await + .expect("failed to fetch quotas") + .parsed_body() + .expect("failed to parse quotas") + } + + async fn provision_instance( + &self, + client: &ClientTestContext, + name: &str, + cpus: u16, + memory: u32, + ) -> Result { + NexusRequest::objects_post( + client, + "/v1/instances?project=project", + ¶ms::InstanceCreate { + identity: IdentityMetadataCreateParams { + name: name.parse().unwrap(), + description: "".into(), + }, + ncpus: InstanceCpuCount(cpus), + memory: ByteCount::from_gibibytes_u32(memory), + hostname: "host".parse().unwrap(), + user_data: b"#cloud-config\nsystem_info:\n default_user:\n name: oxide" + .to_vec(), + ssh_public_keys: Some(Vec::new()), + network_interfaces: params::InstanceNetworkInterfaceAttachment::Default, + external_ips: Vec::::new(), + disks: Vec::::new(), + boot_disk: None, + start: false, + auto_restart_policy: Default::default(), + anti_affinity_groups: Vec::new(), + }, + ) + .authn_as(self.auth.clone()) + .execute() + .await + .expect("Instance should be created regardless of quotas"); + + NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + format!("/v1/instances/{}/start?project=project", name) + .as_str(), + ) + .body(None as Option<&serde_json::Value>), + ) + .authn_as(self.auth.clone()) + .execute() + .await + } + + async fn cleanup_instance( + &self, + client: &ClientTestContext, + name: &str, + ) -> TestResponse { + // Try to stop the instance + NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + format!("/v1/instances/{}/stop?project=project", name).as_str(), + ) + .body(None as Option<&serde_json::Value>), + ) + .authn_as(self.auth.clone()) + .execute() + .await + .expect("failed to stop instance"); + + NexusRequest::object_delete( + client, + format!("/v1/instances/{}?project=project", name).as_str(), + ) + .authn_as(self.auth.clone()) + .execute() + .await + .expect("failed to delete instance") + } + + async fn provision_disk( + &self, + client: &ClientTestContext, + name: &str, + size: u32, + ) -> Result { + NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + "/v1/disks?project=project", + ) + .body(Some(¶ms::DiskCreate { + identity: IdentityMetadataCreateParams { + name: name.parse().unwrap(), + description: "".into(), + }, + size: ByteCount::from_gibibytes_u32(size), + disk_source: params::DiskSource::Blank { + block_size: params::BlockSize::try_from(512).unwrap(), + }, + })), + ) + .authn_as(self.auth.clone()) + .execute() + .await + } +} + +async fn setup_silo_with_quota( + client: &ClientTestContext, + silo_name: &str, + quotas: params::SiloQuotasCreate, +) -> ResourceAllocator { + let silo: Silo = object_create( + client, + "/v1/system/silos", + ¶ms::SiloCreate { + identity: IdentityMetadataCreateParams { + name: silo_name.parse().unwrap(), + description: "".into(), + }, + quotas, + discoverable: true, + identity_mode: shared::SiloIdentityMode::LocalOnly, + admin_group_name: None, + tls_certificates: vec![], + mapped_fleet_roles: Default::default(), + }, + ) + .await; + + // create default pool and link to this silo. can't use + // create_default_ip_pool because that links to the default silo + create_ip_pool(&client, "default", None).await; + link_ip_pool(&client, "default", &silo.identity.id, true).await; + + // Create a silo user + let user = create_local_user( + client, + &silo, + &"user".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + + // Make silo admin + grant_iam( + client, + format!("/v1/system/silos/{}", silo_name).as_str(), + SiloRole::Admin, + user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + let auth_mode = AuthnMode::SiloUser(user.id); + + NexusRequest::objects_post( + client, + "/v1/projects", + ¶ms::ProjectCreate { + identity: IdentityMetadataCreateParams { + name: "project".parse().unwrap(), + description: "".into(), + }, + }, + ) + .authn_as(auth_mode.clone()) + .execute() + .await + .unwrap(); + + ResourceAllocator::new(auth_mode) +} + +#[nexus_test] +async fn test_quotas(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + // Simulate space for disks + DiskTest::new(&cptestctx).await; + + let system = setup_silo_with_quota( + &client, + "quota-test-silo", + params::SiloQuotasCreate::empty(), + ) + .await; + + // Ensure trying to provision an instance with empty quotas fails + let err = system + .provision_instance(client, "instance", 1, 1) + .await + .unwrap() + .parsed_body::() + .expect("failed to parse error body"); + assert!( + err.message.contains("vCPU Limit Exceeded"), + "Unexpected error: {0}", + err.message + ); + system.cleanup_instance(client, "instance").await; + + // Up the CPU, memory quotas + system + .set_quotas( + client, + params::SiloQuotasUpdate { + cpus: Some(4), + memory: Some(ByteCount::from_gibibytes_u32(15)), + storage: Some(ByteCount::from_gibibytes_u32(2)), + }, + ) + .await + .expect("failed to set quotas"); + + let quotas = system.get_quotas(client).await; + assert_eq!(quotas.limits.cpus, 4); + assert_eq!(quotas.limits.memory, ByteCount::from_gibibytes_u32(15)); + assert_eq!(quotas.limits.storage, ByteCount::from_gibibytes_u32(2)); + + // Ensure memory quota is enforced + let err = system + .provision_instance(client, "instance", 1, 16) + .await + .unwrap() + .parsed_body::() + .expect("failed to parse error body"); + assert!( + err.message.contains("Memory Limit Exceeded"), + "Unexpected error: {0}", + err.message + ); + system.cleanup_instance(client, "instance").await; + + // Allocating instance should now succeed + system + .provision_instance(client, "instance", 2, 10) + .await + .expect("Instance should've had enough resources to be provisioned"); + + let err = system + .provision_disk(client, "disk", 3) + .await + .unwrap() + .parsed_body::() + .expect("failed to parse error body"); + assert!( + err.message.contains("Storage Limit Exceeded"), + "Unexpected error: {0}", + err.message + ); + + system + .provision_disk(client, "disk", 1) + .await + .expect("Disk should be provisioned"); +} + +#[nexus_test] +async fn test_quota_limits(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + let system = setup_silo_with_quota( + &client, + "quota-test-silo", + params::SiloQuotasCreate::empty(), + ) + .await; + + // Maximal legal limits should be allowed. + let quota_limit = params::SiloQuotasUpdate { + cpus: Some(i64::MAX), + memory: Some(i64::MAX.try_into().unwrap()), + storage: Some(i64::MAX.try_into().unwrap()), + }; + system + .set_quotas(client, quota_limit.clone()) + .await + .expect("set max quotas"); + let quotas = system.get_quotas(client).await; + assert_eq!(quotas.limits.cpus, quota_limit.cpus.unwrap()); + assert_eq!(quotas.limits.memory, quota_limit.memory.unwrap()); + assert_eq!(quotas.limits.storage, quota_limit.storage.unwrap()); + + // Construct a value that fits in a u64 but not an i64. + let out_of_bounds = u64::try_from(i64::MAX).unwrap() + 1; + + for key in ["cpus", "memory", "storage"] { + // We can't construct a `SiloQuotasUpdate` with higher-than-maximal + // values, but we can construct the equivalent JSON blob of such a + // request. + let request = json!({ key: out_of_bounds }); + + let err = NexusRequest::expect_failure_with_body( + client, + http::StatusCode::BAD_REQUEST, + http::Method::PUT, + "/v1/system/silos/quota-test-silo/quotas", + &request, + ) + .authn_as(system.auth.clone()) + .execute() + .await + .expect("sent quota update") + .parsed_body::() + .expect("parsed error body"); + assert!( + err.message.contains(key) + && (err.message.contains("invalid value") + || err + .message + .contains("value is too large for a byte count")), + "Unexpected error: {0}", + err.message + ); + + // The quota limits we set above should be unchanged. + let quotas = system.get_quotas(client).await; + assert_eq!(quotas.limits.cpus, quota_limit.cpus.unwrap()); + assert_eq!(quotas.limits.memory, quota_limit.memory.unwrap()); + assert_eq!(quotas.limits.storage, quota_limit.storage.unwrap()); + } +} + +#[nexus_test] +async fn test_negative_quota(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + // Can't make a silo with a negative quota + let mut quotas = params::SiloQuotasCreate::empty(); + quotas.cpus = -1; + let response = object_create_error( + client, + "/v1/system/silos", + ¶ms::SiloCreate { + identity: IdentityMetadataCreateParams { + name: "negative-cpus-not-allowed".parse().unwrap(), + description: "".into(), + }, + quotas, + discoverable: true, + identity_mode: shared::SiloIdentityMode::LocalOnly, + admin_group_name: None, + tls_certificates: vec![], + mapped_fleet_roles: Default::default(), + }, + http::StatusCode::BAD_REQUEST, + ) + .await; + + assert!( + response.message.contains( + "Cannot create silo quota: CPU quota must not be negative" + ), + "Unexpected response: {}", + response.message + ); + + // Make the silo with an empty quota + let system = setup_silo_with_quota( + &client, + "quota-test-silo", + params::SiloQuotasCreate::empty(), + ) + .await; + + // Can't update a silo with a negative quota + let quota_limit = params::SiloQuotasUpdate { + cpus: Some(-1), + memory: Some(0_u64.try_into().unwrap()), + storage: Some(0_u64.try_into().unwrap()), + }; + let response = system + .set_quotas_expect_error( + client, + quota_limit.clone(), + http::StatusCode::BAD_REQUEST, + ) + .await; + + assert!( + response.message.contains( + "Cannot update silo quota: CPU quota must not be negative" + ), + "Unexpected response: {}", + response.message + ); +} diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index a2866c5f506..8ca0e3b3713 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) diff --git a/nexus/tests/integration_tests/silos.rs.bak b/nexus/tests/integration_tests/silos.rs.bak new file mode 100644 index 00000000000..2aa271dc8f6 --- /dev/null +++ b/nexus/tests/integration_tests/silos.rs.bak @@ -0,0 +1,2632 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use crate::integration_tests::saml::SAML_IDP_DESCRIPTOR; +use dropshot::ResultsPage; +use nexus_db_lookup::LookupPath; +use nexus_db_queries::authn::silos::AuthenticatedSubject; +use nexus_db_queries::authn::{USER_TEST_PRIVILEGED, USER_TEST_UNPRIVILEGED}; +use nexus_db_queries::authz::{self}; +use nexus_db_queries::context::OpContext; +use nexus_db_queries::db; +use nexus_db_queries::db::fixed_data::silo::DEFAULT_SILO; +use nexus_db_queries::db::identity::Asset; +use nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder}; +use nexus_test_utils::resource_helpers::{ + create_ip_pool, create_local_user, create_project, create_silo, grant_iam, + link_ip_pool, object_create, object_delete, objects_list_page_authz, + projects_list, test_params, +}; +use nexus_test_utils_macros::nexus_test; +use nexus_types::external_api::views::Certificate; +use nexus_types::external_api::views::{ + self, IdentityProvider, Project, SamlIdentityProvider, Silo, +}; +use nexus_types::external_api::{params, shared}; +use nexus_types::silo::DEFAULT_SILO_ID; +use omicron_common::address::{IpRange, Ipv4Range}; +use omicron_common::api::external::{ + IdentityMetadataCreateParams, LookupType, Name, +}; +use omicron_common::api::external::{ObjectIdentity, UserId}; +use omicron_test_utils::certificates::CertificateChain; +use omicron_test_utils::dev::poll::{CondCheckError, wait_for_condition}; +use omicron_uuid_kinds::SiloUserUuid; + +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::fmt::Write; +use std::str::FromStr; + +use base64::Engine; +use hickory_resolver::ResolveErrorKind; +use hickory_resolver::proto::ProtoErrorKind; +use http::StatusCode; +use http::method::Method; +use httptest::{Expectation, Server, matchers::*, responders::*}; +use nexus_types::external_api::shared::{FleetRole, SiloRole}; +use std::convert::Infallible; +use std::net::Ipv4Addr; +use std::time::Duration; + +type ControlPlaneTestContext = + nexus_test_utils::ControlPlaneTestContext; + +#[nexus_test] +async fn test_silos(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + + // Verify that we cannot create a name with the same name as the recovery + // Silo that was created during rack initialization. + let error: dropshot::HttpErrorResponseBody = + NexusRequest::expect_failure_with_body( + client, + StatusCode::BAD_REQUEST, + Method::POST, + "/v1/system/silos", + ¶ms::SiloCreate { + identity: IdentityMetadataCreateParams { + name: cptestctx.silo_name.clone(), + description: "a silo".to_string(), + }, + quotas: params::SiloQuotasCreate::empty(), + discoverable: false, + identity_mode: shared::SiloIdentityMode::LocalOnly, + admin_group_name: None, + tls_certificates: vec![], + mapped_fleet_roles: Default::default(), + }, + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + assert_eq!(error.message, "already exists: silo \"test-suite-silo\""); + + // Create two silos: one discoverable, one not + create_silo( + &client, + "discoverable", + true, + shared::SiloIdentityMode::LocalOnly, + ) + .await; + create_silo(&client, "hidden", false, shared::SiloIdentityMode::LocalOnly) + .await; + + // Verify that an external DNS name was propagated for these Silos. + verify_silo_dns_name(cptestctx, "discoverable", true).await; + verify_silo_dns_name(cptestctx, "hidden", true).await; + + // Verify GET /v1/system/silos/{silo} works for both discoverable and not + let discoverable_url = "/v1/system/silos/discoverable"; + let hidden_url = "/v1/system/silos/hidden"; + + let silo: Silo = NexusRequest::object_get(&client, &discoverable_url) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request") + .parsed_body() + .unwrap(); + assert_eq!(silo.identity.name, "discoverable"); + + let silo: Silo = NexusRequest::object_get(&client, &hidden_url) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request") + .parsed_body() + .unwrap(); + assert_eq!(silo.identity.name, "hidden"); + + // Verify 404 if silo doesn't exist + NexusRequest::expect_failure( + &client, + StatusCode::NOT_FOUND, + Method::GET, + &"/v1/system/silos/testpost", + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request"); + + // Verify GET /v1/system/silos only returns discoverable silos + let silos = + objects_list_page_authz::(client, "/v1/system/silos").await.items; + assert_eq!(silos.len(), 1); + assert_eq!(silos[0].identity.name, "discoverable"); + + // Create a new user in the discoverable silo + let new_silo_user_id = create_local_user( + client, + &silos[0], + &"some-silo-user".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await + .id; + + // Grant the user "admin" privileges on that Silo. + grant_iam( + client, + discoverable_url, + SiloRole::Admin, + new_silo_user_id, + AuthnMode::PrivilegedUser, + ) + .await; + + // TODO-coverage TODO-security Add test for Silo-local session + // when we can use users in another Silo. + + let authn_opctx = nexus.opctx_external_authn(); + + // Create project with built-in user auth + // Note: this currently goes to the built-in silo! + let project_name = "someproj"; + let new_proj_in_default_silo = create_project(&client, project_name).await; + + // default silo project shows up in default silo + let projects_in_default_silo = + projects_list(client, "/v1/projects", "", None).await; + assert_eq!(projects_in_default_silo.len(), 1); + + // default silo project does not show up in our silo + let projects_in_our_silo = NexusRequest::object_get(client, "/v1/projects") + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute_and_parse_unwrap::>() + .await; + assert_eq!(projects_in_our_silo.items.len(), 0); + + // Create a Project of the same name in a different Silo to verify + // that's possible. + let new_proj_in_our_silo = NexusRequest::objects_post( + client, + "/v1/projects", + ¶ms::ProjectCreate { + identity: IdentityMetadataCreateParams { + name: project_name.parse().unwrap(), + description: String::new(), + }, + }, + ) + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .expect("failed to create same-named Project in a different Silo") + .parsed_body::() + .expect("failed to parse new Project"); + assert_eq!( + new_proj_in_default_silo.identity.name, + new_proj_in_our_silo.identity.name + ); + assert_ne!( + new_proj_in_default_silo.identity.id, + new_proj_in_our_silo.identity.id + ); + // delete default subnet from VPC so we can delete the VPC + NexusRequest::object_delete( + client, + &format!( + "/v1/vpc-subnets/default?project={}&vpc=default", + project_name + ), + ) + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .expect("failed to delete test Vpc"); + // delete VPC from project so we can delete the project later + NexusRequest::object_delete( + client, + &format!("/v1/vpcs/default?project={}", project_name), + ) + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .expect("failed to delete test Vpc"); + + // Verify GET /v1/projects works with built-in user auth + let projects = projects_list(client, "/v1/projects", "", None).await; + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].identity.name, "someproj"); + + // Deleting discoverable silo fails because there's still a project in it + NexusRequest::expect_failure( + &client, + StatusCode::BAD_REQUEST, + Method::DELETE, + &discoverable_url, + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request"); + + // Delete project + NexusRequest::object_delete(&client, &"/v1/projects/someproj") + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .expect("failed to make request"); + + // Verify silo DELETE now works + NexusRequest::object_delete(&client, &discoverable_url) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request"); + + // Verify the DNS name was removed. + verify_silo_dns_name(cptestctx, "discoverable", false).await; + + // Verify silo user was also deleted + LookupPath::new(&authn_opctx, nexus.datastore()) + .silo_user_id(new_silo_user_id) + .fetch() + .await + .expect_err("unexpected success"); +} + +// Test that admin group is created if admin_group_name is applied. +#[nexus_test] +async fn test_silo_admin_group(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + + let silo: Silo = object_create( + client, + "/v1/system/silos", + ¶ms::SiloCreate { + identity: IdentityMetadataCreateParams { + name: "silo-name".parse().unwrap(), + description: "a silo".to_string(), + }, + quotas: params::SiloQuotasCreate::empty(), + discoverable: false, + identity_mode: shared::SiloIdentityMode::SamlJit, + admin_group_name: Some("administrator".into()), + tls_certificates: vec![], + mapped_fleet_roles: Default::default(), + }, + ) + .await; + + let authn_opctx = nexus.opctx_external_authn(); + + let (authz_silo, db_silo) = + LookupPath::new(&authn_opctx, nexus.datastore()) + .silo_name(&silo.identity.name.into()) + .fetch() + .await + .unwrap(); + + assert!( + nexus + .datastore() + .silo_group_optional_lookup( + &authn_opctx, + &authz_silo, + "administrator".into(), + ) + .await + .unwrap() + .is_some() + ); + + // Test that a user is granted privileges from their group membership + let admin_group_user = nexus + .silo_user_from_authenticated_subject( + &authn_opctx, + &authz_silo, + &db_silo, + &AuthenticatedSubject { + external_id: "adminuser@company.com".into(), + groups: vec!["administrator".into()], + }, + ) + .await + .unwrap(); + + let group_memberships = nexus + .datastore() + .silo_group_membership_for_user( + &authn_opctx, + &authz_silo, + admin_group_user.id(), + ) + .await + .unwrap(); + + assert_eq!(group_memberships.len(), 1); + + // Create a project + let _org = NexusRequest::objects_post( + client, + "/v1/projects", + ¶ms::ProjectCreate { + identity: IdentityMetadataCreateParams { + name: "myproj".parse().unwrap(), + description: "some proj".into(), + }, + }, + ) + .authn_as(AuthnMode::SiloUser(admin_group_user.id())) + .execute() + .await + .expect("failed to create Project") + .parsed_body::() + .expect("failed to parse as Project"); +} + +// Test listing providers +#[nexus_test] +async fn test_listing_identity_providers(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + create_silo(&client, "test-silo", true, shared::SiloIdentityMode::SamlJit) + .await; + + // List providers - should be none + let providers = objects_list_page_authz::( + client, + "/v1/system/identity-providers?silo=test-silo", + ) + .await + .items; + + assert_eq!(providers.len(), 0); + + // Add some providers + let saml_idp_descriptor = SAML_IDP_DESCRIPTOR; + + let server = Server::run(); + server.expect( + Expectation::matching(request::method_path("GET", "/descriptor")) + .times(1..) + .respond_with(status_code(200).body(saml_idp_descriptor)), + ); + + let silo_saml_idp_1: SamlIdentityProvider = object_create( + client, + &"/v1/system/identity-providers/saml?silo=test-silo", + ¶ms::SamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".to_string(), + }, + + idp_metadata_source: params::IdpMetadataSource::Url { + url: server.url("/descriptor").to_string(), + }, + + idp_entity_id: "entity_id".to_string(), + sp_client_id: "client_id".to_string(), + acs_url: "http://acs".to_string(), + slo_url: "http://slo".to_string(), + technical_contact_email: "technical@fake".to_string(), + + signing_keypair: None, + + group_attribute_name: None, + }, + ) + .await; + + let silo_saml_idp_2: SamlIdentityProvider = object_create( + client, + &"/v1/system/identity-providers/saml?silo=test-silo", + ¶ms::SamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "another-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".to_string(), + }, + + idp_metadata_source: params::IdpMetadataSource::Url { + url: server.url("/descriptor").to_string(), + }, + + idp_entity_id: "entity_id".to_string(), + sp_client_id: "client_id".to_string(), + acs_url: "http://acs".to_string(), + slo_url: "http://slo".to_string(), + technical_contact_email: "technical@fake".to_string(), + + signing_keypair: None, + + group_attribute_name: None, + }, + ) + .await; + + // List providers again - expect 2 + let providers = objects_list_page_authz::( + client, + "/v1/system/identity-providers?silo=test-silo", + ) + .await + .items; + + assert_eq!(providers.len(), 2); + + let provider_name_set = + providers.into_iter().map(|x| x.identity.name).collect::>(); + assert!(provider_name_set.contains(&silo_saml_idp_1.identity.name)); + assert!(provider_name_set.contains(&silo_saml_idp_2.identity.name)); +} + +// Test that deleting the silo deletes the idp +#[nexus_test] +async fn test_deleting_a_silo_deletes_the_idp( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + const SILO_NAME: &str = "test-silo"; + create_silo(&client, SILO_NAME, true, shared::SiloIdentityMode::SamlJit) + .await; + + let saml_idp_descriptor = SAML_IDP_DESCRIPTOR; + + let server = Server::run(); + server.expect( + Expectation::matching(request::method_path("GET", "/descriptor")) + .respond_with(status_code(200).body(saml_idp_descriptor)), + ); + + let silo_saml_idp: SamlIdentityProvider = object_create( + client, + &format!("/v1/system/identity-providers/saml?silo={}", SILO_NAME), + ¶ms::SamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".to_string(), + }, + + idp_metadata_source: params::IdpMetadataSource::Url { + url: server.url("/descriptor").to_string(), + }, + + idp_entity_id: "entity_id".to_string(), + sp_client_id: "client_id".to_string(), + acs_url: "http://acs".to_string(), + slo_url: "http://slo".to_string(), + technical_contact_email: "technical@fake".to_string(), + + signing_keypair: None, + + group_attribute_name: None, + }, + ) + .await; + + // Delete the silo + NexusRequest::object_delete( + &client, + &format!("/v1/system/silos/{}", SILO_NAME), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request"); + + // Expect that the silo is gone + let nexus = &cptestctx.server.server_context().nexus; + + let response = nexus + .datastore() + .identity_provider_lookup( + &nexus.opctx_external_authn(), + &omicron_common::api::external::Name::try_from( + SILO_NAME.to_string(), + ) + .unwrap() + .into(), + &omicron_common::api::external::Name::try_from( + "some-totally-real-saml-provider".to_string(), + ) + .unwrap() + .into(), + ) + .await; + + assert!(response.is_err()); + match response.err().unwrap() { + omicron_common::api::external::Error::ObjectNotFound { + type_name, + lookup_type: _, + } => { + assert_eq!( + type_name, + omicron_common::api::external::ResourceType::Silo + ); + } + + _ => { + assert!(false); + } + } + + // No SSO redirect expected + NexusRequest::new( + RequestBuilder::new( + client, + Method::GET, + &format!( + "/login/{}/saml/{}/redirect", + SILO_NAME, silo_saml_idp.identity.name + ), + ) + .expect_status(Some(StatusCode::NOT_FOUND)), + ) + .execute() + .await + .expect("expected success"); +} + +// Create a Silo with a SAML IdP document string +#[nexus_test] +async fn test_saml_idp_metadata_data_valid( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + create_silo(&client, "blahblah", true, shared::SiloIdentityMode::SamlJit) + .await; + + let silo_saml_idp: SamlIdentityProvider = object_create( + client, + "/v1/system/identity-providers/saml?silo=blahblah", + ¶ms::SamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".to_string(), + }, + + idp_metadata_source: params::IdpMetadataSource::Base64EncodedXml { + data: base64::engine::general_purpose::STANDARD + .encode(SAML_IDP_DESCRIPTOR), + }, + + idp_entity_id: "entity_id".to_string(), + sp_client_id: "client_id".to_string(), + acs_url: "http://acs".to_string(), + slo_url: "http://slo".to_string(), + technical_contact_email: "technical@fake".to_string(), + + signing_keypair: None, + + group_attribute_name: None, + }, + ) + .await; + + // Expect the SSO redirect when trying to log in + let result = NexusRequest::new( + RequestBuilder::new( + client, + Method::GET, + &format!( + "/login/blahblah/saml/{}/redirect", + silo_saml_idp.identity.name + ), + ) + .expect_status(Some(StatusCode::FOUND)), + ) + .execute() + .await + .expect("expected success"); + + assert!( + result.headers["Location"].to_str().unwrap().to_string().starts_with( + "https://idp.example.org/SAML2/SSO/Redirect?SAMLRequest=", + ) + ); +} + +// Fail to create a Silo with a SAML IdP document string that isn't valid +#[nexus_test] +async fn test_saml_idp_metadata_data_truncated( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + create_silo(&client, "blahblah", true, shared::SiloIdentityMode::SamlJit) + .await; + + NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + "/v1/system/identity-providers/saml?silo=blahblah", + ) + .body(Some(¶ms::SamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".to_string(), + }, + + idp_metadata_source: params::IdpMetadataSource::Base64EncodedXml { + data: base64::engine::general_purpose::STANDARD.encode({ + let mut saml_idp_descriptor = + SAML_IDP_DESCRIPTOR.to_string(); + saml_idp_descriptor.truncate(100); + saml_idp_descriptor + }), + }, + + idp_entity_id: "entity_id".to_string(), + sp_client_id: "client_id".to_string(), + acs_url: "http://acs".to_string(), + slo_url: "http://slo".to_string(), + technical_contact_email: "technical@fake".to_string(), + + signing_keypair: None, + + group_attribute_name: None, + })) + .expect_status(Some(StatusCode::BAD_REQUEST)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("unexpected success"); +} + +// Can't create a SAML IdP from bad base64 data +#[nexus_test] +async fn test_saml_idp_metadata_data_invalid( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + const SILO_NAME: &str = "saml-silo"; + create_silo(&client, SILO_NAME, true, shared::SiloIdentityMode::SamlJit) + .await; + + NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + &format!("/v1/system/identity-providers/saml?silo={}", SILO_NAME), + ) + .body(Some(¶ms::SamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".to_string(), + }, + + idp_metadata_source: params::IdpMetadataSource::Base64EncodedXml { + data: "bad data".to_string(), + }, + + idp_entity_id: "entity_id".to_string(), + sp_client_id: "client_id".to_string(), + acs_url: "http://acs".to_string(), + slo_url: "http://slo".to_string(), + technical_contact_email: "technical@fake".to_string(), + + signing_keypair: None, + + group_attribute_name: None, + })) + .expect_status(Some(StatusCode::BAD_REQUEST)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("unexpected success"); +} + +struct TestSiloUserProvisionTypes { + identity_mode: shared::SiloIdentityMode, + existing_silo_user: bool, + expect_user: bool, +} + +#[nexus_test] +async fn test_silo_user_provision_types(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + let datastore = nexus.datastore(); + + let test_cases: Vec = vec![ + // A silo configured with a "ApiOnly" user provision type should fetch a + // user if it exists already. + TestSiloUserProvisionTypes { + identity_mode: shared::SiloIdentityMode::LocalOnly, + existing_silo_user: true, + expect_user: true, + }, + // A silo configured with a "ApiOnly" user provision type should not + // create a user if one does not exist already. + TestSiloUserProvisionTypes { + identity_mode: shared::SiloIdentityMode::LocalOnly, + existing_silo_user: false, + expect_user: false, + }, + // A silo configured with a "JIT" user provision type should fetch a + // user if it exists already. + TestSiloUserProvisionTypes { + identity_mode: shared::SiloIdentityMode::SamlJit, + existing_silo_user: true, + expect_user: true, + }, + // A silo configured with a "JIT" user provision type should create a + // user if one does not exist already. + TestSiloUserProvisionTypes { + identity_mode: shared::SiloIdentityMode::SamlJit, + existing_silo_user: false, + expect_user: true, + }, + ]; + + for test_case in test_cases { + let silo = + create_silo(&client, "test-silo", true, test_case.identity_mode) + .await; + + if test_case.existing_silo_user { + match test_case.identity_mode { + shared::SiloIdentityMode::SamlJit => { + create_jit_user(datastore, &silo, "external-id-com").await; + } + shared::SiloIdentityMode::LocalOnly => { + create_local_user( + client, + &silo, + &"external-id-com".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + } + }; + } + + let authn_opctx = nexus.opctx_external_authn(); + + let (authz_silo, db_silo) = + LookupPath::new(&authn_opctx, nexus.datastore()) + .silo_name(&silo.identity.name.into()) + .fetch() + .await + .unwrap(); + + let existing_silo_user = nexus + .silo_user_from_authenticated_subject( + &authn_opctx, + &authz_silo, + &db_silo, + &AuthenticatedSubject { + external_id: "external-id-com".into(), + groups: vec![], + }, + ) + .await; + + if test_case.expect_user { + assert!(existing_silo_user.is_ok()); + } else { + assert!(existing_silo_user.is_err()); + } + + NexusRequest::object_delete(&client, &"/v1/system/silos/test-silo") + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request"); + } +} + +#[nexus_test] +async fn test_silo_user_fetch_by_external_id( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + + let silo = create_silo( + &client, + "test-silo", + true, + shared::SiloIdentityMode::LocalOnly, + ) + .await; + + let opctx_external_authn = nexus.opctx_external_authn(); + let opctx = OpContext::for_tests( + cptestctx.logctx.log.new(o!()), + nexus.datastore().clone(), + ); + + let (authz_silo, _) = LookupPath::new(&opctx, nexus.datastore()) + .silo_name(&Name::try_from("test-silo".to_string()).unwrap().into()) + .fetch_for(authz::Action::Read) + .await + .unwrap(); + + // Create a user + create_local_user( + client, + &silo, + &"f5513e049dac9468de5bdff36ab17d04f".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + + // Fetching by external id that's not in the db should be Ok(None) + let result = nexus + .datastore() + .silo_user_fetch_by_external_id( + &opctx_external_authn, + &authz_silo, + "123", + ) + .await; + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + + // Fetching by external id that is should be Ok(Some) + let result = nexus + .datastore() + .silo_user_fetch_by_external_id( + &opctx_external_authn, + &authz_silo, + "f5513e049dac9468de5bdff36ab17d04f", + ) + .await; + assert!(result.is_ok()); + assert!(result.unwrap().is_some()); +} + +#[nexus_test] +async fn test_silo_users_list(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + let initial_silo_users: Vec = + NexusRequest::iter_collection_authn(client, "/v1/users", "", None) + .await + .expect("failed to list silo users (1)") + .all_items; + + // In the built-in Silo, we expect the test-privileged and test-unprivileged + // users. + assert_eq!( + initial_silo_users, + vec![ + views::User { + id: USER_TEST_PRIVILEGED.id(), + display_name: USER_TEST_PRIVILEGED.external_id.clone(), + silo_id: DEFAULT_SILO_ID, + }, + views::User { + id: USER_TEST_UNPRIVILEGED.id(), + display_name: USER_TEST_UNPRIVILEGED.external_id.clone(), + silo_id: DEFAULT_SILO_ID, + }, + ] + ); + + // Now create another user and make sure we can see them. While we're at + // it, use a small limit to check that pagination is really working. + let new_silo_user_external_id = "can-we-see-them"; + let new_silo_user_id = create_local_user( + client, + &views::Silo::try_from(DEFAULT_SILO.clone()).unwrap(), + &new_silo_user_external_id.parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await + .id; + + let mut silo_users: Vec = + NexusRequest::iter_collection_authn(client, "/v1/users", "", Some(1)) + .await + .expect("failed to list silo users (2)") + .all_items; + silo_users.sort_by(|u1, u2| u1.display_name.cmp(&u2.display_name)); + assert_eq!( + silo_users, + vec![ + views::User { + id: new_silo_user_id, + display_name: new_silo_user_external_id.into(), + silo_id: DEFAULT_SILO_ID, + }, + views::User { + id: USER_TEST_PRIVILEGED.id(), + display_name: USER_TEST_PRIVILEGED.external_id.clone(), + silo_id: DEFAULT_SILO_ID, + }, + views::User { + id: USER_TEST_UNPRIVILEGED.id(), + display_name: USER_TEST_UNPRIVILEGED.external_id.clone(), + silo_id: DEFAULT_SILO_ID, + }, + ] + ); + + // Create another Silo with a Silo administrator. That user should not be + // able to see the users in the first Silo. + + let silo = + create_silo(client, "silo2", true, shared::SiloIdentityMode::LocalOnly) + .await; + + let new_silo_user_name = String::from("some-silo-user"); + let new_silo_user_id = create_local_user( + client, + &silo, + &new_silo_user_name.parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await + .id; + grant_iam( + client, + "/v1/system/silos/silo2", + SiloRole::Admin, + new_silo_user_id, + AuthnMode::PrivilegedUser, + ) + .await; + + let silo2_users: dropshot::ResultsPage = + NexusRequest::object_get(client, "/v1/users") + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + assert_eq!( + silo2_users.items, + vec![views::User { + id: new_silo_user_id, + display_name: new_silo_user_name, + silo_id: silo.identity.id, + }] + ); + + // The "test-privileged" user also shouldn't see the user in this other + // Silo. + let mut new_silo_users: Vec = + NexusRequest::iter_collection_authn(client, "/v1/users", "", Some(1)) + .await + .expect("failed to list silo users (2)") + .all_items; + new_silo_users.sort_by(|u1, u2| u1.display_name.cmp(&u2.display_name)); + assert_eq!(silo_users, new_silo_users,); + + // TODO-coverage When we have a way to remove or invalidate Silo Users, we + // should test that doing so causes them to stop appearing in the list. +} + +#[nexus_test] +async fn test_silo_groups_jit(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + let datastore = nexus.datastore(); + + let silo = create_silo( + &client, + "test-silo", + true, + shared::SiloIdentityMode::SamlJit, + ) + .await; + + // Create a user in advance + create_jit_user(datastore, &silo, "external@id.com").await; + + let authn_opctx = nexus.opctx_external_authn(); + + let (authz_silo, db_silo) = + LookupPath::new(&authn_opctx, nexus.datastore()) + .silo_name(&silo.identity.name.into()) + .fetch() + .await + .unwrap(); + + // Should create two groups from the authenticated subject + let existing_silo_user = nexus + .silo_user_from_authenticated_subject( + &authn_opctx, + &authz_silo, + &db_silo, + &AuthenticatedSubject { + external_id: "external@id.com".into(), + groups: vec!["a-group".into(), "b-group".into()], + }, + ) + .await + .unwrap(); + + let group_memberships = nexus + .datastore() + .silo_group_membership_for_user( + &authn_opctx, + &authz_silo, + existing_silo_user.id(), + ) + .await + .unwrap(); + + assert_eq!(group_memberships.len(), 2); + + let mut group_names = vec![]; + + for group_membership in &group_memberships { + let (.., db_group) = LookupPath::new(&authn_opctx, nexus.datastore()) + .silo_group_id(group_membership.silo_group_id.into()) + .fetch() + .await + .unwrap(); + + group_names.push(db_group.external_id); + } + + assert!(group_names.contains(&"a-group".to_string())); + assert!(group_names.contains(&"b-group".to_string())); +} + +#[nexus_test] +async fn test_silo_groups_fixed(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + + let silo = create_silo( + &client, + "test-silo", + true, + shared::SiloIdentityMode::LocalOnly, + ) + .await; + + // Create a user in advance + create_local_user( + client, + &silo, + &"external-id-com".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + + let authn_opctx = nexus.opctx_external_authn(); + + let (authz_silo, db_silo) = + LookupPath::new(&authn_opctx, nexus.datastore()) + .silo_name(&silo.identity.name.into()) + .fetch() + .await + .unwrap(); + + // Should not create groups from the authenticated subject + let existing_silo_user = nexus + .silo_user_from_authenticated_subject( + &authn_opctx, + &authz_silo, + &db_silo, + &AuthenticatedSubject { + external_id: "external-id-com".into(), + groups: vec!["a-group".into(), "b-group".into()], + }, + ) + .await + .unwrap(); + + let group_memberships = nexus + .datastore() + .silo_group_membership_for_user( + &authn_opctx, + &authz_silo, + existing_silo_user.id(), + ) + .await + .unwrap(); + + assert_eq!(group_memberships.len(), 0); +} + +#[nexus_test] +async fn test_silo_groups_remove_from_one_group( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + let datastore = nexus.datastore(); + + let silo = create_silo( + &client, + "test-silo", + true, + shared::SiloIdentityMode::SamlJit, + ) + .await; + + // Create a user in advance + create_jit_user(datastore, &silo, "external@id.com").await; + + let authn_opctx = nexus.opctx_external_authn(); + + let (authz_silo, db_silo) = + LookupPath::new(&authn_opctx, nexus.datastore()) + .silo_name(&silo.identity.name.into()) + .fetch() + .await + .unwrap(); + + // Add to two groups + let existing_silo_user = nexus + .silo_user_from_authenticated_subject( + &authn_opctx, + &authz_silo, + &db_silo, + &AuthenticatedSubject { + external_id: "external@id.com".into(), + groups: vec!["a-group".into(), "b-group".into()], + }, + ) + .await + .unwrap(); + + // Check those groups were created and the user was added + let group_memberships = nexus + .datastore() + .silo_group_membership_for_user( + &authn_opctx, + &authz_silo, + existing_silo_user.id(), + ) + .await + .unwrap(); + + assert_eq!(group_memberships.len(), 2); + + let mut group_names = vec![]; + + for group_membership in &group_memberships { + let (.., db_group) = LookupPath::new(&authn_opctx, nexus.datastore()) + .silo_group_id(group_membership.silo_group_id.into()) + .fetch() + .await + .unwrap(); + + group_names.push(db_group.external_id); + } + + assert!(group_names.contains(&"a-group".to_string())); + assert!(group_names.contains(&"b-group".to_string())); + + // Then remove their membership from one group + let existing_silo_user = nexus + .silo_user_from_authenticated_subject( + &authn_opctx, + &authz_silo, + &db_silo, + &AuthenticatedSubject { + external_id: "external@id.com".into(), + groups: vec!["b-group".into()], + }, + ) + .await + .unwrap(); + + let group_memberships = nexus + .datastore() + .silo_group_membership_for_user( + &authn_opctx, + &authz_silo, + existing_silo_user.id(), + ) + .await + .unwrap(); + + assert_eq!(group_memberships.len(), 1); + + let mut group_names = vec![]; + + for group_membership in &group_memberships { + let (.., db_group) = LookupPath::new(&authn_opctx, nexus.datastore()) + .silo_group_id(group_membership.silo_group_id.into()) + .fetch() + .await + .unwrap(); + + group_names.push(db_group.external_id); + } + + assert!(group_names.contains(&"b-group".to_string())); +} + +#[nexus_test] +async fn test_silo_groups_remove_from_both_groups( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + let datastore = nexus.datastore(); + + let silo = create_silo( + &client, + "test-silo", + true, + shared::SiloIdentityMode::SamlJit, + ) + .await; + + // Create a user in advance + create_jit_user(datastore, &silo, "external@id.com").await; + + let authn_opctx = nexus.opctx_external_authn(); + + let (authz_silo, db_silo) = + LookupPath::new(&authn_opctx, nexus.datastore()) + .silo_name(&silo.identity.name.into()) + .fetch() + .await + .unwrap(); + + // Add to two groups + let existing_silo_user = nexus + .silo_user_from_authenticated_subject( + &authn_opctx, + &authz_silo, + &db_silo, + &AuthenticatedSubject { + external_id: "external@id.com".into(), + groups: vec!["a-group".into(), "b-group".into()], + }, + ) + .await + .unwrap(); + + // Check those groups were created and the user was added + let group_memberships = nexus + .datastore() + .silo_group_membership_for_user( + &authn_opctx, + &authz_silo, + existing_silo_user.id(), + ) + .await + .unwrap(); + + assert_eq!(group_memberships.len(), 2); + + let mut group_names = vec![]; + + for group_membership in &group_memberships { + let (.., db_group) = LookupPath::new(&authn_opctx, nexus.datastore()) + .silo_group_id(group_membership.silo_group_id.into()) + .fetch() + .await + .unwrap(); + + group_names.push(db_group.external_id); + } + + assert!(group_names.contains(&"a-group".to_string())); + assert!(group_names.contains(&"b-group".to_string())); + + // Then remove from both groups, and add to a new one + let existing_silo_user = nexus + .silo_user_from_authenticated_subject( + &authn_opctx, + &authz_silo, + &db_silo, + &AuthenticatedSubject { + external_id: "external@id.com".into(), + groups: vec!["c-group".into()], + }, + ) + .await + .unwrap(); + + let group_memberships = nexus + .datastore() + .silo_group_membership_for_user( + &authn_opctx, + &authz_silo, + existing_silo_user.id(), + ) + .await + .unwrap(); + + assert_eq!(group_memberships.len(), 1); + + let mut group_names = vec![]; + + for group_membership in &group_memberships { + let (.., db_group) = LookupPath::new(&authn_opctx, nexus.datastore()) + .silo_group_id(group_membership.silo_group_id.into()) + .fetch() + .await + .unwrap(); + + group_names.push(db_group.external_id); + } + + assert!(group_names.contains(&"c-group".to_string())); +} + +// Test that silo delete cleans up associated groups +#[nexus_test] +async fn test_silo_delete_clean_up_groups(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + + // Create a silo + let silo = create_silo( + &client, + "test-silo", + true, + shared::SiloIdentityMode::SamlJit, + ) + .await; + + let opctx_external_authn = nexus.opctx_external_authn(); + let opctx = OpContext::for_tests( + cptestctx.logctx.log.new(o!()), + nexus.datastore().clone(), + ); + + let (authz_silo, db_silo) = LookupPath::new(&opctx, nexus.datastore()) + .silo_name(&silo.identity.name.into()) + .fetch() + .await + .unwrap(); + + // Add a user with a group membership + let silo_user = nexus + .silo_user_from_authenticated_subject( + &opctx_external_authn, + &authz_silo, + &db_silo, + &AuthenticatedSubject { + external_id: "user@company.com".into(), + groups: vec!["sre".into()], + }, + ) + .await + .expect("silo_user_from_authenticated_subject"); + + // Delete the silo + NexusRequest::object_delete(&client, &"/v1/system/silos/test-silo") + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request"); + + // Expect the group is gone + assert!( + nexus + .datastore() + .silo_group_optional_lookup( + &opctx_external_authn, + &authz_silo, + "a-group".into(), + ) + .await + .expect("silo_group_optional_lookup") + .is_none() + ); + + // Expect the group membership is gone + let memberships = nexus + .datastore() + .silo_group_membership_for_user( + &opctx_external_authn, + &authz_silo, + silo_user.id(), + ) + .await + .expect("silo_group_membership_for_user"); + + assert!(memberships.is_empty()); + + // Expect the user is gone + LookupPath::new(&opctx_external_authn, nexus.datastore()) + .silo_user_id(silo_user.id()) + .fetch() + .await + .expect_err("user found"); +} + +// Test ensuring the same group from different users +#[nexus_test] +async fn test_ensure_same_silo_group(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + + // Create a silo + let silo = create_silo( + &client, + "test-silo", + true, + shared::SiloIdentityMode::SamlJit, + ) + .await; + + let opctx = OpContext::for_tests( + cptestctx.logctx.log.new(o!()), + nexus.datastore().clone(), + ); + + let (authz_silo, db_silo) = LookupPath::new(&opctx, nexus.datastore()) + .silo_name(&silo.identity.name.into()) + .fetch() + .await + .unwrap(); + + // Add the first user with a group membership + let _silo_user_1 = nexus + .silo_user_from_authenticated_subject( + &nexus.opctx_external_authn(), + &authz_silo, + &db_silo, + &AuthenticatedSubject { + external_id: "user1@company.com".into(), + groups: vec!["sre".into()], + }, + ) + .await + .expect("silo_user_from_authenticated_subject 1"); + + // Add the first user with a group membership + let _silo_user_2 = nexus + .silo_user_from_authenticated_subject( + &nexus.opctx_external_authn(), + &authz_silo, + &db_silo, + &AuthenticatedSubject { + external_id: "user2@company.com".into(), + groups: vec!["sre".into()], + }, + ) + .await + .expect("silo_user_from_authenticated_subject 2"); + + // TODO-coverage were we intending to verify something here? +} + +/// Tests the behavior of the per-Silo "list users" and "fetch user" endpoints. +/// +/// We'll run the tests separately for both kinds of Silo. The implementation +/// should be the same, but that's why we're verifying it. +#[nexus_test] +async fn test_silo_user_views(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let datastore = cptestctx.server.server_context().nexus.datastore(); + + // Create the two Silos. + let silo1 = + create_silo(client, "silo1", false, shared::SiloIdentityMode::SamlJit) + .await; + let silo2 = create_silo( + client, + "silo2", + false, + shared::SiloIdentityMode::LocalOnly, + ) + .await; + + // Create two users in each Silo. We need two so that we can verify that an + // ordinary user can see a user other than themselves in each Silo. + let silo1_user1 = create_jit_user(datastore, &silo1, "silo1-user1").await; + let silo1_user1_id = silo1_user1.id; + let silo1_user2 = create_jit_user(datastore, &silo1, "silo1-user2").await; + let silo1_user2_id = silo1_user2.id; + let mut silo1_expected_users = [silo1_user1.clone(), silo1_user2.clone()]; + silo1_expected_users.sort_by_key(|u| u.id); + + let silo2_user1 = create_local_user( + client, + &silo2, + &"silo2-user1".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + let silo2_user1_id = silo2_user1.id; + let silo2_user2 = create_local_user( + client, + &silo2, + &"silo2-user2".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + let silo2_user2_id = silo2_user2.id; + let mut silo2_expected_users = [silo2_user1.clone(), silo2_user2.clone()]; + silo2_expected_users.sort_by_key(|u| u.id); + + let users_by_id = { + let mut users_by_id: BTreeMap = + BTreeMap::new(); + assert_eq!(users_by_id.insert(silo1_user1_id, &silo1_user1), None); + assert_eq!(users_by_id.insert(silo1_user2_id, &silo1_user2), None); + assert_eq!(users_by_id.insert(silo2_user1_id, &silo2_user1), None); + assert_eq!(users_by_id.insert(silo2_user2_id, &silo2_user2), None); + users_by_id + }; + + let users_by_name = users_by_id + .values() + .map(|user| (user.display_name.to_owned(), *user)) + .collect::>(); + + // We'll run through a battery of tests: + // - for each of our test silos + // - for all *five* users ("test-privileged", plus the two users that we + // created in each Silo) + // - test the "list" endpoint + // - for all five user ids + // - test the "view user" endpoint for that user id + // + // This exercises a lot of different behaviors: + // - on success, the "list" and "view" endpoints always return the right + // contents + // - on failure, the "list" and "view" endpoints always return the right + // status code and message for the failure mode + // - that users can always list and fetch all users in their own Silo via + // /v1/system/silos (/users is tested elsewhere) + // - that users without privileges cannot list or fetch users in other Silos + // - that users with privileges on another Silo can list and fetch users in + // that Silo + // - that a user with id "foo" in Silo1 cannot be accessed by that id in + // Silo 2. This case is easy to miss but would be very bad to get wrong! + let all_callers = { + std::iter::once(AuthnMode::PrivilegedUser) + .chain(users_by_name.values().map(|v| AuthnMode::SiloUser(v.id))) + .collect::>() + }; + + struct TestSilo<'a> { + silo: &'a views::Silo, + expected_users: [views::User; 2], + } + + let test_silo1 = + TestSilo { silo: &silo1, expected_users: silo1_expected_users }; + let test_silo2 = + TestSilo { silo: &silo2, expected_users: silo2_expected_users }; + + // Strip the identifier out of error messages because the uuid changes each + // time. + let id_re = regex::Regex::new("\".*?\"").unwrap(); + + let mut output = String::new(); + for test_silo in [test_silo1, test_silo2] { + let silo_name = &test_silo.silo.identity().name; + + write!(&mut output, "SILO: {}\n", silo_name).unwrap(); + + for calling_user in all_callers.iter() { + let caller_label = match calling_user { + AuthnMode::PrivilegedUser => "privileged", + AuthnMode::SiloUser(silo_user_id) => { + let user = users_by_id.get(silo_user_id).unwrap(); + &user.display_name + } + _ => unimplemented!(), + }; + write!(&mut output, " test user {}:\n", caller_label).unwrap(); + + // Test the "list" endpoint. + write!(&mut output, " list = ").unwrap(); + let test_response = NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + &format!("/v1/system/users?silo={}", silo_name), + )) + .authn_as(calling_user.clone()) + .execute() + .await + .unwrap(); + write!(&mut output, "{}", test_response.status.as_str()).unwrap(); + + // If this succeeded, it must have returned the expected users for + // this Silo. + if test_response.status == http::StatusCode::OK { + let found_users = test_response + .parsed_body::>() + .unwrap() + .items; + assert_eq!(found_users, test_silo.expected_users); + } else { + let error = test_response + .parsed_body::() + .unwrap(); + write!(&mut output, " (message = {:?})", error.message) + .unwrap(); + } + + write!(&mut output, "\n").unwrap(); + + // Test the "view" endpoint for each user in this Silo. + for (_, user) in &users_by_name { + let user_id = user.id; + write!(&mut output, " view {:?} = ", user.display_name) + .unwrap(); + let test_response = NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + &format!("/v1/system/users/{}?silo={}", user_id, silo_name), + )) + .authn_as(calling_user.clone()) + .execute() + .await + .unwrap(); + write!(&mut output, "{}", test_response.status.as_str()) + .unwrap(); + // If this succeeded, it must have returned the right user back. + if test_response.status == http::StatusCode::OK { + let found_user = + test_response.parsed_body::().unwrap(); + assert_eq!( + found_user.silo_id, + test_silo.silo.identity().id + ); + assert_eq!(found_user, **user); + } else { + let error = test_response + .parsed_body::() + .unwrap(); + let message = id_re.replace_all(&error.message, "..."); + write!(&mut output, " (message = {:?})", message).unwrap(); + } + + write!(&mut output, "\n").unwrap(); + } + + write!(&mut output, "\n").unwrap(); + } + } + + expectorate::assert_contents( + "tests/output/silo-user-views-output.txt", + &output, + ); +} + +/// Create a user in a SamlJit Silo for testing +/// +/// For local-only Silos, use the real API (via `create_local_user()`). +async fn create_jit_user( + datastore: &db::DataStore, + silo: &views::Silo, + external_id: &str, +) -> views::User { + assert_eq!(silo.identity_mode, shared::SiloIdentityMode::SamlJit); + let silo_id = silo.identity.id; + let silo_user_id = SiloUserUuid::new_v4(); + let authz_silo = + authz::Silo::new(authz::FLEET, silo_id, LookupType::ById(silo_id)); + let silo_user = + db::model::SiloUser::new(silo_id, silo_user_id, external_id.to_owned()); + datastore + .silo_user_create(&authz_silo, silo_user) + .await + .expect("failed to create user in SamlJit Silo") + .1 + .into() +} + +/// Tests that LocalOnly-specific endpoints are not available in SamlJit Silos +#[nexus_test] +async fn test_jit_silo_constraints(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + let datastore = nexus.datastore(); + let silo = + create_silo(&client, "jit", true, shared::SiloIdentityMode::SamlJit) + .await; + + // We need one initial user that would in principle have privileges to + // create other users. + let admin_username = "admin-user"; + let admin_user = create_jit_user(&datastore, &silo, admin_username).await; + + // Grant this user "admin" privileges on that Silo. + grant_iam( + client, + "/v1/system/silos/jit", + SiloRole::Admin, + admin_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + // Neither the "test-privileged" user nor this newly-created admin user + // ought to be able to create a user via the Silo's local identity provider + // (because that provider does not exist). + for caller in + [AuthnMode::PrivilegedUser, AuthnMode::SiloUser(admin_user.id)] + { + verify_local_idp_404( + NexusRequest::expect_failure_with_body( + client, + StatusCode::NOT_FOUND, + Method::POST, + "/v1/system/identity-providers/local/users?silo=jit", + &test_params::UserCreate { + external_id: UserId::from_str("dummy").unwrap(), + password: test_params::UserPassword::LoginDisallowed, + }, + ) + .authn_as(caller), + ) + .await; + } + + // Now create another user, as might happen via JIT. + let other_user_id = + create_jit_user(datastore, &silo, "other-user").await.id; + let user_url_delete = format!( + "/v1/system/identity-providers/local/users/{}?silo=jit", + other_user_id + ); + let user_url_set_password = format!( + "/v1/system/identity-providers/local/users/{}/set-password?silo=jit", + other_user_id + ); + + // Neither the "test-privileged" user nor the Silo Admin ought to be able to + // remove this user via the local identity provider, nor set the user's + // password. + let password = "dummy"; + for caller in + [AuthnMode::PrivilegedUser, AuthnMode::SiloUser(admin_user.id)] + { + verify_local_idp_404( + NexusRequest::expect_failure( + client, + StatusCode::NOT_FOUND, + Method::DELETE, + &user_url_delete, + ) + .authn_as(caller.clone()), + ) + .await; + + verify_local_idp_404( + NexusRequest::expect_failure_with_body( + client, + StatusCode::NOT_FOUND, + Method::POST, + &user_url_set_password, + &test_params::UserPassword::Password(password.to_string()), + ) + .authn_as(caller.clone()), + ) + .await; + } + + // One should also not be able to log into this kind of Silo with a username + // and password. + verify_local_idp_404(NexusRequest::expect_failure_with_body( + client, + StatusCode::NOT_FOUND, + Method::POST, + "/v1/login/jit/local", + &test_params::UsernamePasswordCredentials { + username: UserId::from_str(admin_username).unwrap(), + password: password.to_string(), + }, + )) + .await; + + // They should get the same error for a user that does not exist. + verify_local_idp_404(NexusRequest::expect_failure_with_body( + client, + StatusCode::NOT_FOUND, + Method::POST, + "/v1/login/jit/local", + &test_params::UsernamePasswordCredentials { + username: UserId::from_str("bogus").unwrap(), + password: password.to_string(), + }, + )) + .await; +} + +async fn verify_local_idp_404(request: NexusRequest<'_>) { + let error = request + .execute() + .await + .unwrap() + .parsed_body::() + .unwrap(); + assert_eq!( + error.message, + "not found: identity-provider with name \"local\"" + ); +} + +/// Tests that SamlJit-specific endpoints are not available in LocalOnly Silos +#[nexus_test] +async fn test_local_silo_constraints(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + // Create a "LocalOnly" Silo with its own admin user. + let silo = create_silo( + &client, + "fixed", + true, + shared::SiloIdentityMode::LocalOnly, + ) + .await; + let new_silo_user_id = create_local_user( + client, + &silo, + &"admin-user".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await + .id; + grant_iam( + client, + "/v1/system/silos/fixed", + SiloRole::Admin, + new_silo_user_id, + AuthnMode::PrivilegedUser, + ) + .await; + + // It's not allowed to create an identity provider in a LocalOnly Silo. + let error: dropshot::HttpErrorResponseBody = + NexusRequest::expect_failure_with_body( + client, + StatusCode::BAD_REQUEST, + Method::POST, + "/v1/system/identity-providers/saml?silo=fixed", + ¶ms::SamlIdentityProviderCreate { + identity: IdentityMetadataCreateParams { + name: "some-totally-real-saml-provider" + .to_string() + .parse() + .unwrap(), + description: "a demo provider".to_string(), + }, + + idp_metadata_source: + params::IdpMetadataSource::Base64EncodedXml { + data: base64::engine::general_purpose::STANDARD + .encode(SAML_IDP_DESCRIPTOR), + }, + + idp_entity_id: "entity_id".to_string(), + sp_client_id: "client_id".to_string(), + acs_url: "http://acs".to_string(), + slo_url: "http://slo".to_string(), + technical_contact_email: "technical@fake".to_string(), + + signing_keypair: None, + + group_attribute_name: None, + }, + ) + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + + assert_eq!( + error.message, + "cannot create identity providers in this kind of Silo" + ); + + // The SAML login endpoints should not work, either. + let error: dropshot::HttpErrorResponseBody = NexusRequest::expect_failure( + client, + StatusCode::NOT_FOUND, + Method::GET, + "/login/fixed/saml/foo/redirect", + ) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + assert_eq!(error.message, "not found: identity-provider with name \"foo\""); + let error: dropshot::HttpErrorResponseBody = NexusRequest::expect_failure( + client, + StatusCode::NOT_FOUND, + Method::POST, + "/login/fixed/saml/foo", + ) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + assert_eq!(error.message, "not found: identity-provider with name \"foo\""); +} + +#[nexus_test] +async fn test_local_silo_users(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + // Create a "LocalOnly" Silo for testing. + let silo1 = create_silo( + &client, + "silo1", + true, + shared::SiloIdentityMode::LocalOnly, + ) + .await; + + // We'll run through a battery of tests as each of two different users: the + // usual "test-privileged" user (which should have full access because + // they're a Fleet Administrator) as well as a newly-created Silo Admin + // user. + run_user_tests(client, &silo1, &AuthnMode::PrivilegedUser, &[]).await; + + // Create a Silo Admin in our test Silo and run through the same tests. + let admin_user = create_local_user( + client, + &silo1, + &"admin-user".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + grant_iam( + client, + "/v1/system/silos/silo1", + SiloRole::Admin, + admin_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + run_user_tests( + client, + &silo1, + &AuthnMode::SiloUser(admin_user.id), + std::slice::from_ref(&admin_user), + ) + .await; +} + +/// Runs a sequence of tests for create, read, and delete of API-managed users +async fn run_user_tests( + client: &dropshot::test_util::ClientTestContext, + silo: &views::Silo, + authn_mode: &AuthnMode, + existing_users: &[views::User], +) { + let url_all_users = format!("/v1/system/users?silo={}", silo.identity.name); + let url_local_idp_users = format!( + "/v1/system/identity-providers/local/users?silo={}", + silo.identity.name + ); + let url_user_create = url_local_idp_users.to_string(); + + // Fetch users and verify it matches what the caller expects. + println!("run_user_tests: as {:?}: fetch all users", authn_mode); + let users = NexusRequest::object_get(client, &url_all_users) + .authn_as(authn_mode.clone()) + .execute() + .await + .expect("failed to list users") + .parsed_body::>() + .unwrap() + .items; + println!("users: {:?}", users); + assert_eq!(users, existing_users); + + // Create a user. + let user_created = NexusRequest::objects_post( + client, + &url_user_create, + &test_params::UserCreate { + external_id: UserId::from_str("a-test-user").unwrap(), + password: test_params::UserPassword::LoginDisallowed, + }, + ) + .authn_as(authn_mode.clone()) + .execute() + .await + .expect("failed to create user") + .parsed_body::() + .unwrap(); + assert_eq!(user_created.display_name, "a-test-user"); + println!("created user: {:?}", user_created); + + // Fetch the user we just created. + let user_url_get = format!( + "/v1/system/users/{}?silo={}", + user_created.id, silo.identity.name, + ); + let user_found = NexusRequest::object_get(client, &user_url_get) + .authn_as(authn_mode.clone()) + .execute() + .await + .expect("failed to fetch user we just created") + .parsed_body::() + .unwrap(); + assert_eq!(user_created, user_found); + + // List users. We should find whatever was there before, plus our new one. + let new_users = NexusRequest::object_get(client, &url_all_users) + .authn_as(authn_mode.clone()) + .execute() + .await + .expect("failed to list users") + .parsed_body::>() + .unwrap() + .items; + println!("new_users: {:?}", new_users); + let new_users = new_users + .iter() + .filter(|new_user| !users.iter().any(|old_user| *new_user == old_user)) + .collect::>(); + assert_eq!(new_users, &[&user_created]); + + // Delete the user that we created. + let user_url_delete = format!( + "/v1/system/identity-providers/local/users/{}?silo={}", + user_created.id, silo.identity.name, + ); + NexusRequest::object_delete(client, &user_url_delete) + .authn_as(authn_mode.clone()) + .execute() + .await + .expect("failed to delete the user we just created"); + + // We should not be able to fetch or delete the user again. + for method in [Method::GET, Method::DELETE] { + let url = if method == Method::GET { + &user_url_get + } else { + &user_url_delete + }; + let error = NexusRequest::expect_failure( + client, + StatusCode::NOT_FOUND, + method, + url, + ) + .authn_as(authn_mode.clone()) + .execute() + .await + .expect("unexpectedly succeeded in fetching deleted user") + .parsed_body::() + .unwrap(); + let not_found_message = + format!("not found: silo-user with id \"{}\"", user_created.id); + assert_eq!(error.message, not_found_message); + } + + // List users again. We should just find whatever we started with. + let last_users = NexusRequest::object_get(client, &url_all_users) + .authn_as(authn_mode.clone()) + .execute() + .await + .expect("failed to list users") + .parsed_body::>() + .unwrap() + .items; + println!("last_users: {:?}", last_users); + assert_eq!(last_users, existing_users); +} + +pub async fn verify_silo_dns_name( + cptestctx: &ControlPlaneTestContext, + silo_name: &str, + should_exist: bool, +) { + // The DNS naming scheme for Silo DNS names is just: + // $silo_name.sys.$delegated_name + // This is determined by RFD 357 and also implemented in Nexus. + let dns_name = + format!("{}.sys.{}", silo_name, cptestctx.external_dns_zone_name); + + // We assume that in the test suite, Nexus's "external" address is + // localhost. + let nexus_ip = Ipv4Addr::LOCALHOST; + + wait_for_condition( + || async { + let found = match cptestctx + .external_dns + .resolver() + .await + .expect("Failed to create external DNS resolver") + .ipv4_lookup(&dns_name) + .await + { + Ok(result) => { + let addrs: Vec<_> = result.iter().map(|a| &a.0).collect(); + if addrs.is_empty() { + false + } else { + assert_eq!(addrs, [&nexus_ip]); + true + } + } + Err(error) => match resolve_error_proto_kind(&error) { + Some(ProtoErrorKind::NoRecordsFound { .. }) => false, + _ => panic!( + "unexpected error querying external \ + DNS server for Silo DNS name {:?}: {:#}", + dns_name, error + ), + }, + }; + + if should_exist == found { + Ok(()) + } else { + Err::<_, CondCheckError>(CondCheckError::NotYet) + } + }, + &Duration::from_millis(50), + &Duration::from_secs(15), + ) + .await + .expect("failed to verify external DNS configuration"); +} + +fn resolve_error_proto_kind( + e: &hickory_resolver::ResolveError, +) -> Option<&ProtoErrorKind> { + let ResolveErrorKind::Proto(proto_error) = e.kind() else { return None }; + Some(proto_error.kind()) +} + +// Test the basic behavior of the Silo-level IAM policy that supports +// configuring Silo roles to confer Fleet-level roles. Because we don't support +// modifying Silos at all, we have to use separate Silos to test this behavior. +// +// We'll create a few Silos for testing: +// +// - default-policy: uses the default conferred-roles policy +// - viewer-policy: silo viewers get fleet viewer role +// - admin-policy: silo admins get fleet admin role +// +// For each of these Silos, we'll create an admin user in that Silo and test +// what privileges they have. +// +// This is not an exhaustive test of the policy choices here. That's done +// in the "policy_test" unit test in Nexus. This is an end-to-end test +// exercising _that_ this policy seems to be used when it should be. +#[nexus_test] +async fn test_silo_authn_policy(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + let test_cases = [ + ("default-policy", ExpectedFleetPrivileges::None, BTreeMap::new()), + ( + "viewer-policy", + ExpectedFleetPrivileges::ReadOnly, + BTreeMap::from([( + SiloRole::Viewer, + BTreeSet::from([FleetRole::Viewer]), + )]), + ), + // It's important to test the case of someone with "Fleet Collaborator" + // because that's the only role that would allow someone to create + // ordinary Silos but _not_ Silos that confer additional privileges. + // Thus, this is the only case that tests that we don't allow this + // potentially dangerous privilege escalation! + ( + "collaborator-policy", + ExpectedFleetPrivileges::CreateSilo, + BTreeMap::from([( + SiloRole::Admin, + BTreeSet::from([FleetRole::Collaborator]), + )]), + ), + ( + "admin-policy", + ExpectedFleetPrivileges::CreatePrivilegedSilo, + BTreeMap::from([( + SiloRole::Admin, + BTreeSet::from([FleetRole::Admin]), + )]), + ), + ]; + + for (label, expected_privileges, policy) in test_cases { + println!("test case: {:?}", label); + + // Create a Silo with the expected policy. + let silo_name = label.parse().unwrap(); + let silo = NexusRequest::objects_post( + client, + "/v1/system/silos", + ¶ms::SiloCreate { + identity: IdentityMetadataCreateParams { + name: silo_name, + description: String::new(), + }, + quotas: params::SiloQuotasCreate::empty(), + discoverable: false, + identity_mode: shared::SiloIdentityMode::LocalOnly, + admin_group_name: None, + tls_certificates: vec![], + mapped_fleet_roles: policy, + }, + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body::() + .unwrap(); + + // Create an administrator in this Silo. + let admin_user = create_local_user( + client, + &silo, + &(format!("{}-user", label).parse().unwrap()), + test_params::UserPassword::LoginDisallowed, + ) + .await; + grant_iam( + client, + &format!("/v1/system/silos/{}", label), + SiloRole::Admin, + admin_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + // See what Fleet-level privileges they have. + check_fleet_privileges( + client, + &AuthnMode::SiloUser(admin_user.id), + expected_privileges, + ) + .await; + } +} + +enum ExpectedFleetPrivileges { + None, + ReadOnly, + CreateSilo, + CreatePrivilegedSilo, +} + +async fn check_fleet_privileges( + client: &dropshot::test_util::ClientTestContext, + authn_mode: &AuthnMode, + expected: ExpectedFleetPrivileges, +) { + // To test reading the fleet, we try listing racks. + const URL_RO: &'static str = "/v1/system/hardware/racks"; + let nexus_request = if let ExpectedFleetPrivileges::None = expected { + NexusRequest::expect_failure( + client, + http::StatusCode::FORBIDDEN, + http::Method::GET, + URL_RO, + ) + } else { + NexusRequest::object_get(client, URL_RO) + }; + nexus_request.authn_as(authn_mode.clone()).execute().await.unwrap(); + + // Next, see if the user can create an unprivileged Silo (i.e., one that + // confers no Fleet-level roles). + const URL_SILOS: &'static str = "/v1/system/silos"; + const SILO_NAME: &'static str = "probe-silo"; + let body = params::SiloCreate { + identity: IdentityMetadataCreateParams { + name: SILO_NAME.parse().unwrap(), + description: String::new(), + }, + quotas: params::SiloQuotasCreate::empty(), + discoverable: false, + identity_mode: shared::SiloIdentityMode::LocalOnly, + admin_group_name: None, + tls_certificates: vec![], + mapped_fleet_roles: BTreeMap::new(), + }; + let (do_delete, nexus_request) = match expected { + ExpectedFleetPrivileges::None | ExpectedFleetPrivileges::ReadOnly => ( + false, + NexusRequest::expect_failure_with_body( + client, + http::StatusCode::FORBIDDEN, + http::Method::POST, + URL_SILOS, + &body, + ), + ), + ExpectedFleetPrivileges::CreateSilo + | ExpectedFleetPrivileges::CreatePrivilegedSilo => ( + true, + NexusRequest::objects_post( + client, + URL_SILOS, + ¶ms::SiloCreate { + identity: IdentityMetadataCreateParams { + name: SILO_NAME.parse().unwrap(), + description: String::new(), + }, + quotas: params::SiloQuotasCreate::empty(), + discoverable: false, + identity_mode: shared::SiloIdentityMode::LocalOnly, + admin_group_name: None, + tls_certificates: vec![], + mapped_fleet_roles: BTreeMap::new(), + }, + ), + ), + }; + nexus_request.authn_as(authn_mode.clone()).execute().await.unwrap(); + + if do_delete { + // Try to delete what we created. + let url = format!("{}/{}", URL_SILOS, SILO_NAME); + NexusRequest::object_delete(client, &url) + .authn_as(authn_mode.clone()) + .execute() + .await + .unwrap(); + } + + // Last, see if the user can create a privileged Silo. + let body = params::SiloCreate { + identity: IdentityMetadataCreateParams { + name: SILO_NAME.parse().unwrap(), + description: String::new(), + }, + quotas: params::SiloQuotasCreate::empty(), + discoverable: false, + identity_mode: shared::SiloIdentityMode::LocalOnly, + admin_group_name: None, + tls_certificates: vec![], + mapped_fleet_roles: BTreeMap::from([( + SiloRole::Admin, + BTreeSet::from([FleetRole::Viewer]), + )]), + }; + let (do_delete, nexus_request) = match expected { + ExpectedFleetPrivileges::None + | ExpectedFleetPrivileges::ReadOnly + | ExpectedFleetPrivileges::CreateSilo => ( + false, + NexusRequest::expect_failure_with_body( + client, + http::StatusCode::FORBIDDEN, + http::Method::POST, + URL_SILOS, + &body, + ), + ), + ExpectedFleetPrivileges::CreatePrivilegedSilo => ( + true, + NexusRequest::objects_post( + client, + URL_SILOS, + ¶ms::SiloCreate { + identity: IdentityMetadataCreateParams { + name: SILO_NAME.parse().unwrap(), + description: String::new(), + }, + quotas: params::SiloQuotasCreate::empty(), + discoverable: false, + identity_mode: shared::SiloIdentityMode::LocalOnly, + admin_group_name: None, + tls_certificates: vec![], + mapped_fleet_roles: BTreeMap::new(), + }, + ), + ), + }; + nexus_request.authn_as(authn_mode.clone()).execute().await.unwrap(); + + if do_delete { + // Try to delete what we created. + let url = format!("{}/{}", URL_SILOS, SILO_NAME); + NexusRequest::object_delete(client, &url) + .authn_as(authn_mode.clone()) + .execute() + .await + .unwrap(); + } +} + +// Test that a silo admin can create new certificates for their silo +// +// Internally, the certificate validation check requires the `authz::DNS_CONFIG` +// resource (to check that the certificate is valid for +// `{silo_name}.{external_dns_zone_name}`), which silo admins may not have. We +// have to use an alternate, elevated context to perform that check, and this +// test confirms we do so. +#[nexus_test] +async fn test_silo_admin_can_create_certs(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let certs_url = "/v1/certificates"; + + // Create a silo with an admin user + let silo = create_silo( + client, + "silo-name", + true, + shared::SiloIdentityMode::LocalOnly, + ) + .await; + + let new_silo_user_id = create_local_user( + client, + &silo, + &"admin".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await + .id; + + grant_iam( + client, + "/v1/system/silos/silo-name", + SiloRole::Admin, + new_silo_user_id, + AuthnMode::PrivilegedUser, + ) + .await; + + // The user should be able to create certs for this silo + let chain = CertificateChain::new(cptestctx.wildcard_silo_dns_name()); + let (cert, key) = + (chain.cert_chain_as_pem(), chain.end_cert_private_key_as_pem()); + + let cert: Certificate = NexusRequest::objects_post( + client, + certs_url, + ¶ms::CertificateCreate { + identity: IdentityMetadataCreateParams { + name: "test-cert".parse().unwrap(), + description: "the test cert".to_string(), + }, + cert, + key, + service: shared::ServiceUsingCertificate::ExternalApi, + }, + ) + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .expect("failed to create certificate") + .parsed_body() + .unwrap(); + + // The cert should exist when listing the silo's certs as the silo admin + let silo_certs = + NexusRequest::object_get(client, &format!("{certs_url}?limit=10")) + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .expect("failed to list certificates") + .parsed_body::>() + .expect("failed to parse body as ResultsPage") + .items; + + assert_eq!(silo_certs.len(), 1); + assert_eq!(silo_certs[0].identity.id, cert.identity.id); +} + +// Test that silo delete cleans up associated groups +#[nexus_test] +async fn test_silo_delete_cleans_up_ip_pool_links( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + // Create a silo + let silo1 = + create_silo(&client, "silo1", true, shared::SiloIdentityMode::SamlJit) + .await; + let silo2 = + create_silo(&client, "silo2", true, shared::SiloIdentityMode::SamlJit) + .await; + + // link pool1 to both, link pool2 to silo1 only + let range1 = IpRange::V4( + Ipv4Range::new( + std::net::Ipv4Addr::new(10, 0, 0, 51), + std::net::Ipv4Addr::new(10, 0, 0, 52), + ) + .unwrap(), + ); + create_ip_pool(client, "pool1", Some(range1)).await; + link_ip_pool(client, "pool1", &silo1.identity.id, true).await; + link_ip_pool(client, "pool1", &silo2.identity.id, true).await; + + let range2 = IpRange::V4( + Ipv4Range::new( + std::net::Ipv4Addr::new(10, 0, 0, 53), + std::net::Ipv4Addr::new(10, 0, 0, 54), + ) + .unwrap(), + ); + create_ip_pool(client, "pool2", Some(range2)).await; + link_ip_pool(client, "pool2", &silo1.identity.id, false).await; + + // we want to make sure the links are there before we make sure they're gone + let url = "/v1/system/ip-pools/pool1/silos"; + let links = + objects_list_page_authz::(client, &url).await; + assert_eq!(links.items.len(), 2); + + let url = "/v1/system/ip-pools/pool2/silos"; + let links = + objects_list_page_authz::(client, &url).await; + assert_eq!(links.items.len(), 1); + + // Delete the silo + let url = format!("/v1/system/silos/{}", silo1.identity.id); + object_delete(client, &url).await; + + // Now make sure the links are gone + let url = "/v1/system/ip-pools/pool1/silos"; + let links = + objects_list_page_authz::(client, &url).await; + assert_eq!(links.items.len(), 1); + + let url = "/v1/system/ip-pools/pool2/silos"; + let links = + objects_list_page_authz::(client, &url).await; + assert_eq!(links.items.len(), 0); + + // but the pools are of course still there + let url = "/v1/system/ip-pools"; + let pools = objects_list_page_authz::(client, &url).await; + assert_eq!(pools.items.len(), 2); + assert_eq!(pools.items[0].identity.name, "pool1"); + assert_eq!(pools.items[1].identity.name, "pool2"); + + // nothing prevents us from deleting the pools (except the child ranges -- + // we do have to remove those) + + let url = "/v1/system/ip-pools/pool1/ranges/remove"; + NexusRequest::new( + RequestBuilder::new(client, Method::POST, url) + .body(Some(&range1)) + .expect_status(Some(StatusCode::NO_CONTENT)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("Failed to delete IP range from a pool"); + + let url = "/v1/system/ip-pools/pool2/ranges/remove"; + NexusRequest::new( + RequestBuilder::new(client, Method::POST, url) + .body(Some(&range2)) + .expect_status(Some(StatusCode::NO_CONTENT)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("Failed to delete IP range from a pool"); + + object_delete(client, "/v1/system/ip-pools/pool1").await; + object_delete(client, "/v1/system/ip-pools/pool2").await; +} diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index 2957835398c..1a8dd4160b5 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 4db3c15f22d..2077d5325e6 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/schema/crdb/13.0.0/up01.sql b/schema/crdb/13.0.0/up01.sql new file mode 100644 index 00000000000..c0c766c5ce6 --- /dev/null +++ b/schema/crdb/13.0.0/up01.sql @@ -0,0 +1,7 @@ +-- Add restrict_network_actions column to silo table +-- This column controls whether networking actions (VPC, subnet, etc. create/update/delete) +-- are restricted to Silo Admins only. When false (default), Project Collaborators can +-- perform networking actions. + +ALTER TABLE omicron.public.silo +ADD COLUMN restrict_network_actions BOOL NOT NULL DEFAULT FALSE; \ No newline at end of file diff --git a/schema/crdb/restrict-network-actions/down01.sql b/schema/crdb/restrict-network-actions/down01.sql new file mode 100644 index 00000000000..2fa05401092 --- /dev/null +++ b/schema/crdb/restrict-network-actions/down01.sql @@ -0,0 +1,2 @@ +-- Remove restrict_network_actions column from silo table +ALTER TABLE omicron.public.silo DROP COLUMN restrict_network_actions; \ No newline at end of file diff --git a/schema/crdb/restrict-network-actions/up01.sql b/schema/crdb/restrict-network-actions/up01.sql new file mode 100644 index 00000000000..9c8765b0533 --- /dev/null +++ b/schema/crdb/restrict-network-actions/up01.sql @@ -0,0 +1,3 @@ +-- Add restrict_network_actions column to silo table +-- When true, only Silo Admins can create/update/delete networking resources +ALTER TABLE omicron.public.silo ADD COLUMN restrict_network_actions BOOL NOT NULL DEFAULT FALSE; \ No newline at end of file From 420ed2bc240bab775728d637acc9219081d7f5bf Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 15 Sep 2025 13:23:27 -0700 Subject: [PATCH 02/48] More development of Polar-based change to permissions --- nexus/auth/src/authz/omicron.polar | 67 ------------------- nexus/authz-macros/src/lib.rs | 12 +--- nexus/db-model/src/schema_versions.rs | 3 +- nexus/db-queries/src/db/datastore/rack.rs | 1 + nexus/reconfigurator/execution/src/dns.rs | 1 + nexus/tests/integration_tests/silos.rs | 4 ++ schema/crdb/dbinit.sql | 6 +- .../up.sql} | 0 8 files changed, 14 insertions(+), 80 deletions(-) rename schema/crdb/{13.0.0/up01.sql => restrict-network-actions/up.sql} (100%) diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index d6432d16075..72a13e69bee 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -705,71 +705,4 @@ has_relation(fleet: Fleet, "parent_fleet", collection: AlertClassList) if collection.fleet = fleet; # -# NETWORKING RESTRICTIONS BASED ON SILO SETTINGS -# - -# For networking resources, when a silo restricts networking, only silo admins can modify/create -# This applies to resources that use the InProjectNetworking polar snippet - -# Networking modify permissions with silo restriction logic -# Allow silo admins always (override any restrictions) -has_permission(actor: AuthenticatedActor, "modify", vpc: Vpc) if - has_role(actor, "admin", vpc.project.parent_silo); - -has_permission(actor: AuthenticatedActor, "modify", router: VpcRouter) if - has_role(actor, "admin", router.vpc.project.parent_silo); - -has_permission(actor: AuthenticatedActor, "modify", subnet: VpcSubnet) if - has_role(actor, "admin", subnet.vpc.project.parent_silo); - -has_permission(actor: AuthenticatedActor, "modify", gateway: InternetGateway) if - has_role(actor, "admin", gateway.vpc.project.parent_silo); - -# Allow project collaborators only if silo doesn't restrict networking -has_permission(actor: AuthenticatedActor, "modify", vpc: Vpc) if - has_role(actor, "collaborator", vpc.project) and - not vpc.project.silo.restricts_networking(); - -has_permission(actor: AuthenticatedActor, "modify", router: VpcRouter) if - has_role(actor, "collaborator", router.vpc.project) and - not router.vpc.project.silo.restricts_networking(); - -has_permission(actor: AuthenticatedActor, "modify", subnet: VpcSubnet) if - has_role(actor, "collaborator", subnet.vpc.project) and - not subnet.vpc.project.silo.restricts_networking(); - -has_permission(actor: AuthenticatedActor, "modify", gateway: InternetGateway) if - has_role(actor, "collaborator", gateway.vpc.project) and - not gateway.vpc.project.silo.restricts_networking(); - -# Networking create_child permissions with silo restriction logic -# Allow silo admins always (override any restrictions) -has_permission(actor: AuthenticatedActor, "create_child", vpc: Vpc) if - has_role(actor, "admin", vpc.project.parent_silo); - -has_permission(actor: AuthenticatedActor, "create_child", router: VpcRouter) if - has_role(actor, "admin", router.vpc.project.parent_silo); - -has_permission(actor: AuthenticatedActor, "create_child", subnet: VpcSubnet) if - has_role(actor, "admin", subnet.vpc.project.parent_silo); - -has_permission(actor: AuthenticatedActor, "create_child", gateway: InternetGateway) if - has_role(actor, "admin", gateway.vpc.project.parent_silo); - -# Allow project collaborators only if silo doesn't restrict networking -has_permission(actor: AuthenticatedActor, "create_child", vpc: Vpc) if - has_role(actor, "collaborator", vpc.project) and - not vpc.project.silo.restricts_networking(); - -has_permission(actor: AuthenticatedActor, "create_child", router: VpcRouter) if - has_role(actor, "collaborator", router.vpc.project) and - not router.vpc.project.silo.restricts_networking(); - -has_permission(actor: AuthenticatedActor, "create_child", subnet: VpcSubnet) if - has_role(actor, "collaborator", subnet.vpc.project) and - not subnet.vpc.project.silo.restricts_networking(); - -has_permission(actor: AuthenticatedActor, "create_child", gateway: InternetGateway) if - has_role(actor, "collaborator", gateway.vpc.project) and - not gateway.vpc.project.silo.restricts_networking(); diff --git a/nexus/authz-macros/src/lib.rs b/nexus/authz-macros/src/lib.rs index f00c6bcb4c2..82b014950f4 100644 --- a/nexus/authz-macros/src/lib.rs +++ b/nexus/authz-macros/src/lib.rs @@ -457,11 +457,7 @@ fn do_authz_resource( "list_children" if "viewer" on "containing_project"; "read" if "viewer" on "containing_project"; - # Silo admins can always perform networking actions (override restrictions) - "modify" if "admin" on "containing_project".parent_silo; - "create_child" if "admin" on "containing_project".parent_silo; - - # Project collaborators can perform networking actions (restriction logic via has_permission rules) + # Basic networking permissions - restrictions enforced by has_permission overrides "modify" if "collaborator" on "containing_project"; "create_child" if "collaborator" on "containing_project"; }} @@ -495,11 +491,7 @@ fn do_authz_resource( "list_children" if "viewer" on "containing_project"; "read" if "viewer" on "containing_project"; - # Silo admins can always perform networking actions (override restrictions) - "modify" if "admin" on "containing_project".parent_silo; - "create_child" if "admin" on "containing_project".parent_silo; - - # Project collaborators can perform networking actions (restriction logic via has_permission rules) + # Basic networking permissions - restrictions enforced by has_permission overrides "modify" if "collaborator" on "containing_project"; "create_child" if "collaborator" on "containing_project"; }} diff --git a/nexus/db-model/src/schema_versions.rs b/nexus/db-model/src/schema_versions.rs index b71e984126c..6754572fa0d 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(198, 0, 0); +pub const SCHEMA_VERSION: Version = Version::new(199, 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(199, "restrict-network-actions"), KnownVersion::new(198, "add-ip-pool-reservation-type-column"), KnownVersion::new(197, "scim-users-and-groups"), KnownVersion::new(196, "user-provision-type-for-silo-user-and-group"), diff --git a/nexus/db-queries/src/db/datastore/rack.rs b/nexus/db-queries/src/db/datastore/rack.rs index ab4ce8ddb44..5ab08370fb0 100644 --- a/nexus/db-queries/src/db/datastore/rack.rs +++ b/nexus/db-queries/src/db/datastore/rack.rs @@ -1151,6 +1151,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/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/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index 8ca0e3b3713..39009ea5dc6 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -2457,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 => ( @@ -2486,6 +2487,7 @@ async fn check_fleet_privileges( admin_group_name: None, tls_certificates: vec![], mapped_fleet_roles: BTreeMap::new(), + restrict_network_actions: None, }, ), ), @@ -2517,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 @@ -2547,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/schema/crdb/dbinit.sql b/schema/crdb/dbinit.sql index 5e99f07a4ed..6cbd527412b 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 ( @@ -6770,7 +6772,7 @@ INSERT INTO omicron.public.db_metadata ( version, target_version ) VALUES - (TRUE, NOW(), NOW(), '198.0.0', NULL) + (TRUE, NOW(), NOW(), '199.0.0', NULL) ON CONFLICT DO NOTHING; COMMIT; diff --git a/schema/crdb/13.0.0/up01.sql b/schema/crdb/restrict-network-actions/up.sql similarity index 100% rename from schema/crdb/13.0.0/up01.sql rename to schema/crdb/restrict-network-actions/up.sql From ad106d66e2d92becbcdf65f59ff78491e471c5f1 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 15 Sep 2025 17:36:00 -0700 Subject: [PATCH 03/48] Polar working, perhaps; lots of permission rules --- nexus/auth/src/authz/omicron.polar | 81 ++++++++++++++++++++++++ nexus/auth/src/context.rs | 30 +++++++++ nexus/db-queries/src/db/datastore/vpc.rs | 13 +++- nexus/src/app/vpc.rs | 2 - 4 files changed, 123 insertions(+), 3 deletions(-) diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index 72a13e69bee..6b61e5ee9eb 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -704,5 +704,86 @@ resource AlertClassList { has_relation(fleet: Fleet, "parent_fleet", collection: AlertClassList) if collection.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. + +# Override networking permissions for VPCs +has_permission(actor: AuthenticatedActor, "create_child", vpc: Vpc) if + has_role(actor, "collaborator", vpc.project) and + not vpc.project.silo.restricts_networking(); + +has_permission(actor: AuthenticatedActor, "create_child", vpc: Vpc) if + has_role(actor, "admin", vpc.project.silo); + +has_permission(actor: AuthenticatedActor, "modify", vpc: Vpc) if + has_role(actor, "collaborator", vpc.project) and + not vpc.project.silo.restricts_networking(); + +has_permission(actor: AuthenticatedActor, "modify", vpc: Vpc) if + has_role(actor, "admin", vpc.project.silo); + +# Override networking permissions for VPC Routers +has_permission(actor: AuthenticatedActor, "create_child", router: VpcRouter) if + has_role(actor, "collaborator", router.vpc.project) and + not router.vpc.project.silo.restricts_networking(); + +has_permission(actor: AuthenticatedActor, "create_child", router: VpcRouter) if + has_role(actor, "admin", router.vpc.project.silo); + +has_permission(actor: AuthenticatedActor, "modify", router: VpcRouter) if + has_role(actor, "collaborator", router.vpc.project) and + not router.vpc.project.silo.restricts_networking(); + +has_permission(actor: AuthenticatedActor, "modify", router: VpcRouter) if + has_role(actor, "admin", router.vpc.project.silo); + +# Override networking permissions for VPC Subnets +has_permission(actor: AuthenticatedActor, "create_child", subnet: VpcSubnet) if + has_role(actor, "collaborator", subnet.vpc.project) and + not subnet.vpc.project.silo.restricts_networking(); + +has_permission(actor: AuthenticatedActor, "create_child", subnet: VpcSubnet) if + has_role(actor, "admin", subnet.vpc.project.silo); + +has_permission(actor: AuthenticatedActor, "modify", subnet: VpcSubnet) if + has_role(actor, "collaborator", subnet.vpc.project) and + not subnet.vpc.project.silo.restricts_networking(); + +has_permission(actor: AuthenticatedActor, "modify", subnet: VpcSubnet) if + has_role(actor, "admin", subnet.vpc.project.silo); + +# Override networking permissions for Internet Gateways +has_permission(actor: AuthenticatedActor, "create_child", gateway: InternetGateway) if + has_role(actor, "collaborator", gateway.vpc.project) and + not gateway.vpc.project.silo.restricts_networking(); + +has_permission(actor: AuthenticatedActor, "create_child", gateway: InternetGateway) if + has_role(actor, "admin", gateway.vpc.project.silo); + +has_permission(actor: AuthenticatedActor, "modify", gateway: InternetGateway) if + has_role(actor, "collaborator", gateway.vpc.project) and + not gateway.vpc.project.silo.restricts_networking(); + +has_permission(actor: AuthenticatedActor, "modify", gateway: InternetGateway) if + has_role(actor, "admin", gateway.vpc.project.silo); + +# Override networking permissions for Router Routes +has_permission(actor: AuthenticatedActor, "create_child", route: RouterRoute) if + has_role(actor, "collaborator", route.vpc_router.vpc.project) and + not route.vpc_router.vpc.project.silo.restricts_networking(); + +has_permission(actor: AuthenticatedActor, "create_child", route: RouterRoute) if + has_role(actor, "admin", route.vpc_router.vpc.project.silo); + +has_permission(actor: AuthenticatedActor, "modify", route: RouterRoute) if + has_role(actor, "collaborator", route.vpc_router.vpc.project) and + not route.vpc_router.vpc.project.silo.restricts_networking(); + +has_permission(actor: AuthenticatedActor, "modify", route: RouterRoute) if + has_role(actor, "admin", route.vpc_router.vpc.project.silo); + # diff --git a/nexus/auth/src/context.rs b/nexus/auth/src/context.rs index 8f666cbb0e2..8dcf24688cc 100644 --- a/nexus/auth/src/context.rs +++ b/nexus/auth/src/context.rs @@ -378,6 +378,36 @@ impl OpContext { Ok(()) } } + + /// Authorize a networking action, respecting silo networking restrictions + /// + /// This combines standard project-level authorization with silo-level + /// networking restrictions. If the silo restricts networking actions, + /// only silo admins are allowed to perform the action. + pub async fn authorize_networking( + &self, + action: authz::Action, + authz_resource: Resource, + ) -> Result<(), Error> + where + Resource: AuthorizedResource + Debug + Clone, + { + // First, do the standard authorization check + self.authorize(action, &authz_resource).await?; + + // Then check networking restrictions + if let Some(silo_policy) = self.authn.silo_authn_policy() { + if silo_policy.restrict_network_actions() { + // Networking is restricted - verify user is silo admin + let authz_silo = self.authn.silo_required()?; + self.authorize(authz::Action::Modify, &authz_silo) + .await + .map_err(|_| Error::Forbidden)?; + } + } + + Ok(()) + } } impl Session for ConsoleSessionWithSiloId { diff --git a/nexus/db-queries/src/db/datastore/vpc.rs b/nexus/db-queries/src/db/datastore/vpc.rs index 1e07e37bee7..227256de06f 100644 --- a/nexus/db-queries/src/db/datastore/vpc.rs +++ b/nexus/db-queries/src/db/datastore/vpc.rs @@ -475,7 +475,18 @@ impl DataStore { use nexus_db_schema::schema::vpc::dsl; assert_eq!(authz_project.id(), vpc_query.vpc.project_id); - opctx.authorize(authz::Action::CreateChild, authz_project).await?; + + // Create a VPC authz resource for authorization check + let authz_vpc = authz::Vpc::new( + authz_project.clone(), + vpc_query.vpc.identity.id, + omicron_common::api::external::LookupType::ById( + vpc_query.vpc.identity.id, + ), + ); + + // Check if the actor can create this VPC (including networking restrictions) + opctx.authorize(authz::Action::CreateChild, &authz_vpc).await?; let name = vpc_query.vpc.identity.name.clone(); let project_id = vpc_query.vpc.project_id; diff --git a/nexus/src/app/vpc.rs b/nexus/src/app/vpc.rs index 77751d358a9..a5d385a3252 100644 --- a/nexus/src/app/vpc.rs +++ b/nexus/src/app/vpc.rs @@ -74,8 +74,6 @@ impl super::Nexus { let (.., authz_project) = project_lookup.lookup_for(authz::Action::CreateChild).await?; - opctx.authorize(authz::Action::CreateChild, &authz_project).await?; - let saga_params = sagas::vpc_create::Params { serialized_authn: authn::saga::Serialized::for_opctx(opctx), vpc_create: params.clone(), From 9966d8eb6d14bb10a765634faa89572edfa0cf8e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 17 Sep 2025 13:00:21 -0700 Subject: [PATCH 04/48] refactor; add a few tests that might still need a bit of tweaking --- nexus/auth/src/authz/context.rs | 55 ++++++++++ nexus/auth/src/authz/omicron.polar | 74 ++++--------- nexus/tests/integration_tests/vpcs.rs | 149 ++++++++++++++++++++++++++ 3 files changed, 224 insertions(+), 54 deletions(-) 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 6b61e5ee9eb..4dccc0db48a 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -710,80 +710,46 @@ has_relation(fleet: Fleet, "parent_fleet", collection: AlertClassList) # 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. -# Override networking permissions for VPCs -has_permission(actor: AuthenticatedActor, "create_child", vpc: Vpc) if - has_role(actor, "collaborator", vpc.project) and - not vpc.project.silo.restricts_networking(); +# Helper predicate with explicit OR logic +can_modify_networking_resource(actor: AuthenticatedActor, project: Project) if + (has_role(actor, "collaborator", project) and not project.silo.restricts_networking()) or + has_role(actor, "admin", project.silo); +# Apply networking restrictions to all networking resources +# VPCs (project path: vpc.project) has_permission(actor: AuthenticatedActor, "create_child", vpc: Vpc) if - has_role(actor, "admin", vpc.project.silo); - -has_permission(actor: AuthenticatedActor, "modify", vpc: Vpc) if - has_role(actor, "collaborator", vpc.project) and - not vpc.project.silo.restricts_networking(); + can_modify_networking_resource(actor, vpc.project); has_permission(actor: AuthenticatedActor, "modify", vpc: Vpc) if - has_role(actor, "admin", vpc.project.silo); - -# Override networking permissions for VPC Routers -has_permission(actor: AuthenticatedActor, "create_child", router: VpcRouter) if - has_role(actor, "collaborator", router.vpc.project) and - not router.vpc.project.silo.restricts_networking(); + can_modify_networking_resource(actor, vpc.project); +# VPC Routers (project path: router.vpc.project) has_permission(actor: AuthenticatedActor, "create_child", router: VpcRouter) if - has_role(actor, "admin", router.vpc.project.silo); + can_modify_networking_resource(actor, router.vpc.project); has_permission(actor: AuthenticatedActor, "modify", router: VpcRouter) if - has_role(actor, "collaborator", router.vpc.project) and - not router.vpc.project.silo.restricts_networking(); + can_modify_networking_resource(actor, router.vpc.project); -has_permission(actor: AuthenticatedActor, "modify", router: VpcRouter) if - has_role(actor, "admin", router.vpc.project.silo); - -# Override networking permissions for VPC Subnets +# VPC Subnets (project path: subnet.vpc.project) has_permission(actor: AuthenticatedActor, "create_child", subnet: VpcSubnet) if - has_role(actor, "collaborator", subnet.vpc.project) and - not subnet.vpc.project.silo.restricts_networking(); - -has_permission(actor: AuthenticatedActor, "create_child", subnet: VpcSubnet) if - has_role(actor, "admin", subnet.vpc.project.silo); + can_modify_networking_resource(actor, subnet.vpc.project); has_permission(actor: AuthenticatedActor, "modify", subnet: VpcSubnet) if - has_role(actor, "collaborator", subnet.vpc.project) and - not subnet.vpc.project.silo.restricts_networking(); - -has_permission(actor: AuthenticatedActor, "modify", subnet: VpcSubnet) if - has_role(actor, "admin", subnet.vpc.project.silo); - -# Override networking permissions for Internet Gateways -has_permission(actor: AuthenticatedActor, "create_child", gateway: InternetGateway) if - has_role(actor, "collaborator", gateway.vpc.project) and - not gateway.vpc.project.silo.restricts_networking(); + can_modify_networking_resource(actor, subnet.vpc.project); +# Internet Gateways (project path: gateway.vpc.project) has_permission(actor: AuthenticatedActor, "create_child", gateway: InternetGateway) if - has_role(actor, "admin", gateway.vpc.project.silo); - -has_permission(actor: AuthenticatedActor, "modify", gateway: InternetGateway) if - has_role(actor, "collaborator", gateway.vpc.project) and - not gateway.vpc.project.silo.restricts_networking(); + can_modify_networking_resource(actor, gateway.vpc.project); has_permission(actor: AuthenticatedActor, "modify", gateway: InternetGateway) if - has_role(actor, "admin", gateway.vpc.project.silo); + can_modify_networking_resource(actor, gateway.vpc.project); -# Override networking permissions for Router Routes +# Router Routes (project path: route.vpc_router.vpc.project) has_permission(actor: AuthenticatedActor, "create_child", route: RouterRoute) if - has_role(actor, "collaborator", route.vpc_router.vpc.project) and - not route.vpc_router.vpc.project.silo.restricts_networking(); - -has_permission(actor: AuthenticatedActor, "create_child", route: RouterRoute) if - has_role(actor, "admin", route.vpc_router.vpc.project.silo); - -has_permission(actor: AuthenticatedActor, "modify", route: RouterRoute) if - has_role(actor, "collaborator", route.vpc_router.vpc.project) and - not route.vpc_router.vpc.project.silo.restricts_networking(); + can_modify_networking_resource(actor, route.vpc_router.vpc.project); has_permission(actor: AuthenticatedActor, "modify", route: RouterRoute) if - has_role(actor, "admin", route.vpc_router.vpc.project.silo); + can_modify_networking_resource(actor, route.vpc_router.vpc.project); # diff --git a/nexus/tests/integration_tests/vpcs.rs b/nexus/tests/integration_tests/vpcs.rs index 054a28334e4..1668aa6ff0a 100644 --- a/nexus/tests/integration_tests/vpcs.rs +++ b/nexus/tests/integration_tests/vpcs.rs @@ -251,3 +251,152 @@ 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) { + let client = &cptestctx.external_client; + + // Create a project for testing + let project_name = "test-networking-restrictions"; + let project_url = "/v1/projects"; + let project_params = params::ProjectCreate { + identity: IdentityMetadataCreateParams { + name: project_name.parse().unwrap(), + description: "Test project for networking restrictions".to_string(), + }, + }; + + NexusRequest::object_post(&client, project_url, Some(&project_params)) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("Failed to create project"); + + // Create a silo with networking restrictions enabled + let restricted_silo_name = "restricted-silo"; + let silo_url = "/v1/system/silos"; + let silo_params = nexus_types::external_api::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), // Enable networking restrictions + }; + + NexusRequest::object_post(&client, silo_url, Some(&silo_params)) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("Failed to create restricted silo"); + + // Test 1: VPC creation should fail for project collaborators in restricted silo + let vpcs_url = format!("/v1/vpcs?project={}", project_name); + let vpc_params = params::VpcCreate { + identity: IdentityMetadataCreateParams { + name: "test-vpc".parse().unwrap(), + description: "Test VPC".to_string(), + }, + ipv6_prefix: None, + dns_name: "test-vpc".parse().unwrap(), + }; + + // TODO: This test needs to be run in the context of the restricted silo + // and with a project collaborator user. For now, this establishes the test structure. + // The actual authorization testing is more complex and would require setting up + // users, role assignments, and silo context switching. + + // For now, let's test that VPC creation works normally (without restrictions) + let vpc = NexusRequest::object_post(&client, &vpcs_url, Some(&vpc_params)) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("VPC creation should succeed with privileged user") + .parsed_body::() + .unwrap(); + + assert_eq!(vpc.identity.name, "test-vpc"); + + // Test 2: VPC deletion should also respect networking restrictions + let vpc_url = format!("/v1/vpcs/{}?project={}", "test-vpc", project_name); + NexusRequest::object_delete(&client, &vpc_url) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("VPC deletion should succeed with privileged user"); +} + +#[nexus_test] +async fn test_networking_restrictions_policy_test() { + // This test verifies that our Polar rules work correctly by using the policy test framework + use nexus_auth::authn::{SiloAuthnPolicy, USER_TEST_PRIVILEGED}; + use nexus_auth::authz; + use nexus_auth::context::OpContext; + use nexus_db_queries::db::pub_test_utils::TestDatabase; + use nexus_types::external_api::shared::{FleetRole, SiloRole}; + use omicron_common::api::external::LookupType; + use std::collections::{BTreeMap, BTreeSet}; + use uuid::Uuid; + + let logctx = omicron_test_utils::dev::test_setup_log( + "test_networking_restrictions_policy", + ); + let db = TestDatabase::new_with_datastore(&logctx.log).await; + let (opctx, datastore) = (db.opctx(), db.datastore()); + + // Create a silo with networking restrictions + let restricted_silo_id = Uuid::new_v4(); + let restricted_silo = authz::Silo::new( + authz::FLEET, + restricted_silo_id, + LookupType::ById(restricted_silo_id), + ); + + // Create authentication context with networking restrictions enabled + let mut mapped_fleet_roles = BTreeMap::new(); + mapped_fleet_roles.insert(SiloRole::Admin, BTreeSet::new()); + + let restricted_policy = SiloAuthnPolicy { + mapped_fleet_roles, + restrict_network_actions: true, // Enable restrictions + }; + + let normal_policy = SiloAuthnPolicy { + mapped_fleet_roles: BTreeMap::new(), + restrict_network_actions: false, // No restrictions + }; + + // Create test project and VPC resources + let project_id = Uuid::new_v4(); + let project = authz::Project::new( + restricted_silo.clone(), + project_id, + LookupType::ById(project_id), + ); + + let vpc_id = Uuid::new_v4(); + let vpc = + authz::Vpc::new(project.clone(), vpc_id, LookupType::ById(vpc_id)); + + // Test Case 1: With restrictions disabled, project collaborators can create VPCs + let normal_authn = nexus_auth::authn::Context::internal_api(); + let normal_authz_context = nexus_auth::authz::Context::new( + std::sync::Arc::new(normal_authn), + std::sync::Arc::new(nexus_auth::authz::Authz::new(&logctx.log)), + std::sync::Arc::new(datastore.clone()), + ); + + // Test Case 2: With restrictions enabled, only silo admins can create VPCs + // (This would require more complex setup with actual role assignments) + + // For now, this test demonstrates the structure needed for comprehensive testing + println!("Networking restrictions policy test structure established"); + + db.terminate().await; + logctx.cleanup_successful(); +} From a06feda93c28779bfa62f1543b336d9f18aea656 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 19 Sep 2025 09:56:40 -0700 Subject: [PATCH 05/48] clean up migration files --- schema/crdb/restrict-network-actions/down01.sql | 2 -- schema/crdb/restrict-network-actions/up.sql | 10 ++++++---- schema/crdb/restrict-network-actions/up01.sql | 3 --- 3 files changed, 6 insertions(+), 9 deletions(-) delete mode 100644 schema/crdb/restrict-network-actions/down01.sql delete mode 100644 schema/crdb/restrict-network-actions/up01.sql diff --git a/schema/crdb/restrict-network-actions/down01.sql b/schema/crdb/restrict-network-actions/down01.sql deleted file mode 100644 index 2fa05401092..00000000000 --- a/schema/crdb/restrict-network-actions/down01.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Remove restrict_network_actions column from silo table -ALTER TABLE omicron.public.silo DROP COLUMN restrict_network_actions; \ No newline at end of file diff --git a/schema/crdb/restrict-network-actions/up.sql b/schema/crdb/restrict-network-actions/up.sql index c0c766c5ce6..0eb577856fa 100644 --- a/schema/crdb/restrict-network-actions/up.sql +++ b/schema/crdb/restrict-network-actions/up.sql @@ -1,7 +1,9 @@ -- Add restrict_network_actions column to silo table -- This column controls whether networking actions (VPC, subnet, etc. create/update/delete) --- are restricted to Silo Admins only. When false (default), Project Collaborators can --- perform networking actions. +-- are restricted to Silo Admins only. +-- When false (default), Project Collaborators can perform networking actions. -ALTER TABLE omicron.public.silo -ADD COLUMN restrict_network_actions BOOL NOT NULL DEFAULT FALSE; \ No newline at end of file +ALTER TABLE omicron.public.silo + ADD COLUMN restrict_network_actions BOOL + NOT NULL + DEFAULT FALSE; diff --git a/schema/crdb/restrict-network-actions/up01.sql b/schema/crdb/restrict-network-actions/up01.sql deleted file mode 100644 index 9c8765b0533..00000000000 --- a/schema/crdb/restrict-network-actions/up01.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Add restrict_network_actions column to silo table --- When true, only Silo Admins can create/update/delete networking resources -ALTER TABLE omicron.public.silo ADD COLUMN restrict_network_actions BOOL NOT NULL DEFAULT FALSE; \ No newline at end of file From 062d4410163babdaee061caa7941c9d10d76cf81 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 19 Sep 2025 10:59:47 -0700 Subject: [PATCH 06/48] small cleanup --- schema/crdb/restrict-network-actions/up.sql | 5 ----- 1 file changed, 5 deletions(-) diff --git a/schema/crdb/restrict-network-actions/up.sql b/schema/crdb/restrict-network-actions/up.sql index 0eb577856fa..0c649ff912e 100644 --- a/schema/crdb/restrict-network-actions/up.sql +++ b/schema/crdb/restrict-network-actions/up.sql @@ -1,8 +1,3 @@ --- Add restrict_network_actions column to silo table --- This column controls whether networking actions (VPC, subnet, etc. create/update/delete) --- are restricted to Silo Admins only. --- When false (default), Project Collaborators can perform networking actions. - ALTER TABLE omicron.public.silo ADD COLUMN restrict_network_actions BOOL NOT NULL From 9450227dfd0f05f9412c228117e9000984a0e0bc Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 19 Sep 2025 12:48:28 -0700 Subject: [PATCH 07/48] fix clippy issues --- nexus/tests/integration_tests/vpcs.rs | 49 +++++++++++---------------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/nexus/tests/integration_tests/vpcs.rs b/nexus/tests/integration_tests/vpcs.rs index 1668aa6ff0a..b0b6149bc15 100644 --- a/nexus/tests/integration_tests/vpcs.rs +++ b/nexus/tests/integration_tests/vpcs.rs @@ -266,7 +266,7 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { }, }; - NexusRequest::object_post(&client, project_url, Some(&project_params)) + NexusRequest::objects_post(&client, project_url, &project_params) .authn_as(AuthnMode::PrivilegedUser) .execute() .await @@ -287,9 +287,10 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { tls_certificates: Vec::new(), mapped_fleet_roles: Default::default(), restrict_network_actions: Some(true), // Enable networking restrictions + quotas: params::SiloQuotasCreate::empty(), }; - NexusRequest::object_post(&client, silo_url, Some(&silo_params)) + NexusRequest::objects_post(&client, silo_url, &silo_params) .authn_as(AuthnMode::PrivilegedUser) .execute() .await @@ -312,7 +313,7 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { // users, role assignments, and silo context switching. // For now, let's test that VPC creation works normally (without restrictions) - let vpc = NexusRequest::object_post(&client, &vpcs_url, Some(&vpc_params)) + let vpc = NexusRequest::objects_post(&client, &vpcs_url, &vpc_params) .authn_as(AuthnMode::PrivilegedUser) .execute() .await @@ -332,13 +333,13 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { } #[nexus_test] -async fn test_networking_restrictions_policy_test() { +async fn test_networking_restrictions_policy_test( + _cptestctx: &ControlPlaneTestContext, +) { // This test verifies that our Polar rules work correctly by using the policy test framework - use nexus_auth::authn::{SiloAuthnPolicy, USER_TEST_PRIVILEGED}; + use nexus_auth::authn::SiloAuthnPolicy; use nexus_auth::authz; - use nexus_auth::context::OpContext; - use nexus_db_queries::db::pub_test_utils::TestDatabase; - use nexus_types::external_api::shared::{FleetRole, SiloRole}; + use nexus_types::external_api::shared::SiloRole; use omicron_common::api::external::LookupType; use std::collections::{BTreeMap, BTreeSet}; use uuid::Uuid; @@ -346,8 +347,6 @@ async fn test_networking_restrictions_policy_test() { let logctx = omicron_test_utils::dev::test_setup_log( "test_networking_restrictions_policy", ); - let db = TestDatabase::new_with_datastore(&logctx.log).await; - let (opctx, datastore) = (db.opctx(), db.datastore()); // Create a silo with networking restrictions let restricted_silo_id = Uuid::new_v4(); @@ -361,15 +360,15 @@ async fn test_networking_restrictions_policy_test() { let mut mapped_fleet_roles = BTreeMap::new(); mapped_fleet_roles.insert(SiloRole::Admin, BTreeSet::new()); - let restricted_policy = SiloAuthnPolicy { + let restricted_policy = SiloAuthnPolicy::new( mapped_fleet_roles, - restrict_network_actions: true, // Enable restrictions - }; + true, // Enable restrictions + ); - let normal_policy = SiloAuthnPolicy { - mapped_fleet_roles: BTreeMap::new(), - restrict_network_actions: false, // No restrictions - }; + let normal_policy = SiloAuthnPolicy::new( + BTreeMap::new(), + false, // No restrictions + ); // Create test project and VPC resources let project_id = Uuid::new_v4(); @@ -380,23 +379,15 @@ async fn test_networking_restrictions_policy_test() { ); let vpc_id = Uuid::new_v4(); - let vpc = + let _vpc = authz::Vpc::new(project.clone(), vpc_id, LookupType::ById(vpc_id)); - // Test Case 1: With restrictions disabled, project collaborators can create VPCs - let normal_authn = nexus_auth::authn::Context::internal_api(); - let normal_authz_context = nexus_auth::authz::Context::new( - std::sync::Arc::new(normal_authn), - std::sync::Arc::new(nexus_auth::authz::Authz::new(&logctx.log)), - std::sync::Arc::new(datastore.clone()), - ); - - // Test Case 2: With restrictions enabled, only silo admins can create VPCs - // (This would require more complex setup with actual role assignments) + // Test that the policies can be created correctly + assert!(restricted_policy.restrict_network_actions); + assert!(!normal_policy.restrict_network_actions); // For now, this test demonstrates the structure needed for comprehensive testing println!("Networking restrictions policy test structure established"); - db.terminate().await; logctx.cleanup_successful(); } From 9c709fd688c0fa3d115c6e5de0a046ea2b64ab73 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 19 Sep 2025 15:34:43 -0700 Subject: [PATCH 08/48] safer migratino file --- schema/crdb/restrict-network-actions/up.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema/crdb/restrict-network-actions/up.sql b/schema/crdb/restrict-network-actions/up.sql index 0c649ff912e..63429ad2701 100644 --- a/schema/crdb/restrict-network-actions/up.sql +++ b/schema/crdb/restrict-network-actions/up.sql @@ -1,4 +1,4 @@ ALTER TABLE omicron.public.silo - ADD COLUMN restrict_network_actions BOOL + ADD COLUMN IF NOT EXISTS restrict_network_actions BOOL NOT NULL DEFAULT FALSE; From 37f1e1e139913f42e221562d35913453d961901f Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 19 Sep 2025 15:50:17 -0700 Subject: [PATCH 09/48] merge main and resolve conflicts --- nexus/auth/src/authz/omicron.polar | 3 --- 1 file changed, 3 deletions(-) diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index 4dccc0db48a..29a88e1cf6c 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -750,6 +750,3 @@ has_permission(actor: AuthenticatedActor, "create_child", route: RouterRoute) if has_permission(actor: AuthenticatedActor, "modify", route: RouterRoute) if can_modify_networking_resource(actor, route.vpc_router.vpc.project); - -# - From 91d785610ebb4d16afa2f6a898f50d3012e5a4dd Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 19 Sep 2025 15:46:19 -0700 Subject: [PATCH 10/48] Update nexus, tests --- nexus/tests/integration_tests/vpcs.rs | 8 ++++++++ openapi/nexus.json | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/nexus/tests/integration_tests/vpcs.rs b/nexus/tests/integration_tests/vpcs.rs index b0b6149bc15..2f64c87f412 100644 --- a/nexus/tests/integration_tests/vpcs.rs +++ b/nexus/tests/integration_tests/vpcs.rs @@ -324,6 +324,14 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { assert_eq!(vpc.identity.name, "test-vpc"); // Test 2: VPC deletion should also respect networking restrictions + // First delete the default subnet, then the VPC + let default_subnet_url = format!("/v1/vpc-subnets/default?project={}&vpc=test-vpc", project_name); + NexusRequest::object_delete(&client, &default_subnet_url) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("Default subnet deletion should succeed with privileged user"); + let vpc_url = format!("/v1/vpcs/{}?project={}", "test-vpc", project_name); NexusRequest::object_delete(&client, &vpc_url) .authn_as(AuthnMode::PrivilegedUser) diff --git a/openapi/nexus.json b/openapi/nexus.json index ebed6ecbf2a..d1f0313a9ab 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -23918,6 +23918,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", @@ -23936,6 +23940,7 @@ "identity_mode", "mapped_fleet_roles", "name", + "restrict_network_actions", "time_created", "time_modified" ] @@ -24017,6 +24022,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", From 759f3a682eed8700dd49649bef4fbc589553598c Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 19 Sep 2025 16:01:13 -0700 Subject: [PATCH 11/48] formatting --- nexus/tests/integration_tests/vpcs.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nexus/tests/integration_tests/vpcs.rs b/nexus/tests/integration_tests/vpcs.rs index 2f64c87f412..2f367294725 100644 --- a/nexus/tests/integration_tests/vpcs.rs +++ b/nexus/tests/integration_tests/vpcs.rs @@ -325,7 +325,10 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { // Test 2: VPC deletion should also respect networking restrictions // First delete the default subnet, then the VPC - let default_subnet_url = format!("/v1/vpc-subnets/default?project={}&vpc=test-vpc", project_name); + let default_subnet_url = format!( + "/v1/vpc-subnets/default?project={}&vpc=test-vpc", + project_name + ); NexusRequest::object_delete(&client, &default_subnet_url) .authn_as(AuthnMode::PrivilegedUser) .execute() From 1f54b26d0e040a664f2afaba0fc88bb910930e03 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 2 Oct 2025 04:37:43 -0700 Subject: [PATCH 12/48] remove unused method --- nexus/auth/src/authz/actor.rs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/nexus/auth/src/authz/actor.rs b/nexus/auth/src/authz/actor.rs index 6702d9b48a8..26f7458b3b8 100644 --- a/nexus/auth/src/authz/actor.rs +++ b/nexus/auth/src/authz/actor.rs @@ -88,14 +88,6 @@ impl AuthenticatedActor { }) .collect() } - - /// Returns true if this actor's Silo restricts networking actions to Silo Admins only - pub fn restricts_networking(&self) -> bool { - self.silo_policy - .as_ref() - .map(|policy| policy.restrict_network_actions()) - .unwrap_or(false) - } } impl PartialEq for AuthenticatedActor { @@ -159,8 +151,5 @@ impl oso::PolarClass for AuthenticatedActor { authn::Actor::UserBuiltin { .. } => false, }, ) - .add_method("restricts_networking", |a: &AuthenticatedActor| { - a.restricts_networking() - }) } } From 01434b2260d09c6e2ab6c4f68d8b37a7ffac48c7 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 15 Oct 2025 13:22:41 -0700 Subject: [PATCH 13/48] Move logic from silo to project --- nexus/auth/src/authz/api_resources.rs | 181 ++++++++++--- nexus/auth/src/authz/omicron.polar | 9 +- nexus/auth/src/authz/oso_generic.rs | 2 +- nexus/db-lookup/src/lookup.rs | 325 +++++++++++++++++++++++- nexus/db-queries/src/policy_test/mod.rs | 1 + 5 files changed, 469 insertions(+), 49 deletions(-) diff --git a/nexus/auth/src/authz/api_resources.rs b/nexus/auth/src/authz/api_resources.rs index b8cc1bc2ae3..75f8c40a098 100644 --- a/nexus/auth/src/authz/api_resources.rs +++ b/nexus/auth/src/authz/api_resources.rs @@ -1018,12 +1018,149 @@ impl AuthorizedResource for AlertClassList { // Main resource hierarchy: Projects and their resources -authz_resource! { - name = "Project", - parent = "Silo", - primary_key = Uuid, - roles_allowed = true, - polar_snippet = Custom, +/// `authz` type for a resource of type Project +/// Used to uniquely identify a resource of type Project across renames, moves, +/// etc., and to do authorization checks (see [`crate::context::OpContext::authorize()`]). +/// See [`crate::authz`] module-level documentation for more information. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Project { + parent: Silo, + key: Uuid, + lookup_type: LookupType, + /// Whether this project's silo restricts networking actions to Silo Admins only + /// This is populated from the parent Silo's restrict_network_actions field + restrict_network_actions: bool, +} + +impl Eq for Project {} +impl PartialEq for Project { + fn eq(&self, other: &Self) -> bool { + self.key == other.key + } +} + +impl Project { + /// Makes a new `authz` struct for this resource with the given + /// `parent`, unique key `key`, looked up as described by `lookup_type`, + /// and optionally with the networking restrictions setting from the parent Silo. + /// + /// If `restrict_network_actions` is not provided, it defaults to `false`. + /// Use `with_network_restrictions()` to populate it with the actual Silo value. + pub fn new( + parent: Silo, + key: Uuid, + lookup_type: LookupType, + ) -> Project { + Project { + parent, + key, + lookup_type, + restrict_network_actions: false, // Default, should be updated via with_network_restrictions + } + } + + /// A version of `new` that takes the primary key type directly. + /// This is only different from [`Self::new`] if this resource + /// uses a different input key type. + pub fn with_primary_key( + parent: Silo, + key: Uuid, + lookup_type: LookupType, + ) -> Project { + Project { + parent, + key, + lookup_type, + restrict_network_actions: false, // Default, should be updated via with_network_restrictions + } + } + + /// Update this Project with the actual restrict_network_actions value from the Silo. + /// This should be called after construction to populate the correct value. + pub fn with_network_restrictions(mut self, restrict_network_actions: bool) -> Self { + self.restrict_network_actions = restrict_network_actions; + self + } + + pub fn id(&self) -> Uuid { + self.key + } + + /// Returns true if this project's silo restricts networking actions to Silo Admins only + pub fn restricts_networking(&self) -> bool { + self.restrict_network_actions + } + + /// Describes how to register this type with Oso + pub(super) fn init() -> Init { + // Create a custom class builder that includes the restricts_networking method + let class = oso::Class::builder() + .with_equality_check() + .add_method( + "has_role", + |r: &Project, actor: AuthenticatedActor, role: String| { + actor.has_role_resource(ResourceType::Project, r.key, &role) + }, + ) + .add_attribute_getter("silo", |r: &Project| r.parent.clone()) + .add_method("restricts_networking", |project: &Project| { + project.restricts_networking() + }) + .build(); + + Init { + polar_snippet: "", // Custom snippet defined in omicron.polar + polar_class: class, + } + } +} + +impl oso::PolarClass for Project { + fn get_polar_class_builder() -> oso::ClassBuilder { + oso::Class::builder() + .with_equality_check() + .add_method( + "has_role", + |r: &Project, actor: AuthenticatedActor, role: String| { + actor.has_role_resource(ResourceType::Project, r.key, &role) + }, + ) + .add_attribute_getter("silo", |r: &Project| r.parent.clone()) + .add_method("restricts_networking", |project: &Project| { + project.restricts_networking() + }) + } +} + +impl ApiResource for Project { + fn parent(&self) -> Option<&dyn AuthorizedResource> { + Some(&self.parent) + } + + fn resource_type(&self) -> ResourceType { + ResourceType::Project + } + + fn lookup_type(&self) -> &LookupType { + &self.lookup_type + } + + fn as_resource_with_roles(&self) -> Option<&dyn ApiResourceWithRoles> { + Some(self) + } +} + +impl ApiResourceWithRoles for Project { + fn resource_id(&self) -> Uuid { + self.key + } + + fn conferred_roles_by( + &self, + _authn: &authn::Context, + ) -> Result, Error> { + Ok(None) + } } impl ApiResourceWithRolesType for Project { @@ -1254,38 +1391,6 @@ impl ApiResourceWithRolesType for Silo { type AllowedRoles = SiloRole; } -// Add methods that can be called from Polar -impl Silo { - pub fn restricts_networking(&self) -> bool { - // This method should not be called if project.silo refers to the database silo - // Returning false to maintain existing behavior - false - } - - /// Custom init method that adds restricts_networking to the Polar class - pub(super) fn init_with_networking() -> Init { - // Create a custom class builder that includes the restricts_networking method - let class = oso::Class::builder() - .with_equality_check() - .add_method( - "has_role", - |r: &Silo, actor: AuthenticatedActor, role: String| { - actor.has_role_resource(ResourceType::Silo, r.key, &role) - }, - ) - .add_attribute_getter("fleet", |r: &Silo| r.parent) - .add_method("restricts_networking", |silo: &Silo| { - silo.restricts_networking() - }) - .build(); - - Init { - polar_snippet: "", // Custom snippet defined in omicron.polar - polar_class: class, - } - } -} - authz_resource! { name = "SiloUser", parent = "Silo", diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index 29a88e1cf6c..d0066439bfe 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -710,10 +710,13 @@ has_relation(fleet: Fleet, "parent_fleet", collection: AlertClassList) # 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. -# Helper predicate with explicit OR logic +# Determine if the actor has permissions to modify networking resources can_modify_networking_resource(actor: AuthenticatedActor, project: Project) if - (has_role(actor, "collaborator", project) and not project.silo.restricts_networking()) or - has_role(actor, "admin", project.silo); + # Always allow silo admins to update networking resources + has_role(actor, "admin", project.silo) or + # Allow project admins to update networking resources if the project's silo allows it + # Note that the restriction is configured at the silo level, but affects the projects on the silo + (has_role(actor, "collaborator", project) and not project.restricts_networking()); # Apply networking restrictions to all networking resources # VPCs (project path: vpc.project) diff --git a/nexus/auth/src/authz/oso_generic.rs b/nexus/auth/src/authz/oso_generic.rs index e848c59b0ec..1278b24382c 100644 --- a/nexus/auth/src/authz/oso_generic.rs +++ b/nexus/auth/src/authz/oso_generic.rs @@ -159,7 +159,7 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result { PhysicalDisk::init(), Rack::init(), SshKey::init(), - Silo::init_with_networking(), + Silo::init(), SiloUser::init(), SiloGroup::init(), SupportBundle::init(), diff --git a/nexus/db-lookup/src/lookup.rs b/nexus/db-lookup/src/lookup.rs index 4a949503cbd..a70fed602cc 100644 --- a/nexus/db-lookup/src/lookup.rs +++ b/nexus/db-lookup/src/lookup.rs @@ -15,7 +15,7 @@ use async_bb8_diesel::AsyncRunQueryDsl; use db_macros::lookup_resource; -use diesel::{ExpressionMethods, QueryDsl, SelectableHelper}; +use diesel::{ExpressionMethods, JoinOnDsl, QueryDsl, SelectableHelper}; use ipnetwork::IpNetwork; use nexus_auth::authn; use nexus_auth::authz; @@ -597,12 +597,323 @@ lookup_resource! { primary_key_columns = [ { column_name = "id", rust_type = Uuid } ] } -lookup_resource! { - name = "Project", - ancestors = [ "Silo" ], - lookup_by_name = true, - soft_deletes = true, - primary_key_columns = [ { column_name = "id", rust_type = Uuid } ] +// Project lookup is hand-written (instead of using the macro) so we can fetch +// restrict_network_actions from the Silo table during lookup +pub enum Project<'a> { + Error(Root<'a>, Error), + Name(Silo<'a>, &'a Name), + OwnedName(Silo<'a>, Name), + PrimaryKey(Root<'a>, Uuid), +} + +impl<'a> Project<'a> { + pub async fn fetch( + &self, + ) -> LookupResult<(authz::Silo, authz::Project, nexus_db_model::Project)> { + self.fetch_for(authz::Action::Read).await + } + + pub async fn optional_fetch( + &self, + ) -> LookupResult> { + self.optional_fetch_for(authz::Action::Read).await + } + + pub async fn fetch_for( + &self, + action: authz::Action, + ) -> LookupResult<(authz::Silo, authz::Project, nexus_db_model::Project)> { + let lookup = self.lookup_root(); + let opctx = &lookup.opctx; + let datastore = lookup.datastore; + match &self { + Project::Error(_, error) => Err(error.clone()), + Project::Name(parent, &ref name) | Project::OwnedName(parent, ref name) => { + let (authz_silo,) = parent.lookup().await?; + let (authz_project, db_row) = Self::fetch_by_name_for( + opctx, + datastore, + &authz_silo, + name, + action, + ) + .await?; + Ok((authz_silo, authz_project, db_row)) + } + Project::PrimaryKey(_, v0) => { + Self::fetch_by_id_for(opctx, datastore, v0, action).await + } + } + .and_then(|input| { + let (ref authz_silo, .., ref authz_project, ref _db_row) = &input; + Self::silo_check(opctx, authz_silo, authz_project)?; + Ok(input) + }) + } + + pub async fn optional_fetch_for( + &self, + action: authz::Action, + ) -> LookupResult> { + let result = self.fetch_for(action).await; + match result { + Err(Error::ObjectNotFound { type_name: _, lookup_type: _ }) => Ok(None), + _ => Ok(Some(result?)), + } + } + + pub async fn lookup_for( + &self, + action: authz::Action, + ) -> LookupResult<(authz::Silo, authz::Project)> { + let lookup = self.lookup_root(); + let opctx = &lookup.opctx; + let (authz_silo, authz_project) = self.lookup().await?; + opctx.authorize(action, &authz_project).await?; + Ok((authz_silo, authz_project)) + .and_then(|input| { + let (ref authz_silo, .., ref authz_project) = &input; + Self::silo_check(opctx, authz_silo, authz_project)?; + Ok(input) + }) + } + + pub async fn optional_lookup_for( + &self, + action: authz::Action, + ) -> LookupResult> { + let result = self.lookup_for(action).await; + match result { + Err(Error::ObjectNotFound { type_name: _, lookup_type: _ }) => Ok(None), + _ => Ok(Some(result?)), + } + } + + async fn lookup(&self) -> LookupResult<(authz::Silo, authz::Project)> { + let lookup = self.lookup_root(); + let opctx = &lookup.opctx; + let datastore = lookup.datastore; + match &self { + Project::Error(_, error) => Err(error.clone()), + Project::Name(parent, &ref name) | Project::OwnedName(parent, ref name) => { + let (authz_silo,) = parent.lookup().await?; + let (authz_project, _) = Self::lookup_by_name_no_authz( + opctx, + datastore, + &authz_silo, + name, + ) + .await?; + Ok((authz_silo, authz_project)) + } + Project::PrimaryKey(_, v0) => { + let (authz_silo, authz_project, _) = Self::lookup_by_id_no_authz( + opctx, + datastore, + v0, + ) + .await?; + Ok((authz_silo, authz_project)) + } + } + } + + fn lookup_root(&self) -> &LookupPath<'a> { + match &self { + Project::Error(root, ..) => root.lookup_root(), + Project::Name(parent, _) | Project::OwnedName(parent, _) => { + parent.lookup_root() + } + Project::PrimaryKey(root, ..) => root.lookup_root(), + } + } + + fn silo_check( + opctx: &OpContext, + authz_silo: &authz::Silo, + authz_project: &authz::Project, + ) -> Result<(), Error> { + let log = &opctx.log; + let actor_silo_id = match opctx + .authn + .silo_or_builtin() + .internal_context("siloed resource check") + { + Ok(Some(silo)) => silo.id(), + Ok(None) => { + trace!( + log, + "successful lookup of siloed resource {:?} \ + using built-in user", + "Project", + ); + return Ok(()); + } + Err(error) => { + error!( + log, + "unexpected successful lookup of siloed resource \ + {:?} with no actor in OpContext", + "Project", + ); + return Err(error); + } + }; + let resource_silo_id = authz_silo.id(); + if resource_silo_id != actor_silo_id { + use nexus_auth::authz::ApiResource; + error!( + log, + "unexpected successful lookup of siloed resource \ + {:?} in a different Silo from current actor (resource \ + Silo {}, actor Silo {})", + "Project", resource_silo_id, actor_silo_id, + ); + Err(authz_project.not_found()) + } else { + Ok(()) + } + } + + async fn fetch_by_name_for( + opctx: &OpContext, + datastore: &dyn LookupDataStore, + authz_silo: &authz::Silo, + name: &Name, + action: authz::Action, + ) -> LookupResult<(authz::Project, nexus_db_model::Project)> { + let (authz_project, db_row) = Self::lookup_by_name_no_authz( + opctx, + datastore, + authz_silo, + name, + ) + .await?; + opctx.authorize(action, &authz_project).await?; + Ok((authz_project, db_row)) + } + + // CUSTOM: This function is customized to JOIN with the silo table to fetch restrict_network_actions + async fn lookup_by_name_no_authz( + opctx: &OpContext, + datastore: &dyn LookupDataStore, + authz_silo: &authz::Silo, + name: &Name, + ) -> LookupResult<(authz::Project, nexus_db_model::Project)> { + use nexus_db_schema::schema::project::dsl as project_dsl; + use nexus_db_schema::schema::silo::dsl as silo_dsl; + + let (db_row, restrict_network_actions): (nexus_db_model::Project, bool) = project_dsl::project + .filter(project_dsl::time_deleted.is_null()) + .filter(project_dsl::name.eq(name.clone())) + .filter(project_dsl::silo_id.eq(authz_silo.id())) + .inner_join(silo_dsl::silo.on(project_dsl::silo_id.eq(silo_dsl::id))) + .select(( + nexus_db_model::Project::as_select(), + silo_dsl::restrict_network_actions, + )) + .get_result_async(&*datastore.pool_connection_authorized(opctx).await?) + .await + .map_err(|e| { + public_error_from_diesel( + e, + ErrorHandler::NotFoundByLookup( + ResourceType::Project, + LookupType::ByName(name.as_str().to_string()), + ), + ) + })?; + + let authz_project = authz::Project::with_primary_key( + authz_silo.clone(), + db_row.id(), + LookupType::ByName(name.as_str().to_string()) + ) + .with_network_restrictions(restrict_network_actions); + + Ok((authz_project, db_row)) + } + + async fn fetch_by_id_for( + opctx: &OpContext, + datastore: &dyn LookupDataStore, + v0: &Uuid, + action: authz::Action, + ) -> LookupResult<(authz::Silo, authz::Project, nexus_db_model::Project)> { + let (authz_silo, authz_project, db_row) = Self::lookup_by_id_no_authz( + opctx, + datastore, + v0, + ) + .await?; + opctx.authorize(action, &authz_project).await?; + Ok((authz_silo, authz_project, db_row)) + } + + // CUSTOM: This function is customized to JOIN with the silo table to fetch restrict_network_actions + async fn lookup_by_id_no_authz( + opctx: &OpContext, + datastore: &dyn LookupDataStore, + v0: &Uuid, + ) -> LookupResult<(authz::Silo, authz::Project, nexus_db_model::Project)> { + use nexus_db_schema::schema::project::dsl as project_dsl; + use nexus_db_schema::schema::silo::dsl as silo_dsl; + + let (db_row, restrict_network_actions): (nexus_db_model::Project, bool) = project_dsl::project + .filter(project_dsl::time_deleted.is_null()) + .filter(project_dsl::id.eq(v0.clone())) + .inner_join(silo_dsl::silo.on(project_dsl::silo_id.eq(silo_dsl::id))) + .select(( + nexus_db_model::Project::as_select(), + silo_dsl::restrict_network_actions, + )) + .get_result_async(&*datastore.pool_connection_authorized(opctx).await?) + .await + .map_err(|e| { + public_error_from_diesel( + e, + ErrorHandler::NotFoundByLookup( + ResourceType::Project, + LookupType::ById( + ::omicron_uuid_kinds::GenericUuid::into_untyped_uuid(*v0), + ), + ), + ) + })?; + + let (authz_silo, _) = Silo::lookup_by_id_no_authz( + opctx, + datastore, + &db_row.silo_id.into(), + ) + .await?; + let authz_project = authz::Project::with_primary_key( + authz_silo.clone(), + db_row.id(), + LookupType::ById(::omicron_uuid_kinds::GenericUuid::into_untyped_uuid(*v0)), + ) + .with_network_restrictions(restrict_network_actions); + + Ok((authz_silo, authz_project, db_row)) + } +} + +// Child selector functions for Silo +impl<'a> Silo<'a> { + pub fn project_name<'b, 'c>(self, name: &'b Name) -> Project<'c> + where + 'a: 'c, + 'b: 'c, + { + Project::Name(self, name) + } + + pub fn project_name_owned<'c>(self, name: Name) -> Project<'c> + where + 'a: 'c, + { + Project::OwnedName(self, name) + } } lookup_resource! { diff --git a/nexus/db-queries/src/policy_test/mod.rs b/nexus/db-queries/src/policy_test/mod.rs index 86bf65e89e7..4c73f3545bd 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( From 56af8c8debde1af40afbaf8a64be9ed62776585d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 15 Oct 2025 13:49:27 -0700 Subject: [PATCH 14/48] Remove accidentally committed .bak files --- nexus/tests/integration_tests/quotas.rs.bak | 475 ---- nexus/tests/integration_tests/silos.rs.bak | 2632 ------------------- 2 files changed, 3107 deletions(-) delete mode 100644 nexus/tests/integration_tests/quotas.rs.bak delete mode 100644 nexus/tests/integration_tests/silos.rs.bak diff --git a/nexus/tests/integration_tests/quotas.rs.bak b/nexus/tests/integration_tests/quotas.rs.bak deleted file mode 100644 index c11ce97695c..00000000000 --- a/nexus/tests/integration_tests/quotas.rs.bak +++ /dev/null @@ -1,475 +0,0 @@ -use anyhow::Error; -use dropshot::HttpErrorResponseBody; -use dropshot::test_util::ClientTestContext; -use http::Method; -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::http_testing::TestResponse; -use nexus_test_utils::resource_helpers::DiskTest; -use nexus_test_utils::resource_helpers::create_ip_pool; -use nexus_test_utils::resource_helpers::create_local_user; -use nexus_test_utils::resource_helpers::grant_iam; -use nexus_test_utils::resource_helpers::link_ip_pool; -use nexus_test_utils::resource_helpers::object_create; -use nexus_test_utils::resource_helpers::object_create_error; -use nexus_test_utils::resource_helpers::test_params; -use nexus_test_utils_macros::nexus_test; -use nexus_types::external_api::params; -use nexus_types::external_api::shared; -use nexus_types::external_api::shared::SiloRole; -use nexus_types::external_api::views::{Silo, SiloQuotas}; -use omicron_common::api::external::ByteCount; -use omicron_common::api::external::IdentityMetadataCreateParams; -use omicron_common::api::external::InstanceCpuCount; -use serde_json::json; - -type ControlPlaneTestContext = - nexus_test_utils::ControlPlaneTestContext; - -struct ResourceAllocator { - auth: AuthnMode, -} - -impl ResourceAllocator { - fn new(auth: AuthnMode) -> Self { - Self { auth } - } - - async fn set_quotas( - &self, - client: &ClientTestContext, - quotas: params::SiloQuotasUpdate, - ) -> Result { - NexusRequest::object_put( - client, - "/v1/system/silos/quota-test-silo/quotas", - Some("as), - ) - .authn_as(self.auth.clone()) - .execute() - .await - } - - async fn set_quotas_expect_error( - &self, - client: &ClientTestContext, - quotas: params::SiloQuotasUpdate, - code: http::StatusCode, - ) -> HttpErrorResponseBody { - NexusRequest::expect_failure_with_body( - client, - code, - http::Method::PUT, - "/v1/system/silos/quota-test-silo/quotas", - &Some("as), - ) - .authn_as(self.auth.clone()) - .execute() - .await - .expect("Expected failure updating quotas") - .parsed_body::() - .expect("Failed to read response after setting quotas") - } - - async fn get_quotas(&self, client: &ClientTestContext) -> SiloQuotas { - NexusRequest::object_get( - client, - "/v1/system/silos/quota-test-silo/quotas", - ) - .authn_as(self.auth.clone()) - .execute() - .await - .expect("failed to fetch quotas") - .parsed_body() - .expect("failed to parse quotas") - } - - async fn provision_instance( - &self, - client: &ClientTestContext, - name: &str, - cpus: u16, - memory: u32, - ) -> Result { - NexusRequest::objects_post( - client, - "/v1/instances?project=project", - ¶ms::InstanceCreate { - identity: IdentityMetadataCreateParams { - name: name.parse().unwrap(), - description: "".into(), - }, - ncpus: InstanceCpuCount(cpus), - memory: ByteCount::from_gibibytes_u32(memory), - hostname: "host".parse().unwrap(), - user_data: b"#cloud-config\nsystem_info:\n default_user:\n name: oxide" - .to_vec(), - ssh_public_keys: Some(Vec::new()), - network_interfaces: params::InstanceNetworkInterfaceAttachment::Default, - external_ips: Vec::::new(), - disks: Vec::::new(), - boot_disk: None, - start: false, - auto_restart_policy: Default::default(), - anti_affinity_groups: Vec::new(), - }, - ) - .authn_as(self.auth.clone()) - .execute() - .await - .expect("Instance should be created regardless of quotas"); - - NexusRequest::new( - RequestBuilder::new( - client, - Method::POST, - format!("/v1/instances/{}/start?project=project", name) - .as_str(), - ) - .body(None as Option<&serde_json::Value>), - ) - .authn_as(self.auth.clone()) - .execute() - .await - } - - async fn cleanup_instance( - &self, - client: &ClientTestContext, - name: &str, - ) -> TestResponse { - // Try to stop the instance - NexusRequest::new( - RequestBuilder::new( - client, - Method::POST, - format!("/v1/instances/{}/stop?project=project", name).as_str(), - ) - .body(None as Option<&serde_json::Value>), - ) - .authn_as(self.auth.clone()) - .execute() - .await - .expect("failed to stop instance"); - - NexusRequest::object_delete( - client, - format!("/v1/instances/{}?project=project", name).as_str(), - ) - .authn_as(self.auth.clone()) - .execute() - .await - .expect("failed to delete instance") - } - - async fn provision_disk( - &self, - client: &ClientTestContext, - name: &str, - size: u32, - ) -> Result { - NexusRequest::new( - RequestBuilder::new( - client, - Method::POST, - "/v1/disks?project=project", - ) - .body(Some(¶ms::DiskCreate { - identity: IdentityMetadataCreateParams { - name: name.parse().unwrap(), - description: "".into(), - }, - size: ByteCount::from_gibibytes_u32(size), - disk_source: params::DiskSource::Blank { - block_size: params::BlockSize::try_from(512).unwrap(), - }, - })), - ) - .authn_as(self.auth.clone()) - .execute() - .await - } -} - -async fn setup_silo_with_quota( - client: &ClientTestContext, - silo_name: &str, - quotas: params::SiloQuotasCreate, -) -> ResourceAllocator { - let silo: Silo = object_create( - client, - "/v1/system/silos", - ¶ms::SiloCreate { - identity: IdentityMetadataCreateParams { - name: silo_name.parse().unwrap(), - description: "".into(), - }, - quotas, - discoverable: true, - identity_mode: shared::SiloIdentityMode::LocalOnly, - admin_group_name: None, - tls_certificates: vec![], - mapped_fleet_roles: Default::default(), - }, - ) - .await; - - // create default pool and link to this silo. can't use - // create_default_ip_pool because that links to the default silo - create_ip_pool(&client, "default", None).await; - link_ip_pool(&client, "default", &silo.identity.id, true).await; - - // Create a silo user - let user = create_local_user( - client, - &silo, - &"user".parse().unwrap(), - test_params::UserPassword::LoginDisallowed, - ) - .await; - - // Make silo admin - grant_iam( - client, - format!("/v1/system/silos/{}", silo_name).as_str(), - SiloRole::Admin, - user.id, - AuthnMode::PrivilegedUser, - ) - .await; - - let auth_mode = AuthnMode::SiloUser(user.id); - - NexusRequest::objects_post( - client, - "/v1/projects", - ¶ms::ProjectCreate { - identity: IdentityMetadataCreateParams { - name: "project".parse().unwrap(), - description: "".into(), - }, - }, - ) - .authn_as(auth_mode.clone()) - .execute() - .await - .unwrap(); - - ResourceAllocator::new(auth_mode) -} - -#[nexus_test] -async fn test_quotas(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - - // Simulate space for disks - DiskTest::new(&cptestctx).await; - - let system = setup_silo_with_quota( - &client, - "quota-test-silo", - params::SiloQuotasCreate::empty(), - ) - .await; - - // Ensure trying to provision an instance with empty quotas fails - let err = system - .provision_instance(client, "instance", 1, 1) - .await - .unwrap() - .parsed_body::() - .expect("failed to parse error body"); - assert!( - err.message.contains("vCPU Limit Exceeded"), - "Unexpected error: {0}", - err.message - ); - system.cleanup_instance(client, "instance").await; - - // Up the CPU, memory quotas - system - .set_quotas( - client, - params::SiloQuotasUpdate { - cpus: Some(4), - memory: Some(ByteCount::from_gibibytes_u32(15)), - storage: Some(ByteCount::from_gibibytes_u32(2)), - }, - ) - .await - .expect("failed to set quotas"); - - let quotas = system.get_quotas(client).await; - assert_eq!(quotas.limits.cpus, 4); - assert_eq!(quotas.limits.memory, ByteCount::from_gibibytes_u32(15)); - assert_eq!(quotas.limits.storage, ByteCount::from_gibibytes_u32(2)); - - // Ensure memory quota is enforced - let err = system - .provision_instance(client, "instance", 1, 16) - .await - .unwrap() - .parsed_body::() - .expect("failed to parse error body"); - assert!( - err.message.contains("Memory Limit Exceeded"), - "Unexpected error: {0}", - err.message - ); - system.cleanup_instance(client, "instance").await; - - // Allocating instance should now succeed - system - .provision_instance(client, "instance", 2, 10) - .await - .expect("Instance should've had enough resources to be provisioned"); - - let err = system - .provision_disk(client, "disk", 3) - .await - .unwrap() - .parsed_body::() - .expect("failed to parse error body"); - assert!( - err.message.contains("Storage Limit Exceeded"), - "Unexpected error: {0}", - err.message - ); - - system - .provision_disk(client, "disk", 1) - .await - .expect("Disk should be provisioned"); -} - -#[nexus_test] -async fn test_quota_limits(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - - let system = setup_silo_with_quota( - &client, - "quota-test-silo", - params::SiloQuotasCreate::empty(), - ) - .await; - - // Maximal legal limits should be allowed. - let quota_limit = params::SiloQuotasUpdate { - cpus: Some(i64::MAX), - memory: Some(i64::MAX.try_into().unwrap()), - storage: Some(i64::MAX.try_into().unwrap()), - }; - system - .set_quotas(client, quota_limit.clone()) - .await - .expect("set max quotas"); - let quotas = system.get_quotas(client).await; - assert_eq!(quotas.limits.cpus, quota_limit.cpus.unwrap()); - assert_eq!(quotas.limits.memory, quota_limit.memory.unwrap()); - assert_eq!(quotas.limits.storage, quota_limit.storage.unwrap()); - - // Construct a value that fits in a u64 but not an i64. - let out_of_bounds = u64::try_from(i64::MAX).unwrap() + 1; - - for key in ["cpus", "memory", "storage"] { - // We can't construct a `SiloQuotasUpdate` with higher-than-maximal - // values, but we can construct the equivalent JSON blob of such a - // request. - let request = json!({ key: out_of_bounds }); - - let err = NexusRequest::expect_failure_with_body( - client, - http::StatusCode::BAD_REQUEST, - http::Method::PUT, - "/v1/system/silos/quota-test-silo/quotas", - &request, - ) - .authn_as(system.auth.clone()) - .execute() - .await - .expect("sent quota update") - .parsed_body::() - .expect("parsed error body"); - assert!( - err.message.contains(key) - && (err.message.contains("invalid value") - || err - .message - .contains("value is too large for a byte count")), - "Unexpected error: {0}", - err.message - ); - - // The quota limits we set above should be unchanged. - let quotas = system.get_quotas(client).await; - assert_eq!(quotas.limits.cpus, quota_limit.cpus.unwrap()); - assert_eq!(quotas.limits.memory, quota_limit.memory.unwrap()); - assert_eq!(quotas.limits.storage, quota_limit.storage.unwrap()); - } -} - -#[nexus_test] -async fn test_negative_quota(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - - // Can't make a silo with a negative quota - let mut quotas = params::SiloQuotasCreate::empty(); - quotas.cpus = -1; - let response = object_create_error( - client, - "/v1/system/silos", - ¶ms::SiloCreate { - identity: IdentityMetadataCreateParams { - name: "negative-cpus-not-allowed".parse().unwrap(), - description: "".into(), - }, - quotas, - discoverable: true, - identity_mode: shared::SiloIdentityMode::LocalOnly, - admin_group_name: None, - tls_certificates: vec![], - mapped_fleet_roles: Default::default(), - }, - http::StatusCode::BAD_REQUEST, - ) - .await; - - assert!( - response.message.contains( - "Cannot create silo quota: CPU quota must not be negative" - ), - "Unexpected response: {}", - response.message - ); - - // Make the silo with an empty quota - let system = setup_silo_with_quota( - &client, - "quota-test-silo", - params::SiloQuotasCreate::empty(), - ) - .await; - - // Can't update a silo with a negative quota - let quota_limit = params::SiloQuotasUpdate { - cpus: Some(-1), - memory: Some(0_u64.try_into().unwrap()), - storage: Some(0_u64.try_into().unwrap()), - }; - let response = system - .set_quotas_expect_error( - client, - quota_limit.clone(), - http::StatusCode::BAD_REQUEST, - ) - .await; - - assert!( - response.message.contains( - "Cannot update silo quota: CPU quota must not be negative" - ), - "Unexpected response: {}", - response.message - ); -} diff --git a/nexus/tests/integration_tests/silos.rs.bak b/nexus/tests/integration_tests/silos.rs.bak deleted file mode 100644 index 2aa271dc8f6..00000000000 --- a/nexus/tests/integration_tests/silos.rs.bak +++ /dev/null @@ -1,2632 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at https://mozilla.org/MPL/2.0/. - -use crate::integration_tests::saml::SAML_IDP_DESCRIPTOR; -use dropshot::ResultsPage; -use nexus_db_lookup::LookupPath; -use nexus_db_queries::authn::silos::AuthenticatedSubject; -use nexus_db_queries::authn::{USER_TEST_PRIVILEGED, USER_TEST_UNPRIVILEGED}; -use nexus_db_queries::authz::{self}; -use nexus_db_queries::context::OpContext; -use nexus_db_queries::db; -use nexus_db_queries::db::fixed_data::silo::DEFAULT_SILO; -use nexus_db_queries::db::identity::Asset; -use nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder}; -use nexus_test_utils::resource_helpers::{ - create_ip_pool, create_local_user, create_project, create_silo, grant_iam, - link_ip_pool, object_create, object_delete, objects_list_page_authz, - projects_list, test_params, -}; -use nexus_test_utils_macros::nexus_test; -use nexus_types::external_api::views::Certificate; -use nexus_types::external_api::views::{ - self, IdentityProvider, Project, SamlIdentityProvider, Silo, -}; -use nexus_types::external_api::{params, shared}; -use nexus_types::silo::DEFAULT_SILO_ID; -use omicron_common::address::{IpRange, Ipv4Range}; -use omicron_common::api::external::{ - IdentityMetadataCreateParams, LookupType, Name, -}; -use omicron_common::api::external::{ObjectIdentity, UserId}; -use omicron_test_utils::certificates::CertificateChain; -use omicron_test_utils::dev::poll::{CondCheckError, wait_for_condition}; -use omicron_uuid_kinds::SiloUserUuid; - -use std::collections::{BTreeMap, BTreeSet, HashSet}; -use std::fmt::Write; -use std::str::FromStr; - -use base64::Engine; -use hickory_resolver::ResolveErrorKind; -use hickory_resolver::proto::ProtoErrorKind; -use http::StatusCode; -use http::method::Method; -use httptest::{Expectation, Server, matchers::*, responders::*}; -use nexus_types::external_api::shared::{FleetRole, SiloRole}; -use std::convert::Infallible; -use std::net::Ipv4Addr; -use std::time::Duration; - -type ControlPlaneTestContext = - nexus_test_utils::ControlPlaneTestContext; - -#[nexus_test] -async fn test_silos(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - let nexus = &cptestctx.server.server_context().nexus; - - // Verify that we cannot create a name with the same name as the recovery - // Silo that was created during rack initialization. - let error: dropshot::HttpErrorResponseBody = - NexusRequest::expect_failure_with_body( - client, - StatusCode::BAD_REQUEST, - Method::POST, - "/v1/system/silos", - ¶ms::SiloCreate { - identity: IdentityMetadataCreateParams { - name: cptestctx.silo_name.clone(), - description: "a silo".to_string(), - }, - quotas: params::SiloQuotasCreate::empty(), - discoverable: false, - identity_mode: shared::SiloIdentityMode::LocalOnly, - admin_group_name: None, - tls_certificates: vec![], - mapped_fleet_roles: Default::default(), - }, - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .unwrap() - .parsed_body() - .unwrap(); - assert_eq!(error.message, "already exists: silo \"test-suite-silo\""); - - // Create two silos: one discoverable, one not - create_silo( - &client, - "discoverable", - true, - shared::SiloIdentityMode::LocalOnly, - ) - .await; - create_silo(&client, "hidden", false, shared::SiloIdentityMode::LocalOnly) - .await; - - // Verify that an external DNS name was propagated for these Silos. - verify_silo_dns_name(cptestctx, "discoverable", true).await; - verify_silo_dns_name(cptestctx, "hidden", true).await; - - // Verify GET /v1/system/silos/{silo} works for both discoverable and not - let discoverable_url = "/v1/system/silos/discoverable"; - let hidden_url = "/v1/system/silos/hidden"; - - let silo: Silo = NexusRequest::object_get(&client, &discoverable_url) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("failed to make request") - .parsed_body() - .unwrap(); - assert_eq!(silo.identity.name, "discoverable"); - - let silo: Silo = NexusRequest::object_get(&client, &hidden_url) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("failed to make request") - .parsed_body() - .unwrap(); - assert_eq!(silo.identity.name, "hidden"); - - // Verify 404 if silo doesn't exist - NexusRequest::expect_failure( - &client, - StatusCode::NOT_FOUND, - Method::GET, - &"/v1/system/silos/testpost", - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("failed to make request"); - - // Verify GET /v1/system/silos only returns discoverable silos - let silos = - objects_list_page_authz::(client, "/v1/system/silos").await.items; - assert_eq!(silos.len(), 1); - assert_eq!(silos[0].identity.name, "discoverable"); - - // Create a new user in the discoverable silo - let new_silo_user_id = create_local_user( - client, - &silos[0], - &"some-silo-user".parse().unwrap(), - test_params::UserPassword::LoginDisallowed, - ) - .await - .id; - - // Grant the user "admin" privileges on that Silo. - grant_iam( - client, - discoverable_url, - SiloRole::Admin, - new_silo_user_id, - AuthnMode::PrivilegedUser, - ) - .await; - - // TODO-coverage TODO-security Add test for Silo-local session - // when we can use users in another Silo. - - let authn_opctx = nexus.opctx_external_authn(); - - // Create project with built-in user auth - // Note: this currently goes to the built-in silo! - let project_name = "someproj"; - let new_proj_in_default_silo = create_project(&client, project_name).await; - - // default silo project shows up in default silo - let projects_in_default_silo = - projects_list(client, "/v1/projects", "", None).await; - assert_eq!(projects_in_default_silo.len(), 1); - - // default silo project does not show up in our silo - let projects_in_our_silo = NexusRequest::object_get(client, "/v1/projects") - .authn_as(AuthnMode::SiloUser(new_silo_user_id)) - .execute_and_parse_unwrap::>() - .await; - assert_eq!(projects_in_our_silo.items.len(), 0); - - // Create a Project of the same name in a different Silo to verify - // that's possible. - let new_proj_in_our_silo = NexusRequest::objects_post( - client, - "/v1/projects", - ¶ms::ProjectCreate { - identity: IdentityMetadataCreateParams { - name: project_name.parse().unwrap(), - description: String::new(), - }, - }, - ) - .authn_as(AuthnMode::SiloUser(new_silo_user_id)) - .execute() - .await - .expect("failed to create same-named Project in a different Silo") - .parsed_body::() - .expect("failed to parse new Project"); - assert_eq!( - new_proj_in_default_silo.identity.name, - new_proj_in_our_silo.identity.name - ); - assert_ne!( - new_proj_in_default_silo.identity.id, - new_proj_in_our_silo.identity.id - ); - // delete default subnet from VPC so we can delete the VPC - NexusRequest::object_delete( - client, - &format!( - "/v1/vpc-subnets/default?project={}&vpc=default", - project_name - ), - ) - .authn_as(AuthnMode::SiloUser(new_silo_user_id)) - .execute() - .await - .expect("failed to delete test Vpc"); - // delete VPC from project so we can delete the project later - NexusRequest::object_delete( - client, - &format!("/v1/vpcs/default?project={}", project_name), - ) - .authn_as(AuthnMode::SiloUser(new_silo_user_id)) - .execute() - .await - .expect("failed to delete test Vpc"); - - // Verify GET /v1/projects works with built-in user auth - let projects = projects_list(client, "/v1/projects", "", None).await; - assert_eq!(projects.len(), 1); - assert_eq!(projects[0].identity.name, "someproj"); - - // Deleting discoverable silo fails because there's still a project in it - NexusRequest::expect_failure( - &client, - StatusCode::BAD_REQUEST, - Method::DELETE, - &discoverable_url, - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("failed to make request"); - - // Delete project - NexusRequest::object_delete(&client, &"/v1/projects/someproj") - .authn_as(AuthnMode::SiloUser(new_silo_user_id)) - .execute() - .await - .expect("failed to make request"); - - // Verify silo DELETE now works - NexusRequest::object_delete(&client, &discoverable_url) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("failed to make request"); - - // Verify the DNS name was removed. - verify_silo_dns_name(cptestctx, "discoverable", false).await; - - // Verify silo user was also deleted - LookupPath::new(&authn_opctx, nexus.datastore()) - .silo_user_id(new_silo_user_id) - .fetch() - .await - .expect_err("unexpected success"); -} - -// Test that admin group is created if admin_group_name is applied. -#[nexus_test] -async fn test_silo_admin_group(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - let nexus = &cptestctx.server.server_context().nexus; - - let silo: Silo = object_create( - client, - "/v1/system/silos", - ¶ms::SiloCreate { - identity: IdentityMetadataCreateParams { - name: "silo-name".parse().unwrap(), - description: "a silo".to_string(), - }, - quotas: params::SiloQuotasCreate::empty(), - discoverable: false, - identity_mode: shared::SiloIdentityMode::SamlJit, - admin_group_name: Some("administrator".into()), - tls_certificates: vec![], - mapped_fleet_roles: Default::default(), - }, - ) - .await; - - let authn_opctx = nexus.opctx_external_authn(); - - let (authz_silo, db_silo) = - LookupPath::new(&authn_opctx, nexus.datastore()) - .silo_name(&silo.identity.name.into()) - .fetch() - .await - .unwrap(); - - assert!( - nexus - .datastore() - .silo_group_optional_lookup( - &authn_opctx, - &authz_silo, - "administrator".into(), - ) - .await - .unwrap() - .is_some() - ); - - // Test that a user is granted privileges from their group membership - let admin_group_user = nexus - .silo_user_from_authenticated_subject( - &authn_opctx, - &authz_silo, - &db_silo, - &AuthenticatedSubject { - external_id: "adminuser@company.com".into(), - groups: vec!["administrator".into()], - }, - ) - .await - .unwrap(); - - let group_memberships = nexus - .datastore() - .silo_group_membership_for_user( - &authn_opctx, - &authz_silo, - admin_group_user.id(), - ) - .await - .unwrap(); - - assert_eq!(group_memberships.len(), 1); - - // Create a project - let _org = NexusRequest::objects_post( - client, - "/v1/projects", - ¶ms::ProjectCreate { - identity: IdentityMetadataCreateParams { - name: "myproj".parse().unwrap(), - description: "some proj".into(), - }, - }, - ) - .authn_as(AuthnMode::SiloUser(admin_group_user.id())) - .execute() - .await - .expect("failed to create Project") - .parsed_body::() - .expect("failed to parse as Project"); -} - -// Test listing providers -#[nexus_test] -async fn test_listing_identity_providers(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - create_silo(&client, "test-silo", true, shared::SiloIdentityMode::SamlJit) - .await; - - // List providers - should be none - let providers = objects_list_page_authz::( - client, - "/v1/system/identity-providers?silo=test-silo", - ) - .await - .items; - - assert_eq!(providers.len(), 0); - - // Add some providers - let saml_idp_descriptor = SAML_IDP_DESCRIPTOR; - - let server = Server::run(); - server.expect( - Expectation::matching(request::method_path("GET", "/descriptor")) - .times(1..) - .respond_with(status_code(200).body(saml_idp_descriptor)), - ); - - let silo_saml_idp_1: SamlIdentityProvider = object_create( - client, - &"/v1/system/identity-providers/saml?silo=test-silo", - ¶ms::SamlIdentityProviderCreate { - identity: IdentityMetadataCreateParams { - name: "some-totally-real-saml-provider" - .to_string() - .parse() - .unwrap(), - description: "a demo provider".to_string(), - }, - - idp_metadata_source: params::IdpMetadataSource::Url { - url: server.url("/descriptor").to_string(), - }, - - idp_entity_id: "entity_id".to_string(), - sp_client_id: "client_id".to_string(), - acs_url: "http://acs".to_string(), - slo_url: "http://slo".to_string(), - technical_contact_email: "technical@fake".to_string(), - - signing_keypair: None, - - group_attribute_name: None, - }, - ) - .await; - - let silo_saml_idp_2: SamlIdentityProvider = object_create( - client, - &"/v1/system/identity-providers/saml?silo=test-silo", - ¶ms::SamlIdentityProviderCreate { - identity: IdentityMetadataCreateParams { - name: "another-totally-real-saml-provider" - .to_string() - .parse() - .unwrap(), - description: "a demo provider".to_string(), - }, - - idp_metadata_source: params::IdpMetadataSource::Url { - url: server.url("/descriptor").to_string(), - }, - - idp_entity_id: "entity_id".to_string(), - sp_client_id: "client_id".to_string(), - acs_url: "http://acs".to_string(), - slo_url: "http://slo".to_string(), - technical_contact_email: "technical@fake".to_string(), - - signing_keypair: None, - - group_attribute_name: None, - }, - ) - .await; - - // List providers again - expect 2 - let providers = objects_list_page_authz::( - client, - "/v1/system/identity-providers?silo=test-silo", - ) - .await - .items; - - assert_eq!(providers.len(), 2); - - let provider_name_set = - providers.into_iter().map(|x| x.identity.name).collect::>(); - assert!(provider_name_set.contains(&silo_saml_idp_1.identity.name)); - assert!(provider_name_set.contains(&silo_saml_idp_2.identity.name)); -} - -// Test that deleting the silo deletes the idp -#[nexus_test] -async fn test_deleting_a_silo_deletes_the_idp( - cptestctx: &ControlPlaneTestContext, -) { - let client = &cptestctx.external_client; - - const SILO_NAME: &str = "test-silo"; - create_silo(&client, SILO_NAME, true, shared::SiloIdentityMode::SamlJit) - .await; - - let saml_idp_descriptor = SAML_IDP_DESCRIPTOR; - - let server = Server::run(); - server.expect( - Expectation::matching(request::method_path("GET", "/descriptor")) - .respond_with(status_code(200).body(saml_idp_descriptor)), - ); - - let silo_saml_idp: SamlIdentityProvider = object_create( - client, - &format!("/v1/system/identity-providers/saml?silo={}", SILO_NAME), - ¶ms::SamlIdentityProviderCreate { - identity: IdentityMetadataCreateParams { - name: "some-totally-real-saml-provider" - .to_string() - .parse() - .unwrap(), - description: "a demo provider".to_string(), - }, - - idp_metadata_source: params::IdpMetadataSource::Url { - url: server.url("/descriptor").to_string(), - }, - - idp_entity_id: "entity_id".to_string(), - sp_client_id: "client_id".to_string(), - acs_url: "http://acs".to_string(), - slo_url: "http://slo".to_string(), - technical_contact_email: "technical@fake".to_string(), - - signing_keypair: None, - - group_attribute_name: None, - }, - ) - .await; - - // Delete the silo - NexusRequest::object_delete( - &client, - &format!("/v1/system/silos/{}", SILO_NAME), - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("failed to make request"); - - // Expect that the silo is gone - let nexus = &cptestctx.server.server_context().nexus; - - let response = nexus - .datastore() - .identity_provider_lookup( - &nexus.opctx_external_authn(), - &omicron_common::api::external::Name::try_from( - SILO_NAME.to_string(), - ) - .unwrap() - .into(), - &omicron_common::api::external::Name::try_from( - "some-totally-real-saml-provider".to_string(), - ) - .unwrap() - .into(), - ) - .await; - - assert!(response.is_err()); - match response.err().unwrap() { - omicron_common::api::external::Error::ObjectNotFound { - type_name, - lookup_type: _, - } => { - assert_eq!( - type_name, - omicron_common::api::external::ResourceType::Silo - ); - } - - _ => { - assert!(false); - } - } - - // No SSO redirect expected - NexusRequest::new( - RequestBuilder::new( - client, - Method::GET, - &format!( - "/login/{}/saml/{}/redirect", - SILO_NAME, silo_saml_idp.identity.name - ), - ) - .expect_status(Some(StatusCode::NOT_FOUND)), - ) - .execute() - .await - .expect("expected success"); -} - -// Create a Silo with a SAML IdP document string -#[nexus_test] -async fn test_saml_idp_metadata_data_valid( - cptestctx: &ControlPlaneTestContext, -) { - let client = &cptestctx.external_client; - - create_silo(&client, "blahblah", true, shared::SiloIdentityMode::SamlJit) - .await; - - let silo_saml_idp: SamlIdentityProvider = object_create( - client, - "/v1/system/identity-providers/saml?silo=blahblah", - ¶ms::SamlIdentityProviderCreate { - identity: IdentityMetadataCreateParams { - name: "some-totally-real-saml-provider" - .to_string() - .parse() - .unwrap(), - description: "a demo provider".to_string(), - }, - - idp_metadata_source: params::IdpMetadataSource::Base64EncodedXml { - data: base64::engine::general_purpose::STANDARD - .encode(SAML_IDP_DESCRIPTOR), - }, - - idp_entity_id: "entity_id".to_string(), - sp_client_id: "client_id".to_string(), - acs_url: "http://acs".to_string(), - slo_url: "http://slo".to_string(), - technical_contact_email: "technical@fake".to_string(), - - signing_keypair: None, - - group_attribute_name: None, - }, - ) - .await; - - // Expect the SSO redirect when trying to log in - let result = NexusRequest::new( - RequestBuilder::new( - client, - Method::GET, - &format!( - "/login/blahblah/saml/{}/redirect", - silo_saml_idp.identity.name - ), - ) - .expect_status(Some(StatusCode::FOUND)), - ) - .execute() - .await - .expect("expected success"); - - assert!( - result.headers["Location"].to_str().unwrap().to_string().starts_with( - "https://idp.example.org/SAML2/SSO/Redirect?SAMLRequest=", - ) - ); -} - -// Fail to create a Silo with a SAML IdP document string that isn't valid -#[nexus_test] -async fn test_saml_idp_metadata_data_truncated( - cptestctx: &ControlPlaneTestContext, -) { - let client = &cptestctx.external_client; - - create_silo(&client, "blahblah", true, shared::SiloIdentityMode::SamlJit) - .await; - - NexusRequest::new( - RequestBuilder::new( - client, - Method::POST, - "/v1/system/identity-providers/saml?silo=blahblah", - ) - .body(Some(¶ms::SamlIdentityProviderCreate { - identity: IdentityMetadataCreateParams { - name: "some-totally-real-saml-provider" - .to_string() - .parse() - .unwrap(), - description: "a demo provider".to_string(), - }, - - idp_metadata_source: params::IdpMetadataSource::Base64EncodedXml { - data: base64::engine::general_purpose::STANDARD.encode({ - let mut saml_idp_descriptor = - SAML_IDP_DESCRIPTOR.to_string(); - saml_idp_descriptor.truncate(100); - saml_idp_descriptor - }), - }, - - idp_entity_id: "entity_id".to_string(), - sp_client_id: "client_id".to_string(), - acs_url: "http://acs".to_string(), - slo_url: "http://slo".to_string(), - technical_contact_email: "technical@fake".to_string(), - - signing_keypair: None, - - group_attribute_name: None, - })) - .expect_status(Some(StatusCode::BAD_REQUEST)), - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("unexpected success"); -} - -// Can't create a SAML IdP from bad base64 data -#[nexus_test] -async fn test_saml_idp_metadata_data_invalid( - cptestctx: &ControlPlaneTestContext, -) { - let client = &cptestctx.external_client; - - const SILO_NAME: &str = "saml-silo"; - create_silo(&client, SILO_NAME, true, shared::SiloIdentityMode::SamlJit) - .await; - - NexusRequest::new( - RequestBuilder::new( - client, - Method::POST, - &format!("/v1/system/identity-providers/saml?silo={}", SILO_NAME), - ) - .body(Some(¶ms::SamlIdentityProviderCreate { - identity: IdentityMetadataCreateParams { - name: "some-totally-real-saml-provider" - .to_string() - .parse() - .unwrap(), - description: "a demo provider".to_string(), - }, - - idp_metadata_source: params::IdpMetadataSource::Base64EncodedXml { - data: "bad data".to_string(), - }, - - idp_entity_id: "entity_id".to_string(), - sp_client_id: "client_id".to_string(), - acs_url: "http://acs".to_string(), - slo_url: "http://slo".to_string(), - technical_contact_email: "technical@fake".to_string(), - - signing_keypair: None, - - group_attribute_name: None, - })) - .expect_status(Some(StatusCode::BAD_REQUEST)), - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("unexpected success"); -} - -struct TestSiloUserProvisionTypes { - identity_mode: shared::SiloIdentityMode, - existing_silo_user: bool, - expect_user: bool, -} - -#[nexus_test] -async fn test_silo_user_provision_types(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - let nexus = &cptestctx.server.server_context().nexus; - let datastore = nexus.datastore(); - - let test_cases: Vec = vec![ - // A silo configured with a "ApiOnly" user provision type should fetch a - // user if it exists already. - TestSiloUserProvisionTypes { - identity_mode: shared::SiloIdentityMode::LocalOnly, - existing_silo_user: true, - expect_user: true, - }, - // A silo configured with a "ApiOnly" user provision type should not - // create a user if one does not exist already. - TestSiloUserProvisionTypes { - identity_mode: shared::SiloIdentityMode::LocalOnly, - existing_silo_user: false, - expect_user: false, - }, - // A silo configured with a "JIT" user provision type should fetch a - // user if it exists already. - TestSiloUserProvisionTypes { - identity_mode: shared::SiloIdentityMode::SamlJit, - existing_silo_user: true, - expect_user: true, - }, - // A silo configured with a "JIT" user provision type should create a - // user if one does not exist already. - TestSiloUserProvisionTypes { - identity_mode: shared::SiloIdentityMode::SamlJit, - existing_silo_user: false, - expect_user: true, - }, - ]; - - for test_case in test_cases { - let silo = - create_silo(&client, "test-silo", true, test_case.identity_mode) - .await; - - if test_case.existing_silo_user { - match test_case.identity_mode { - shared::SiloIdentityMode::SamlJit => { - create_jit_user(datastore, &silo, "external-id-com").await; - } - shared::SiloIdentityMode::LocalOnly => { - create_local_user( - client, - &silo, - &"external-id-com".parse().unwrap(), - test_params::UserPassword::LoginDisallowed, - ) - .await; - } - }; - } - - let authn_opctx = nexus.opctx_external_authn(); - - let (authz_silo, db_silo) = - LookupPath::new(&authn_opctx, nexus.datastore()) - .silo_name(&silo.identity.name.into()) - .fetch() - .await - .unwrap(); - - let existing_silo_user = nexus - .silo_user_from_authenticated_subject( - &authn_opctx, - &authz_silo, - &db_silo, - &AuthenticatedSubject { - external_id: "external-id-com".into(), - groups: vec![], - }, - ) - .await; - - if test_case.expect_user { - assert!(existing_silo_user.is_ok()); - } else { - assert!(existing_silo_user.is_err()); - } - - NexusRequest::object_delete(&client, &"/v1/system/silos/test-silo") - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("failed to make request"); - } -} - -#[nexus_test] -async fn test_silo_user_fetch_by_external_id( - cptestctx: &ControlPlaneTestContext, -) { - let client = &cptestctx.external_client; - let nexus = &cptestctx.server.server_context().nexus; - - let silo = create_silo( - &client, - "test-silo", - true, - shared::SiloIdentityMode::LocalOnly, - ) - .await; - - let opctx_external_authn = nexus.opctx_external_authn(); - let opctx = OpContext::for_tests( - cptestctx.logctx.log.new(o!()), - nexus.datastore().clone(), - ); - - let (authz_silo, _) = LookupPath::new(&opctx, nexus.datastore()) - .silo_name(&Name::try_from("test-silo".to_string()).unwrap().into()) - .fetch_for(authz::Action::Read) - .await - .unwrap(); - - // Create a user - create_local_user( - client, - &silo, - &"f5513e049dac9468de5bdff36ab17d04f".parse().unwrap(), - test_params::UserPassword::LoginDisallowed, - ) - .await; - - // Fetching by external id that's not in the db should be Ok(None) - let result = nexus - .datastore() - .silo_user_fetch_by_external_id( - &opctx_external_authn, - &authz_silo, - "123", - ) - .await; - assert!(result.is_ok()); - assert!(result.unwrap().is_none()); - - // Fetching by external id that is should be Ok(Some) - let result = nexus - .datastore() - .silo_user_fetch_by_external_id( - &opctx_external_authn, - &authz_silo, - "f5513e049dac9468de5bdff36ab17d04f", - ) - .await; - assert!(result.is_ok()); - assert!(result.unwrap().is_some()); -} - -#[nexus_test] -async fn test_silo_users_list(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - - let initial_silo_users: Vec = - NexusRequest::iter_collection_authn(client, "/v1/users", "", None) - .await - .expect("failed to list silo users (1)") - .all_items; - - // In the built-in Silo, we expect the test-privileged and test-unprivileged - // users. - assert_eq!( - initial_silo_users, - vec![ - views::User { - id: USER_TEST_PRIVILEGED.id(), - display_name: USER_TEST_PRIVILEGED.external_id.clone(), - silo_id: DEFAULT_SILO_ID, - }, - views::User { - id: USER_TEST_UNPRIVILEGED.id(), - display_name: USER_TEST_UNPRIVILEGED.external_id.clone(), - silo_id: DEFAULT_SILO_ID, - }, - ] - ); - - // Now create another user and make sure we can see them. While we're at - // it, use a small limit to check that pagination is really working. - let new_silo_user_external_id = "can-we-see-them"; - let new_silo_user_id = create_local_user( - client, - &views::Silo::try_from(DEFAULT_SILO.clone()).unwrap(), - &new_silo_user_external_id.parse().unwrap(), - test_params::UserPassword::LoginDisallowed, - ) - .await - .id; - - let mut silo_users: Vec = - NexusRequest::iter_collection_authn(client, "/v1/users", "", Some(1)) - .await - .expect("failed to list silo users (2)") - .all_items; - silo_users.sort_by(|u1, u2| u1.display_name.cmp(&u2.display_name)); - assert_eq!( - silo_users, - vec![ - views::User { - id: new_silo_user_id, - display_name: new_silo_user_external_id.into(), - silo_id: DEFAULT_SILO_ID, - }, - views::User { - id: USER_TEST_PRIVILEGED.id(), - display_name: USER_TEST_PRIVILEGED.external_id.clone(), - silo_id: DEFAULT_SILO_ID, - }, - views::User { - id: USER_TEST_UNPRIVILEGED.id(), - display_name: USER_TEST_UNPRIVILEGED.external_id.clone(), - silo_id: DEFAULT_SILO_ID, - }, - ] - ); - - // Create another Silo with a Silo administrator. That user should not be - // able to see the users in the first Silo. - - let silo = - create_silo(client, "silo2", true, shared::SiloIdentityMode::LocalOnly) - .await; - - let new_silo_user_name = String::from("some-silo-user"); - let new_silo_user_id = create_local_user( - client, - &silo, - &new_silo_user_name.parse().unwrap(), - test_params::UserPassword::LoginDisallowed, - ) - .await - .id; - grant_iam( - client, - "/v1/system/silos/silo2", - SiloRole::Admin, - new_silo_user_id, - AuthnMode::PrivilegedUser, - ) - .await; - - let silo2_users: dropshot::ResultsPage = - NexusRequest::object_get(client, "/v1/users") - .authn_as(AuthnMode::SiloUser(new_silo_user_id)) - .execute() - .await - .unwrap() - .parsed_body() - .unwrap(); - assert_eq!( - silo2_users.items, - vec![views::User { - id: new_silo_user_id, - display_name: new_silo_user_name, - silo_id: silo.identity.id, - }] - ); - - // The "test-privileged" user also shouldn't see the user in this other - // Silo. - let mut new_silo_users: Vec = - NexusRequest::iter_collection_authn(client, "/v1/users", "", Some(1)) - .await - .expect("failed to list silo users (2)") - .all_items; - new_silo_users.sort_by(|u1, u2| u1.display_name.cmp(&u2.display_name)); - assert_eq!(silo_users, new_silo_users,); - - // TODO-coverage When we have a way to remove or invalidate Silo Users, we - // should test that doing so causes them to stop appearing in the list. -} - -#[nexus_test] -async fn test_silo_groups_jit(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - let nexus = &cptestctx.server.server_context().nexus; - let datastore = nexus.datastore(); - - let silo = create_silo( - &client, - "test-silo", - true, - shared::SiloIdentityMode::SamlJit, - ) - .await; - - // Create a user in advance - create_jit_user(datastore, &silo, "external@id.com").await; - - let authn_opctx = nexus.opctx_external_authn(); - - let (authz_silo, db_silo) = - LookupPath::new(&authn_opctx, nexus.datastore()) - .silo_name(&silo.identity.name.into()) - .fetch() - .await - .unwrap(); - - // Should create two groups from the authenticated subject - let existing_silo_user = nexus - .silo_user_from_authenticated_subject( - &authn_opctx, - &authz_silo, - &db_silo, - &AuthenticatedSubject { - external_id: "external@id.com".into(), - groups: vec!["a-group".into(), "b-group".into()], - }, - ) - .await - .unwrap(); - - let group_memberships = nexus - .datastore() - .silo_group_membership_for_user( - &authn_opctx, - &authz_silo, - existing_silo_user.id(), - ) - .await - .unwrap(); - - assert_eq!(group_memberships.len(), 2); - - let mut group_names = vec![]; - - for group_membership in &group_memberships { - let (.., db_group) = LookupPath::new(&authn_opctx, nexus.datastore()) - .silo_group_id(group_membership.silo_group_id.into()) - .fetch() - .await - .unwrap(); - - group_names.push(db_group.external_id); - } - - assert!(group_names.contains(&"a-group".to_string())); - assert!(group_names.contains(&"b-group".to_string())); -} - -#[nexus_test] -async fn test_silo_groups_fixed(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - let nexus = &cptestctx.server.server_context().nexus; - - let silo = create_silo( - &client, - "test-silo", - true, - shared::SiloIdentityMode::LocalOnly, - ) - .await; - - // Create a user in advance - create_local_user( - client, - &silo, - &"external-id-com".parse().unwrap(), - test_params::UserPassword::LoginDisallowed, - ) - .await; - - let authn_opctx = nexus.opctx_external_authn(); - - let (authz_silo, db_silo) = - LookupPath::new(&authn_opctx, nexus.datastore()) - .silo_name(&silo.identity.name.into()) - .fetch() - .await - .unwrap(); - - // Should not create groups from the authenticated subject - let existing_silo_user = nexus - .silo_user_from_authenticated_subject( - &authn_opctx, - &authz_silo, - &db_silo, - &AuthenticatedSubject { - external_id: "external-id-com".into(), - groups: vec!["a-group".into(), "b-group".into()], - }, - ) - .await - .unwrap(); - - let group_memberships = nexus - .datastore() - .silo_group_membership_for_user( - &authn_opctx, - &authz_silo, - existing_silo_user.id(), - ) - .await - .unwrap(); - - assert_eq!(group_memberships.len(), 0); -} - -#[nexus_test] -async fn test_silo_groups_remove_from_one_group( - cptestctx: &ControlPlaneTestContext, -) { - let client = &cptestctx.external_client; - let nexus = &cptestctx.server.server_context().nexus; - let datastore = nexus.datastore(); - - let silo = create_silo( - &client, - "test-silo", - true, - shared::SiloIdentityMode::SamlJit, - ) - .await; - - // Create a user in advance - create_jit_user(datastore, &silo, "external@id.com").await; - - let authn_opctx = nexus.opctx_external_authn(); - - let (authz_silo, db_silo) = - LookupPath::new(&authn_opctx, nexus.datastore()) - .silo_name(&silo.identity.name.into()) - .fetch() - .await - .unwrap(); - - // Add to two groups - let existing_silo_user = nexus - .silo_user_from_authenticated_subject( - &authn_opctx, - &authz_silo, - &db_silo, - &AuthenticatedSubject { - external_id: "external@id.com".into(), - groups: vec!["a-group".into(), "b-group".into()], - }, - ) - .await - .unwrap(); - - // Check those groups were created and the user was added - let group_memberships = nexus - .datastore() - .silo_group_membership_for_user( - &authn_opctx, - &authz_silo, - existing_silo_user.id(), - ) - .await - .unwrap(); - - assert_eq!(group_memberships.len(), 2); - - let mut group_names = vec![]; - - for group_membership in &group_memberships { - let (.., db_group) = LookupPath::new(&authn_opctx, nexus.datastore()) - .silo_group_id(group_membership.silo_group_id.into()) - .fetch() - .await - .unwrap(); - - group_names.push(db_group.external_id); - } - - assert!(group_names.contains(&"a-group".to_string())); - assert!(group_names.contains(&"b-group".to_string())); - - // Then remove their membership from one group - let existing_silo_user = nexus - .silo_user_from_authenticated_subject( - &authn_opctx, - &authz_silo, - &db_silo, - &AuthenticatedSubject { - external_id: "external@id.com".into(), - groups: vec!["b-group".into()], - }, - ) - .await - .unwrap(); - - let group_memberships = nexus - .datastore() - .silo_group_membership_for_user( - &authn_opctx, - &authz_silo, - existing_silo_user.id(), - ) - .await - .unwrap(); - - assert_eq!(group_memberships.len(), 1); - - let mut group_names = vec![]; - - for group_membership in &group_memberships { - let (.., db_group) = LookupPath::new(&authn_opctx, nexus.datastore()) - .silo_group_id(group_membership.silo_group_id.into()) - .fetch() - .await - .unwrap(); - - group_names.push(db_group.external_id); - } - - assert!(group_names.contains(&"b-group".to_string())); -} - -#[nexus_test] -async fn test_silo_groups_remove_from_both_groups( - cptestctx: &ControlPlaneTestContext, -) { - let client = &cptestctx.external_client; - let nexus = &cptestctx.server.server_context().nexus; - let datastore = nexus.datastore(); - - let silo = create_silo( - &client, - "test-silo", - true, - shared::SiloIdentityMode::SamlJit, - ) - .await; - - // Create a user in advance - create_jit_user(datastore, &silo, "external@id.com").await; - - let authn_opctx = nexus.opctx_external_authn(); - - let (authz_silo, db_silo) = - LookupPath::new(&authn_opctx, nexus.datastore()) - .silo_name(&silo.identity.name.into()) - .fetch() - .await - .unwrap(); - - // Add to two groups - let existing_silo_user = nexus - .silo_user_from_authenticated_subject( - &authn_opctx, - &authz_silo, - &db_silo, - &AuthenticatedSubject { - external_id: "external@id.com".into(), - groups: vec!["a-group".into(), "b-group".into()], - }, - ) - .await - .unwrap(); - - // Check those groups were created and the user was added - let group_memberships = nexus - .datastore() - .silo_group_membership_for_user( - &authn_opctx, - &authz_silo, - existing_silo_user.id(), - ) - .await - .unwrap(); - - assert_eq!(group_memberships.len(), 2); - - let mut group_names = vec![]; - - for group_membership in &group_memberships { - let (.., db_group) = LookupPath::new(&authn_opctx, nexus.datastore()) - .silo_group_id(group_membership.silo_group_id.into()) - .fetch() - .await - .unwrap(); - - group_names.push(db_group.external_id); - } - - assert!(group_names.contains(&"a-group".to_string())); - assert!(group_names.contains(&"b-group".to_string())); - - // Then remove from both groups, and add to a new one - let existing_silo_user = nexus - .silo_user_from_authenticated_subject( - &authn_opctx, - &authz_silo, - &db_silo, - &AuthenticatedSubject { - external_id: "external@id.com".into(), - groups: vec!["c-group".into()], - }, - ) - .await - .unwrap(); - - let group_memberships = nexus - .datastore() - .silo_group_membership_for_user( - &authn_opctx, - &authz_silo, - existing_silo_user.id(), - ) - .await - .unwrap(); - - assert_eq!(group_memberships.len(), 1); - - let mut group_names = vec![]; - - for group_membership in &group_memberships { - let (.., db_group) = LookupPath::new(&authn_opctx, nexus.datastore()) - .silo_group_id(group_membership.silo_group_id.into()) - .fetch() - .await - .unwrap(); - - group_names.push(db_group.external_id); - } - - assert!(group_names.contains(&"c-group".to_string())); -} - -// Test that silo delete cleans up associated groups -#[nexus_test] -async fn test_silo_delete_clean_up_groups(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - let nexus = &cptestctx.server.server_context().nexus; - - // Create a silo - let silo = create_silo( - &client, - "test-silo", - true, - shared::SiloIdentityMode::SamlJit, - ) - .await; - - let opctx_external_authn = nexus.opctx_external_authn(); - let opctx = OpContext::for_tests( - cptestctx.logctx.log.new(o!()), - nexus.datastore().clone(), - ); - - let (authz_silo, db_silo) = LookupPath::new(&opctx, nexus.datastore()) - .silo_name(&silo.identity.name.into()) - .fetch() - .await - .unwrap(); - - // Add a user with a group membership - let silo_user = nexus - .silo_user_from_authenticated_subject( - &opctx_external_authn, - &authz_silo, - &db_silo, - &AuthenticatedSubject { - external_id: "user@company.com".into(), - groups: vec!["sre".into()], - }, - ) - .await - .expect("silo_user_from_authenticated_subject"); - - // Delete the silo - NexusRequest::object_delete(&client, &"/v1/system/silos/test-silo") - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("failed to make request"); - - // Expect the group is gone - assert!( - nexus - .datastore() - .silo_group_optional_lookup( - &opctx_external_authn, - &authz_silo, - "a-group".into(), - ) - .await - .expect("silo_group_optional_lookup") - .is_none() - ); - - // Expect the group membership is gone - let memberships = nexus - .datastore() - .silo_group_membership_for_user( - &opctx_external_authn, - &authz_silo, - silo_user.id(), - ) - .await - .expect("silo_group_membership_for_user"); - - assert!(memberships.is_empty()); - - // Expect the user is gone - LookupPath::new(&opctx_external_authn, nexus.datastore()) - .silo_user_id(silo_user.id()) - .fetch() - .await - .expect_err("user found"); -} - -// Test ensuring the same group from different users -#[nexus_test] -async fn test_ensure_same_silo_group(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - let nexus = &cptestctx.server.server_context().nexus; - - // Create a silo - let silo = create_silo( - &client, - "test-silo", - true, - shared::SiloIdentityMode::SamlJit, - ) - .await; - - let opctx = OpContext::for_tests( - cptestctx.logctx.log.new(o!()), - nexus.datastore().clone(), - ); - - let (authz_silo, db_silo) = LookupPath::new(&opctx, nexus.datastore()) - .silo_name(&silo.identity.name.into()) - .fetch() - .await - .unwrap(); - - // Add the first user with a group membership - let _silo_user_1 = nexus - .silo_user_from_authenticated_subject( - &nexus.opctx_external_authn(), - &authz_silo, - &db_silo, - &AuthenticatedSubject { - external_id: "user1@company.com".into(), - groups: vec!["sre".into()], - }, - ) - .await - .expect("silo_user_from_authenticated_subject 1"); - - // Add the first user with a group membership - let _silo_user_2 = nexus - .silo_user_from_authenticated_subject( - &nexus.opctx_external_authn(), - &authz_silo, - &db_silo, - &AuthenticatedSubject { - external_id: "user2@company.com".into(), - groups: vec!["sre".into()], - }, - ) - .await - .expect("silo_user_from_authenticated_subject 2"); - - // TODO-coverage were we intending to verify something here? -} - -/// Tests the behavior of the per-Silo "list users" and "fetch user" endpoints. -/// -/// We'll run the tests separately for both kinds of Silo. The implementation -/// should be the same, but that's why we're verifying it. -#[nexus_test] -async fn test_silo_user_views(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - let datastore = cptestctx.server.server_context().nexus.datastore(); - - // Create the two Silos. - let silo1 = - create_silo(client, "silo1", false, shared::SiloIdentityMode::SamlJit) - .await; - let silo2 = create_silo( - client, - "silo2", - false, - shared::SiloIdentityMode::LocalOnly, - ) - .await; - - // Create two users in each Silo. We need two so that we can verify that an - // ordinary user can see a user other than themselves in each Silo. - let silo1_user1 = create_jit_user(datastore, &silo1, "silo1-user1").await; - let silo1_user1_id = silo1_user1.id; - let silo1_user2 = create_jit_user(datastore, &silo1, "silo1-user2").await; - let silo1_user2_id = silo1_user2.id; - let mut silo1_expected_users = [silo1_user1.clone(), silo1_user2.clone()]; - silo1_expected_users.sort_by_key(|u| u.id); - - let silo2_user1 = create_local_user( - client, - &silo2, - &"silo2-user1".parse().unwrap(), - test_params::UserPassword::LoginDisallowed, - ) - .await; - let silo2_user1_id = silo2_user1.id; - let silo2_user2 = create_local_user( - client, - &silo2, - &"silo2-user2".parse().unwrap(), - test_params::UserPassword::LoginDisallowed, - ) - .await; - let silo2_user2_id = silo2_user2.id; - let mut silo2_expected_users = [silo2_user1.clone(), silo2_user2.clone()]; - silo2_expected_users.sort_by_key(|u| u.id); - - let users_by_id = { - let mut users_by_id: BTreeMap = - BTreeMap::new(); - assert_eq!(users_by_id.insert(silo1_user1_id, &silo1_user1), None); - assert_eq!(users_by_id.insert(silo1_user2_id, &silo1_user2), None); - assert_eq!(users_by_id.insert(silo2_user1_id, &silo2_user1), None); - assert_eq!(users_by_id.insert(silo2_user2_id, &silo2_user2), None); - users_by_id - }; - - let users_by_name = users_by_id - .values() - .map(|user| (user.display_name.to_owned(), *user)) - .collect::>(); - - // We'll run through a battery of tests: - // - for each of our test silos - // - for all *five* users ("test-privileged", plus the two users that we - // created in each Silo) - // - test the "list" endpoint - // - for all five user ids - // - test the "view user" endpoint for that user id - // - // This exercises a lot of different behaviors: - // - on success, the "list" and "view" endpoints always return the right - // contents - // - on failure, the "list" and "view" endpoints always return the right - // status code and message for the failure mode - // - that users can always list and fetch all users in their own Silo via - // /v1/system/silos (/users is tested elsewhere) - // - that users without privileges cannot list or fetch users in other Silos - // - that users with privileges on another Silo can list and fetch users in - // that Silo - // - that a user with id "foo" in Silo1 cannot be accessed by that id in - // Silo 2. This case is easy to miss but would be very bad to get wrong! - let all_callers = { - std::iter::once(AuthnMode::PrivilegedUser) - .chain(users_by_name.values().map(|v| AuthnMode::SiloUser(v.id))) - .collect::>() - }; - - struct TestSilo<'a> { - silo: &'a views::Silo, - expected_users: [views::User; 2], - } - - let test_silo1 = - TestSilo { silo: &silo1, expected_users: silo1_expected_users }; - let test_silo2 = - TestSilo { silo: &silo2, expected_users: silo2_expected_users }; - - // Strip the identifier out of error messages because the uuid changes each - // time. - let id_re = regex::Regex::new("\".*?\"").unwrap(); - - let mut output = String::new(); - for test_silo in [test_silo1, test_silo2] { - let silo_name = &test_silo.silo.identity().name; - - write!(&mut output, "SILO: {}\n", silo_name).unwrap(); - - for calling_user in all_callers.iter() { - let caller_label = match calling_user { - AuthnMode::PrivilegedUser => "privileged", - AuthnMode::SiloUser(silo_user_id) => { - let user = users_by_id.get(silo_user_id).unwrap(); - &user.display_name - } - _ => unimplemented!(), - }; - write!(&mut output, " test user {}:\n", caller_label).unwrap(); - - // Test the "list" endpoint. - write!(&mut output, " list = ").unwrap(); - let test_response = NexusRequest::new(RequestBuilder::new( - client, - Method::GET, - &format!("/v1/system/users?silo={}", silo_name), - )) - .authn_as(calling_user.clone()) - .execute() - .await - .unwrap(); - write!(&mut output, "{}", test_response.status.as_str()).unwrap(); - - // If this succeeded, it must have returned the expected users for - // this Silo. - if test_response.status == http::StatusCode::OK { - let found_users = test_response - .parsed_body::>() - .unwrap() - .items; - assert_eq!(found_users, test_silo.expected_users); - } else { - let error = test_response - .parsed_body::() - .unwrap(); - write!(&mut output, " (message = {:?})", error.message) - .unwrap(); - } - - write!(&mut output, "\n").unwrap(); - - // Test the "view" endpoint for each user in this Silo. - for (_, user) in &users_by_name { - let user_id = user.id; - write!(&mut output, " view {:?} = ", user.display_name) - .unwrap(); - let test_response = NexusRequest::new(RequestBuilder::new( - client, - Method::GET, - &format!("/v1/system/users/{}?silo={}", user_id, silo_name), - )) - .authn_as(calling_user.clone()) - .execute() - .await - .unwrap(); - write!(&mut output, "{}", test_response.status.as_str()) - .unwrap(); - // If this succeeded, it must have returned the right user back. - if test_response.status == http::StatusCode::OK { - let found_user = - test_response.parsed_body::().unwrap(); - assert_eq!( - found_user.silo_id, - test_silo.silo.identity().id - ); - assert_eq!(found_user, **user); - } else { - let error = test_response - .parsed_body::() - .unwrap(); - let message = id_re.replace_all(&error.message, "..."); - write!(&mut output, " (message = {:?})", message).unwrap(); - } - - write!(&mut output, "\n").unwrap(); - } - - write!(&mut output, "\n").unwrap(); - } - } - - expectorate::assert_contents( - "tests/output/silo-user-views-output.txt", - &output, - ); -} - -/// Create a user in a SamlJit Silo for testing -/// -/// For local-only Silos, use the real API (via `create_local_user()`). -async fn create_jit_user( - datastore: &db::DataStore, - silo: &views::Silo, - external_id: &str, -) -> views::User { - assert_eq!(silo.identity_mode, shared::SiloIdentityMode::SamlJit); - let silo_id = silo.identity.id; - let silo_user_id = SiloUserUuid::new_v4(); - let authz_silo = - authz::Silo::new(authz::FLEET, silo_id, LookupType::ById(silo_id)); - let silo_user = - db::model::SiloUser::new(silo_id, silo_user_id, external_id.to_owned()); - datastore - .silo_user_create(&authz_silo, silo_user) - .await - .expect("failed to create user in SamlJit Silo") - .1 - .into() -} - -/// Tests that LocalOnly-specific endpoints are not available in SamlJit Silos -#[nexus_test] -async fn test_jit_silo_constraints(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - let nexus = &cptestctx.server.server_context().nexus; - let datastore = nexus.datastore(); - let silo = - create_silo(&client, "jit", true, shared::SiloIdentityMode::SamlJit) - .await; - - // We need one initial user that would in principle have privileges to - // create other users. - let admin_username = "admin-user"; - let admin_user = create_jit_user(&datastore, &silo, admin_username).await; - - // Grant this user "admin" privileges on that Silo. - grant_iam( - client, - "/v1/system/silos/jit", - SiloRole::Admin, - admin_user.id, - AuthnMode::PrivilegedUser, - ) - .await; - - // Neither the "test-privileged" user nor this newly-created admin user - // ought to be able to create a user via the Silo's local identity provider - // (because that provider does not exist). - for caller in - [AuthnMode::PrivilegedUser, AuthnMode::SiloUser(admin_user.id)] - { - verify_local_idp_404( - NexusRequest::expect_failure_with_body( - client, - StatusCode::NOT_FOUND, - Method::POST, - "/v1/system/identity-providers/local/users?silo=jit", - &test_params::UserCreate { - external_id: UserId::from_str("dummy").unwrap(), - password: test_params::UserPassword::LoginDisallowed, - }, - ) - .authn_as(caller), - ) - .await; - } - - // Now create another user, as might happen via JIT. - let other_user_id = - create_jit_user(datastore, &silo, "other-user").await.id; - let user_url_delete = format!( - "/v1/system/identity-providers/local/users/{}?silo=jit", - other_user_id - ); - let user_url_set_password = format!( - "/v1/system/identity-providers/local/users/{}/set-password?silo=jit", - other_user_id - ); - - // Neither the "test-privileged" user nor the Silo Admin ought to be able to - // remove this user via the local identity provider, nor set the user's - // password. - let password = "dummy"; - for caller in - [AuthnMode::PrivilegedUser, AuthnMode::SiloUser(admin_user.id)] - { - verify_local_idp_404( - NexusRequest::expect_failure( - client, - StatusCode::NOT_FOUND, - Method::DELETE, - &user_url_delete, - ) - .authn_as(caller.clone()), - ) - .await; - - verify_local_idp_404( - NexusRequest::expect_failure_with_body( - client, - StatusCode::NOT_FOUND, - Method::POST, - &user_url_set_password, - &test_params::UserPassword::Password(password.to_string()), - ) - .authn_as(caller.clone()), - ) - .await; - } - - // One should also not be able to log into this kind of Silo with a username - // and password. - verify_local_idp_404(NexusRequest::expect_failure_with_body( - client, - StatusCode::NOT_FOUND, - Method::POST, - "/v1/login/jit/local", - &test_params::UsernamePasswordCredentials { - username: UserId::from_str(admin_username).unwrap(), - password: password.to_string(), - }, - )) - .await; - - // They should get the same error for a user that does not exist. - verify_local_idp_404(NexusRequest::expect_failure_with_body( - client, - StatusCode::NOT_FOUND, - Method::POST, - "/v1/login/jit/local", - &test_params::UsernamePasswordCredentials { - username: UserId::from_str("bogus").unwrap(), - password: password.to_string(), - }, - )) - .await; -} - -async fn verify_local_idp_404(request: NexusRequest<'_>) { - let error = request - .execute() - .await - .unwrap() - .parsed_body::() - .unwrap(); - assert_eq!( - error.message, - "not found: identity-provider with name \"local\"" - ); -} - -/// Tests that SamlJit-specific endpoints are not available in LocalOnly Silos -#[nexus_test] -async fn test_local_silo_constraints(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - - // Create a "LocalOnly" Silo with its own admin user. - let silo = create_silo( - &client, - "fixed", - true, - shared::SiloIdentityMode::LocalOnly, - ) - .await; - let new_silo_user_id = create_local_user( - client, - &silo, - &"admin-user".parse().unwrap(), - test_params::UserPassword::LoginDisallowed, - ) - .await - .id; - grant_iam( - client, - "/v1/system/silos/fixed", - SiloRole::Admin, - new_silo_user_id, - AuthnMode::PrivilegedUser, - ) - .await; - - // It's not allowed to create an identity provider in a LocalOnly Silo. - let error: dropshot::HttpErrorResponseBody = - NexusRequest::expect_failure_with_body( - client, - StatusCode::BAD_REQUEST, - Method::POST, - "/v1/system/identity-providers/saml?silo=fixed", - ¶ms::SamlIdentityProviderCreate { - identity: IdentityMetadataCreateParams { - name: "some-totally-real-saml-provider" - .to_string() - .parse() - .unwrap(), - description: "a demo provider".to_string(), - }, - - idp_metadata_source: - params::IdpMetadataSource::Base64EncodedXml { - data: base64::engine::general_purpose::STANDARD - .encode(SAML_IDP_DESCRIPTOR), - }, - - idp_entity_id: "entity_id".to_string(), - sp_client_id: "client_id".to_string(), - acs_url: "http://acs".to_string(), - slo_url: "http://slo".to_string(), - technical_contact_email: "technical@fake".to_string(), - - signing_keypair: None, - - group_attribute_name: None, - }, - ) - .authn_as(AuthnMode::SiloUser(new_silo_user_id)) - .execute() - .await - .unwrap() - .parsed_body() - .unwrap(); - - assert_eq!( - error.message, - "cannot create identity providers in this kind of Silo" - ); - - // The SAML login endpoints should not work, either. - let error: dropshot::HttpErrorResponseBody = NexusRequest::expect_failure( - client, - StatusCode::NOT_FOUND, - Method::GET, - "/login/fixed/saml/foo/redirect", - ) - .execute() - .await - .unwrap() - .parsed_body() - .unwrap(); - assert_eq!(error.message, "not found: identity-provider with name \"foo\""); - let error: dropshot::HttpErrorResponseBody = NexusRequest::expect_failure( - client, - StatusCode::NOT_FOUND, - Method::POST, - "/login/fixed/saml/foo", - ) - .execute() - .await - .unwrap() - .parsed_body() - .unwrap(); - assert_eq!(error.message, "not found: identity-provider with name \"foo\""); -} - -#[nexus_test] -async fn test_local_silo_users(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - - // Create a "LocalOnly" Silo for testing. - let silo1 = create_silo( - &client, - "silo1", - true, - shared::SiloIdentityMode::LocalOnly, - ) - .await; - - // We'll run through a battery of tests as each of two different users: the - // usual "test-privileged" user (which should have full access because - // they're a Fleet Administrator) as well as a newly-created Silo Admin - // user. - run_user_tests(client, &silo1, &AuthnMode::PrivilegedUser, &[]).await; - - // Create a Silo Admin in our test Silo and run through the same tests. - let admin_user = create_local_user( - client, - &silo1, - &"admin-user".parse().unwrap(), - test_params::UserPassword::LoginDisallowed, - ) - .await; - grant_iam( - client, - "/v1/system/silos/silo1", - SiloRole::Admin, - admin_user.id, - AuthnMode::PrivilegedUser, - ) - .await; - run_user_tests( - client, - &silo1, - &AuthnMode::SiloUser(admin_user.id), - std::slice::from_ref(&admin_user), - ) - .await; -} - -/// Runs a sequence of tests for create, read, and delete of API-managed users -async fn run_user_tests( - client: &dropshot::test_util::ClientTestContext, - silo: &views::Silo, - authn_mode: &AuthnMode, - existing_users: &[views::User], -) { - let url_all_users = format!("/v1/system/users?silo={}", silo.identity.name); - let url_local_idp_users = format!( - "/v1/system/identity-providers/local/users?silo={}", - silo.identity.name - ); - let url_user_create = url_local_idp_users.to_string(); - - // Fetch users and verify it matches what the caller expects. - println!("run_user_tests: as {:?}: fetch all users", authn_mode); - let users = NexusRequest::object_get(client, &url_all_users) - .authn_as(authn_mode.clone()) - .execute() - .await - .expect("failed to list users") - .parsed_body::>() - .unwrap() - .items; - println!("users: {:?}", users); - assert_eq!(users, existing_users); - - // Create a user. - let user_created = NexusRequest::objects_post( - client, - &url_user_create, - &test_params::UserCreate { - external_id: UserId::from_str("a-test-user").unwrap(), - password: test_params::UserPassword::LoginDisallowed, - }, - ) - .authn_as(authn_mode.clone()) - .execute() - .await - .expect("failed to create user") - .parsed_body::() - .unwrap(); - assert_eq!(user_created.display_name, "a-test-user"); - println!("created user: {:?}", user_created); - - // Fetch the user we just created. - let user_url_get = format!( - "/v1/system/users/{}?silo={}", - user_created.id, silo.identity.name, - ); - let user_found = NexusRequest::object_get(client, &user_url_get) - .authn_as(authn_mode.clone()) - .execute() - .await - .expect("failed to fetch user we just created") - .parsed_body::() - .unwrap(); - assert_eq!(user_created, user_found); - - // List users. We should find whatever was there before, plus our new one. - let new_users = NexusRequest::object_get(client, &url_all_users) - .authn_as(authn_mode.clone()) - .execute() - .await - .expect("failed to list users") - .parsed_body::>() - .unwrap() - .items; - println!("new_users: {:?}", new_users); - let new_users = new_users - .iter() - .filter(|new_user| !users.iter().any(|old_user| *new_user == old_user)) - .collect::>(); - assert_eq!(new_users, &[&user_created]); - - // Delete the user that we created. - let user_url_delete = format!( - "/v1/system/identity-providers/local/users/{}?silo={}", - user_created.id, silo.identity.name, - ); - NexusRequest::object_delete(client, &user_url_delete) - .authn_as(authn_mode.clone()) - .execute() - .await - .expect("failed to delete the user we just created"); - - // We should not be able to fetch or delete the user again. - for method in [Method::GET, Method::DELETE] { - let url = if method == Method::GET { - &user_url_get - } else { - &user_url_delete - }; - let error = NexusRequest::expect_failure( - client, - StatusCode::NOT_FOUND, - method, - url, - ) - .authn_as(authn_mode.clone()) - .execute() - .await - .expect("unexpectedly succeeded in fetching deleted user") - .parsed_body::() - .unwrap(); - let not_found_message = - format!("not found: silo-user with id \"{}\"", user_created.id); - assert_eq!(error.message, not_found_message); - } - - // List users again. We should just find whatever we started with. - let last_users = NexusRequest::object_get(client, &url_all_users) - .authn_as(authn_mode.clone()) - .execute() - .await - .expect("failed to list users") - .parsed_body::>() - .unwrap() - .items; - println!("last_users: {:?}", last_users); - assert_eq!(last_users, existing_users); -} - -pub async fn verify_silo_dns_name( - cptestctx: &ControlPlaneTestContext, - silo_name: &str, - should_exist: bool, -) { - // The DNS naming scheme for Silo DNS names is just: - // $silo_name.sys.$delegated_name - // This is determined by RFD 357 and also implemented in Nexus. - let dns_name = - format!("{}.sys.{}", silo_name, cptestctx.external_dns_zone_name); - - // We assume that in the test suite, Nexus's "external" address is - // localhost. - let nexus_ip = Ipv4Addr::LOCALHOST; - - wait_for_condition( - || async { - let found = match cptestctx - .external_dns - .resolver() - .await - .expect("Failed to create external DNS resolver") - .ipv4_lookup(&dns_name) - .await - { - Ok(result) => { - let addrs: Vec<_> = result.iter().map(|a| &a.0).collect(); - if addrs.is_empty() { - false - } else { - assert_eq!(addrs, [&nexus_ip]); - true - } - } - Err(error) => match resolve_error_proto_kind(&error) { - Some(ProtoErrorKind::NoRecordsFound { .. }) => false, - _ => panic!( - "unexpected error querying external \ - DNS server for Silo DNS name {:?}: {:#}", - dns_name, error - ), - }, - }; - - if should_exist == found { - Ok(()) - } else { - Err::<_, CondCheckError>(CondCheckError::NotYet) - } - }, - &Duration::from_millis(50), - &Duration::from_secs(15), - ) - .await - .expect("failed to verify external DNS configuration"); -} - -fn resolve_error_proto_kind( - e: &hickory_resolver::ResolveError, -) -> Option<&ProtoErrorKind> { - let ResolveErrorKind::Proto(proto_error) = e.kind() else { return None }; - Some(proto_error.kind()) -} - -// Test the basic behavior of the Silo-level IAM policy that supports -// configuring Silo roles to confer Fleet-level roles. Because we don't support -// modifying Silos at all, we have to use separate Silos to test this behavior. -// -// We'll create a few Silos for testing: -// -// - default-policy: uses the default conferred-roles policy -// - viewer-policy: silo viewers get fleet viewer role -// - admin-policy: silo admins get fleet admin role -// -// For each of these Silos, we'll create an admin user in that Silo and test -// what privileges they have. -// -// This is not an exhaustive test of the policy choices here. That's done -// in the "policy_test" unit test in Nexus. This is an end-to-end test -// exercising _that_ this policy seems to be used when it should be. -#[nexus_test] -async fn test_silo_authn_policy(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - - let test_cases = [ - ("default-policy", ExpectedFleetPrivileges::None, BTreeMap::new()), - ( - "viewer-policy", - ExpectedFleetPrivileges::ReadOnly, - BTreeMap::from([( - SiloRole::Viewer, - BTreeSet::from([FleetRole::Viewer]), - )]), - ), - // It's important to test the case of someone with "Fleet Collaborator" - // because that's the only role that would allow someone to create - // ordinary Silos but _not_ Silos that confer additional privileges. - // Thus, this is the only case that tests that we don't allow this - // potentially dangerous privilege escalation! - ( - "collaborator-policy", - ExpectedFleetPrivileges::CreateSilo, - BTreeMap::from([( - SiloRole::Admin, - BTreeSet::from([FleetRole::Collaborator]), - )]), - ), - ( - "admin-policy", - ExpectedFleetPrivileges::CreatePrivilegedSilo, - BTreeMap::from([( - SiloRole::Admin, - BTreeSet::from([FleetRole::Admin]), - )]), - ), - ]; - - for (label, expected_privileges, policy) in test_cases { - println!("test case: {:?}", label); - - // Create a Silo with the expected policy. - let silo_name = label.parse().unwrap(); - let silo = NexusRequest::objects_post( - client, - "/v1/system/silos", - ¶ms::SiloCreate { - identity: IdentityMetadataCreateParams { - name: silo_name, - description: String::new(), - }, - quotas: params::SiloQuotasCreate::empty(), - discoverable: false, - identity_mode: shared::SiloIdentityMode::LocalOnly, - admin_group_name: None, - tls_certificates: vec![], - mapped_fleet_roles: policy, - }, - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .unwrap() - .parsed_body::() - .unwrap(); - - // Create an administrator in this Silo. - let admin_user = create_local_user( - client, - &silo, - &(format!("{}-user", label).parse().unwrap()), - test_params::UserPassword::LoginDisallowed, - ) - .await; - grant_iam( - client, - &format!("/v1/system/silos/{}", label), - SiloRole::Admin, - admin_user.id, - AuthnMode::PrivilegedUser, - ) - .await; - - // See what Fleet-level privileges they have. - check_fleet_privileges( - client, - &AuthnMode::SiloUser(admin_user.id), - expected_privileges, - ) - .await; - } -} - -enum ExpectedFleetPrivileges { - None, - ReadOnly, - CreateSilo, - CreatePrivilegedSilo, -} - -async fn check_fleet_privileges( - client: &dropshot::test_util::ClientTestContext, - authn_mode: &AuthnMode, - expected: ExpectedFleetPrivileges, -) { - // To test reading the fleet, we try listing racks. - const URL_RO: &'static str = "/v1/system/hardware/racks"; - let nexus_request = if let ExpectedFleetPrivileges::None = expected { - NexusRequest::expect_failure( - client, - http::StatusCode::FORBIDDEN, - http::Method::GET, - URL_RO, - ) - } else { - NexusRequest::object_get(client, URL_RO) - }; - nexus_request.authn_as(authn_mode.clone()).execute().await.unwrap(); - - // Next, see if the user can create an unprivileged Silo (i.e., one that - // confers no Fleet-level roles). - const URL_SILOS: &'static str = "/v1/system/silos"; - const SILO_NAME: &'static str = "probe-silo"; - let body = params::SiloCreate { - identity: IdentityMetadataCreateParams { - name: SILO_NAME.parse().unwrap(), - description: String::new(), - }, - quotas: params::SiloQuotasCreate::empty(), - discoverable: false, - identity_mode: shared::SiloIdentityMode::LocalOnly, - admin_group_name: None, - tls_certificates: vec![], - mapped_fleet_roles: BTreeMap::new(), - }; - let (do_delete, nexus_request) = match expected { - ExpectedFleetPrivileges::None | ExpectedFleetPrivileges::ReadOnly => ( - false, - NexusRequest::expect_failure_with_body( - client, - http::StatusCode::FORBIDDEN, - http::Method::POST, - URL_SILOS, - &body, - ), - ), - ExpectedFleetPrivileges::CreateSilo - | ExpectedFleetPrivileges::CreatePrivilegedSilo => ( - true, - NexusRequest::objects_post( - client, - URL_SILOS, - ¶ms::SiloCreate { - identity: IdentityMetadataCreateParams { - name: SILO_NAME.parse().unwrap(), - description: String::new(), - }, - quotas: params::SiloQuotasCreate::empty(), - discoverable: false, - identity_mode: shared::SiloIdentityMode::LocalOnly, - admin_group_name: None, - tls_certificates: vec![], - mapped_fleet_roles: BTreeMap::new(), - }, - ), - ), - }; - nexus_request.authn_as(authn_mode.clone()).execute().await.unwrap(); - - if do_delete { - // Try to delete what we created. - let url = format!("{}/{}", URL_SILOS, SILO_NAME); - NexusRequest::object_delete(client, &url) - .authn_as(authn_mode.clone()) - .execute() - .await - .unwrap(); - } - - // Last, see if the user can create a privileged Silo. - let body = params::SiloCreate { - identity: IdentityMetadataCreateParams { - name: SILO_NAME.parse().unwrap(), - description: String::new(), - }, - quotas: params::SiloQuotasCreate::empty(), - discoverable: false, - identity_mode: shared::SiloIdentityMode::LocalOnly, - admin_group_name: None, - tls_certificates: vec![], - mapped_fleet_roles: BTreeMap::from([( - SiloRole::Admin, - BTreeSet::from([FleetRole::Viewer]), - )]), - }; - let (do_delete, nexus_request) = match expected { - ExpectedFleetPrivileges::None - | ExpectedFleetPrivileges::ReadOnly - | ExpectedFleetPrivileges::CreateSilo => ( - false, - NexusRequest::expect_failure_with_body( - client, - http::StatusCode::FORBIDDEN, - http::Method::POST, - URL_SILOS, - &body, - ), - ), - ExpectedFleetPrivileges::CreatePrivilegedSilo => ( - true, - NexusRequest::objects_post( - client, - URL_SILOS, - ¶ms::SiloCreate { - identity: IdentityMetadataCreateParams { - name: SILO_NAME.parse().unwrap(), - description: String::new(), - }, - quotas: params::SiloQuotasCreate::empty(), - discoverable: false, - identity_mode: shared::SiloIdentityMode::LocalOnly, - admin_group_name: None, - tls_certificates: vec![], - mapped_fleet_roles: BTreeMap::new(), - }, - ), - ), - }; - nexus_request.authn_as(authn_mode.clone()).execute().await.unwrap(); - - if do_delete { - // Try to delete what we created. - let url = format!("{}/{}", URL_SILOS, SILO_NAME); - NexusRequest::object_delete(client, &url) - .authn_as(authn_mode.clone()) - .execute() - .await - .unwrap(); - } -} - -// Test that a silo admin can create new certificates for their silo -// -// Internally, the certificate validation check requires the `authz::DNS_CONFIG` -// resource (to check that the certificate is valid for -// `{silo_name}.{external_dns_zone_name}`), which silo admins may not have. We -// have to use an alternate, elevated context to perform that check, and this -// test confirms we do so. -#[nexus_test] -async fn test_silo_admin_can_create_certs(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - let certs_url = "/v1/certificates"; - - // Create a silo with an admin user - let silo = create_silo( - client, - "silo-name", - true, - shared::SiloIdentityMode::LocalOnly, - ) - .await; - - let new_silo_user_id = create_local_user( - client, - &silo, - &"admin".parse().unwrap(), - test_params::UserPassword::LoginDisallowed, - ) - .await - .id; - - grant_iam( - client, - "/v1/system/silos/silo-name", - SiloRole::Admin, - new_silo_user_id, - AuthnMode::PrivilegedUser, - ) - .await; - - // The user should be able to create certs for this silo - let chain = CertificateChain::new(cptestctx.wildcard_silo_dns_name()); - let (cert, key) = - (chain.cert_chain_as_pem(), chain.end_cert_private_key_as_pem()); - - let cert: Certificate = NexusRequest::objects_post( - client, - certs_url, - ¶ms::CertificateCreate { - identity: IdentityMetadataCreateParams { - name: "test-cert".parse().unwrap(), - description: "the test cert".to_string(), - }, - cert, - key, - service: shared::ServiceUsingCertificate::ExternalApi, - }, - ) - .authn_as(AuthnMode::SiloUser(new_silo_user_id)) - .execute() - .await - .expect("failed to create certificate") - .parsed_body() - .unwrap(); - - // The cert should exist when listing the silo's certs as the silo admin - let silo_certs = - NexusRequest::object_get(client, &format!("{certs_url}?limit=10")) - .authn_as(AuthnMode::SiloUser(new_silo_user_id)) - .execute() - .await - .expect("failed to list certificates") - .parsed_body::>() - .expect("failed to parse body as ResultsPage") - .items; - - assert_eq!(silo_certs.len(), 1); - assert_eq!(silo_certs[0].identity.id, cert.identity.id); -} - -// Test that silo delete cleans up associated groups -#[nexus_test] -async fn test_silo_delete_cleans_up_ip_pool_links( - cptestctx: &ControlPlaneTestContext, -) { - let client = &cptestctx.external_client; - - // Create a silo - let silo1 = - create_silo(&client, "silo1", true, shared::SiloIdentityMode::SamlJit) - .await; - let silo2 = - create_silo(&client, "silo2", true, shared::SiloIdentityMode::SamlJit) - .await; - - // link pool1 to both, link pool2 to silo1 only - let range1 = IpRange::V4( - Ipv4Range::new( - std::net::Ipv4Addr::new(10, 0, 0, 51), - std::net::Ipv4Addr::new(10, 0, 0, 52), - ) - .unwrap(), - ); - create_ip_pool(client, "pool1", Some(range1)).await; - link_ip_pool(client, "pool1", &silo1.identity.id, true).await; - link_ip_pool(client, "pool1", &silo2.identity.id, true).await; - - let range2 = IpRange::V4( - Ipv4Range::new( - std::net::Ipv4Addr::new(10, 0, 0, 53), - std::net::Ipv4Addr::new(10, 0, 0, 54), - ) - .unwrap(), - ); - create_ip_pool(client, "pool2", Some(range2)).await; - link_ip_pool(client, "pool2", &silo1.identity.id, false).await; - - // we want to make sure the links are there before we make sure they're gone - let url = "/v1/system/ip-pools/pool1/silos"; - let links = - objects_list_page_authz::(client, &url).await; - assert_eq!(links.items.len(), 2); - - let url = "/v1/system/ip-pools/pool2/silos"; - let links = - objects_list_page_authz::(client, &url).await; - assert_eq!(links.items.len(), 1); - - // Delete the silo - let url = format!("/v1/system/silos/{}", silo1.identity.id); - object_delete(client, &url).await; - - // Now make sure the links are gone - let url = "/v1/system/ip-pools/pool1/silos"; - let links = - objects_list_page_authz::(client, &url).await; - assert_eq!(links.items.len(), 1); - - let url = "/v1/system/ip-pools/pool2/silos"; - let links = - objects_list_page_authz::(client, &url).await; - assert_eq!(links.items.len(), 0); - - // but the pools are of course still there - let url = "/v1/system/ip-pools"; - let pools = objects_list_page_authz::(client, &url).await; - assert_eq!(pools.items.len(), 2); - assert_eq!(pools.items[0].identity.name, "pool1"); - assert_eq!(pools.items[1].identity.name, "pool2"); - - // nothing prevents us from deleting the pools (except the child ranges -- - // we do have to remove those) - - let url = "/v1/system/ip-pools/pool1/ranges/remove"; - NexusRequest::new( - RequestBuilder::new(client, Method::POST, url) - .body(Some(&range1)) - .expect_status(Some(StatusCode::NO_CONTENT)), - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("Failed to delete IP range from a pool"); - - let url = "/v1/system/ip-pools/pool2/ranges/remove"; - NexusRequest::new( - RequestBuilder::new(client, Method::POST, url) - .body(Some(&range2)) - .expect_status(Some(StatusCode::NO_CONTENT)), - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("Failed to delete IP range from a pool"); - - object_delete(client, "/v1/system/ip-pools/pool1").await; - object_delete(client, "/v1/system/ip-pools/pool2").await; -} From ced5348058ab942dea8608b6fcc72c08b1ad2325 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 15 Oct 2025 13:58:17 -0700 Subject: [PATCH 15/48] cargo fmt --- nexus/auth/src/authz/api_resources.rs | 11 +- nexus/db-lookup/src/lookup.rs | 140 +++++++++++++----------- nexus/db-queries/src/policy_test/mod.rs | 2 +- 3 files changed, 82 insertions(+), 71 deletions(-) diff --git a/nexus/auth/src/authz/api_resources.rs b/nexus/auth/src/authz/api_resources.rs index 75f8c40a098..46f9a34395e 100644 --- a/nexus/auth/src/authz/api_resources.rs +++ b/nexus/auth/src/authz/api_resources.rs @@ -1046,11 +1046,7 @@ impl Project { /// /// If `restrict_network_actions` is not provided, it defaults to `false`. /// Use `with_network_restrictions()` to populate it with the actual Silo value. - pub fn new( - parent: Silo, - key: Uuid, - lookup_type: LookupType, - ) -> Project { + pub fn new(parent: Silo, key: Uuid, lookup_type: LookupType) -> Project { Project { parent, key, @@ -1077,7 +1073,10 @@ impl Project { /// Update this Project with the actual restrict_network_actions value from the Silo. /// This should be called after construction to populate the correct value. - pub fn with_network_restrictions(mut self, restrict_network_actions: bool) -> Self { + pub fn with_network_restrictions( + mut self, + restrict_network_actions: bool, + ) -> Self { self.restrict_network_actions = restrict_network_actions; self } diff --git a/nexus/db-lookup/src/lookup.rs b/nexus/db-lookup/src/lookup.rs index a70fed602cc..5c3c0825991 100644 --- a/nexus/db-lookup/src/lookup.rs +++ b/nexus/db-lookup/src/lookup.rs @@ -609,55 +609,64 @@ pub enum Project<'a> { impl<'a> Project<'a> { pub async fn fetch( &self, - ) -> LookupResult<(authz::Silo, authz::Project, nexus_db_model::Project)> { + ) -> LookupResult<(authz::Silo, authz::Project, nexus_db_model::Project)> + { self.fetch_for(authz::Action::Read).await } pub async fn optional_fetch( &self, - ) -> LookupResult> { + ) -> LookupResult< + Option<(authz::Silo, authz::Project, nexus_db_model::Project)>, + > { self.optional_fetch_for(authz::Action::Read).await } pub async fn fetch_for( &self, action: authz::Action, - ) -> LookupResult<(authz::Silo, authz::Project, nexus_db_model::Project)> { + ) -> LookupResult<(authz::Silo, authz::Project, nexus_db_model::Project)> + { let lookup = self.lookup_root(); let opctx = &lookup.opctx; let datastore = lookup.datastore; match &self { Project::Error(_, error) => Err(error.clone()), - Project::Name(parent, &ref name) | Project::OwnedName(parent, ref name) => { + Project::Name(parent, &ref name) + | Project::OwnedName(parent, ref name) => { let (authz_silo,) = parent.lookup().await?; let (authz_project, db_row) = Self::fetch_by_name_for( - opctx, - datastore, - &authz_silo, - name, - action, - ) - .await?; + opctx, + datastore, + &authz_silo, + name, + action, + ) + .await?; Ok((authz_silo, authz_project, db_row)) } Project::PrimaryKey(_, v0) => { Self::fetch_by_id_for(opctx, datastore, v0, action).await } } - .and_then(|input| { - let (ref authz_silo, .., ref authz_project, ref _db_row) = &input; - Self::silo_check(opctx, authz_silo, authz_project)?; - Ok(input) - }) + .and_then(|input| { + let (ref authz_silo, .., ref authz_project, ref _db_row) = &input; + Self::silo_check(opctx, authz_silo, authz_project)?; + Ok(input) + }) } pub async fn optional_fetch_for( &self, action: authz::Action, - ) -> LookupResult> { + ) -> LookupResult< + Option<(authz::Silo, authz::Project, nexus_db_model::Project)>, + > { let result = self.fetch_for(action).await; match result { - Err(Error::ObjectNotFound { type_name: _, lookup_type: _ }) => Ok(None), + Err(Error::ObjectNotFound { type_name: _, lookup_type: _ }) => { + Ok(None) + } _ => Ok(Some(result?)), } } @@ -670,12 +679,11 @@ impl<'a> Project<'a> { let opctx = &lookup.opctx; let (authz_silo, authz_project) = self.lookup().await?; opctx.authorize(action, &authz_project).await?; - Ok((authz_silo, authz_project)) - .and_then(|input| { - let (ref authz_silo, .., ref authz_project) = &input; - Self::silo_check(opctx, authz_silo, authz_project)?; - Ok(input) - }) + Ok((authz_silo, authz_project)).and_then(|input| { + let (ref authz_silo, .., ref authz_project) = &input; + Self::silo_check(opctx, authz_silo, authz_project)?; + Ok(input) + }) } pub async fn optional_lookup_for( @@ -684,7 +692,9 @@ impl<'a> Project<'a> { ) -> LookupResult> { let result = self.lookup_for(action).await; match result { - Err(Error::ObjectNotFound { type_name: _, lookup_type: _ }) => Ok(None), + Err(Error::ObjectNotFound { type_name: _, lookup_type: _ }) => { + Ok(None) + } _ => Ok(Some(result?)), } } @@ -695,24 +705,21 @@ impl<'a> Project<'a> { let datastore = lookup.datastore; match &self { Project::Error(_, error) => Err(error.clone()), - Project::Name(parent, &ref name) | Project::OwnedName(parent, ref name) => { + Project::Name(parent, &ref name) + | Project::OwnedName(parent, ref name) => { let (authz_silo,) = parent.lookup().await?; let (authz_project, _) = Self::lookup_by_name_no_authz( - opctx, - datastore, - &authz_silo, - name, - ) - .await?; + opctx, + datastore, + &authz_silo, + name, + ) + .await?; Ok((authz_silo, authz_project)) } Project::PrimaryKey(_, v0) => { - let (authz_silo, authz_project, _) = Self::lookup_by_id_no_authz( - opctx, - datastore, - v0, - ) - .await?; + let (authz_silo, authz_project, _) = + Self::lookup_by_id_no_authz(opctx, datastore, v0).await?; Ok((authz_silo, authz_project)) } } @@ -767,7 +774,9 @@ impl<'a> Project<'a> { "unexpected successful lookup of siloed resource \ {:?} in a different Silo from current actor (resource \ Silo {}, actor Silo {})", - "Project", resource_silo_id, actor_silo_id, + "Project", + resource_silo_id, + actor_silo_id, ); Err(authz_project.not_found()) } else { @@ -782,13 +791,9 @@ impl<'a> Project<'a> { name: &Name, action: authz::Action, ) -> LookupResult<(authz::Project, nexus_db_model::Project)> { - let (authz_project, db_row) = Self::lookup_by_name_no_authz( - opctx, - datastore, - authz_silo, - name, - ) - .await?; + let (authz_project, db_row) = + Self::lookup_by_name_no_authz(opctx, datastore, authz_silo, name) + .await?; opctx.authorize(action, &authz_project).await?; Ok((authz_project, db_row)) } @@ -803,16 +808,23 @@ impl<'a> Project<'a> { use nexus_db_schema::schema::project::dsl as project_dsl; use nexus_db_schema::schema::silo::dsl as silo_dsl; - let (db_row, restrict_network_actions): (nexus_db_model::Project, bool) = project_dsl::project + let (db_row, restrict_network_actions): ( + nexus_db_model::Project, + bool, + ) = project_dsl::project .filter(project_dsl::time_deleted.is_null()) .filter(project_dsl::name.eq(name.clone())) .filter(project_dsl::silo_id.eq(authz_silo.id())) - .inner_join(silo_dsl::silo.on(project_dsl::silo_id.eq(silo_dsl::id))) + .inner_join( + silo_dsl::silo.on(project_dsl::silo_id.eq(silo_dsl::id)), + ) .select(( nexus_db_model::Project::as_select(), silo_dsl::restrict_network_actions, )) - .get_result_async(&*datastore.pool_connection_authorized(opctx).await?) + .get_result_async( + &*datastore.pool_connection_authorized(opctx).await?, + ) .await .map_err(|e| { public_error_from_diesel( @@ -827,7 +839,7 @@ impl<'a> Project<'a> { let authz_project = authz::Project::with_primary_key( authz_silo.clone(), db_row.id(), - LookupType::ByName(name.as_str().to_string()) + LookupType::ByName(name.as_str().to_string()), ) .with_network_restrictions(restrict_network_actions); @@ -839,13 +851,10 @@ impl<'a> Project<'a> { datastore: &dyn LookupDataStore, v0: &Uuid, action: authz::Action, - ) -> LookupResult<(authz::Silo, authz::Project, nexus_db_model::Project)> { - let (authz_silo, authz_project, db_row) = Self::lookup_by_id_no_authz( - opctx, - datastore, - v0, - ) - .await?; + ) -> LookupResult<(authz::Silo, authz::Project, nexus_db_model::Project)> + { + let (authz_silo, authz_project, db_row) = + Self::lookup_by_id_no_authz(opctx, datastore, v0).await?; opctx.authorize(action, &authz_project).await?; Ok((authz_silo, authz_project, db_row)) } @@ -855,7 +864,8 @@ impl<'a> Project<'a> { opctx: &OpContext, datastore: &dyn LookupDataStore, v0: &Uuid, - ) -> LookupResult<(authz::Silo, authz::Project, nexus_db_model::Project)> { + ) -> LookupResult<(authz::Silo, authz::Project, nexus_db_model::Project)> + { use nexus_db_schema::schema::project::dsl as project_dsl; use nexus_db_schema::schema::silo::dsl as silo_dsl; @@ -882,15 +892,17 @@ impl<'a> Project<'a> { })?; let (authz_silo, _) = Silo::lookup_by_id_no_authz( - opctx, - datastore, - &db_row.silo_id.into(), - ) - .await?; + opctx, + datastore, + &db_row.silo_id.into(), + ) + .await?; let authz_project = authz::Project::with_primary_key( authz_silo.clone(), db_row.id(), - LookupType::ById(::omicron_uuid_kinds::GenericUuid::into_untyped_uuid(*v0)), + LookupType::ById( + ::omicron_uuid_kinds::GenericUuid::into_untyped_uuid(*v0), + ), ) .with_network_restrictions(restrict_network_actions); diff --git a/nexus/db-queries/src/policy_test/mod.rs b/nexus/db-queries/src/policy_test/mod.rs index 4c73f3545bd..6e5048f5bfd 100644 --- a/nexus/db-queries/src/policy_test/mod.rs +++ b/nexus/db-queries/src/policy_test/mod.rs @@ -92,7 +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 + restrict_network_actions: None, // Default: no restrictions }, &[], DnsVersionUpdateBuilder::new( From 6cad94e66e164aeab70c2350d31c17ee2416be38 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 15 Oct 2025 15:54:20 -0700 Subject: [PATCH 16/48] fix clippy issues --- nexus/db-lookup/src/lookup.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nexus/db-lookup/src/lookup.rs b/nexus/db-lookup/src/lookup.rs index 5c3c0825991..14c16a83b14 100644 --- a/nexus/db-lookup/src/lookup.rs +++ b/nexus/db-lookup/src/lookup.rs @@ -871,7 +871,7 @@ impl<'a> Project<'a> { let (db_row, restrict_network_actions): (nexus_db_model::Project, bool) = project_dsl::project .filter(project_dsl::time_deleted.is_null()) - .filter(project_dsl::id.eq(v0.clone())) + .filter(project_dsl::id.eq(*v0)) .inner_join(silo_dsl::silo.on(project_dsl::silo_id.eq(silo_dsl::id))) .select(( nexus_db_model::Project::as_select(), @@ -894,7 +894,7 @@ impl<'a> Project<'a> { let (authz_silo, _) = Silo::lookup_by_id_no_authz( opctx, datastore, - &db_row.silo_id.into(), + &db_row.silo_id, ) .await?; let authz_project = authz::Project::with_primary_key( From 7737776e2a84c1f04b03f7e08866208cfa6880e9 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 15 Oct 2025 16:12:27 -0700 Subject: [PATCH 17/48] cargo fmt again --- nexus/db-lookup/src/lookup.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/nexus/db-lookup/src/lookup.rs b/nexus/db-lookup/src/lookup.rs index 14c16a83b14..cb4ef009d67 100644 --- a/nexus/db-lookup/src/lookup.rs +++ b/nexus/db-lookup/src/lookup.rs @@ -891,12 +891,9 @@ impl<'a> Project<'a> { ) })?; - let (authz_silo, _) = Silo::lookup_by_id_no_authz( - opctx, - datastore, - &db_row.silo_id, - ) - .await?; + let (authz_silo, _) = + Silo::lookup_by_id_no_authz(opctx, datastore, &db_row.silo_id) + .await?; let authz_project = authz::Project::with_primary_key( authz_silo.clone(), db_row.id(), From ed3e5f4e02558ea1fbe6cfbe12bfd5c9176c69ca Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 16 Oct 2025 02:37:19 -0700 Subject: [PATCH 18/48] Update tests --- nexus/tests/integration_tests/vpcs.rs | 284 ++++++++++++++++---------- 1 file changed, 181 insertions(+), 103 deletions(-) diff --git a/nexus/tests/integration_tests/vpcs.rs b/nexus/tests/integration_tests/vpcs.rs index 2f367294725..37dd47e19a8 100644 --- a/nexus/tests/integration_tests/vpcs.rs +++ b/nexus/tests/integration_tests/vpcs.rs @@ -12,10 +12,11 @@ use nexus_test_utils::http_testing::RequestBuilder; use nexus_test_utils::identity_eq; 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::{params, shared, views::Vpc}; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_common::api::external::IdentityMetadataUpdateParams; @@ -254,28 +255,43 @@ fn vpcs_eq(vpc1: &Vpc, vpc2: &Vpc) { #[nexus_test] async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { + use nexus_types::external_api::params; + let client = &cptestctx.external_client; - // Create a project for testing - let project_name = "test-networking-restrictions"; - let project_url = "/v1/projects"; - let project_params = params::ProjectCreate { + // Test Part 1: Normal silo (restrict_network_actions = false) + // In the default silo, project collaborators CAN create VPCs + let normal_project_name = "normal-project"; + create_project(&client, normal_project_name).await; + + let normal_vpcs_url = format!("/v1/vpcs?project={}", normal_project_name); + let vpc_params = params::VpcCreate { identity: IdentityMetadataCreateParams { - name: project_name.parse().unwrap(), - description: "Test project for networking restrictions".to_string(), + name: "normal-vpc".parse().unwrap(), + description: "VPC in normal silo".to_string(), }, + ipv6_prefix: None, + dns_name: "normal".parse().unwrap(), }; - NexusRequest::objects_post(&client, project_url, &project_params) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("Failed to create project"); + // As privileged user (silo admin), VPC creation should succeed + let vpc = + NexusRequest::objects_post(&client, &normal_vpcs_url, &vpc_params) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("VPC creation should succeed in normal silo") + .parsed_body::() + .unwrap(); + assert_eq!(vpc.identity.name, "normal-vpc"); + assert_eq!(vpc.dns_name, "normal"); + + // Test Part 2: Restricted silo (restrict_network_actions = true) // Create a silo with networking restrictions enabled let restricted_silo_name = "restricted-silo"; let silo_url = "/v1/system/silos"; - let silo_params = nexus_types::external_api::params::SiloCreate { + let silo_params = params::SiloCreate { identity: IdentityMetadataCreateParams { name: restricted_silo_name.parse().unwrap(), description: "Silo with networking restrictions".to_string(), @@ -290,115 +306,177 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { quotas: params::SiloQuotasCreate::empty(), }; - NexusRequest::objects_post(&client, silo_url, &silo_params) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("Failed to create restricted silo"); + let restricted_silo: nexus_types::external_api::views::Silo = + NexusRequest::objects_post(&client, silo_url, &silo_params) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("Failed to create restricted silo") + .parsed_body() + .unwrap(); - // Test 1: VPC creation should fail for project collaborators in restricted silo - let vpcs_url = format!("/v1/vpcs?project={}", project_name); - let vpc_params = params::VpcCreate { - identity: IdentityMetadataCreateParams { - name: "test-vpc".parse().unwrap(), - description: "Test VPC".to_string(), - }, - ipv6_prefix: None, - dns_name: "test-vpc".parse().unwrap(), - }; + // Verify the silo has networking restrictions enabled + assert_eq!( + restricted_silo.identity.name, + restricted_silo_name + .parse::() + .unwrap() + ); - // TODO: This test needs to be run in the context of the restricted silo - // and with a project collaborator user. For now, this establishes the test structure. - // The actual authorization testing is more complex and would require setting up - // users, role assignments, and silo context switching. + // Verify we can read the silo back and see the restriction flag + let silo_get_url = format!("/v1/system/silos/{}", restricted_silo_name); + let fetched_silo: nexus_types::external_api::views::Silo = + NexusRequest::object_get(&client, &silo_get_url) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); - // For now, let's test that VPC creation works normally (without restrictions) - let vpc = NexusRequest::objects_post(&client, &vpcs_url, &vpc_params) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("VPC creation should succeed with privileged user") - .parsed_body::() - .unwrap(); + assert_eq!( + fetched_silo.identity.name, + restricted_silo_name + .parse::() + .unwrap() + ); - assert_eq!(vpc.identity.name, "test-vpc"); + // Test Part 3: Test authorization with different user roles + // Create a user in the restricted silo + let test_user = create_local_user( + client, + &restricted_silo, + &"test-user".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; - // Test 2: VPC deletion should also respect networking restrictions - // First delete the default subnet, then the VPC - let default_subnet_url = format!( - "/v1/vpc-subnets/default?project={}&vpc=test-vpc", - project_name - ); - NexusRequest::object_delete(&client, &default_subnet_url) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("Default subnet deletion should succeed with privileged user"); + // Create a project in the restricted silo + let restricted_project_name = "restricted-project"; + let restricted_project_url = + format!("/v1/projects?silo={}", restricted_silo_name); + let project_params = params::ProjectCreate { + identity: IdentityMetadataCreateParams { + name: restricted_project_name.parse().unwrap(), + description: "Project in restricted silo".to_string(), + }, + }; - let vpc_url = format!("/v1/vpcs/{}?project={}", "test-vpc", project_name); - NexusRequest::object_delete(&client, &vpc_url) + let _restricted_project: nexus_types::external_api::views::Project = + NexusRequest::objects_post( + &client, + &restricted_project_url, + &project_params, + ) .authn_as(AuthnMode::PrivilegedUser) .execute() .await - .expect("VPC deletion should succeed with privileged user"); -} + .expect("Failed to create project in restricted silo") + .parsed_body() + .unwrap(); -#[nexus_test] -async fn test_networking_restrictions_policy_test( - _cptestctx: &ControlPlaneTestContext, -) { - // This test verifies that our Polar rules work correctly by using the policy test framework - use nexus_auth::authn::SiloAuthnPolicy; - use nexus_auth::authz; - use nexus_types::external_api::shared::SiloRole; - use omicron_common::api::external::LookupType; - use std::collections::{BTreeMap, BTreeSet}; - use uuid::Uuid; - - let logctx = omicron_test_utils::dev::test_setup_log( - "test_networking_restrictions_policy", + // Grant the user Project Admin role (but NOT Silo Admin) + let project_url = format!( + "/v1/projects/{}?silo={}", + restricted_project_name, restricted_silo_name ); + grant_iam( + client, + &project_url, + shared::ProjectRole::Admin, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; - // Create a silo with networking restrictions - let restricted_silo_id = Uuid::new_v4(); - let restricted_silo = authz::Silo::new( - authz::FLEET, - restricted_silo_id, - LookupType::ById(restricted_silo_id), + // Try to create a VPC as the Project Admin - should FAIL with 403 Forbidden + let restricted_vpcs_url = format!( + "/v1/vpcs?project={}&silo={}", + restricted_project_name, restricted_silo_name ); + let restricted_vpc_params = params::VpcCreate { + identity: IdentityMetadataCreateParams { + name: "restricted-vpc".parse().unwrap(), + description: "VPC in restricted silo".to_string(), + }, + ipv6_prefix: None, + dns_name: "restricted".parse().unwrap(), + }; - // Create authentication context with networking restrictions enabled - let mut mapped_fleet_roles = BTreeMap::new(); - mapped_fleet_roles.insert(SiloRole::Admin, BTreeSet::new()); + let error: HttpErrorResponseBody = NexusRequest::new( + RequestBuilder::new(client, Method::POST, &restricted_vpcs_url) + .body(Some(&restricted_vpc_params)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("VPC creation should fail for Project Admin in restricted silo") + .parsed_body() + .unwrap(); - let restricted_policy = SiloAuthnPolicy::new( - mapped_fleet_roles, - true, // Enable restrictions + assert!( + error.message.contains("forbidden") + || error.message.contains("Forbidden"), + "Expected forbidden error for Project Admin, got: {}", + error.message ); - let normal_policy = SiloAuthnPolicy::new( - BTreeMap::new(), - false, // No restrictions - ); + // Project Collaborators also cannot create VPCs in restricted silos + grant_iam( + client, + &project_url, + shared::ProjectRole::Collaborator, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; - // Create test project and VPC resources - let project_id = Uuid::new_v4(); - let project = authz::Project::new( - restricted_silo.clone(), - project_id, - LookupType::ById(project_id), - ); + let error: HttpErrorResponseBody = NexusRequest::new( + RequestBuilder::new(client, Method::POST, &restricted_vpcs_url) + .body(Some(&restricted_vpc_params)) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect( + "VPC creation should fail for Project Collaborator in restricted silo", + ) + .parsed_body() + .unwrap(); - let vpc_id = Uuid::new_v4(); - let _vpc = - authz::Vpc::new(project.clone(), vpc_id, LookupType::ById(vpc_id)); + assert!( + error.message.contains("forbidden") + || error.message.contains("Forbidden"), + "Expected forbidden error for Project Collaborator, got: {}", + error.message + ); - // Test that the policies can be created correctly - assert!(restricted_policy.restrict_network_actions); - assert!(!normal_policy.restrict_network_actions); + // Now grant the user Silo Admin role + let silo_url = format!("/v1/system/silos/{}", restricted_silo_name); + grant_iam( + client, + &silo_url, + shared::SiloRole::Admin, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; - // For now, this test demonstrates the structure needed for comprehensive testing - println!("Networking restrictions policy test structure established"); + // Try to create a VPC again as Silo Admin - should succeed + let vpc_as_admin: Vpc = NexusRequest::objects_post( + &client, + &restricted_vpcs_url, + &restricted_vpc_params, + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("VPC creation should succeed for Silo Admin in restricted silo") + .parsed_body() + .unwrap(); - logctx.cleanup_successful(); + assert_eq!(vpc_as_admin.identity.name, "restricted-vpc"); + assert_eq!(vpc_as_admin.dns_name, "restricted"); } From 6f411311509da6bfd45928a6a0275ed9f3071c57 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 17 Oct 2025 10:11:54 -0700 Subject: [PATCH 19/48] Update version number in dbint.sql --- schema/crdb/dbinit.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema/crdb/dbinit.sql b/schema/crdb/dbinit.sql index 4935823c786..a41696a5475 100644 --- a/schema/crdb/dbinit.sql +++ b/schema/crdb/dbinit.sql @@ -6794,7 +6794,7 @@ INSERT INTO omicron.public.db_metadata ( version, target_version ) VALUES - (TRUE, NOW(), NOW(), '199.0.0', NULL) + (TRUE, NOW(), NOW(), '200.0.0', NULL) ON CONFLICT DO NOTHING; COMMIT; From 51207caed0ea0800a2b275093ba92e619c1e28c0 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 17 Oct 2025 10:43:29 -0700 Subject: [PATCH 20/48] remove redundant Silo query --- nexus/db-lookup/src/lookup.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/nexus/db-lookup/src/lookup.rs b/nexus/db-lookup/src/lookup.rs index cb4ef009d67..6124b98d520 100644 --- a/nexus/db-lookup/src/lookup.rs +++ b/nexus/db-lookup/src/lookup.rs @@ -891,9 +891,11 @@ impl<'a> Project<'a> { ) })?; - let (authz_silo, _) = - Silo::lookup_by_id_no_authz(opctx, datastore, &db_row.silo_id) - .await?; + let authz_silo = authz::Silo::new( + authz::FLEET, + db_row.silo_id, + LookupType::ById(db_row.silo_id), + ); let authz_project = authz::Project::with_primary_key( authz_silo.clone(), db_row.id(), From f3605a5c364583e578a62934378f88d56dfa94fd Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 17 Oct 2025 14:14:50 -0700 Subject: [PATCH 21/48] Update tests --- nexus/src/app/vpc.rs | 10 +- nexus/tests/integration_tests/vpcs.rs | 139 +++++++++----------------- 2 files changed, 54 insertions(+), 95 deletions(-) diff --git a/nexus/src/app/vpc.rs b/nexus/src/app/vpc.rs index a5d385a3252..90a9e8588d9 100644 --- a/nexus/src/app/vpc.rs +++ b/nexus/src/app/vpc.rs @@ -71,9 +71,17 @@ impl super::Nexus { project_lookup: &lookup::Project<'_>, params: ¶ms::VpcCreate, ) -> CreateResult { - let (.., authz_project) = + let (authz_silo, authz_project) = project_lookup.lookup_for(authz::Action::CreateChild).await?; + // Additional check: if the project's silo has networking restrictions, + // only Silo Admins can create VPCs (Modify permission on Silo implies Silo Admin) + if authz_project.restricts_networking() { + opctx + .authorize(authz::Action::Modify, &authz_silo) + .await?; + } + let saga_params = sagas::vpc_create::Params { serialized_authn: authn::saga::Serialized::for_opctx(opctx), vpc_create: params.clone(), diff --git a/nexus/tests/integration_tests/vpcs.rs b/nexus/tests/integration_tests/vpcs.rs index 37dd47e19a8..39c32d9cd14 100644 --- a/nexus/tests/integration_tests/vpcs.rs +++ b/nexus/tests/integration_tests/vpcs.rs @@ -10,12 +10,14 @@ 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_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::views; use nexus_types::external_api::{params, shared, views::Vpc}; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_common::api::external::IdentityMetadataUpdateParams; @@ -227,11 +229,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( @@ -241,11 +240,8 @@ 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) { @@ -275,14 +271,7 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { }; // As privileged user (silo admin), VPC creation should succeed - let vpc = - NexusRequest::objects_post(&client, &normal_vpcs_url, &vpc_params) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("VPC creation should succeed in normal silo") - .parsed_body::() - .unwrap(); + let vpc: Vpc = object_create(&client, &normal_vpcs_url, &vpc_params).await; assert_eq!(vpc.identity.name, "normal-vpc"); assert_eq!(vpc.dns_name, "normal"); @@ -306,14 +295,8 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { quotas: params::SiloQuotasCreate::empty(), }; - let restricted_silo: nexus_types::external_api::views::Silo = - NexusRequest::objects_post(&client, silo_url, &silo_params) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("Failed to create restricted silo") - .parsed_body() - .unwrap(); + let restricted_silo: views::Silo = + object_create(&client, silo_url, &silo_params).await; // Verify the silo has networking restrictions enabled assert_eq!( @@ -351,10 +334,33 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { ) .await; - // Create a project in the restricted silo + // Grant the user Silo Collaborator role so they can create a project + let silo_policy_url = format!("/v1/system/silos/{}/policy", restricted_silo_name); + let existing_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 new_role_assignment = + shared::RoleAssignment::for_silo_user(test_user.id, shared::SiloRole::Collaborator); + let new_role_assignments = existing_silo_policy + .role_assignments + .into_iter() + .chain(std::iter::once(new_role_assignment)) + .collect(); + let new_silo_policy = shared::Policy { role_assignments: new_role_assignments }; + NexusRequest::object_put(client, &silo_policy_url, Some(&new_silo_policy)) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to update silo policy"); + + // Create a project in the restricted silo AS THE SILO USER + // This ensures the project is properly set up for that user let restricted_project_name = "restricted-project"; - let restricted_project_url = - format!("/v1/projects?silo={}", restricted_silo_name); let project_params = params::ProjectCreate { identity: IdentityMetadataCreateParams { name: restricted_project_name.parse().unwrap(), @@ -362,37 +368,22 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { }, }; - let _restricted_project: nexus_types::external_api::views::Project = + let _restricted_project: views::Project = NexusRequest::objects_post( &client, - &restricted_project_url, + "/v1/projects", &project_params, ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("Failed to create project in restricted silo") - .parsed_body() - .unwrap(); + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; - // Grant the user Project Admin role (but NOT Silo Admin) - let project_url = format!( - "/v1/projects/{}?silo={}", - restricted_project_name, restricted_silo_name - ); - grant_iam( - client, - &project_url, - shared::ProjectRole::Admin, - test_user.id, - AuthnMode::PrivilegedUser, - ) - .await; - - // Try to create a VPC as the Project Admin - should FAIL with 403 Forbidden + // Try to create a VPC as a Silo Collaborator (with Project Creator role) + // Should FAIL with 403 Forbidden because the silo has restrict_network_actions=true + // Note: When authenticated as a silo user, the silo context is implicit let restricted_vpcs_url = format!( - "/v1/vpcs?project={}&silo={}", - restricted_project_name, restricted_silo_name + "/v1/vpcs?project={}", + restricted_project_name ); let restricted_vpc_params = params::VpcCreate { identity: IdentityMetadataCreateParams { @@ -403,36 +394,7 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { dns_name: "restricted".parse().unwrap(), }; - let error: HttpErrorResponseBody = NexusRequest::new( - RequestBuilder::new(client, Method::POST, &restricted_vpcs_url) - .body(Some(&restricted_vpc_params)) - .expect_status(Some(StatusCode::FORBIDDEN)), - ) - .authn_as(AuthnMode::SiloUser(test_user.id)) - .execute() - .await - .expect("VPC creation should fail for Project Admin in restricted silo") - .parsed_body() - .unwrap(); - - assert!( - error.message.contains("forbidden") - || error.message.contains("Forbidden"), - "Expected forbidden error for Project Admin, got: {}", - error.message - ); - - // Project Collaborators also cannot create VPCs in restricted silos - grant_iam( - client, - &project_url, - shared::ProjectRole::Collaborator, - test_user.id, - AuthnMode::PrivilegedUser, - ) - .await; - - let error: HttpErrorResponseBody = NexusRequest::new( + NexusRequest::new( RequestBuilder::new(client, Method::POST, &restricted_vpcs_url) .body(Some(&restricted_vpc_params)) .expect_status(Some(StatusCode::FORBIDDEN)), @@ -440,18 +402,7 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { .authn_as(AuthnMode::SiloUser(test_user.id)) .execute() .await - .expect( - "VPC creation should fail for Project Collaborator in restricted silo", - ) - .parsed_body() - .unwrap(); - - assert!( - error.message.contains("forbidden") - || error.message.contains("Forbidden"), - "Expected forbidden error for Project Collaborator, got: {}", - error.message - ); + .expect("VPC create should fail for Silo Collaborator in restricted silo"); // Now grant the user Silo Admin role let silo_url = format!("/v1/system/silos/{}", restricted_silo_name); From d9b8bcd75cf49e25a0d5e58f8a04cc2e61ed61b8 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 17 Oct 2025 14:48:55 -0700 Subject: [PATCH 22/48] cargo fmt --- nexus/src/app/vpc.rs | 4 +--- nexus/tests/integration_tests/vpcs.rs | 30 +++++++++++++-------------- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/nexus/src/app/vpc.rs b/nexus/src/app/vpc.rs index 90a9e8588d9..501cf2a8071 100644 --- a/nexus/src/app/vpc.rs +++ b/nexus/src/app/vpc.rs @@ -77,9 +77,7 @@ impl super::Nexus { // Additional check: if the project's silo has networking restrictions, // only Silo Admins can create VPCs (Modify permission on Silo implies Silo Admin) if authz_project.restricts_networking() { - opctx - .authorize(authz::Action::Modify, &authz_silo) - .await?; + opctx.authorize(authz::Action::Modify, &authz_silo).await?; } let saga_params = sagas::vpc_create::Params { diff --git a/nexus/tests/integration_tests/vpcs.rs b/nexus/tests/integration_tests/vpcs.rs index 39c32d9cd14..ecfea70d254 100644 --- a/nexus/tests/integration_tests/vpcs.rs +++ b/nexus/tests/integration_tests/vpcs.rs @@ -335,7 +335,8 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { .await; // Grant the user Silo Collaborator role so they can create a project - let silo_policy_url = format!("/v1/system/silos/{}/policy", restricted_silo_name); + let silo_policy_url = + format!("/v1/system/silos/{}/policy", restricted_silo_name); let existing_silo_policy: shared::Policy = NexusRequest::object_get(client, &silo_policy_url) .authn_as(AuthnMode::PrivilegedUser) @@ -344,14 +345,17 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { .expect("failed to fetch silo policy") .parsed_body() .expect("failed to parse silo policy"); - let new_role_assignment = - shared::RoleAssignment::for_silo_user(test_user.id, shared::SiloRole::Collaborator); + let new_role_assignment = shared::RoleAssignment::for_silo_user( + test_user.id, + shared::SiloRole::Collaborator, + ); let new_role_assignments = existing_silo_policy .role_assignments .into_iter() .chain(std::iter::once(new_role_assignment)) .collect(); - let new_silo_policy = shared::Policy { role_assignments: new_role_assignments }; + let new_silo_policy = + shared::Policy { role_assignments: new_role_assignments }; NexusRequest::object_put(client, &silo_policy_url, Some(&new_silo_policy)) .authn_as(AuthnMode::PrivilegedUser) .execute() @@ -369,22 +373,16 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { }; 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; + NexusRequest::objects_post(&client, "/v1/projects", &project_params) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; // Try to create a VPC as a Silo Collaborator (with Project Creator role) // Should FAIL with 403 Forbidden because the silo has restrict_network_actions=true // Note: When authenticated as a silo user, the silo context is implicit - let restricted_vpcs_url = format!( - "/v1/vpcs?project={}", - restricted_project_name - ); + let restricted_vpcs_url = + format!("/v1/vpcs?project={}", restricted_project_name); let restricted_vpc_params = params::VpcCreate { identity: IdentityMetadataCreateParams { name: "restricted-vpc".parse().unwrap(), From 388e9033419aad98ba79a56fe66990b8811cab7b Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 20 Oct 2025 12:00:02 -0700 Subject: [PATCH 23/48] Move restriction check to actor silo policy, rather than project silo --- nexus/auth/src/authz/actor.rs | 13 + nexus/auth/src/authz/api_resources.rs | 158 +----------- nexus/auth/src/authz/context.rs | 55 ----- nexus/auth/src/authz/omicron.polar | 6 +- nexus/auth/src/context.rs | 30 --- nexus/authz-macros/src/lib.rs | 77 ------ nexus/db-lookup/src/lookup.rs | 336 +------------------------- nexus/src/app/vpc.rs | 26 +- 8 files changed, 54 insertions(+), 647 deletions(-) diff --git a/nexus/auth/src/authz/actor.rs b/nexus/auth/src/authz/actor.rs index 26f7458b3b8..a22a9472451 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 { @@ -151,5 +160,9 @@ impl oso::PolarClass for AuthenticatedActor { authn::Actor::UserBuiltin { .. } => 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 46f9a34395e..4dd37e19b62 100644 --- a/nexus/auth/src/authz/api_resources.rs +++ b/nexus/auth/src/authz/api_resources.rs @@ -1018,148 +1018,12 @@ impl AuthorizedResource for AlertClassList { // Main resource hierarchy: Projects and their resources -/// `authz` type for a resource of type Project -/// Used to uniquely identify a resource of type Project across renames, moves, -/// etc., and to do authorization checks (see [`crate::context::OpContext::authorize()`]). -/// See [`crate::authz`] module-level documentation for more information. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Project { - parent: Silo, - key: Uuid, - lookup_type: LookupType, - /// Whether this project's silo restricts networking actions to Silo Admins only - /// This is populated from the parent Silo's restrict_network_actions field - restrict_network_actions: bool, -} - -impl Eq for Project {} -impl PartialEq for Project { - fn eq(&self, other: &Self) -> bool { - self.key == other.key - } -} - -impl Project { - /// Makes a new `authz` struct for this resource with the given - /// `parent`, unique key `key`, looked up as described by `lookup_type`, - /// and optionally with the networking restrictions setting from the parent Silo. - /// - /// If `restrict_network_actions` is not provided, it defaults to `false`. - /// Use `with_network_restrictions()` to populate it with the actual Silo value. - pub fn new(parent: Silo, key: Uuid, lookup_type: LookupType) -> Project { - Project { - parent, - key, - lookup_type, - restrict_network_actions: false, // Default, should be updated via with_network_restrictions - } - } - - /// A version of `new` that takes the primary key type directly. - /// This is only different from [`Self::new`] if this resource - /// uses a different input key type. - pub fn with_primary_key( - parent: Silo, - key: Uuid, - lookup_type: LookupType, - ) -> Project { - Project { - parent, - key, - lookup_type, - restrict_network_actions: false, // Default, should be updated via with_network_restrictions - } - } - - /// Update this Project with the actual restrict_network_actions value from the Silo. - /// This should be called after construction to populate the correct value. - pub fn with_network_restrictions( - mut self, - restrict_network_actions: bool, - ) -> Self { - self.restrict_network_actions = restrict_network_actions; - self - } - - pub fn id(&self) -> Uuid { - self.key - } - - /// Returns true if this project's silo restricts networking actions to Silo Admins only - pub fn restricts_networking(&self) -> bool { - self.restrict_network_actions - } - - /// Describes how to register this type with Oso - pub(super) fn init() -> Init { - // Create a custom class builder that includes the restricts_networking method - let class = oso::Class::builder() - .with_equality_check() - .add_method( - "has_role", - |r: &Project, actor: AuthenticatedActor, role: String| { - actor.has_role_resource(ResourceType::Project, r.key, &role) - }, - ) - .add_attribute_getter("silo", |r: &Project| r.parent.clone()) - .add_method("restricts_networking", |project: &Project| { - project.restricts_networking() - }) - .build(); - - Init { - polar_snippet: "", // Custom snippet defined in omicron.polar - polar_class: class, - } - } -} - -impl oso::PolarClass for Project { - fn get_polar_class_builder() -> oso::ClassBuilder { - oso::Class::builder() - .with_equality_check() - .add_method( - "has_role", - |r: &Project, actor: AuthenticatedActor, role: String| { - actor.has_role_resource(ResourceType::Project, r.key, &role) - }, - ) - .add_attribute_getter("silo", |r: &Project| r.parent.clone()) - .add_method("restricts_networking", |project: &Project| { - project.restricts_networking() - }) - } -} - -impl ApiResource for Project { - fn parent(&self) -> Option<&dyn AuthorizedResource> { - Some(&self.parent) - } - - fn resource_type(&self) -> ResourceType { - ResourceType::Project - } - - fn lookup_type(&self) -> &LookupType { - &self.lookup_type - } - - fn as_resource_with_roles(&self) -> Option<&dyn ApiResourceWithRoles> { - Some(self) - } -} - -impl ApiResourceWithRoles for Project { - fn resource_id(&self) -> Uuid { - self.key - } - - fn conferred_roles_by( - &self, - _authn: &authn::Context, - ) -> Result, Error> { - Ok(None) - } +authz_resource! { + name = "Project", + parent = "Silo", + primary_key = Uuid, + roles_allowed = true, + polar_snippet = Custom, } impl ApiResourceWithRolesType for Project { @@ -1227,7 +1091,7 @@ authz_resource! { parent = "Project", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProjectNetworking, + polar_snippet = InProject, } authz_resource! { @@ -1235,7 +1099,7 @@ authz_resource! { parent = "Vpc", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProjectNetworking, + polar_snippet = InProject, } authz_resource! { @@ -1243,7 +1107,7 @@ authz_resource! { parent = "VpcRouter", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProjectNetworking, + polar_snippet = InProject, } authz_resource! { @@ -1251,7 +1115,7 @@ authz_resource! { parent = "Vpc", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProjectNetworking, + polar_snippet = InProject, } authz_resource! { @@ -1259,7 +1123,7 @@ authz_resource! { parent = "Vpc", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProjectNetworking, + polar_snippet = InProject, } authz_resource! { diff --git a/nexus/auth/src/authz/context.rs b/nexus/auth/src/authz/context.rs index 36684ec15d9..20437b7f535 100644 --- a/nexus/auth/src/authz/context.rs +++ b/nexus/auth/src/authz/context.rs @@ -235,61 +235,6 @@ 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 d0066439bfe..f9e81a31b23 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -714,9 +714,9 @@ has_relation(fleet: Fleet, "parent_fleet", collection: AlertClassList) 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 admins to update networking resources if the project's silo allows it - # Note that the restriction is configured at the silo level, but affects the projects on the silo - (has_role(actor, "collaborator", project) and not project.restricts_networking()); + # 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()); # Apply networking restrictions to all networking resources # VPCs (project path: vpc.project) diff --git a/nexus/auth/src/context.rs b/nexus/auth/src/context.rs index 8dcf24688cc..8f666cbb0e2 100644 --- a/nexus/auth/src/context.rs +++ b/nexus/auth/src/context.rs @@ -378,36 +378,6 @@ impl OpContext { Ok(()) } } - - /// Authorize a networking action, respecting silo networking restrictions - /// - /// This combines standard project-level authorization with silo-level - /// networking restrictions. If the silo restricts networking actions, - /// only silo admins are allowed to perform the action. - pub async fn authorize_networking( - &self, - action: authz::Action, - authz_resource: Resource, - ) -> Result<(), Error> - where - Resource: AuthorizedResource + Debug + Clone, - { - // First, do the standard authorization check - self.authorize(action, &authz_resource).await?; - - // Then check networking restrictions - if let Some(silo_policy) = self.authn.silo_authn_policy() { - if silo_policy.restrict_network_actions() { - // Networking is restricted - verify user is silo admin - let authz_silo = self.authn.silo_required()?; - self.authorize(authz::Action::Modify, &authz_silo) - .await - .map_err(|_| Error::Forbidden)?; - } - } - - Ok(()) - } } impl Session for ConsoleSessionWithSiloId { diff --git a/nexus/authz-macros/src/lib.rs b/nexus/authz-macros/src/lib.rs index 82b014950f4..59ae8d9a963 100644 --- a/nexus/authz-macros/src/lib.rs +++ b/nexus/authz-macros/src/lib.rs @@ -265,10 +265,6 @@ enum PolarSnippet { /// Generate it as a resource nested within a Project (either directly or /// indirectly) InProject, - - /// Generate it as a resource nested within a Project with networking restrictions - /// When the Silo's restrict_network_actions is true, only Silo Admins can perform networking actions - InProjectNetworking, } /// Implementation of [`authz_resource!`] @@ -437,79 +433,6 @@ fn do_authz_resource( resource_name, parent_as_snake, ), - - // If this networking resource is directly inside a Project, we need to - // check if the Silo restricts networking actions to Silo admins only. - // Read/list actions are always allowed for Project Collaborators. - (PolarSnippet::InProjectNetworking, "Project") => format!( - r#" - resource {} {{ - permissions = [ - "list_children", - "modify", - "read", - "create_child", - ]; - - relations = {{ containing_project: Project }}; - - # Read/list actions are always allowed for Project viewers/collaborators - "list_children" if "viewer" on "containing_project"; - "read" if "viewer" on "containing_project"; - - # Basic networking permissions - restrictions enforced by has_permission overrides - "modify" if "collaborator" on "containing_project"; - "create_child" if "collaborator" on "containing_project"; - }} - - has_relation(parent: Project, "containing_project", child: {}) - if child.project = parent; - "#, - resource_name, resource_name, - ), - - // If this networking resource is nested under something else within the Project, - // we need to define both the "parent" relationship and the (indirect) - // relationship to the containing Project, with networking restrictions. - // Read/list actions are always allowed for Project Collaborators. - (PolarSnippet::InProjectNetworking, _) => format!( - r#" - resource {} {{ - permissions = [ - "list_children", - "modify", - "read", - "create_child", - ]; - - relations = {{ - containing_project: Project, - parent: {} - }}; - - # Read/list actions are always allowed for Project viewers/collaborators - "list_children" if "viewer" on "containing_project"; - "read" if "viewer" on "containing_project"; - - # Basic networking permissions - restrictions enforced by has_permission overrides - "modify" if "collaborator" on "containing_project"; - "create_child" if "collaborator" on "containing_project"; - }} - - 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-lookup/src/lookup.rs b/nexus/db-lookup/src/lookup.rs index 6124b98d520..4a949503cbd 100644 --- a/nexus/db-lookup/src/lookup.rs +++ b/nexus/db-lookup/src/lookup.rs @@ -15,7 +15,7 @@ use async_bb8_diesel::AsyncRunQueryDsl; use db_macros::lookup_resource; -use diesel::{ExpressionMethods, JoinOnDsl, QueryDsl, SelectableHelper}; +use diesel::{ExpressionMethods, QueryDsl, SelectableHelper}; use ipnetwork::IpNetwork; use nexus_auth::authn; use nexus_auth::authz; @@ -597,334 +597,12 @@ lookup_resource! { primary_key_columns = [ { column_name = "id", rust_type = Uuid } ] } -// Project lookup is hand-written (instead of using the macro) so we can fetch -// restrict_network_actions from the Silo table during lookup -pub enum Project<'a> { - Error(Root<'a>, Error), - Name(Silo<'a>, &'a Name), - OwnedName(Silo<'a>, Name), - PrimaryKey(Root<'a>, Uuid), -} - -impl<'a> Project<'a> { - pub async fn fetch( - &self, - ) -> LookupResult<(authz::Silo, authz::Project, nexus_db_model::Project)> - { - self.fetch_for(authz::Action::Read).await - } - - pub async fn optional_fetch( - &self, - ) -> LookupResult< - Option<(authz::Silo, authz::Project, nexus_db_model::Project)>, - > { - self.optional_fetch_for(authz::Action::Read).await - } - - pub async fn fetch_for( - &self, - action: authz::Action, - ) -> LookupResult<(authz::Silo, authz::Project, nexus_db_model::Project)> - { - let lookup = self.lookup_root(); - let opctx = &lookup.opctx; - let datastore = lookup.datastore; - match &self { - Project::Error(_, error) => Err(error.clone()), - Project::Name(parent, &ref name) - | Project::OwnedName(parent, ref name) => { - let (authz_silo,) = parent.lookup().await?; - let (authz_project, db_row) = Self::fetch_by_name_for( - opctx, - datastore, - &authz_silo, - name, - action, - ) - .await?; - Ok((authz_silo, authz_project, db_row)) - } - Project::PrimaryKey(_, v0) => { - Self::fetch_by_id_for(opctx, datastore, v0, action).await - } - } - .and_then(|input| { - let (ref authz_silo, .., ref authz_project, ref _db_row) = &input; - Self::silo_check(opctx, authz_silo, authz_project)?; - Ok(input) - }) - } - - pub async fn optional_fetch_for( - &self, - action: authz::Action, - ) -> LookupResult< - Option<(authz::Silo, authz::Project, nexus_db_model::Project)>, - > { - let result = self.fetch_for(action).await; - match result { - Err(Error::ObjectNotFound { type_name: _, lookup_type: _ }) => { - Ok(None) - } - _ => Ok(Some(result?)), - } - } - - pub async fn lookup_for( - &self, - action: authz::Action, - ) -> LookupResult<(authz::Silo, authz::Project)> { - let lookup = self.lookup_root(); - let opctx = &lookup.opctx; - let (authz_silo, authz_project) = self.lookup().await?; - opctx.authorize(action, &authz_project).await?; - Ok((authz_silo, authz_project)).and_then(|input| { - let (ref authz_silo, .., ref authz_project) = &input; - Self::silo_check(opctx, authz_silo, authz_project)?; - Ok(input) - }) - } - - pub async fn optional_lookup_for( - &self, - action: authz::Action, - ) -> LookupResult> { - let result = self.lookup_for(action).await; - match result { - Err(Error::ObjectNotFound { type_name: _, lookup_type: _ }) => { - Ok(None) - } - _ => Ok(Some(result?)), - } - } - - async fn lookup(&self) -> LookupResult<(authz::Silo, authz::Project)> { - let lookup = self.lookup_root(); - let opctx = &lookup.opctx; - let datastore = lookup.datastore; - match &self { - Project::Error(_, error) => Err(error.clone()), - Project::Name(parent, &ref name) - | Project::OwnedName(parent, ref name) => { - let (authz_silo,) = parent.lookup().await?; - let (authz_project, _) = Self::lookup_by_name_no_authz( - opctx, - datastore, - &authz_silo, - name, - ) - .await?; - Ok((authz_silo, authz_project)) - } - Project::PrimaryKey(_, v0) => { - let (authz_silo, authz_project, _) = - Self::lookup_by_id_no_authz(opctx, datastore, v0).await?; - Ok((authz_silo, authz_project)) - } - } - } - - fn lookup_root(&self) -> &LookupPath<'a> { - match &self { - Project::Error(root, ..) => root.lookup_root(), - Project::Name(parent, _) | Project::OwnedName(parent, _) => { - parent.lookup_root() - } - Project::PrimaryKey(root, ..) => root.lookup_root(), - } - } - - fn silo_check( - opctx: &OpContext, - authz_silo: &authz::Silo, - authz_project: &authz::Project, - ) -> Result<(), Error> { - let log = &opctx.log; - let actor_silo_id = match opctx - .authn - .silo_or_builtin() - .internal_context("siloed resource check") - { - Ok(Some(silo)) => silo.id(), - Ok(None) => { - trace!( - log, - "successful lookup of siloed resource {:?} \ - using built-in user", - "Project", - ); - return Ok(()); - } - Err(error) => { - error!( - log, - "unexpected successful lookup of siloed resource \ - {:?} with no actor in OpContext", - "Project", - ); - return Err(error); - } - }; - let resource_silo_id = authz_silo.id(); - if resource_silo_id != actor_silo_id { - use nexus_auth::authz::ApiResource; - error!( - log, - "unexpected successful lookup of siloed resource \ - {:?} in a different Silo from current actor (resource \ - Silo {}, actor Silo {})", - "Project", - resource_silo_id, - actor_silo_id, - ); - Err(authz_project.not_found()) - } else { - Ok(()) - } - } - - async fn fetch_by_name_for( - opctx: &OpContext, - datastore: &dyn LookupDataStore, - authz_silo: &authz::Silo, - name: &Name, - action: authz::Action, - ) -> LookupResult<(authz::Project, nexus_db_model::Project)> { - let (authz_project, db_row) = - Self::lookup_by_name_no_authz(opctx, datastore, authz_silo, name) - .await?; - opctx.authorize(action, &authz_project).await?; - Ok((authz_project, db_row)) - } - - // CUSTOM: This function is customized to JOIN with the silo table to fetch restrict_network_actions - async fn lookup_by_name_no_authz( - opctx: &OpContext, - datastore: &dyn LookupDataStore, - authz_silo: &authz::Silo, - name: &Name, - ) -> LookupResult<(authz::Project, nexus_db_model::Project)> { - use nexus_db_schema::schema::project::dsl as project_dsl; - use nexus_db_schema::schema::silo::dsl as silo_dsl; - - let (db_row, restrict_network_actions): ( - nexus_db_model::Project, - bool, - ) = project_dsl::project - .filter(project_dsl::time_deleted.is_null()) - .filter(project_dsl::name.eq(name.clone())) - .filter(project_dsl::silo_id.eq(authz_silo.id())) - .inner_join( - silo_dsl::silo.on(project_dsl::silo_id.eq(silo_dsl::id)), - ) - .select(( - nexus_db_model::Project::as_select(), - silo_dsl::restrict_network_actions, - )) - .get_result_async( - &*datastore.pool_connection_authorized(opctx).await?, - ) - .await - .map_err(|e| { - public_error_from_diesel( - e, - ErrorHandler::NotFoundByLookup( - ResourceType::Project, - LookupType::ByName(name.as_str().to_string()), - ), - ) - })?; - - let authz_project = authz::Project::with_primary_key( - authz_silo.clone(), - db_row.id(), - LookupType::ByName(name.as_str().to_string()), - ) - .with_network_restrictions(restrict_network_actions); - - Ok((authz_project, db_row)) - } - - async fn fetch_by_id_for( - opctx: &OpContext, - datastore: &dyn LookupDataStore, - v0: &Uuid, - action: authz::Action, - ) -> LookupResult<(authz::Silo, authz::Project, nexus_db_model::Project)> - { - let (authz_silo, authz_project, db_row) = - Self::lookup_by_id_no_authz(opctx, datastore, v0).await?; - opctx.authorize(action, &authz_project).await?; - Ok((authz_silo, authz_project, db_row)) - } - - // CUSTOM: This function is customized to JOIN with the silo table to fetch restrict_network_actions - async fn lookup_by_id_no_authz( - opctx: &OpContext, - datastore: &dyn LookupDataStore, - v0: &Uuid, - ) -> LookupResult<(authz::Silo, authz::Project, nexus_db_model::Project)> - { - use nexus_db_schema::schema::project::dsl as project_dsl; - use nexus_db_schema::schema::silo::dsl as silo_dsl; - - let (db_row, restrict_network_actions): (nexus_db_model::Project, bool) = project_dsl::project - .filter(project_dsl::time_deleted.is_null()) - .filter(project_dsl::id.eq(*v0)) - .inner_join(silo_dsl::silo.on(project_dsl::silo_id.eq(silo_dsl::id))) - .select(( - nexus_db_model::Project::as_select(), - silo_dsl::restrict_network_actions, - )) - .get_result_async(&*datastore.pool_connection_authorized(opctx).await?) - .await - .map_err(|e| { - public_error_from_diesel( - e, - ErrorHandler::NotFoundByLookup( - ResourceType::Project, - LookupType::ById( - ::omicron_uuid_kinds::GenericUuid::into_untyped_uuid(*v0), - ), - ), - ) - })?; - - let authz_silo = authz::Silo::new( - authz::FLEET, - db_row.silo_id, - LookupType::ById(db_row.silo_id), - ); - let authz_project = authz::Project::with_primary_key( - authz_silo.clone(), - db_row.id(), - LookupType::ById( - ::omicron_uuid_kinds::GenericUuid::into_untyped_uuid(*v0), - ), - ) - .with_network_restrictions(restrict_network_actions); - - Ok((authz_silo, authz_project, db_row)) - } -} - -// Child selector functions for Silo -impl<'a> Silo<'a> { - pub fn project_name<'b, 'c>(self, name: &'b Name) -> Project<'c> - where - 'a: 'c, - 'b: 'c, - { - Project::Name(self, name) - } - - pub fn project_name_owned<'c>(self, name: Name) -> Project<'c> - where - 'a: 'c, - { - Project::OwnedName(self, name) - } +lookup_resource! { + name = "Project", + ancestors = [ "Silo" ], + lookup_by_name = true, + soft_deletes = true, + primary_key_columns = [ { column_name = "id", rust_type = Uuid } ] } lookup_resource! { diff --git a/nexus/src/app/vpc.rs b/nexus/src/app/vpc.rs index 501cf2a8071..badc0880348 100644 --- a/nexus/src/app/vpc.rs +++ b/nexus/src/app/vpc.rs @@ -71,13 +71,26 @@ impl super::Nexus { project_lookup: &lookup::Project<'_>, params: ¶ms::VpcCreate, ) -> CreateResult { - let (authz_silo, authz_project) = + let (.., authz_project) = project_lookup.lookup_for(authz::Action::CreateChild).await?; - // Additional check: if the project's silo has networking restrictions, - // only Silo Admins can create VPCs (Modify permission on Silo implies Silo Admin) - if authz_project.restricts_networking() { - opctx.authorize(authz::Action::Modify, &authz_silo).await?; + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can create VPCs + 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?; + } + } + } } let saga_params = sagas::vpc_create::Params { @@ -128,7 +141,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::Modify).await?; let authz_vpc_router = authz::VpcRouter::new( authz_vpc.clone(), From 4b4c39290ad52eb6ad8aeaac0c2d7698a32a64ed Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 20 Oct 2025 14:32:19 -0700 Subject: [PATCH 24/48] cargo fmt --- nexus/src/app/vpc.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nexus/src/app/vpc.rs b/nexus/src/app/vpc.rs index badc0880348..3428c3be89a 100644 --- a/nexus/src/app/vpc.rs +++ b/nexus/src/app/vpc.rs @@ -87,7 +87,9 @@ impl super::Nexus { silo_id, LookupType::ById(silo_id), ); - opctx.authorize(authz::Action::Modify, &authz_silo).await?; + opctx + .authorize(authz::Action::Modify, &authz_silo) + .await?; } } } From 2dae54995b8d66a498a1639d6d20bfd9bd0b0d96 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 20 Oct 2025 17:39:51 -0700 Subject: [PATCH 25/48] Add test back in --- nexus/auth/src/authz/context.rs | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) 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"); From 109b96671dc00da539d27eb04768c806641780ae Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 21 Oct 2025 09:59:55 -0700 Subject: [PATCH 26/48] Update checks for VPC update, more tests --- nexus/src/app/vpc.rs | 46 ++++++-- nexus/tests/integration_tests/vpcs.rs | 155 ++++++++++++++++++++++---- 2 files changed, 168 insertions(+), 33 deletions(-) diff --git a/nexus/src/app/vpc.rs b/nexus/src/app/vpc.rs index 3428c3be89a..fc15a06a68e 100644 --- a/nexus/src/app/vpc.rs +++ b/nexus/src/app/vpc.rs @@ -65,17 +65,19 @@ impl super::Nexus { } } - pub(crate) async fn project_create_vpc( - self: &Arc, + /// Check if the actor's silo restricts networking actions, and if so, + /// verify the actor has Silo Admin permissions. + /// + /// 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. + async fn check_networking_restrictions( + &self, opctx: &OpContext, - project_lookup: &lookup::Project<'_>, - params: ¶ms::VpcCreate, - ) -> CreateResult { - let (.., authz_project) = - project_lookup.lookup_for(authz::Action::CreateChild).await?; - - // Check networking restrictions: if the actor's silo restricts networking - // actions, only Silo Admins can create VPCs + ) -> 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(); @@ -94,6 +96,21 @@ impl super::Nexus { } } } + Ok(()) + } + + pub(crate) async fn project_create_vpc( + self: &Arc, + opctx: &OpContext, + project_lookup: &lookup::Project<'_>, + params: ¶ms::VpcCreate, + ) -> CreateResult { + let (.., authz_project) = + project_lookup.lookup_for(authz::Action::CreateChild).await?; + + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can create VPCs + self.check_networking_restrictions(opctx).await?; let saga_params = sagas::vpc_create::Params { serialized_authn: authn::saga::Serialized::for_opctx(opctx), @@ -133,6 +150,11 @@ impl super::Nexus { ) -> UpdateResult { let (.., authz_vpc) = vpc_lookup.lookup_for(authz::Action::Modify).await?; + + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can update VPCs + self.check_networking_restrictions(opctx).await?; + self.db_datastore .project_update_vpc(opctx, &authz_vpc, params.clone().into()) .await @@ -146,6 +168,10 @@ impl super::Nexus { let (.., authz_vpc, db_vpc) = vpc_lookup.fetch_for(authz::Action::Modify).await?; + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can delete VPCs + self.check_networking_restrictions(opctx).await?; + let authz_vpc_router = authz::VpcRouter::new( authz_vpc.clone(), db_vpc.system_router_id, diff --git a/nexus/tests/integration_tests/vpcs.rs b/nexus/tests/integration_tests/vpcs.rs index ecfea70d254..d988c2e3992 100644 --- a/nexus/tests/integration_tests/vpcs.rs +++ b/nexus/tests/integration_tests/vpcs.rs @@ -21,6 +21,7 @@ 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_uuid_kinds::GenericUuid; type ControlPlaneTestContext = nexus_test_utils::ControlPlaneTestContext; @@ -324,7 +325,7 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { .unwrap() ); - // Test Part 3: Test authorization with different user roles + // Test Part 3: Test as Collaborator - CREATE and UPDATE should FAIL // Create a user in the restricted silo let test_user = create_local_user( client, @@ -363,7 +364,6 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { .expect("failed to update silo policy"); // Create a project in the restricted silo AS THE SILO USER - // This ensures the project is properly set up for that user let restricted_project_name = "restricted-project"; let project_params = params::ProjectCreate { identity: IdentityMetadataCreateParams { @@ -378,32 +378,112 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { .execute_and_parse_unwrap() .await; - // Try to create a VPC as a Silo Collaborator (with Project Creator role) - // Should FAIL with 403 Forbidden because the silo has restrict_network_actions=true - // Note: When authenticated as a silo user, the silo context is implicit let restricted_vpcs_url = format!("/v1/vpcs?project={}", restricted_project_name); - let restricted_vpc_params = params::VpcCreate { + + // First, temporarily grant Admin so we can create a VPC to test updates + 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 VPC as Admin so we have something to test update with + 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(), + }; + + NexusRequest::objects_post(&client, &restricted_vpcs_url, &test_vpc_params) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap::() + .await; + + // Remove Admin role, back to just 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 collaborator_only_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(); + + let collaborator_policy = + shared::Policy { role_assignments: collaborator_only_assignments }; + 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 VPC as Collaborator - should FAIL + let collab_vpc_params = params::VpcCreate { identity: IdentityMetadataCreateParams { - name: "restricted-vpc".parse().unwrap(), - description: "VPC in restricted silo".to_string(), + name: "collab-vpc".parse().unwrap(), + description: "Collaborator creation attempt".to_string(), }, ipv6_prefix: None, - dns_name: "restricted".parse().unwrap(), + dns_name: "collab".parse().unwrap(), }; NexusRequest::new( RequestBuilder::new(client, Method::POST, &restricted_vpcs_url) - .body(Some(&restricted_vpc_params)) + .body(Some(&collab_vpc_params)) .expect_status(Some(StatusCode::FORBIDDEN)), ) .authn_as(AuthnMode::SiloUser(test_user.id)) .execute() .await - .expect("VPC create should fail for Silo Collaborator in restricted silo"); + .expect("Collaborator should not be able to create VPC"); - // Now grant the user Silo Admin role - let silo_url = format!("/v1/system/silos/{}", restricted_silo_name); + // Try to UPDATE the VPC as Collaborator - should FAIL + let vpc_update_url = + format!("/v1/vpcs/test-vpc?project={}", restricted_project_name); + 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"); + + // Test Part 4: Test as Admin - CREATE and UPDATE should SUCCEED + // Grant the user Silo Admin role again grant_iam( client, &silo_url, @@ -413,19 +493,48 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { ) .await; - // Try to create a VPC again as Silo Admin - should succeed - let vpc_as_admin: Vpc = NexusRequest::objects_post( + // Try to CREATE a VPC as Admin - should SUCCEED + let admin_vpc_params = params::VpcCreate { + identity: IdentityMetadataCreateParams { + name: "admin-vpc".parse().unwrap(), + description: "VPC created by admin".to_string(), + }, + ipv6_prefix: None, + dns_name: "admin".parse().unwrap(), + }; + + let created_vpc: Vpc = NexusRequest::objects_post( &client, &restricted_vpcs_url, - &restricted_vpc_params, + &admin_vpc_params, ) .authn_as(AuthnMode::SiloUser(test_user.id)) - .execute() - .await - .expect("VPC creation should succeed for Silo Admin in restricted silo") - .parsed_body() - .unwrap(); + .execute_and_parse_unwrap() + .await; + + assert_eq!(created_vpc.identity.name, "admin-vpc"); + assert_eq!(created_vpc.dns_name, "admin"); + + // Try to UPDATE the VPC as Admin - should SUCCEED + let vpc_update_params_admin = params::VpcUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some("Admin update successful".to_string()), + }, + dns_name: None, + }; + + let updated_vpc: Vpc = NexusRequest::object_put( + &client, + &vpc_update_url, + Some(&vpc_update_params_admin), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + assert_eq!(updated_vpc.identity.description, "Admin update successful"); - assert_eq!(vpc_as_admin.identity.name, "restricted-vpc"); - assert_eq!(vpc_as_admin.dns_name, "restricted"); + // TODO: Add delete tests once we handle subnet deletion + // (VPCs can't be deleted if they have subnets) } From 4768df9d766d437d6cd402f65ba692f0dabcb0b4 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 21 Oct 2025 10:15:57 -0700 Subject: [PATCH 27/48] cargo fmt --- nexus/tests/integration_tests/vpcs.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/nexus/tests/integration_tests/vpcs.rs b/nexus/tests/integration_tests/vpcs.rs index d988c2e3992..ae9b5b4b105 100644 --- a/nexus/tests/integration_tests/vpcs.rs +++ b/nexus/tests/integration_tests/vpcs.rs @@ -435,11 +435,15 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { let collaborator_policy = shared::Policy { role_assignments: collaborator_only_assignments }; - NexusRequest::object_put(client, &silo_policy_url, Some(&collaborator_policy)) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("failed to update silo policy"); + 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 VPC as Collaborator - should FAIL let collab_vpc_params = params::VpcCreate { From 15713f520713273ebdb7ae9ad6bedad824c2f86f Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 21 Oct 2025 12:47:47 -0700 Subject: [PATCH 28/48] Add VPC subnet restriction and tests --- nexus/src/app/vpc.rs | 2 +- nexus/src/app/vpc_subnet.rs | 12 ++ nexus/tests/integration_tests/vpcs.rs | 242 ++++++++++++++++++++++++++ 3 files changed, 255 insertions(+), 1 deletion(-) diff --git a/nexus/src/app/vpc.rs b/nexus/src/app/vpc.rs index fc15a06a68e..eb38bdb000b 100644 --- a/nexus/src/app/vpc.rs +++ b/nexus/src/app/vpc.rs @@ -74,7 +74,7 @@ impl super::Nexus { /// /// Returns Err if the silo restricts networking and the actor is not /// a Silo Admin. - async fn check_networking_restrictions( + pub(crate) async fn check_networking_restrictions( &self, opctx: &OpContext, ) -> Result<(), Error> { diff --git a/nexus/src/app/vpc_subnet.rs b/nexus/src/app/vpc_subnet.rs index f1c40c86ed7..c1a8f6a092d 100644 --- a/nexus/src/app/vpc_subnet.rs +++ b/nexus/src/app/vpc_subnet.rs @@ -77,6 +77,10 @@ impl super::Nexus { .vpc_router_id(db_vpc.system_router_id) .lookup_for(authz::Action::CreateChild) .await?; + + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can create VPC subnets + self.check_networking_restrictions(opctx).await?; let custom_router = match ¶ms.custom_router { Some(k) => Some( self.vpc_router_lookup_for_attach(opctx, k, &authz_vpc).await?, @@ -189,6 +193,10 @@ impl super::Nexus { let (.., authz_vpc, authz_subnet) = vpc_subnet_lookup.lookup_for(authz::Action::Modify).await?; + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can update VPC subnets + self.check_networking_restrictions(opctx).await?; + let custom_router = match ¶ms.custom_router { Some(k) => Some( self.vpc_router_lookup_for_attach(opctx, k, &authz_vpc).await?, @@ -229,6 +237,10 @@ impl super::Nexus { let (.., authz_vpc, authz_subnet, db_subnet) = vpc_subnet_lookup.fetch_for(authz::Action::Delete).await?; + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can delete VPC subnets + self.check_networking_restrictions(opctx).await?; + let saga_params = sagas::vpc_subnet_delete::Params { serialized_authn: authn::saga::Serialized::for_opctx(opctx), authz_vpc, diff --git a/nexus/tests/integration_tests/vpcs.rs b/nexus/tests/integration_tests/vpcs.rs index ae9b5b4b105..f2d01518441 100644 --- a/nexus/tests/integration_tests/vpcs.rs +++ b/nexus/tests/integration_tests/vpcs.rs @@ -542,3 +542,245 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { // TODO: Add delete tests once we handle subnet deletion // (VPCs can't be deleted if they have subnets) } + +#[nexus_test] +async fn test_vpc_subnet_networking_restrictions( + cptestctx: &ControlPlaneTestContext, +) { + use nexus_types::external_api::params; + + let client = &cptestctx.external_client; + + // Test Part 1: Create a restricted silo with networking restrictions enabled + 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), // 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, + &"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 the user Admin role first so they can create a project + // (project creation automatically creates a default VPC, which requires Admin in restricted silos) + grant_iam( + client, + &silo_url, + shared::SiloRole::Admin, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + // Create a project in the restricted silo AS THE SILO USER (who is currently Admin) + // 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; + + // Test Part 3: Demote to Collaborator + // Now add Collaborator role and remove Admin, so user has just Collaborator + // The default VPC and default subnet already exist from project creation + 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 == 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"); + + // Test Part 4: Test as Collaborator - CREATE, UPDATE, DELETE should all FAIL + let restricted_subnets_url = format!( + "/v1/vpc-subnets?project={}&vpc=default", + restricted_project_name + ); + + // Try to CREATE a subnet as Collaborator - should FAIL + let collab_subnet_params = params::VpcSubnetCreate { + identity: IdentityMetadataCreateParams { + name: "collab-subnet".parse().unwrap(), + description: "Collaborator creation attempt".to_string(), + }, + ipv4_block: "10.1.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"); + + // Try to UPDATE the default subnet as Collaborator - should FAIL + let subnet_update_url = format!( + "/v1/vpc-subnets/default?project={}&vpc=default", + restricted_project_name + ); + 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"); + + // Try to DELETE the default subnet as Collaborator - 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"); + + // Test Part 5: Test as Admin - CREATE and UPDATE should SUCCEED + // Grant the user Silo Admin role again + grant_iam( + client, + &silo_url, + shared::SiloRole::Admin, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + // Try to CREATE a subnet as Admin - should SUCCEED + let admin_subnet_params = params::VpcSubnetCreate { + identity: IdentityMetadataCreateParams { + name: "admin-subnet".parse().unwrap(), + description: "Subnet created by admin".to_string(), + }, + ipv4_block: "10.2.0.0/22".parse().unwrap(), + ipv6_block: None, + custom_router: None, + }; + + let created_subnet: views::VpcSubnet = NexusRequest::objects_post( + &client, + &restricted_subnets_url, + &admin_subnet_params, + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + assert_eq!(created_subnet.identity.name, "admin-subnet"); + assert_eq!(created_subnet.identity.description, "Subnet created by admin"); + + // Try to UPDATE the default subnet as Admin - should SUCCEED + let subnet_update_params_admin = params::VpcSubnetUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some("Admin update successful".to_string()), + }, + custom_router: None, + }; + + let updated_subnet: views::VpcSubnet = NexusRequest::object_put( + &client, + &subnet_update_url, + Some(&subnet_update_params_admin), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + assert_eq!(updated_subnet.identity.description, "Admin update successful"); + + // Try to DELETE the admin-created subnet as Admin - should SUCCEED + let admin_subnet_url = format!( + "/v1/vpc-subnets/admin-subnet?project={}&vpc=default", + restricted_project_name + ); + NexusRequest::object_delete(&client, &admin_subnet_url) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute() + .await + .expect("Admin should be able to delete VPC subnet"); +} From 7bb9e3544ce1f9be1bdb684957644e3c1c61c4a3 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 21 Oct 2025 13:18:21 -0700 Subject: [PATCH 29/48] Add routers and router route checks and tests --- nexus/src/app/vpc_router.rs | 27 ++ nexus/tests/integration_tests/vpc_routers.rs | 430 +++++++++++++++++++ 2 files changed, 457 insertions(+) diff --git a/nexus/src/app/vpc_router.rs b/nexus/src/app/vpc_router.rs index b08de606b71..71134f20d1a 100644 --- a/nexus/src/app/vpc_router.rs +++ b/nexus/src/app/vpc_router.rs @@ -114,6 +114,11 @@ impl super::Nexus { ) -> CreateResult { let (.., authz_vpc) = vpc_lookup.lookup_for(authz::Action::CreateChild).await?; + + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can create VPC routers + self.check_networking_restrictions(opctx).await?; + let id = Uuid::new_v4(); let router = db::model::VpcRouter::new( id, @@ -155,6 +160,11 @@ impl super::Nexus { ) -> UpdateResult { let (.., authz_router) = vpc_router_lookup.lookup_for(authz::Action::Modify).await?; + + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can update VPC routers + self.check_networking_restrictions(opctx).await?; + self.db_datastore .vpc_update_router(opctx, &authz_router, params.clone().into()) .await @@ -167,6 +177,11 @@ impl super::Nexus { ) -> DeleteResult { let (.., authz_router, db_router) = vpc_router_lookup.fetch_for(authz::Action::Delete).await?; + + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can delete VPC routers + self.check_networking_restrictions(opctx).await?; + // TODO-performance shouldn't this check be part of the "update" // database query? This shouldn't affect correctness, assuming that a // router kind cannot be changed, but it might be able to save us a @@ -235,6 +250,10 @@ impl super::Nexus { let (.., authz_router, db_router) = router_lookup.fetch_for(authz::Action::CreateChild).await?; + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can create router routes + self.check_networking_restrictions(opctx).await?; + if db_router.kind == VpcRouterKind::System { return Err(Error::invalid_request( "user-provided routes cannot be added to a system router", @@ -286,6 +305,10 @@ impl super::Nexus { let (.., authz_router, authz_route, db_route) = route_lookup.fetch_for(authz::Action::Modify).await?; + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can update router routes + self.check_networking_restrictions(opctx).await?; + match db_route.kind.0 { // Default routes allow a constrained form of modification: // only the target may change. @@ -334,6 +357,10 @@ impl super::Nexus { let (.., authz_router, authz_route, db_route) = route_lookup.fetch_for(authz::Action::Delete).await?; + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can delete router routes + self.check_networking_restrictions(opctx).await?; + // Only custom routes can be deleted // TODO Shouldn't this constraint be checked by the database query? if db_route.kind.0 != RouterRouteKind::Custom { 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"); +} From bbf0c19d025148ccb79acf285bde15da8cb80341 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 21 Oct 2025 13:41:26 -0700 Subject: [PATCH 30/48] Add networking restrictions check to Internet Gateways and Firewall Rules --- nexus/src/app/internet_gateway.rs | 9 + nexus/src/app/vpc.rs | 5 + nexus/tests/integration_tests/vpcs.rs | 310 ++++++++++++++++++++++++++ 3 files changed, 324 insertions(+) diff --git a/nexus/src/app/internet_gateway.rs b/nexus/src/app/internet_gateway.rs index 23bb9d3bcac..b16780792c9 100644 --- a/nexus/src/app/internet_gateway.rs +++ b/nexus/src/app/internet_gateway.rs @@ -70,6 +70,11 @@ impl super::Nexus { ) -> CreateResult { let (.., authz_vpc) = vpc_lookup.lookup_for(authz::Action::CreateChild).await?; + + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can create internet gateways + self.check_networking_restrictions(opctx).await?; + let id = Uuid::new_v4(); let router = db::model::InternetGateway::new(id, authz_vpc.id(), params.clone()); @@ -113,6 +118,10 @@ impl super::Nexus { let (.., authz_vpc, authz_igw, _db_igw) = lookup.fetch_for(authz::Action::Delete).await?; + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can delete internet gateways + self.check_networking_restrictions(opctx).await?; + let out = self .db_datastore .vpc_delete_internet_gateway( diff --git a/nexus/src/app/vpc.rs b/nexus/src/app/vpc.rs index eb38bdb000b..924faa8e932 100644 --- a/nexus/src/app/vpc.rs +++ b/nexus/src/app/vpc.rs @@ -225,6 +225,11 @@ impl super::Nexus { ) -> UpdateResult> { let (.., authz_vpc, db_vpc) = vpc_lookup.fetch_for(authz::Action::Modify).await?; + + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can update VPC firewall rules + self.check_networking_restrictions(opctx).await?; + let rules = db::model::VpcFirewallRule::vec_from_params( authz_vpc.id(), params.clone(), diff --git a/nexus/tests/integration_tests/vpcs.rs b/nexus/tests/integration_tests/vpcs.rs index f2d01518441..e101803cc02 100644 --- a/nexus/tests/integration_tests/vpcs.rs +++ b/nexus/tests/integration_tests/vpcs.rs @@ -784,3 +784,313 @@ async fn test_vpc_subnet_networking_restrictions( .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; + + // Test Part 1: Create a restricted silo with networking restrictions enabled + 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), // 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, + &"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 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 = "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; + + // 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 Internet Gateway operations as Collaborator - CREATE and DELETE should FAIL + let restricted_igws_url = format!( + "/v1/internet-gateways?project={}&vpc=default", + restricted_project_name + ); + + // Try to CREATE an internet gateway as Collaborator - 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"); + + // Test Part 5: Test as Admin - CREATE and DELETE 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 an internet gateway as Admin - should SUCCEED + let admin_igw_params = params::InternetGatewayCreate { + identity: IdentityMetadataCreateParams { + name: "admin-igw".parse().unwrap(), + description: "IGW created by admin".to_string(), + }, + }; + + let created_igw: views::InternetGateway = NexusRequest::objects_post( + &client, + &restricted_igws_url, + &admin_igw_params, + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + assert_eq!(created_igw.identity.name, "admin-igw"); + assert_eq!(created_igw.identity.description, "IGW created by admin"); + + // 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 DELETE the internet gateway as Collaborator - should FAIL + let igw_delete_url = format!( + "/v1/internet-gateways/admin-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"); + + // Promote back to Admin and DELETE should succeed + grant_iam( + client, + &silo_url, + SiloRole::Admin, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + // Try to DELETE the internet gateway as Admin - should SUCCEED + 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 Part 6: Test Firewall Rules - 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"); + + let firewall_rules_url = format!( + "/v1/vpc-firewall-rules?project={}&vpc=default", + restricted_project_name + ); + + // Try to UPDATE firewall rules as Collaborator - 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"); + + // Promote back to Admin + grant_iam( + client, + &silo_url, + SiloRole::Admin, + test_user.id, + AuthnMode::PrivilegedUser, + ) + .await; + + // Try to UPDATE firewall rules as Admin - should SUCCEED + let admin_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 updated_rules: VpcFirewallRules = NexusRequest::object_put( + &client, + &firewall_rules_url, + Some(&admin_firewall_params), + ) + .authn_as(AuthnMode::SiloUser(test_user.id)) + .execute_and_parse_unwrap() + .await; + + assert_eq!(updated_rules.rules.len(), 1); + assert_eq!(updated_rules.rules[0].identity.name, "allow-ssh"); +} From 1320eb2baf607dad3e099509bbb7d0080ecb0226 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 21 Oct 2025 15:20:06 -0700 Subject: [PATCH 31/48] Refactor tests --- nexus/tests/integration_tests/vpcs.rs | 600 +++++++++++--------------- 1 file changed, 240 insertions(+), 360 deletions(-) diff --git a/nexus/tests/integration_tests/vpcs.rs b/nexus/tests/integration_tests/vpcs.rs index e101803cc02..a425820607b 100644 --- a/nexus/tests/integration_tests/vpcs.rs +++ b/nexus/tests/integration_tests/vpcs.rs @@ -256,29 +256,7 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { let client = &cptestctx.external_client; - // Test Part 1: Normal silo (restrict_network_actions = false) - // In the default silo, project collaborators CAN create VPCs - let normal_project_name = "normal-project"; - create_project(&client, normal_project_name).await; - - let normal_vpcs_url = format!("/v1/vpcs?project={}", normal_project_name); - let vpc_params = params::VpcCreate { - identity: IdentityMetadataCreateParams { - name: "normal-vpc".parse().unwrap(), - description: "VPC in normal silo".to_string(), - }, - ipv6_prefix: None, - dns_name: "normal".parse().unwrap(), - }; - - // As privileged user (silo admin), VPC creation should succeed - let vpc: Vpc = object_create(&client, &normal_vpcs_url, &vpc_params).await; - - assert_eq!(vpc.identity.name, "normal-vpc"); - assert_eq!(vpc.dns_name, "normal"); - - // Test Part 2: Restricted silo (restrict_network_actions = true) - // Create a silo with networking restrictions enabled + // 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 { @@ -292,41 +270,13 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { admin_group_name: None, tls_certificates: Vec::new(), mapped_fleet_roles: Default::default(), - restrict_network_actions: Some(true), // Enable networking restrictions + restrict_network_actions: Some(true), quotas: params::SiloQuotasCreate::empty(), }; let restricted_silo: views::Silo = object_create(&client, silo_url, &silo_params).await; - // Verify the silo has networking restrictions enabled - assert_eq!( - restricted_silo.identity.name, - restricted_silo_name - .parse::() - .unwrap() - ); - - // Verify we can read the silo back and see the restriction flag - let silo_get_url = format!("/v1/system/silos/{}", restricted_silo_name); - let fetched_silo: nexus_types::external_api::views::Silo = - NexusRequest::object_get(&client, &silo_get_url) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .unwrap() - .parsed_body() - .unwrap(); - - assert_eq!( - fetched_silo.identity.name, - restricted_silo_name - .parse::() - .unwrap() - ); - - // Test Part 3: Test as Collaborator - CREATE and UPDATE should FAIL - // Create a user in the restricted silo let test_user = create_local_user( client, &restricted_silo, @@ -335,35 +285,19 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { ) .await; - // Grant the user Silo Collaborator role so they can create a project let silo_policy_url = format!("/v1/system/silos/{}/policy", restricted_silo_name); - let existing_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 new_role_assignment = shared::RoleAssignment::for_silo_user( + let silo_url = format!("/v1/system/silos/{}", restricted_silo_name); + + grant_iam( + client, + &silo_url, + shared::SiloRole::Admin, test_user.id, - shared::SiloRole::Collaborator, - ); - let new_role_assignments = existing_silo_policy - .role_assignments - .into_iter() - .chain(std::iter::once(new_role_assignment)) - .collect(); - let new_silo_policy = - shared::Policy { role_assignments: new_role_assignments }; - NexusRequest::object_put(client, &silo_policy_url, Some(&new_silo_policy)) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("failed to update silo policy"); + AuthnMode::PrivilegedUser, + ) + .await; - // Create a project in the restricted silo AS THE SILO USER let restricted_project_name = "restricted-project"; let project_params = params::ProjectCreate { identity: IdentityMetadataCreateParams { @@ -381,18 +315,7 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { let restricted_vpcs_url = format!("/v1/vpcs?project={}", restricted_project_name); - // First, temporarily grant Admin so we can create a VPC to test updates - 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 VPC as Admin so we have something to test update with + // STEP 2: As Admin - Create VPC let test_vpc_params = params::VpcCreate { identity: IdentityMetadataCreateParams { name: "test-vpc".parse().unwrap(), @@ -402,12 +325,41 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { dns_name: "test".parse().unwrap(), }; - NexusRequest::objects_post(&client, &restricted_vpcs_url, &test_vpc_params) - .authn_as(AuthnMode::SiloUser(test_user.id)) - .execute_and_parse_unwrap::() - .await; + 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"); - // Remove Admin role, back to just Collaborator + // 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) @@ -418,7 +370,8 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { .expect("failed to parse silo policy"); let test_user_uuid = test_user.id.into_untyped_uuid(); - let collaborator_only_assignments: Vec<_> = silo_policy + + let mut new_assignments: Vec<_> = silo_policy .role_assignments .into_iter() .filter(|ra| { @@ -433,8 +386,14 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { }) .collect(); + new_assignments.push(shared::RoleAssignment::for_silo_user( + test_user.id, + shared::SiloRole::Collaborator, + )); + let collaborator_policy = - shared::Policy { role_assignments: collaborator_only_assignments }; + shared::Policy { role_assignments: new_assignments }; + NexusRequest::object_put( client, &silo_policy_url, @@ -445,7 +404,7 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { .await .expect("failed to update silo policy"); - // Try to CREATE a VPC as Collaborator - should FAIL + // STEP 5: As Collaborator - Try to CREATE VPC (should fail) let collab_vpc_params = params::VpcCreate { identity: IdentityMetadataCreateParams { name: "collab-vpc".parse().unwrap(), @@ -465,9 +424,7 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { .await .expect("Collaborator should not be able to create VPC"); - // Try to UPDATE the VPC as Collaborator - should FAIL - let vpc_update_url = - format!("/v1/vpcs/test-vpc?project={}", restricted_project_name); + // STEP 6: As Collaborator - Try to UPDATE VPC (should fail) let vpc_update_params_collab = params::VpcUpdate { identity: IdentityMetadataUpdateParams { name: None, @@ -486,8 +443,17 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { .await .expect("Collaborator should not be able to update VPC"); - // Test Part 4: Test as Admin - CREATE and UPDATE should SUCCEED - // Grant the user Silo Admin role again + // 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, @@ -497,50 +463,24 @@ async fn test_vpc_networking_restrictions(cptestctx: &ControlPlaneTestContext) { ) .await; - // Try to CREATE a VPC as Admin - should SUCCEED - let admin_vpc_params = params::VpcCreate { - identity: IdentityMetadataCreateParams { - name: "admin-vpc".parse().unwrap(), - description: "VPC created by admin".to_string(), - }, - ipv6_prefix: None, - dns_name: "admin".parse().unwrap(), - }; - - let created_vpc: Vpc = NexusRequest::objects_post( - &client, - &restricted_vpcs_url, - &admin_vpc_params, - ) - .authn_as(AuthnMode::SiloUser(test_user.id)) - .execute_and_parse_unwrap() - .await; - - assert_eq!(created_vpc.identity.name, "admin-vpc"); - assert_eq!(created_vpc.dns_name, "admin"); - - // Try to UPDATE the VPC as Admin - should SUCCEED - let vpc_update_params_admin = params::VpcUpdate { - identity: IdentityMetadataUpdateParams { - name: None, - description: Some("Admin update successful".to_string()), - }, - dns_name: None, - }; - - let updated_vpc: Vpc = NexusRequest::object_put( - &client, - &vpc_update_url, - Some(&vpc_update_params_admin), - ) - .authn_as(AuthnMode::SiloUser(test_user.id)) - .execute_and_parse_unwrap() - .await; - - assert_eq!(updated_vpc.identity.description, "Admin update successful"); + // 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"); - // TODO: Add delete tests once we handle subnet deletion - // (VPCs can't be deleted if they have subnets) + // 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] @@ -551,7 +491,7 @@ async fn test_vpc_subnet_networking_restrictions( let client = &cptestctx.external_client; - // Test Part 1: Create a restricted silo with networking restrictions enabled + // 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 { @@ -565,14 +505,13 @@ async fn test_vpc_subnet_networking_restrictions( admin_group_name: None, tls_certificates: Vec::new(), mapped_fleet_roles: Default::default(), - restrict_network_actions: Some(true), // Enable networking restrictions + restrict_network_actions: Some(true), 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, @@ -585,8 +524,6 @@ async fn test_vpc_subnet_networking_restrictions( 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 - // (project creation automatically creates a default VPC, which requires Admin in restricted silos) grant_iam( client, &silo_url, @@ -596,8 +533,7 @@ async fn test_vpc_subnet_networking_restrictions( ) .await; - // Create a project in the restricted silo AS THE SILO USER (who is currently Admin) - // This will automatically create a default VPC with a default subnet + // 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 { @@ -612,9 +548,59 @@ async fn test_vpc_subnet_networking_restrictions( .execute_and_parse_unwrap() .await; - // Test Part 3: Demote to Collaborator - // Now add Collaborator role and remove Admin, so user has just Collaborator - // The default VPC and default subnet already exist from project creation + 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) @@ -626,7 +612,6 @@ async fn test_vpc_subnet_networking_restrictions( 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() @@ -649,6 +634,7 @@ async fn test_vpc_subnet_networking_restrictions( let collaborator_policy = shared::Policy { role_assignments: new_assignments }; + NexusRequest::object_put( client, &silo_policy_url, @@ -659,19 +645,13 @@ async fn test_vpc_subnet_networking_restrictions( .await .expect("failed to update silo policy"); - // Test Part 4: Test as Collaborator - CREATE, UPDATE, DELETE should all FAIL - let restricted_subnets_url = format!( - "/v1/vpc-subnets?project={}&vpc=default", - restricted_project_name - ); - - // Try to CREATE a subnet as Collaborator - should FAIL + // 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.1.0.0/22".parse().unwrap(), + ipv4_block: "10.2.0.0/22".parse().unwrap(), ipv6_block: None, custom_router: None, }; @@ -686,11 +666,7 @@ async fn test_vpc_subnet_networking_restrictions( .await .expect("Collaborator should not be able to create VPC subnet"); - // Try to UPDATE the default subnet as Collaborator - should FAIL - let subnet_update_url = format!( - "/v1/vpc-subnets/default?project={}&vpc=default", - restricted_project_name - ); + // STEP 6: As Collaborator - Try to UPDATE subnet (should fail) let subnet_update_params_collab = params::VpcSubnetUpdate { identity: IdentityMetadataUpdateParams { name: None, @@ -709,7 +685,7 @@ async fn test_vpc_subnet_networking_restrictions( .await .expect("Collaborator should not be able to update VPC subnet"); - // Try to DELETE the default subnet as Collaborator - should FAIL + // 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)), @@ -719,8 +695,7 @@ async fn test_vpc_subnet_networking_restrictions( .await .expect("Collaborator should not be able to delete VPC subnet"); - // Test Part 5: Test as Admin - CREATE and UPDATE should SUCCEED - // Grant the user Silo Admin role again + // STEP 8: Promote back to Admin grant_iam( client, &silo_url, @@ -730,55 +705,8 @@ async fn test_vpc_subnet_networking_restrictions( ) .await; - // Try to CREATE a subnet as Admin - should SUCCEED - let admin_subnet_params = params::VpcSubnetCreate { - identity: IdentityMetadataCreateParams { - name: "admin-subnet".parse().unwrap(), - description: "Subnet created by admin".to_string(), - }, - ipv4_block: "10.2.0.0/22".parse().unwrap(), - ipv6_block: None, - custom_router: None, - }; - - let created_subnet: views::VpcSubnet = NexusRequest::objects_post( - &client, - &restricted_subnets_url, - &admin_subnet_params, - ) - .authn_as(AuthnMode::SiloUser(test_user.id)) - .execute_and_parse_unwrap() - .await; - - assert_eq!(created_subnet.identity.name, "admin-subnet"); - assert_eq!(created_subnet.identity.description, "Subnet created by admin"); - - // Try to UPDATE the default subnet as Admin - should SUCCEED - let subnet_update_params_admin = params::VpcSubnetUpdate { - identity: IdentityMetadataUpdateParams { - name: None, - description: Some("Admin update successful".to_string()), - }, - custom_router: None, - }; - - let updated_subnet: views::VpcSubnet = NexusRequest::object_put( - &client, - &subnet_update_url, - Some(&subnet_update_params_admin), - ) - .authn_as(AuthnMode::SiloUser(test_user.id)) - .execute_and_parse_unwrap() - .await; - - assert_eq!(updated_subnet.identity.description, "Admin update successful"); - - // Try to DELETE the admin-created subnet as Admin - should SUCCEED - let admin_subnet_url = format!( - "/v1/vpc-subnets/admin-subnet?project={}&vpc=default", - restricted_project_name - ); - NexusRequest::object_delete(&client, &admin_subnet_url) + // 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 @@ -804,7 +732,7 @@ async fn test_internet_gateway_firewall_networking_restrictions( let client = &cptestctx.external_client; - // Test Part 1: Create a restricted silo with networking restrictions enabled + // 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 { @@ -818,14 +746,13 @@ async fn test_internet_gateway_firewall_networking_restrictions( admin_group_name: None, tls_certificates: Vec::new(), mapped_fleet_roles: Default::default(), - restrict_network_actions: Some(true), // Enable networking restrictions + restrict_network_actions: Some(true), 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, @@ -838,7 +765,6 @@ async fn test_internet_gateway_firewall_networking_restrictions( 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, @@ -848,7 +774,6 @@ async fn test_internet_gateway_firewall_networking_restrictions( ) .await; - // Create a project in the restricted silo AS THE SILO USER (who is currently Admin) let restricted_project_name = "igw-restricted-project"; let project_params = params::ProjectCreate { identity: IdentityMetadataCreateParams { @@ -863,7 +788,70 @@ async fn test_internet_gateway_firewall_networking_restrictions( .execute_and_parse_unwrap() .await; - // Test Part 3: Demote to Collaborator + 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) @@ -875,7 +863,6 @@ async fn test_internet_gateway_firewall_networking_restrictions( 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() @@ -898,6 +885,7 @@ async fn test_internet_gateway_firewall_networking_restrictions( let collaborator_policy = shared::Policy { role_assignments: new_assignments }; + NexusRequest::object_put( client, &silo_policy_url, @@ -908,13 +896,7 @@ async fn test_internet_gateway_firewall_networking_restrictions( .await .expect("failed to update silo policy"); - // Test Part 4: Test Internet Gateway operations as Collaborator - CREATE and DELETE should FAIL - let restricted_igws_url = format!( - "/v1/internet-gateways?project={}&vpc=default", - restricted_project_name - ); - - // Try to CREATE an internet gateway as Collaborator - should FAIL + // STEP 5: As Collaborator - Try to CREATE internet gateway (should fail) let collab_igw_params = params::InternetGatewayCreate { identity: IdentityMetadataCreateParams { name: "collab-igw".parse().unwrap(), @@ -932,98 +914,7 @@ async fn test_internet_gateway_firewall_networking_restrictions( .await .expect("Collaborator should not be able to create internet gateway"); - // Test Part 5: Test as Admin - CREATE and DELETE 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 an internet gateway as Admin - should SUCCEED - let admin_igw_params = params::InternetGatewayCreate { - identity: IdentityMetadataCreateParams { - name: "admin-igw".parse().unwrap(), - description: "IGW created by admin".to_string(), - }, - }; - - let created_igw: views::InternetGateway = NexusRequest::objects_post( - &client, - &restricted_igws_url, - &admin_igw_params, - ) - .authn_as(AuthnMode::SiloUser(test_user.id)) - .execute_and_parse_unwrap() - .await; - - assert_eq!(created_igw.identity.name, "admin-igw"); - assert_eq!(created_igw.identity.description, "IGW created by admin"); - - // 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 DELETE the internet gateway as Collaborator - should FAIL - let igw_delete_url = format!( - "/v1/internet-gateways/admin-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"); - - // Promote back to Admin and DELETE should succeed - grant_iam( - client, - &silo_url, - SiloRole::Admin, - test_user.id, - AuthnMode::PrivilegedUser, - ) - .await; - - // Try to DELETE the internet gateway as Admin - should SUCCEED - 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 Part 6: Test Firewall Rules - 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"); - - let firewall_rules_url = format!( - "/v1/vpc-firewall-rules?project={}&vpc=default", - restricted_project_name - ); - - // Try to UPDATE firewall rules as Collaborator - should FAIL + // STEP 6: As Collaborator - Try to UPDATE firewall rules (should fail) let collab_firewall_params = VpcFirewallRuleUpdateParams { rules: vec![VpcFirewallRuleUpdate { name: "allow-icmp".parse().unwrap(), @@ -1051,7 +942,22 @@ async fn test_internet_gateway_firewall_networking_restrictions( .await .expect("Collaborator should not be able to update firewall rules"); - // Promote back to Admin + // 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, @@ -1061,36 +967,10 @@ async fn test_internet_gateway_firewall_networking_restrictions( ) .await; - // Try to UPDATE firewall rules as Admin - should SUCCEED - let admin_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 updated_rules: VpcFirewallRules = NexusRequest::object_put( - &client, - &firewall_rules_url, - Some(&admin_firewall_params), - ) - .authn_as(AuthnMode::SiloUser(test_user.id)) - .execute_and_parse_unwrap() - .await; - - assert_eq!(updated_rules.rules.len(), 1); - assert_eq!(updated_rules.rules[0].identity.name, "allow-ssh"); + // 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"); } From 49cbce0478d9a1736de03928cd813dcf85f83c0b Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 21 Oct 2025 15:39:59 -0700 Subject: [PATCH 32/48] Add internet gateway attach/detach restrictions --- nexus/src/app/internet_gateway.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/nexus/src/app/internet_gateway.rs b/nexus/src/app/internet_gateway.rs index b16780792c9..b92e0089462 100644 --- a/nexus/src/app/internet_gateway.rs +++ b/nexus/src/app/internet_gateway.rs @@ -210,6 +210,10 @@ impl super::Nexus { let (.., authz_igw, _) = lookup.fetch_for(authz::Action::CreateChild).await?; + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can attach IP pools to internet gateways + self.check_networking_restrictions(opctx).await?; + // need to use this method so it works for non-fleet users let (authz_pool, ..) = self.silo_ip_pool_fetch(&opctx, ¶ms.ip_pool).await?; @@ -245,6 +249,10 @@ impl super::Nexus { let (.., authz_vpc, _authz_igw, authz_pool, db_pool) = lookup.fetch_for(authz::Action::Delete).await?; + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can detach IP pools from internet gateways + self.check_networking_restrictions(opctx).await?; + let (.., igw) = LookupPath::new(opctx, &self.db_datastore) .internet_gateway_id(db_pool.internet_gateway_id) .fetch() @@ -340,6 +348,10 @@ impl super::Nexus { let (.., authz_igw, _) = lookup.fetch_for(authz::Action::CreateChild).await?; + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can attach IP addresses to internet gateways + self.check_networking_restrictions(opctx).await?; + let id = Uuid::new_v4(); let route = db::model::InternetGatewayIpAddress::new( id, @@ -370,6 +382,10 @@ impl super::Nexus { let (.., authz_vpc, _authz_igw, authz_addr, db_addr) = lookup.fetch_for(authz::Action::Delete).await?; + // Check networking restrictions: if the actor's silo restricts networking + // actions, only Silo Admins can detach IP addresses from internet gateways + self.check_networking_restrictions(opctx).await?; + let (.., igw) = LookupPath::new(opctx, &self.db_datastore) .internet_gateway_id(db_addr.internet_gateway_id) .fetch() From 7ce7cc125ff576579cdce11cc9a717336b82f5d0 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 21 Oct 2025 15:58:05 -0700 Subject: [PATCH 33/48] Add tests for IP Pools / Addresses --- nexus/tests/integration_tests/vpcs.rs | 331 ++++++++++++++++++++++++++ 1 file changed, 331 insertions(+) diff --git a/nexus/tests/integration_tests/vpcs.rs b/nexus/tests/integration_tests/vpcs.rs index a425820607b..a98f99e4e1b 100644 --- a/nexus/tests/integration_tests/vpcs.rs +++ b/nexus/tests/integration_tests/vpcs.rs @@ -21,6 +21,7 @@ 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 = @@ -974,3 +975,333 @@ async fn test_internet_gateway_firewall_networking_restrictions( .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"); +} From 2daa80e2d4b40f5f49d5ba4b9646b497172858b3 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 21 Oct 2025 18:26:41 -0700 Subject: [PATCH 34/48] Add bypass on VPC creation saga in restricted environments --- nexus/src/app/project.rs | 17 +++ nexus/src/app/sagas/project_create.rs | 33 +++-- nexus/tests/integration_tests/vpcs.rs | 189 ++++++++++++++++++++++++++ 3 files changed, 228 insertions(+), 11 deletions(-) diff --git a/nexus/src/app/project.rs b/nexus/src/app/project.rs index 0f994a8c58e..f5b392ca577 100644 --- a/nexus/src/app/project.rs +++ b/nexus/src/app/project.rs @@ -53,10 +53,27 @@ 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/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/tests/integration_tests/vpcs.rs b/nexus/tests/integration_tests/vpcs.rs index a98f99e4e1b..6a6b344d4c3 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; @@ -1305,3 +1306,191 @@ async fn test_igw_ip_pool_address_attach_detach_restrictions( .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"); +} From 5d2b21c47f19132ecbcef88ce384c8db96084e47 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 21 Oct 2025 18:27:05 -0700 Subject: [PATCH 35/48] cargo fmt --- nexus/src/app/project.rs | 26 +++++++----- nexus/tests/integration_tests/vpcs.rs | 59 ++++++++++++++++----------- 2 files changed, 51 insertions(+), 34 deletions(-) diff --git a/nexus/src/app/project.rs b/nexus/src/app/project.rs index f5b392ca577..3cf7ff2b876 100644 --- a/nexus/src/app/project.rs +++ b/nexus/src/app/project.rs @@ -55,19 +55,23 @@ impl super::Nexus { // 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() + 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 networking restrictions, create VPC + // No policy, create VPC true - } - } else { - // No policy, create VPC - true - }; + }; let saga_params = sagas::project_create::Params { serialized_authn: authn::saga::Serialized::for_opctx(opctx), diff --git a/nexus/tests/integration_tests/vpcs.rs b/nexus/tests/integration_tests/vpcs.rs index 6a6b344d4c3..f703082a595 100644 --- a/nexus/tests/integration_tests/vpcs.rs +++ b/nexus/tests/integration_tests/vpcs.rs @@ -1370,11 +1370,14 @@ async fn test_project_create_networking_restrictions( }, }; - 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; + 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); @@ -1384,7 +1387,11 @@ async fn test_project_create_networking_restrictions( .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.len(), + 1, + "Admin project should have default VPC" + ); assert_eq!(admin_vpcs.items[0].identity.name, "default"); // STEP 3: Demote to Collaborator @@ -1414,9 +1421,8 @@ async fn test_project_create_networking_restrictions( }) .collect(); - let updated_policy = shared::Policy { - role_assignments: updated_role_assignments, - }; + let updated_policy = + shared::Policy { role_assignments: updated_role_assignments }; NexusRequest::object_put(client, &silo_policy_url, Some(&updated_policy)) .authn_as(AuthnMode::PrivilegedUser) @@ -1433,18 +1439,22 @@ async fn test_project_create_networking_restrictions( }, }; - 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; + 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_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!( @@ -1472,11 +1482,14 @@ async fn test_project_create_networking_restrictions( }, }; - 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; + 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); From 680a1a193913e1c34e7de6b3d890048bd259e3e4 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 22 Oct 2025 05:46:30 -0700 Subject: [PATCH 36/48] Update dbint.sql version again --- schema/crdb/dbinit.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema/crdb/dbinit.sql b/schema/crdb/dbinit.sql index 2adc17e9b52..3fdedbc7e52 100644 --- a/schema/crdb/dbinit.sql +++ b/schema/crdb/dbinit.sql @@ -6828,7 +6828,7 @@ INSERT INTO omicron.public.db_metadata ( version, target_version ) VALUES - (TRUE, NOW(), NOW(), '200.0.0', NULL) + (TRUE, NOW(), NOW(), '201.0.0', NULL) ON CONFLICT DO NOTHING; COMMIT; From 479a696d85bbd7ab3fb6632857dbec5a956f46ed Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 22 Oct 2025 09:22:53 -0700 Subject: [PATCH 37/48] remove pub from method --- nexus/auth/src/authn/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexus/auth/src/authn/mod.rs b/nexus/auth/src/authn/mod.rs index 5c588b50a4f..5c95465dba6 100644 --- a/nexus/auth/src/authn/mod.rs +++ b/nexus/auth/src/authn/mod.rs @@ -270,7 +270,7 @@ pub struct SiloAuthnPolicy { mapped_fleet_roles: BTreeMap>, /// When true, restricts networking actions to Silo Admins only - pub restrict_network_actions: bool, + restrict_network_actions: bool, } impl SiloAuthnPolicy { From b9b5465822859de18a73e33c0a79093c846ca286 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 22 Oct 2025 10:46:34 -0700 Subject: [PATCH 38/48] Add missing Polar rules --- nexus/auth/src/authz/omicron.polar | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index f9e81a31b23..fb49fe71f38 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -733,6 +733,9 @@ has_permission(actor: AuthenticatedActor, "create_child", router: VpcRouter) if has_permission(actor: AuthenticatedActor, "modify", router: VpcRouter) if can_modify_networking_resource(actor, router.vpc.project); +has_permission(actor: AuthenticatedActor, "delete", router: VpcRouter) if + can_modify_networking_resource(actor, router.vpc.project); + # VPC Subnets (project path: subnet.vpc.project) has_permission(actor: AuthenticatedActor, "create_child", subnet: VpcSubnet) if can_modify_networking_resource(actor, subnet.vpc.project); @@ -740,6 +743,9 @@ has_permission(actor: AuthenticatedActor, "create_child", subnet: VpcSubnet) if has_permission(actor: AuthenticatedActor, "modify", subnet: VpcSubnet) if can_modify_networking_resource(actor, subnet.vpc.project); +has_permission(actor: AuthenticatedActor, "delete", subnet: VpcSubnet) if + can_modify_networking_resource(actor, subnet.vpc.project); + # Internet Gateways (project path: gateway.vpc.project) has_permission(actor: AuthenticatedActor, "create_child", gateway: InternetGateway) if can_modify_networking_resource(actor, gateway.vpc.project); @@ -747,9 +753,25 @@ has_permission(actor: AuthenticatedActor, "create_child", gateway: InternetGatew has_permission(actor: AuthenticatedActor, "modify", gateway: InternetGateway) if can_modify_networking_resource(actor, gateway.vpc.project); +has_permission(actor: AuthenticatedActor, "delete", gateway: InternetGateway) if + can_modify_networking_resource(actor, gateway.vpc.project); + # Router Routes (project path: route.vpc_router.vpc.project) has_permission(actor: AuthenticatedActor, "create_child", route: RouterRoute) if can_modify_networking_resource(actor, route.vpc_router.vpc.project); has_permission(actor: AuthenticatedActor, "modify", route: RouterRoute) if can_modify_networking_resource(actor, route.vpc_router.vpc.project); + +has_permission(actor: AuthenticatedActor, "delete", route: RouterRoute) if + can_modify_networking_resource(actor, route.vpc_router.vpc.project); + +# Internet Gateway IP Pool attachments (project path: pool.internet_gateway.vpc.project) +# Note: create_child is already handled by InternetGateway "create_child" rule above +has_permission(actor: AuthenticatedActor, "delete", pool: InternetGatewayIpPool) if + can_modify_networking_resource(actor, pool.internet_gateway.vpc.project); + +# Internet Gateway IP Address attachments (project path: addr.internet_gateway.vpc.project) +# Note: create_child is already handled by InternetGateway "create_child" rule above +has_permission(actor: AuthenticatedActor, "delete", addr: InternetGatewayIpAddress) if + can_modify_networking_resource(actor, addr.internet_gateway.vpc.project); From 28ba5f33ece986a45a3d75b342c00665f4e0b537 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 22 Oct 2025 12:34:18 -0700 Subject: [PATCH 39/48] Use InProjectNetworking snippet instead of permissive InProject snippet --- nexus/auth/src/authz/api_resources.rs | 14 +++--- nexus/auth/src/authz/omicron.polar | 42 +++++++++++++++++ nexus/authz-macros/src/lib.rs | 66 +++++++++++++++++++++++++++ nexus/src/app/vpc.rs | 13 ++++-- 4 files changed, 124 insertions(+), 11 deletions(-) diff --git a/nexus/auth/src/authz/api_resources.rs b/nexus/auth/src/authz/api_resources.rs index 4dd37e19b62..3d15f06dac3 100644 --- a/nexus/auth/src/authz/api_resources.rs +++ b/nexus/auth/src/authz/api_resources.rs @@ -1091,7 +1091,7 @@ authz_resource! { parent = "Project", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProject, + polar_snippet = InProjectNetworking, } authz_resource! { @@ -1099,7 +1099,7 @@ authz_resource! { parent = "Vpc", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProject, + polar_snippet = InProjectNetworking, } authz_resource! { @@ -1107,7 +1107,7 @@ authz_resource! { parent = "VpcRouter", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProject, + polar_snippet = InProjectNetworking, } authz_resource! { @@ -1115,7 +1115,7 @@ authz_resource! { parent = "Vpc", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProject, + polar_snippet = InProjectNetworking, } authz_resource! { @@ -1123,7 +1123,7 @@ authz_resource! { parent = "Vpc", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProject, + polar_snippet = InProjectNetworking, } authz_resource! { @@ -1131,7 +1131,7 @@ authz_resource! { parent = "InternetGateway", primary_key = Uuid, roles_allowed = false, - polar_snippet = InProject, + polar_snippet = InProjectNetworking, } authz_resource! { @@ -1139,7 +1139,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/omicron.polar b/nexus/auth/src/authz/omicron.polar index fb49fe71f38..fcf5971d1a1 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -726,6 +726,12 @@ has_permission(actor: AuthenticatedActor, "create_child", vpc: Vpc) if has_permission(actor: AuthenticatedActor, "modify", vpc: Vpc) if can_modify_networking_resource(actor, vpc.project); +has_permission(actor: AuthenticatedActor, "read", vpc: Vpc) if + has_role(actor, "viewer", vpc.project); + +has_permission(actor: AuthenticatedActor, "list_children", vpc: Vpc) if + has_role(actor, "viewer", vpc.project); + # VPC Routers (project path: router.vpc.project) has_permission(actor: AuthenticatedActor, "create_child", router: VpcRouter) if can_modify_networking_resource(actor, router.vpc.project); @@ -736,6 +742,12 @@ has_permission(actor: AuthenticatedActor, "modify", router: VpcRouter) if has_permission(actor: AuthenticatedActor, "delete", router: VpcRouter) if can_modify_networking_resource(actor, router.vpc.project); +has_permission(actor: AuthenticatedActor, "read", router: VpcRouter) if + has_role(actor, "viewer", router.vpc.project); + +has_permission(actor: AuthenticatedActor, "list_children", router: VpcRouter) if + has_role(actor, "viewer", router.vpc.project); + # VPC Subnets (project path: subnet.vpc.project) has_permission(actor: AuthenticatedActor, "create_child", subnet: VpcSubnet) if can_modify_networking_resource(actor, subnet.vpc.project); @@ -746,6 +758,12 @@ has_permission(actor: AuthenticatedActor, "modify", subnet: VpcSubnet) if has_permission(actor: AuthenticatedActor, "delete", subnet: VpcSubnet) if can_modify_networking_resource(actor, subnet.vpc.project); +has_permission(actor: AuthenticatedActor, "read", subnet: VpcSubnet) if + has_role(actor, "viewer", subnet.vpc.project); + +has_permission(actor: AuthenticatedActor, "list_children", subnet: VpcSubnet) if + has_role(actor, "viewer", subnet.vpc.project); + # Internet Gateways (project path: gateway.vpc.project) has_permission(actor: AuthenticatedActor, "create_child", gateway: InternetGateway) if can_modify_networking_resource(actor, gateway.vpc.project); @@ -756,6 +774,12 @@ has_permission(actor: AuthenticatedActor, "modify", gateway: InternetGateway) if has_permission(actor: AuthenticatedActor, "delete", gateway: InternetGateway) if can_modify_networking_resource(actor, gateway.vpc.project); +has_permission(actor: AuthenticatedActor, "read", gateway: InternetGateway) if + has_role(actor, "viewer", gateway.vpc.project); + +has_permission(actor: AuthenticatedActor, "list_children", gateway: InternetGateway) if + has_role(actor, "viewer", gateway.vpc.project); + # Router Routes (project path: route.vpc_router.vpc.project) has_permission(actor: AuthenticatedActor, "create_child", route: RouterRoute) if can_modify_networking_resource(actor, route.vpc_router.vpc.project); @@ -766,12 +790,30 @@ has_permission(actor: AuthenticatedActor, "modify", route: RouterRoute) if has_permission(actor: AuthenticatedActor, "delete", route: RouterRoute) if can_modify_networking_resource(actor, route.vpc_router.vpc.project); +has_permission(actor: AuthenticatedActor, "read", route: RouterRoute) if + has_role(actor, "viewer", route.vpc_router.vpc.project); + +has_permission(actor: AuthenticatedActor, "list_children", route: RouterRoute) if + has_role(actor, "viewer", route.vpc_router.vpc.project); + # Internet Gateway IP Pool attachments (project path: pool.internet_gateway.vpc.project) # Note: create_child is already handled by InternetGateway "create_child" rule above has_permission(actor: AuthenticatedActor, "delete", pool: InternetGatewayIpPool) if can_modify_networking_resource(actor, pool.internet_gateway.vpc.project); +has_permission(actor: AuthenticatedActor, "read", pool: InternetGatewayIpPool) if + has_role(actor, "viewer", pool.internet_gateway.vpc.project); + +has_permission(actor: AuthenticatedActor, "list_children", pool: InternetGatewayIpPool) if + has_role(actor, "viewer", pool.internet_gateway.vpc.project); + # Internet Gateway IP Address attachments (project path: addr.internet_gateway.vpc.project) # Note: create_child is already handled by InternetGateway "create_child" rule above has_permission(actor: AuthenticatedActor, "delete", addr: InternetGatewayIpAddress) if can_modify_networking_resource(actor, addr.internet_gateway.vpc.project); + +has_permission(actor: AuthenticatedActor, "read", addr: InternetGatewayIpAddress) if + has_role(actor, "viewer", addr.internet_gateway.vpc.project); + +has_permission(actor: AuthenticatedActor, "list_children", addr: InternetGatewayIpAddress) if + has_role(actor, "viewer", 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/src/app/vpc.rs b/nexus/src/app/vpc.rs index 924faa8e932..3e491356439 100644 --- a/nexus/src/app/vpc.rs +++ b/nexus/src/app/vpc.rs @@ -76,11 +76,16 @@ impl super::Nexus { /// a Silo Admin. pub(crate) async fn check_networking_restrictions( &self, - opctx: &OpContext, + _opctx: &OpContext, ) -> Result<(), Error> { - if let Some(actor) = opctx.authn.actor() { + // TEMPORARY: Early return to test Polar-only authorization + // Remove this return statement to re-enable explicit checks + return Ok(()); + + #[allow(unreachable_code)] + if let Some(actor) = _opctx.authn.actor() { if let Some(silo_id) = actor.silo_id() { - let silo_policy = opctx.authn.silo_authn_policy(); + 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 @@ -89,7 +94,7 @@ impl super::Nexus { silo_id, LookupType::ById(silo_id), ); - opctx + _opctx .authorize(authz::Action::Modify, &authz_silo) .await?; } From fefa84775130639b16fc89f4dda64855cf9a572d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 22 Oct 2025 13:22:53 -0700 Subject: [PATCH 40/48] Adjust VPC deletion --- nexus/auth/src/authz/omicron.polar | 3 +++ nexus/src/app/vpc.rs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index fcf5971d1a1..5aa288016e6 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -726,6 +726,9 @@ has_permission(actor: AuthenticatedActor, "create_child", vpc: Vpc) if has_permission(actor: AuthenticatedActor, "modify", vpc: Vpc) if can_modify_networking_resource(actor, vpc.project); +has_permission(actor: AuthenticatedActor, "delete", vpc: Vpc) if + can_modify_networking_resource(actor, vpc.project); + has_permission(actor: AuthenticatedActor, "read", vpc: Vpc) if has_role(actor, "viewer", vpc.project); diff --git a/nexus/src/app/vpc.rs b/nexus/src/app/vpc.rs index 3e491356439..193377e5aeb 100644 --- a/nexus/src/app/vpc.rs +++ b/nexus/src/app/vpc.rs @@ -171,7 +171,7 @@ impl super::Nexus { vpc_lookup: &lookup::Vpc<'_>, ) -> DeleteResult { let (.., authz_vpc, db_vpc) = - vpc_lookup.fetch_for(authz::Action::Modify).await?; + vpc_lookup.fetch_for(authz::Action::Delete).await?; // Check networking restrictions: if the actor's silo restricts networking // actions, only Silo Admins can delete VPCs From b56ff84dfb553b645b42d1f6ca5a00f9086e8f2d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 22 Oct 2025 14:00:32 -0700 Subject: [PATCH 41/48] Use project:createChild check for VPC creation in lieu of creating a VPC and then checking permissions on it --- nexus/db-queries/src/db/datastore/vpc.rs | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/nexus/db-queries/src/db/datastore/vpc.rs b/nexus/db-queries/src/db/datastore/vpc.rs index 218aa9a7aff..bb2ff53ccfd 100644 --- a/nexus/db-queries/src/db/datastore/vpc.rs +++ b/nexus/db-queries/src/db/datastore/vpc.rs @@ -475,18 +475,7 @@ impl DataStore { use nexus_db_schema::schema::vpc::dsl; assert_eq!(authz_project.id(), vpc_query.vpc.project_id); - - // Create a VPC authz resource for authorization check - let authz_vpc = authz::Vpc::new( - authz_project.clone(), - vpc_query.vpc.identity.id, - omicron_common::api::external::LookupType::ById( - vpc_query.vpc.identity.id, - ), - ); - - // Check if the actor can create this VPC (including networking restrictions) - opctx.authorize(authz::Action::CreateChild, &authz_vpc).await?; + opctx.authorize(authz::Action::CreateChild, authz_project).await?; let name = vpc_query.vpc.identity.name.clone(); let project_id = vpc_query.vpc.project_id; From 2331d3f897288cf80e081fedecb65a017cd8f2ae Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 22 Oct 2025 14:24:36 -0700 Subject: [PATCH 42/48] Comment out callsites for check_networking_restrictions; enable for VPC creation --- nexus/src/app/internet_gateway.rs | 30 ++++++++++++------------------ nexus/src/app/vpc.rs | 30 ++++++++++++------------------ nexus/src/app/vpc_router.rs | 30 ++++++++++++------------------ nexus/src/app/vpc_subnet.rs | 15 ++++++--------- 4 files changed, 42 insertions(+), 63 deletions(-) diff --git a/nexus/src/app/internet_gateway.rs b/nexus/src/app/internet_gateway.rs index b92e0089462..a83a7ea12b5 100644 --- a/nexus/src/app/internet_gateway.rs +++ b/nexus/src/app/internet_gateway.rs @@ -71,9 +71,8 @@ impl super::Nexus { let (.., authz_vpc) = vpc_lookup.lookup_for(authz::Action::CreateChild).await?; - // Check networking restrictions: if the actor's silo restricts networking - // actions, only Silo Admins can create internet gateways - self.check_networking_restrictions(opctx).await?; + // Networking restrictions are enforced by Polar rules (InternetGateway create_child permission) + // self.check_networking_restrictions(opctx).await?; let id = Uuid::new_v4(); let router = @@ -118,9 +117,8 @@ impl super::Nexus { let (.., authz_vpc, authz_igw, _db_igw) = lookup.fetch_for(authz::Action::Delete).await?; - // Check networking restrictions: if the actor's silo restricts networking - // actions, only Silo Admins can delete internet gateways - self.check_networking_restrictions(opctx).await?; + // Networking restrictions are enforced by Polar rules (InternetGateway delete permission) + // self.check_networking_restrictions(opctx).await?; let out = self .db_datastore @@ -210,9 +208,8 @@ impl super::Nexus { let (.., authz_igw, _) = lookup.fetch_for(authz::Action::CreateChild).await?; - // Check networking restrictions: if the actor's silo restricts networking - // actions, only Silo Admins can attach IP pools to internet gateways - self.check_networking_restrictions(opctx).await?; + // Networking restrictions are enforced by Polar rules (InternetGatewayIpPool create_child permission) + // self.check_networking_restrictions(opctx).await?; // need to use this method so it works for non-fleet users let (authz_pool, ..) = @@ -249,9 +246,8 @@ impl super::Nexus { let (.., authz_vpc, _authz_igw, authz_pool, db_pool) = lookup.fetch_for(authz::Action::Delete).await?; - // Check networking restrictions: if the actor's silo restricts networking - // actions, only Silo Admins can detach IP pools from internet gateways - self.check_networking_restrictions(opctx).await?; + // Networking restrictions are enforced by Polar rules (InternetGatewayIpPool delete permission) + // self.check_networking_restrictions(opctx).await?; let (.., igw) = LookupPath::new(opctx, &self.db_datastore) .internet_gateway_id(db_pool.internet_gateway_id) @@ -348,9 +344,8 @@ impl super::Nexus { let (.., authz_igw, _) = lookup.fetch_for(authz::Action::CreateChild).await?; - // Check networking restrictions: if the actor's silo restricts networking - // actions, only Silo Admins can attach IP addresses to internet gateways - self.check_networking_restrictions(opctx).await?; + // Networking restrictions are enforced by Polar rules (InternetGatewayIpAddress create_child permission) + // self.check_networking_restrictions(opctx).await?; let id = Uuid::new_v4(); let route = db::model::InternetGatewayIpAddress::new( @@ -382,9 +377,8 @@ impl super::Nexus { let (.., authz_vpc, _authz_igw, authz_addr, db_addr) = lookup.fetch_for(authz::Action::Delete).await?; - // Check networking restrictions: if the actor's silo restricts networking - // actions, only Silo Admins can detach IP addresses from internet gateways - self.check_networking_restrictions(opctx).await?; + // Networking restrictions are enforced by Polar rules (InternetGatewayIpAddress delete permission) + // self.check_networking_restrictions(opctx).await?; let (.., igw) = LookupPath::new(opctx, &self.db_datastore) .internet_gateway_id(db_addr.internet_gateway_id) diff --git a/nexus/src/app/vpc.rs b/nexus/src/app/vpc.rs index 193377e5aeb..46642eabf52 100644 --- a/nexus/src/app/vpc.rs +++ b/nexus/src/app/vpc.rs @@ -68,6 +68,8 @@ 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 @@ -76,16 +78,11 @@ impl super::Nexus { /// a Silo Admin. pub(crate) async fn check_networking_restrictions( &self, - _opctx: &OpContext, + opctx: &OpContext, ) -> Result<(), Error> { - // TEMPORARY: Early return to test Polar-only authorization - // Remove this return statement to re-enable explicit checks - return Ok(()); - - #[allow(unreachable_code)] - if let Some(actor) = _opctx.authn.actor() { + if let Some(actor) = opctx.authn.actor() { if let Some(silo_id) = actor.silo_id() { - let silo_policy = _opctx.authn.silo_authn_policy(); + 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 @@ -94,7 +91,7 @@ impl super::Nexus { silo_id, LookupType::ById(silo_id), ); - _opctx + opctx .authorize(authz::Action::Modify, &authz_silo) .await?; } @@ -156,9 +153,8 @@ impl super::Nexus { let (.., authz_vpc) = vpc_lookup.lookup_for(authz::Action::Modify).await?; - // Check networking restrictions: if the actor's silo restricts networking - // actions, only Silo Admins can update VPCs - self.check_networking_restrictions(opctx).await?; + // Networking restrictions are enforced by Polar rules (VPC modify permission) + // self.check_networking_restrictions(opctx).await?; self.db_datastore .project_update_vpc(opctx, &authz_vpc, params.clone().into()) @@ -173,9 +169,8 @@ impl super::Nexus { let (.., authz_vpc, db_vpc) = vpc_lookup.fetch_for(authz::Action::Delete).await?; - // Check networking restrictions: if the actor's silo restricts networking - // actions, only Silo Admins can delete VPCs - self.check_networking_restrictions(opctx).await?; + // Networking restrictions are enforced by Polar rules (VPC delete permission) + // self.check_networking_restrictions(opctx).await?; let authz_vpc_router = authz::VpcRouter::new( authz_vpc.clone(), @@ -231,9 +226,8 @@ impl super::Nexus { let (.., authz_vpc, db_vpc) = vpc_lookup.fetch_for(authz::Action::Modify).await?; - // Check networking restrictions: if the actor's silo restricts networking - // actions, only Silo Admins can update VPC firewall rules - self.check_networking_restrictions(opctx).await?; + // Networking restrictions are enforced by Polar rules (VPC modify permission) + // self.check_networking_restrictions(opctx).await?; let rules = db::model::VpcFirewallRule::vec_from_params( authz_vpc.id(), diff --git a/nexus/src/app/vpc_router.rs b/nexus/src/app/vpc_router.rs index 71134f20d1a..861b66c1862 100644 --- a/nexus/src/app/vpc_router.rs +++ b/nexus/src/app/vpc_router.rs @@ -115,9 +115,8 @@ impl super::Nexus { let (.., authz_vpc) = vpc_lookup.lookup_for(authz::Action::CreateChild).await?; - // Check networking restrictions: if the actor's silo restricts networking - // actions, only Silo Admins can create VPC routers - self.check_networking_restrictions(opctx).await?; + // Networking restrictions are enforced by Polar rules (VpcRouter create_child permission) + // self.check_networking_restrictions(opctx).await?; let id = Uuid::new_v4(); let router = db::model::VpcRouter::new( @@ -161,9 +160,8 @@ impl super::Nexus { let (.., authz_router) = vpc_router_lookup.lookup_for(authz::Action::Modify).await?; - // Check networking restrictions: if the actor's silo restricts networking - // actions, only Silo Admins can update VPC routers - self.check_networking_restrictions(opctx).await?; + // Networking restrictions are enforced by Polar rules (VpcRouter modify permission) + // self.check_networking_restrictions(opctx).await?; self.db_datastore .vpc_update_router(opctx, &authz_router, params.clone().into()) @@ -178,9 +176,8 @@ impl super::Nexus { let (.., authz_router, db_router) = vpc_router_lookup.fetch_for(authz::Action::Delete).await?; - // Check networking restrictions: if the actor's silo restricts networking - // actions, only Silo Admins can delete VPC routers - self.check_networking_restrictions(opctx).await?; + // Networking restrictions are enforced by Polar rules (VpcRouter delete permission) + // self.check_networking_restrictions(opctx).await?; // TODO-performance shouldn't this check be part of the "update" // database query? This shouldn't affect correctness, assuming that a @@ -250,9 +247,8 @@ impl super::Nexus { let (.., authz_router, db_router) = router_lookup.fetch_for(authz::Action::CreateChild).await?; - // Check networking restrictions: if the actor's silo restricts networking - // actions, only Silo Admins can create router routes - self.check_networking_restrictions(opctx).await?; + // Networking restrictions are enforced by Polar rules (RouterRoute create_child permission) + // self.check_networking_restrictions(opctx).await?; if db_router.kind == VpcRouterKind::System { return Err(Error::invalid_request( @@ -305,9 +301,8 @@ impl super::Nexus { let (.., authz_router, authz_route, db_route) = route_lookup.fetch_for(authz::Action::Modify).await?; - // Check networking restrictions: if the actor's silo restricts networking - // actions, only Silo Admins can update router routes - self.check_networking_restrictions(opctx).await?; + // Networking restrictions are enforced by Polar rules (RouterRoute modify permission) + // self.check_networking_restrictions(opctx).await?; match db_route.kind.0 { // Default routes allow a constrained form of modification: @@ -357,9 +352,8 @@ impl super::Nexus { let (.., authz_router, authz_route, db_route) = route_lookup.fetch_for(authz::Action::Delete).await?; - // Check networking restrictions: if the actor's silo restricts networking - // actions, only Silo Admins can delete router routes - self.check_networking_restrictions(opctx).await?; + // Networking restrictions are enforced by Polar rules (RouterRoute delete permission) + // self.check_networking_restrictions(opctx).await?; // Only custom routes can be deleted // TODO Shouldn't this constraint be checked by the database query? diff --git a/nexus/src/app/vpc_subnet.rs b/nexus/src/app/vpc_subnet.rs index c1a8f6a092d..55c7613bb38 100644 --- a/nexus/src/app/vpc_subnet.rs +++ b/nexus/src/app/vpc_subnet.rs @@ -78,9 +78,8 @@ impl super::Nexus { .lookup_for(authz::Action::CreateChild) .await?; - // Check networking restrictions: if the actor's silo restricts networking - // actions, only Silo Admins can create VPC subnets - self.check_networking_restrictions(opctx).await?; + // Networking restrictions are enforced by Polar rules (VpcSubnet create_child permission) + // self.check_networking_restrictions(opctx).await?; let custom_router = match ¶ms.custom_router { Some(k) => Some( self.vpc_router_lookup_for_attach(opctx, k, &authz_vpc).await?, @@ -193,9 +192,8 @@ impl super::Nexus { let (.., authz_vpc, authz_subnet) = vpc_subnet_lookup.lookup_for(authz::Action::Modify).await?; - // Check networking restrictions: if the actor's silo restricts networking - // actions, only Silo Admins can update VPC subnets - self.check_networking_restrictions(opctx).await?; + // Networking restrictions are enforced by Polar rules (VpcSubnet modify permission) + // self.check_networking_restrictions(opctx).await?; let custom_router = match ¶ms.custom_router { Some(k) => Some( @@ -237,9 +235,8 @@ impl super::Nexus { let (.., authz_vpc, authz_subnet, db_subnet) = vpc_subnet_lookup.fetch_for(authz::Action::Delete).await?; - // Check networking restrictions: if the actor's silo restricts networking - // actions, only Silo Admins can delete VPC subnets - self.check_networking_restrictions(opctx).await?; + // Networking restrictions are enforced by Polar rules (VpcSubnet delete permission) + // self.check_networking_restrictions(opctx).await?; let saga_params = sagas::vpc_subnet_delete::Params { serialized_authn: authn::saga::Serialized::for_opctx(opctx), From acc70afecb372396fada2303c4e115d6f8a88dc3 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 23 Oct 2025 09:18:58 -0700 Subject: [PATCH 43/48] Remove unneeded Rust checks and add missing Polar rules --- nexus/auth/src/authz/omicron.polar | 14 ++++++++++++-- nexus/src/app/internet_gateway.rs | 18 ------------------ nexus/src/app/vpc.rs | 13 +++---------- nexus/src/app/vpc_router.rs | 18 ------------------ nexus/src/app/vpc_subnet.rs | 8 -------- 5 files changed, 15 insertions(+), 56 deletions(-) diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index 5aa288016e6..f5e31f30c52 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -800,7 +800,12 @@ has_permission(actor: AuthenticatedActor, "list_children", route: RouterRoute) i has_role(actor, "viewer", route.vpc_router.vpc.project); # Internet Gateway IP Pool attachments (project path: pool.internet_gateway.vpc.project) -# Note: create_child is already handled by InternetGateway "create_child" rule above +has_permission(actor: AuthenticatedActor, "create_child", pool: InternetGatewayIpPool) if + can_modify_networking_resource(actor, pool.internet_gateway.vpc.project); + +has_permission(actor: AuthenticatedActor, "modify", pool: InternetGatewayIpPool) if + can_modify_networking_resource(actor, pool.internet_gateway.vpc.project); + has_permission(actor: AuthenticatedActor, "delete", pool: InternetGatewayIpPool) if can_modify_networking_resource(actor, pool.internet_gateway.vpc.project); @@ -811,7 +816,12 @@ has_permission(actor: AuthenticatedActor, "list_children", pool: InternetGateway has_role(actor, "viewer", pool.internet_gateway.vpc.project); # Internet Gateway IP Address attachments (project path: addr.internet_gateway.vpc.project) -# Note: create_child is already handled by InternetGateway "create_child" rule above +has_permission(actor: AuthenticatedActor, "create_child", addr: InternetGatewayIpAddress) if + can_modify_networking_resource(actor, addr.internet_gateway.vpc.project); + +has_permission(actor: AuthenticatedActor, "modify", addr: InternetGatewayIpAddress) if + can_modify_networking_resource(actor, addr.internet_gateway.vpc.project); + has_permission(actor: AuthenticatedActor, "delete", addr: InternetGatewayIpAddress) if can_modify_networking_resource(actor, addr.internet_gateway.vpc.project); diff --git a/nexus/src/app/internet_gateway.rs b/nexus/src/app/internet_gateway.rs index a83a7ea12b5..1141aff7c50 100644 --- a/nexus/src/app/internet_gateway.rs +++ b/nexus/src/app/internet_gateway.rs @@ -71,9 +71,6 @@ impl super::Nexus { let (.., authz_vpc) = vpc_lookup.lookup_for(authz::Action::CreateChild).await?; - // Networking restrictions are enforced by Polar rules (InternetGateway create_child permission) - // self.check_networking_restrictions(opctx).await?; - let id = Uuid::new_v4(); let router = db::model::InternetGateway::new(id, authz_vpc.id(), params.clone()); @@ -117,9 +114,6 @@ impl super::Nexus { let (.., authz_vpc, authz_igw, _db_igw) = lookup.fetch_for(authz::Action::Delete).await?; - // Networking restrictions are enforced by Polar rules (InternetGateway delete permission) - // self.check_networking_restrictions(opctx).await?; - let out = self .db_datastore .vpc_delete_internet_gateway( @@ -208,9 +202,6 @@ impl super::Nexus { let (.., authz_igw, _) = lookup.fetch_for(authz::Action::CreateChild).await?; - // Networking restrictions are enforced by Polar rules (InternetGatewayIpPool create_child permission) - // self.check_networking_restrictions(opctx).await?; - // need to use this method so it works for non-fleet users let (authz_pool, ..) = self.silo_ip_pool_fetch(&opctx, ¶ms.ip_pool).await?; @@ -246,9 +237,6 @@ impl super::Nexus { let (.., authz_vpc, _authz_igw, authz_pool, db_pool) = lookup.fetch_for(authz::Action::Delete).await?; - // Networking restrictions are enforced by Polar rules (InternetGatewayIpPool delete permission) - // self.check_networking_restrictions(opctx).await?; - let (.., igw) = LookupPath::new(opctx, &self.db_datastore) .internet_gateway_id(db_pool.internet_gateway_id) .fetch() @@ -344,9 +332,6 @@ impl super::Nexus { let (.., authz_igw, _) = lookup.fetch_for(authz::Action::CreateChild).await?; - // Networking restrictions are enforced by Polar rules (InternetGatewayIpAddress create_child permission) - // self.check_networking_restrictions(opctx).await?; - let id = Uuid::new_v4(); let route = db::model::InternetGatewayIpAddress::new( id, @@ -377,9 +362,6 @@ impl super::Nexus { let (.., authz_vpc, _authz_igw, authz_addr, db_addr) = lookup.fetch_for(authz::Action::Delete).await?; - // Networking restrictions are enforced by Polar rules (InternetGatewayIpAddress delete permission) - // self.check_networking_restrictions(opctx).await?; - let (.., igw) = LookupPath::new(opctx, &self.db_datastore) .internet_gateway_id(db_addr.internet_gateway_id) .fetch() diff --git a/nexus/src/app/vpc.rs b/nexus/src/app/vpc.rs index 46642eabf52..cb63a5cf487 100644 --- a/nexus/src/app/vpc.rs +++ b/nexus/src/app/vpc.rs @@ -111,7 +111,9 @@ impl super::Nexus { project_lookup.lookup_for(authz::Action::CreateChild).await?; // Check networking restrictions: if the actor's silo restricts networking - // actions, only Silo Admins can create VPCs + // 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 { @@ -153,9 +155,6 @@ impl super::Nexus { let (.., authz_vpc) = vpc_lookup.lookup_for(authz::Action::Modify).await?; - // Networking restrictions are enforced by Polar rules (VPC modify permission) - // self.check_networking_restrictions(opctx).await?; - self.db_datastore .project_update_vpc(opctx, &authz_vpc, params.clone().into()) .await @@ -169,9 +168,6 @@ impl super::Nexus { let (.., authz_vpc, db_vpc) = vpc_lookup.fetch_for(authz::Action::Delete).await?; - // Networking restrictions are enforced by Polar rules (VPC delete permission) - // self.check_networking_restrictions(opctx).await?; - let authz_vpc_router = authz::VpcRouter::new( authz_vpc.clone(), db_vpc.system_router_id, @@ -226,9 +222,6 @@ impl super::Nexus { let (.., authz_vpc, db_vpc) = vpc_lookup.fetch_for(authz::Action::Modify).await?; - // Networking restrictions are enforced by Polar rules (VPC modify permission) - // self.check_networking_restrictions(opctx).await?; - let rules = db::model::VpcFirewallRule::vec_from_params( authz_vpc.id(), params.clone(), diff --git a/nexus/src/app/vpc_router.rs b/nexus/src/app/vpc_router.rs index 861b66c1862..fba34fbf450 100644 --- a/nexus/src/app/vpc_router.rs +++ b/nexus/src/app/vpc_router.rs @@ -115,9 +115,6 @@ impl super::Nexus { let (.., authz_vpc) = vpc_lookup.lookup_for(authz::Action::CreateChild).await?; - // Networking restrictions are enforced by Polar rules (VpcRouter create_child permission) - // self.check_networking_restrictions(opctx).await?; - let id = Uuid::new_v4(); let router = db::model::VpcRouter::new( id, @@ -160,9 +157,6 @@ impl super::Nexus { let (.., authz_router) = vpc_router_lookup.lookup_for(authz::Action::Modify).await?; - // Networking restrictions are enforced by Polar rules (VpcRouter modify permission) - // self.check_networking_restrictions(opctx).await?; - self.db_datastore .vpc_update_router(opctx, &authz_router, params.clone().into()) .await @@ -176,9 +170,6 @@ impl super::Nexus { let (.., authz_router, db_router) = vpc_router_lookup.fetch_for(authz::Action::Delete).await?; - // Networking restrictions are enforced by Polar rules (VpcRouter delete permission) - // self.check_networking_restrictions(opctx).await?; - // TODO-performance shouldn't this check be part of the "update" // database query? This shouldn't affect correctness, assuming that a // router kind cannot be changed, but it might be able to save us a @@ -247,9 +238,6 @@ impl super::Nexus { let (.., authz_router, db_router) = router_lookup.fetch_for(authz::Action::CreateChild).await?; - // Networking restrictions are enforced by Polar rules (RouterRoute create_child permission) - // self.check_networking_restrictions(opctx).await?; - if db_router.kind == VpcRouterKind::System { return Err(Error::invalid_request( "user-provided routes cannot be added to a system router", @@ -301,9 +289,6 @@ impl super::Nexus { let (.., authz_router, authz_route, db_route) = route_lookup.fetch_for(authz::Action::Modify).await?; - // Networking restrictions are enforced by Polar rules (RouterRoute modify permission) - // self.check_networking_restrictions(opctx).await?; - match db_route.kind.0 { // Default routes allow a constrained form of modification: // only the target may change. @@ -352,9 +337,6 @@ impl super::Nexus { let (.., authz_router, authz_route, db_route) = route_lookup.fetch_for(authz::Action::Delete).await?; - // Networking restrictions are enforced by Polar rules (RouterRoute delete permission) - // self.check_networking_restrictions(opctx).await?; - // Only custom routes can be deleted // TODO Shouldn't this constraint be checked by the database query? if db_route.kind.0 != RouterRouteKind::Custom { diff --git a/nexus/src/app/vpc_subnet.rs b/nexus/src/app/vpc_subnet.rs index 55c7613bb38..3dae453a2da 100644 --- a/nexus/src/app/vpc_subnet.rs +++ b/nexus/src/app/vpc_subnet.rs @@ -78,8 +78,6 @@ impl super::Nexus { .lookup_for(authz::Action::CreateChild) .await?; - // Networking restrictions are enforced by Polar rules (VpcSubnet create_child permission) - // self.check_networking_restrictions(opctx).await?; let custom_router = match ¶ms.custom_router { Some(k) => Some( self.vpc_router_lookup_for_attach(opctx, k, &authz_vpc).await?, @@ -192,9 +190,6 @@ impl super::Nexus { let (.., authz_vpc, authz_subnet) = vpc_subnet_lookup.lookup_for(authz::Action::Modify).await?; - // Networking restrictions are enforced by Polar rules (VpcSubnet modify permission) - // self.check_networking_restrictions(opctx).await?; - let custom_router = match ¶ms.custom_router { Some(k) => Some( self.vpc_router_lookup_for_attach(opctx, k, &authz_vpc).await?, @@ -235,9 +230,6 @@ impl super::Nexus { let (.., authz_vpc, authz_subnet, db_subnet) = vpc_subnet_lookup.fetch_for(authz::Action::Delete).await?; - // Networking restrictions are enforced by Polar rules (VpcSubnet delete permission) - // self.check_networking_restrictions(opctx).await?; - let saga_params = sagas::vpc_subnet_delete::Params { serialized_authn: authn::saga::Serialized::for_opctx(opctx), authz_vpc, From 0b826122d83334a99f048ff6d9314b028c62977c Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 23 Oct 2025 09:19:20 -0700 Subject: [PATCH 44/48] Add tests --- nexus/tests/integration_tests/vpcs.rs | 882 ++++++++++++++++++++++++++ 1 file changed, 882 insertions(+) diff --git a/nexus/tests/integration_tests/vpcs.rs b/nexus/tests/integration_tests/vpcs.rs index f703082a595..2ba9368cda8 100644 --- a/nexus/tests/integration_tests/vpcs.rs +++ b/nexus/tests/integration_tests/vpcs.rs @@ -1507,3 +1507,885 @@ async fn test_project_create_networking_restrictions( ); 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!"); +} From 79a849e8aeb5bd9080e5fbbf06702bb7c166e19d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 23 Oct 2025 09:33:49 -0700 Subject: [PATCH 45/48] cargo fmt --- nexus/tests/integration_tests/vpcs.rs | 187 ++++++++++++++++++-------- 1 file changed, 128 insertions(+), 59 deletions(-) diff --git a/nexus/tests/integration_tests/vpcs.rs b/nexus/tests/integration_tests/vpcs.rs index 2ba9368cda8..ad6d14612c7 100644 --- a/nexus/tests/integration_tests/vpcs.rs +++ b/nexus/tests/integration_tests/vpcs.rs @@ -1524,9 +1524,15 @@ impl PermissionTest { } fn print_table(tests: &[PermissionTest]) { - println!("\n┌─────────────────────────┬────────┬──────┬────────┬────────┐"); - println!("│ Role │ CREATE │ READ │ MODIFY │ DELETE │"); - println!("├─────────────────────────┼────────┼──────┼────────┼────────┤"); + println!( + "\n┌─────────────────────────┬────────┬──────┬────────┬────────┐" + ); + println!( + "│ Role │ CREATE │ READ │ MODIFY │ DELETE │" + ); + println!( + "├─────────────────────────┼────────┼──────┼────────┼────────┤" + ); for test in tests { println!( "│ {:<23} │ {} │ {} │ {} │ {} │", @@ -1537,7 +1543,9 @@ impl PermissionTest { if test.delete { "✓" } else { "✗" }, ); } - println!("└─────────────────────────┴────────┴──────┴────────┴────────┘"); + println!( + "└─────────────────────────┴────────┴──────┴────────┴────────┘" + ); } } @@ -1584,7 +1592,8 @@ async fn test_vpc_networking_permissions_unrestricted( quotas: params::SiloQuotasCreate::empty(), }; - let silo: views::Silo = object_create(&client, silo_url, &silo_params).await; + let silo: views::Silo = + object_create(&client, silo_url, &silo_params).await; // Create users with different roles let silo_admin = create_local_user( @@ -1692,10 +1701,11 @@ async fn test_vpc_networking_permissions_unrestricted( 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; + 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; // ======================================================================== @@ -1711,10 +1721,11 @@ async fn test_vpc_networking_permissions_unrestricted( 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; + 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; // ======================================================================== @@ -1730,17 +1741,19 @@ async fn test_vpc_networking_permissions_unrestricted( 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; + 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 collab_vpc_url = + format!("/v1/vpcs/collab-vpc?project={}", project_name); let vpc_update = params::VpcUpdate { identity: IdentityMetadataUpdateParams { name: None, @@ -1749,10 +1762,11 @@ async fn test_vpc_networking_permissions_unrestricted( 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; + 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; // ======================================================================== @@ -1768,17 +1782,19 @@ async fn test_vpc_networking_permissions_unrestricted( 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; + 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 proj_collab_vpc_url = + format!("/v1/vpcs/proj-collab-vpc?project={}", project_name); let vpc_update = params::VpcUpdate { identity: IdentityMetadataUpdateParams { name: None, @@ -1787,10 +1803,14 @@ async fn test_vpc_networking_permissions_unrestricted( 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; + 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; // ======================================================================== @@ -1927,14 +1947,34 @@ async fn test_vpc_networking_permissions_unrestricted( 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"); + 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!"); } @@ -1982,7 +2022,8 @@ async fn test_vpc_networking_permissions_restricted( quotas: params::SiloQuotasCreate::empty(), }; - let silo: views::Silo = object_create(&client, silo_url, &silo_params).await; + let silo: views::Silo = + object_create(&client, silo_url, &silo_params).await; // Create users with different roles let silo_admin = create_local_user( @@ -2090,10 +2131,11 @@ async fn test_vpc_networking_permissions_restricted( 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; + 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; // ======================================================================== @@ -2109,10 +2151,11 @@ async fn test_vpc_networking_permissions_restricted( 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; + 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; // ======================================================================== @@ -2212,7 +2255,9 @@ async fn test_vpc_networking_permissions_restricted( .await .expect("Project Collaborator should NOT be able to CREATE VPCs in restricted silo"); - println!("✓ Project Collaborator CANNOT CREATE VPCs (restricted by policy)"); + println!( + "✓ Project Collaborator CANNOT CREATE VPCs (restricted by policy)" + ); // ======================================================================== // TEST: Project Collaborator CAN READ VPCs (read is allowed) @@ -2246,7 +2291,9 @@ async fn test_vpc_networking_permissions_restricted( .await .expect("Project Collaborator should NOT be able to MODIFY VPCs in restricted silo"); - println!("✓ Project Collaborator CANNOT MODIFY VPCs (restricted by policy)"); + println!( + "✓ Project Collaborator CANNOT MODIFY VPCs (restricted by policy)" + ); // ======================================================================== // TEST: Project Collaborator CANNOT DELETE VPCs (restricted) @@ -2261,7 +2308,9 @@ async fn test_vpc_networking_permissions_restricted( .await .expect("Project Collaborator should NOT be able to DELETE VPCs in restricted silo"); - println!("✓ Project Collaborator CANNOT DELETE VPCs (restricted by policy)"); + println!( + "✓ Project Collaborator CANNOT DELETE VPCs (restricted by policy)" + ); // ======================================================================== // TEST: Project Viewer CAN READ VPCs @@ -2378,14 +2427,34 @@ async fn test_vpc_networking_permissions_restricted( 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"); + 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!"); } From 40fd6ba5b756f324e724524b34282064caf38759 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 23 Oct 2025 10:30:14 -0700 Subject: [PATCH 46/48] Fix compilation errors --- nexus/auth/src/authn/mod.rs | 2 +- nexus/auth/src/authz/api_resources.rs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/nexus/auth/src/authn/mod.rs b/nexus/auth/src/authn/mod.rs index 98e46233e64..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(), } diff --git a/nexus/auth/src/authz/api_resources.rs b/nexus/auth/src/authz/api_resources.rs index 2b861908e45..36ea25d6a18 100644 --- a/nexus/auth/src/authz/api_resources.rs +++ b/nexus/auth/src/authz/api_resources.rs @@ -40,7 +40,6 @@ use authz_macros::authz_resource; use futures::FutureExt; use futures::future::BoxFuture; use nexus_db_fixed_data::FLEET_ID; -use nexus_db_model; use nexus_types::external_api::shared::{FleetRole, ProjectRole, SiloRole}; use omicron_common::api::external::{Error, LookupType, ResourceType}; use oso::PolarClass; From c0ac662c2e9a3a1830c603be3f4b39ee917bf8bc Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 23 Oct 2025 12:17:14 -0700 Subject: [PATCH 47/48] Refactor Polar rules --- nexus/auth/src/authz/omicron.polar | 128 +++++++++-------------------- 1 file changed, 37 insertions(+), 91 deletions(-) diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index 1f730a84c4c..dc2924c426a 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -763,115 +763,61 @@ can_modify_networking_resource(actor: AuthenticatedActor, project: Project) if # 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()); -# Apply networking restrictions to all networking resources -# VPCs (project path: vpc.project) -has_permission(actor: AuthenticatedActor, "create_child", vpc: Vpc) if - can_modify_networking_resource(actor, vpc.project); - -has_permission(actor: AuthenticatedActor, "modify", vpc: Vpc) if - can_modify_networking_resource(actor, vpc.project); +# 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); -has_permission(actor: AuthenticatedActor, "delete", vpc: Vpc) if - can_modify_networking_resource(actor, vpc.project); +networking_read_perm(actor: AuthenticatedActor, action: String, project: Project) if + action in ["read", "list_children"] and + has_role(actor, "viewer", project); -has_permission(actor: AuthenticatedActor, "read", vpc: Vpc) if - has_role(actor, "viewer", vpc.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, "list_children", vpc: Vpc) if - has_role(actor, "viewer", 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, "create_child", router: VpcRouter) if - can_modify_networking_resource(actor, router.vpc.project); - -has_permission(actor: AuthenticatedActor, "modify", router: VpcRouter) if - can_modify_networking_resource(actor, router.vpc.project); +has_permission(actor: AuthenticatedActor, action: String, router: VpcRouter) if + networking_write_perm(actor, action, router.vpc.project); -has_permission(actor: AuthenticatedActor, "delete", router: VpcRouter) if - can_modify_networking_resource(actor, router.vpc.project); - -has_permission(actor: AuthenticatedActor, "read", router: VpcRouter) if - has_role(actor, "viewer", router.vpc.project); - -has_permission(actor: AuthenticatedActor, "list_children", router: VpcRouter) if - has_role(actor, "viewer", 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, "create_child", subnet: VpcSubnet) if - can_modify_networking_resource(actor, subnet.vpc.project); - -has_permission(actor: AuthenticatedActor, "modify", subnet: VpcSubnet) if - can_modify_networking_resource(actor, subnet.vpc.project); +has_permission(actor: AuthenticatedActor, action: String, subnet: VpcSubnet) if + networking_write_perm(actor, action, subnet.vpc.project); -has_permission(actor: AuthenticatedActor, "delete", subnet: VpcSubnet) if - can_modify_networking_resource(actor, subnet.vpc.project); - -has_permission(actor: AuthenticatedActor, "read", subnet: VpcSubnet) if - has_role(actor, "viewer", subnet.vpc.project); - -has_permission(actor: AuthenticatedActor, "list_children", subnet: VpcSubnet) if - has_role(actor, "viewer", 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, "create_child", gateway: InternetGateway) if - can_modify_networking_resource(actor, gateway.vpc.project); - -has_permission(actor: AuthenticatedActor, "modify", gateway: InternetGateway) if - can_modify_networking_resource(actor, gateway.vpc.project); +has_permission(actor: AuthenticatedActor, action: String, gateway: InternetGateway) if + networking_write_perm(actor, action, gateway.vpc.project); -has_permission(actor: AuthenticatedActor, "delete", gateway: InternetGateway) if - can_modify_networking_resource(actor, gateway.vpc.project); - -has_permission(actor: AuthenticatedActor, "read", gateway: InternetGateway) if - has_role(actor, "viewer", gateway.vpc.project); - -has_permission(actor: AuthenticatedActor, "list_children", gateway: InternetGateway) if - has_role(actor, "viewer", 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, "create_child", route: RouterRoute) if - can_modify_networking_resource(actor, route.vpc_router.vpc.project); - -has_permission(actor: AuthenticatedActor, "modify", route: RouterRoute) if - can_modify_networking_resource(actor, 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, "delete", route: RouterRoute) if - can_modify_networking_resource(actor, route.vpc_router.vpc.project); - -has_permission(actor: AuthenticatedActor, "read", route: RouterRoute) if - has_role(actor, "viewer", route.vpc_router.vpc.project); - -has_permission(actor: AuthenticatedActor, "list_children", route: RouterRoute) if - has_role(actor, "viewer", 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, "create_child", pool: InternetGatewayIpPool) if - can_modify_networking_resource(actor, pool.internet_gateway.vpc.project); - -has_permission(actor: AuthenticatedActor, "modify", pool: InternetGatewayIpPool) if - can_modify_networking_resource(actor, 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, "delete", pool: InternetGatewayIpPool) if - can_modify_networking_resource(actor, pool.internet_gateway.vpc.project); - -has_permission(actor: AuthenticatedActor, "read", pool: InternetGatewayIpPool) if - has_role(actor, "viewer", pool.internet_gateway.vpc.project); - -has_permission(actor: AuthenticatedActor, "list_children", pool: InternetGatewayIpPool) if - has_role(actor, "viewer", 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, "create_child", addr: InternetGatewayIpAddress) if - can_modify_networking_resource(actor, addr.internet_gateway.vpc.project); - -has_permission(actor: AuthenticatedActor, "modify", addr: InternetGatewayIpAddress) if - can_modify_networking_resource(actor, addr.internet_gateway.vpc.project); - -has_permission(actor: AuthenticatedActor, "delete", addr: InternetGatewayIpAddress) if - can_modify_networking_resource(actor, addr.internet_gateway.vpc.project); - -has_permission(actor: AuthenticatedActor, "read", addr: InternetGatewayIpAddress) if - has_role(actor, "viewer", 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, "list_children", addr: InternetGatewayIpAddress) if - has_role(actor, "viewer", addr.internet_gateway.vpc.project); +has_permission(actor: AuthenticatedActor, action: String, addr: InternetGatewayIpAddress) if + networking_read_perm(actor, action, addr.internet_gateway.vpc.project); From 134af5a009cc188428b755ee1c8de207142a7d0a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 23 Oct 2025 12:54:03 -0700 Subject: [PATCH 48/48] Remove empty lines --- nexus/src/app/internet_gateway.rs | 1 - nexus/src/app/vpc.rs | 2 -- nexus/src/app/vpc_router.rs | 3 --- nexus/src/app/vpc_subnet.rs | 1 - 4 files changed, 7 deletions(-) diff --git a/nexus/src/app/internet_gateway.rs b/nexus/src/app/internet_gateway.rs index 1141aff7c50..23bb9d3bcac 100644 --- a/nexus/src/app/internet_gateway.rs +++ b/nexus/src/app/internet_gateway.rs @@ -70,7 +70,6 @@ impl super::Nexus { ) -> CreateResult { let (.., authz_vpc) = vpc_lookup.lookup_for(authz::Action::CreateChild).await?; - let id = Uuid::new_v4(); let router = db::model::InternetGateway::new(id, authz_vpc.id(), params.clone()); diff --git a/nexus/src/app/vpc.rs b/nexus/src/app/vpc.rs index cb63a5cf487..ea155255a85 100644 --- a/nexus/src/app/vpc.rs +++ b/nexus/src/app/vpc.rs @@ -154,7 +154,6 @@ impl super::Nexus { ) -> UpdateResult { let (.., authz_vpc) = vpc_lookup.lookup_for(authz::Action::Modify).await?; - self.db_datastore .project_update_vpc(opctx, &authz_vpc, params.clone().into()) .await @@ -221,7 +220,6 @@ impl super::Nexus { ) -> UpdateResult> { let (.., authz_vpc, db_vpc) = vpc_lookup.fetch_for(authz::Action::Modify).await?; - let rules = db::model::VpcFirewallRule::vec_from_params( authz_vpc.id(), params.clone(), diff --git a/nexus/src/app/vpc_router.rs b/nexus/src/app/vpc_router.rs index fba34fbf450..b08de606b71 100644 --- a/nexus/src/app/vpc_router.rs +++ b/nexus/src/app/vpc_router.rs @@ -114,7 +114,6 @@ impl super::Nexus { ) -> CreateResult { let (.., authz_vpc) = vpc_lookup.lookup_for(authz::Action::CreateChild).await?; - let id = Uuid::new_v4(); let router = db::model::VpcRouter::new( id, @@ -156,7 +155,6 @@ impl super::Nexus { ) -> UpdateResult { let (.., authz_router) = vpc_router_lookup.lookup_for(authz::Action::Modify).await?; - self.db_datastore .vpc_update_router(opctx, &authz_router, params.clone().into()) .await @@ -169,7 +167,6 @@ impl super::Nexus { ) -> DeleteResult { let (.., authz_router, db_router) = vpc_router_lookup.fetch_for(authz::Action::Delete).await?; - // TODO-performance shouldn't this check be part of the "update" // database query? This shouldn't affect correctness, assuming that a // router kind cannot be changed, but it might be able to save us a diff --git a/nexus/src/app/vpc_subnet.rs b/nexus/src/app/vpc_subnet.rs index 3dae453a2da..f1c40c86ed7 100644 --- a/nexus/src/app/vpc_subnet.rs +++ b/nexus/src/app/vpc_subnet.rs @@ -77,7 +77,6 @@ impl super::Nexus { .vpc_router_id(db_vpc.system_router_id) .lookup_for(authz::Action::CreateChild) .await?; - let custom_router = match ¶ms.custom_router { Some(k) => Some( self.vpc_router_lookup_for_attach(opctx, k, &authz_vpc).await?,