diff --git a/nexus/src/app/mod.rs b/nexus/src/app/mod.rs index 50b1db65356..1ad0a3f34de 100644 --- a/nexus/src/app/mod.rs +++ b/nexus/src/app/mod.rs @@ -454,6 +454,16 @@ impl Nexus { let mid = self.samael_max_issue_delay.lock().unwrap(); *mid } + + // Convenience function that exists solely because writing + // LookupPath::new(&opctx, &nexus.datastore()) in an endpoint handler feels + // like too much + pub fn db_lookup<'a>( + &'a self, + opctx: &'a OpContext, + ) -> db::lookup::LookupPath { + db::lookup::LookupPath::new(opctx, &self.db_datastore) + } } /// For unimplemented endpoints, indicates whether the resource identified diff --git a/nexus/src/app/silo.rs b/nexus/src/app/silo.rs index ddf08dfc7ef..52fe72ec8b9 100644 --- a/nexus/src/app/silo.rs +++ b/nexus/src/app/silo.rs @@ -80,12 +80,10 @@ impl super::Nexus { pub async fn silo_fetch_policy( &self, opctx: &OpContext, - silo_name: &Name, + silo_lookup: db::lookup::Silo<'_>, ) -> LookupResult> { - let (.., authz_silo) = LookupPath::new(opctx, &self.db_datastore) - .silo_name(silo_name) - .lookup_for(authz::Action::ReadPolicy) - .await?; + let (.., authz_silo) = + silo_lookup.lookup_for(authz::Action::ReadPolicy).await?; let role_assignments = self .db_datastore .role_assignment_fetch_visible(opctx, &authz_silo) @@ -100,13 +98,11 @@ impl super::Nexus { pub async fn silo_update_policy( &self, opctx: &OpContext, - silo_name: &Name, + silo_lookup: db::lookup::Silo<'_>, policy: &shared::Policy, ) -> UpdateResult> { - let (.., authz_silo) = LookupPath::new(opctx, &self.db_datastore) - .silo_name(silo_name) - .lookup_for(authz::Action::ModifyPolicy) - .await?; + let (.., authz_silo) = + silo_lookup.lookup_for(authz::Action::ModifyPolicy).await?; let role_assignments = self .db_datastore diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 2b0a6af814a..caabbf9f2ed 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -80,6 +80,9 @@ type NexusApiDescription = ApiDescription>; /// Returns a description of the external nexus API pub fn external_api() -> NexusApiDescription { fn register_endpoints(api: &mut NexusApiDescription) -> Result<(), String> { + api.register(global_policy_view)?; + api.register(global_policy_update)?; + api.register(policy_view)?; api.register(policy_update)?; @@ -316,10 +319,10 @@ pub fn external_api() -> NexusApiDescription { /// Fetch the top-level IAM policy #[endpoint { method = GET, - path = "/policy", + path = "/global/policy", tags = ["policy"], }] -async fn policy_view( +async fn global_policy_view( rqctx: Arc>>, ) -> Result>, HttpError> { let apictx = rqctx.context(); @@ -342,10 +345,10 @@ struct ByIdPathParams { /// Update the top-level IAM policy #[endpoint { method = PUT, - path = "/policy", + path = "/global/policy", tags = ["policy"], }] -async fn policy_update( +async fn global_policy_update( rqctx: Arc>>, new_policy: TypedBody>, ) -> Result>, HttpError> { @@ -364,6 +367,62 @@ async fn policy_update( apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } +/// Fetch the current silo's IAM policy +#[endpoint { + method = GET, + path = "/policy", + tags = ["silos"], + }] +pub async fn policy_view( + rqctx: Arc>>, +) -> Result>, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let authz_silo = opctx + .authn + .silo_required() + .internal_context("loading current silo")?; + + let lookup = nexus.db_lookup(&opctx).silo_id(authz_silo.id()); + let policy = nexus.silo_fetch_policy(&opctx, lookup).await?; + Ok(HttpResponseOk(policy)) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Update the current silo's IAM policy +#[endpoint { + method = PUT, + path = "/policy", + tags = ["silos"], +}] +async fn policy_update( + rqctx: Arc>>, + new_policy: TypedBody>, +) -> Result>, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let new_policy = new_policy.into_inner(); + + let handler = async { + let nasgns = new_policy.role_assignments.len(); + // This should have been validated during parsing. + bail_unless!(nasgns <= shared::MAX_ROLE_ASSIGNMENTS_PER_RESOURCE); + let opctx = OpContext::for_external_api(&rqctx).await?; + let authz_silo = opctx + .authn + .silo_required() + .internal_context("loading current silo")?; + let lookup = nexus.db_lookup(&opctx).silo_id(authz_silo.id()); + let policy = + nexus.silo_update_policy(&opctx, lookup, &new_policy).await?; + Ok(HttpResponseOk(policy)) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + /// List silos /// /// Lists silos that are discoverable based on the current permissions. @@ -502,7 +561,8 @@ async fn silo_policy_view( let handler = async { let opctx = OpContext::for_external_api(&rqctx).await?; - let policy = nexus.silo_fetch_policy(&opctx, silo_name).await?; + let lookup = nexus.db_lookup(&opctx).silo_name(silo_name); + let policy = nexus.silo_fetch_policy(&opctx, lookup).await?; Ok(HttpResponseOk(policy)) }; apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await @@ -530,8 +590,9 @@ async fn silo_policy_update( // This should have been validated during parsing. bail_unless!(nasgns <= shared::MAX_ROLE_ASSIGNMENTS_PER_RESOURCE); let opctx = OpContext::for_external_api(&rqctx).await?; + let lookup = nexus.db_lookup(&opctx).silo_name(silo_name); let policy = - nexus.silo_update_policy(&opctx, silo_name, &new_policy).await?; + nexus.silo_update_policy(&opctx, lookup, &new_policy).await?; Ok(HttpResponseOk(policy)) }; apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 08a2e17100c..2c9924c693c 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -40,7 +40,7 @@ lazy_static! { format!("/hardware/sleds/{}", SLED_AGENT_UUID); // Global policy - pub static ref POLICY_URL: &'static str = "/policy"; + pub static ref GLOBAL_POLICY_URL: &'static str = "/global/policy"; // Silo used for testing pub static ref DEMO_SILO_NAME: Name = "demo-silo".parse().unwrap(); @@ -520,7 +520,7 @@ lazy_static! { pub static ref VERIFY_ENDPOINTS: Vec = vec![ // Global IAM policy VerifyEndpoint { - url: *POLICY_URL, + url: *GLOBAL_POLICY_URL, visibility: Visibility::Public, unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ @@ -676,6 +676,21 @@ lazy_static! { ), ], }, + VerifyEndpoint { + url: "/policy", + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::ReadOnly, + allowed_methods: vec![ + AllowedMethod::Get, + AllowedMethod::Put( + serde_json::to_value( + &shared::Policy:: { + role_assignments: vec![] + } + ).unwrap() + ), + ], + }, VerifyEndpoint { url: "/users", diff --git a/nexus/tests/integration_tests/role_assignments.rs b/nexus/tests/integration_tests/role_assignments.rs index 1c59cb91ed2..9db99c25111 100644 --- a/nexus/tests/integration_tests/role_assignments.rs +++ b/nexus/tests/integration_tests/role_assignments.rs @@ -108,7 +108,7 @@ async fn test_role_assignments_fleet(cptestctx: &ControlPlaneTestContext) { const ROLE: Self::RoleType = authz::FleetRole::Admin; const VISIBLE_TO_UNPRIVILEGED: bool = true; fn policy_url(&self) -> String { - String::from("/policy") + String::from("/global/policy") } fn verify_initial<'a, 'b, 'c, 'd>( @@ -218,6 +218,59 @@ async fn test_role_assignments_silo(cptestctx: &ControlPlaneTestContext) { run_test(client, SiloRoleAssignmentTest {}).await; } +// same as above except for /policy, where silo is implicit in auth +#[nexus_test] +async fn test_role_assignments_silo_implicit( + cptestctx: &ControlPlaneTestContext, +) { + struct SiloRoleAssignmentTest; + impl RoleAssignmentTest for SiloRoleAssignmentTest { + type RoleType = authz::SiloRole; + const ROLE: Self::RoleType = authz::SiloRole::Admin; + const VISIBLE_TO_UNPRIVILEGED: bool = true; + fn policy_url(&self) -> String { + "/policy".to_string() + } + + fn verify_initial<'a, 'b, 'c, 'd>( + &'a self, + _: &'b ClientTestContext, + _current_policy: &'c shared::Policy, + ) -> BoxFuture<'d, ()> + where + 'a: 'd, + 'b: 'd, + 'c: 'd, + { + async { + // TODO-coverage TODO-security There is currently nothing that + // requires the ability to modify a Silo. Once there is, we + // should test it here. + } + .boxed() + } + + fn verify_privileged<'a, 'b, 'c>( + &'a self, + _: &'b ClientTestContext, + ) -> BoxFuture<'c, ()> + where + 'a: 'c, + 'b: 'c, + { + async { + // TODO-coverage TODO-security There is currently nothing that + // requires the ability to modify a Silo. Once there is, we + // should test it here. + } + .boxed() + } + } + + let client = &cptestctx.external_client; + run_test(client, SiloRoleAssignmentTest {}).await; +} + #[nexus_test] async fn test_role_assignments_organization( cptestctx: &ControlPlaneTestContext, diff --git a/nexus/tests/output/nexus_tags.txt b/nexus/tests/output/nexus_tags.txt index f670745a411..59e62d7bb80 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -99,8 +99,8 @@ organization_view_by_id /by-id/organizations/{id} API operations found with tag "policy" OPERATION ID URL PATH -policy_update /policy -policy_view /policy +global_policy_update /global/policy +global_policy_view /global/policy API operations found with tag "projects" OPERATION ID URL PATH @@ -132,6 +132,8 @@ session_sshkey_view /session/me/sshkeys/{ssh_key_name} API operations found with tag "silos" OPERATION ID URL PATH +policy_update /policy +policy_view /policy silo_create /silos silo_delete /silos/{silo_name} silo_identity_provider_create /silos/{silo_name}/saml-identity-providers diff --git a/openapi/nexus.json b/openapi/nexus.json index e1c11b06733..74366ef974f 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -569,6 +569,68 @@ } } }, + "/global/policy": { + "get": { + "tags": [ + "policy" + ], + "summary": "Fetch the top-level IAM policy", + "operationId": "global_policy_view", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FleetRolePolicy" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "tags": [ + "policy" + ], + "summary": "Update the top-level IAM policy", + "operationId": "global_policy_update", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FleetRolePolicy" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FleetRolePolicy" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/hardware/racks": { "get": { "tags": [ @@ -5912,9 +5974,9 @@ "/policy": { "get": { "tags": [ - "policy" + "silos" ], - "summary": "Fetch the top-level IAM policy", + "summary": "Fetch the current silo's IAM policy", "operationId": "policy_view", "responses": { "200": { @@ -5922,7 +5984,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FleetRolePolicy" + "$ref": "#/components/schemas/SiloRolePolicy" } } } @@ -5937,15 +5999,15 @@ }, "put": { "tags": [ - "policy" + "silos" ], - "summary": "Update the top-level IAM policy", + "summary": "Update the current silo's IAM policy", "operationId": "policy_update", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FleetRolePolicy" + "$ref": "#/components/schemas/SiloRolePolicy" } } }, @@ -5957,7 +6019,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FleetRolePolicy" + "$ref": "#/components/schemas/SiloRolePolicy" } } }