From 1b7b3b941fdffd9308fc8d77c5c0a07ddf435b61 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 2 Jun 2025 22:50:35 -0500 Subject: [PATCH] add fleet and silo role to /v1/me response --- nexus/auth/src/authz/context.rs | 14 ++ nexus/auth/src/authz/roles.rs | 24 ++- nexus/auth/src/context.rs | 11 ++ nexus/src/external_api/http_entrypoints.rs | 25 ++- nexus/test-utils/src/resource_helpers.rs | 44 ++++- nexus/tests/integration_tests/console_api.rs | 170 ++++++++++++++++--- nexus/types/src/external_api/shared.rs | 1 + nexus/types/src/external_api/views.rs | 21 ++- openapi/nexus.json | 12 +- 9 files changed, 292 insertions(+), 30 deletions(-) diff --git a/nexus/auth/src/authz/context.rs b/nexus/auth/src/authz/context.rs index 20437b7f535..413da458a0e 100644 --- a/nexus/auth/src/authz/context.rs +++ b/nexus/auth/src/authz/context.rs @@ -149,6 +149,20 @@ impl Context { } } } + + /// Get the opctx actor's roles on a given resource + pub async fn get_roles( + &self, + opctx: &OpContext, + resource: Resource, + ) -> Result + where + Resource: AuthorizedResource + Clone, + { + let mut roles = RoleSet::new(); + resource.load_roles(opctx, &self.authn, &mut roles).await?; + Ok(roles) + } } pub trait AuthorizedResource: oso::ToPolar + Send + Sync + 'static { diff --git a/nexus/auth/src/authz/roles.rs b/nexus/auth/src/authz/roles.rs index 0716e05bc71..8e3bffbc6d2 100644 --- a/nexus/auth/src/authz/roles.rs +++ b/nexus/auth/src/authz/roles.rs @@ -49,7 +49,7 @@ use uuid::Uuid; /// For more on roles, see dbinit.rs. #[derive(Clone, Debug)] pub struct RoleSet { - roles: BTreeSet<(ResourceType, Uuid, String)>, + pub roles: BTreeSet<(ResourceType, Uuid, String)>, } impl RoleSet { @@ -82,6 +82,28 @@ impl RoleSet { String::from(role_name), )); } + + /// Effective, i.e., strongest, role in set for specified resource + pub fn effective_role( + &self, + resource_type: &ResourceType, + resource_id: &Uuid, + ) -> Option { + self.roles + .iter() + .filter(|(typ, id, _)| typ == resource_type && id == resource_id) + .max_by_key(|(_, _, role)| role_num(role)) + .map(|(_, _, role)| role.clone()) + } +} + +fn role_num(role: &str) -> u8 { + match role { + "viewer" => 1, + "collaborator" => 2, + "admin" => 3, + _ => 0, + } } pub async fn load_roles_for_resource_tree( diff --git a/nexus/auth/src/context.rs b/nexus/auth/src/context.rs index 5aab22b762e..ad8fdb9834e 100644 --- a/nexus/auth/src/context.rs +++ b/nexus/auth/src/context.rs @@ -8,6 +8,7 @@ use super::authz; use crate::authn::ConsoleSessionWithSiloId; use crate::authn::external::session_cookie::Session; use crate::authz::AuthorizedResource; +use crate::authz::RoleSet; use crate::storage::Storage; use chrono::{DateTime, Utc}; use omicron_common::api::external::Error; @@ -285,6 +286,16 @@ impl OpContext { result } + pub async fn get_roles( + &self, + resource: &Resource, + ) -> Result + where + Resource: AuthorizedResource + Debug + Clone, + { + self.authz.get_roles(self, resource.clone()).await + } + /// Returns an error if we're currently in a context where expensive or /// complex operations should not be allowed /// diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 9f31c067505..ccc339bf392 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -39,6 +39,7 @@ use dropshot::{WebsocketChannelResult, WebsocketConnection}; use dropshot::{http_response_found, http_response_see_other}; use http::{Response, StatusCode, header}; use ipnetwork::IpNetwork; +use nexus_auth::authz::{ApiResource, ApiResourceWithRoles}; use nexus_db_lookup::lookup::ImageLookup; use nexus_db_lookup::lookup::ImageParentLookup; use nexus_db_queries::authn::external::session_cookie::{self, SessionStore}; @@ -6942,11 +6943,31 @@ impl NexusExternalApi for NexusExternalApiImpl { let handler = async { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let user = nexus.silo_user_fetch_self(&opctx).await?; - let (_, silo) = nexus.current_silo_lookup(&opctx)?.fetch().await?; + let (authz_silo, db_silo) = + nexus.current_silo_lookup(&opctx)?.fetch().await?; + + let silo_roles = opctx.get_roles(&authz_silo).await; Ok(HttpResponseOk(views::CurrentUser { user: user.into(), - silo_name: silo.name().clone(), + silo_name: db_silo.name().clone(), + fleet_role: silo_roles.clone().map_or(None, |roleset| { + roleset.effective_role( + &authz::FLEET.resource_type(), + &authz::FLEET.resource_id(), + ) + }), + // TODO: one weird wrinkle here is that the polar policy gives + // every user read perms on their own silo, but that is not + // equivalent to viewer because it's not inherited, so we are + // probably fine leaving the no-role case as None + silo_role: silo_roles.map_or(None, |roleset| { + roleset.effective_role( + &authz_silo.resource_type(), + &authz_silo.resource_id(), + ) + }), })) }; apictx diff --git a/nexus/test-utils/src/resource_helpers.rs b/nexus/test-utils/src/resource_helpers.rs index 54c6228d3a1..9dbe4789e97 100644 --- a/nexus/test-utils/src/resource_helpers.rs +++ b/nexus/test-utils/src/resource_helpers.rs @@ -1106,11 +1106,8 @@ pub async fn grant_iam( let existing_policy: shared::Policy = NexusRequest::object_get(client, &policy_url) .authn_as(run_as.clone()) - .execute() - .await - .expect("failed to fetch policy") - .parsed_body() - .expect("failed to parse policy"); + .execute_and_parse_unwrap() + .await; let new_role_assignment = shared::RoleAssignment { identity_type: IdentityType::SiloUser, identity_id: grant_user, @@ -1132,6 +1129,43 @@ pub async fn grant_iam( .expect("failed to update policy"); } +/// Same as `grant_iam`, except that instead of adding a role, we are +/// filtering out any matching roles for the user on that resource +pub async fn revoke_iam( + client: &ClientTestContext, + grant_resource_url: &str, + grant_role: T, + grant_user: Uuid, + run_as: AuthnMode, +) where + T: serde::Serialize + serde::de::DeserializeOwned + PartialEq, +{ + let policy_url = format!("{}/policy", grant_resource_url); + let existing_policy: shared::Policy = + NexusRequest::object_get(client, &policy_url) + .authn_as(run_as.clone()) + .execute_and_parse_unwrap() + .await; + + let filtered_role_assignments = existing_policy + .role_assignments + .into_iter() + .filter(|ra| { + !(ra.identity_id == grant_user && ra.role_name == grant_role) + }) + .collect(); + + let new_policy = + shared::Policy { role_assignments: filtered_role_assignments }; + + // TODO-correctness use etag when we have it + NexusRequest::object_put(client, &policy_url, Some(&new_policy)) + .authn_as(run_as) + .execute() + .await + .expect("failed to update policy"); +} + pub async fn project_get( client: &ClientTestContext, project_url: &str, diff --git a/nexus/tests/integration_tests/console_api.rs b/nexus/tests/integration_tests/console_api.rs index 80800d95c58..57c5bc57fd2 100644 --- a/nexus/tests/integration_tests/console_api.rs +++ b/nexus/tests/integration_tests/console_api.rs @@ -8,7 +8,9 @@ use dropshot::ResultsPage; use dropshot::test_util::ClientTestContext; use http::header::HeaderName; use http::{StatusCode, header, method::Method}; +use nexus_auth::authz; use nexus_auth::context::OpContext; +use nexus_types::silo::DEFAULT_SILO_ID; use std::env::current_dir; use crate::integration_tests::saml::SAML_RESPONSE_IDP_DESCRIPTOR; @@ -20,18 +22,22 @@ use nexus_db_queries::db::identity::{Asset, Resource}; use nexus_test_utils::http_testing::{ AuthnMode, NexusRequest, RequestBuilder, TestResponse, }; -use nexus_test_utils::resource_helpers::test_params; use nexus_test_utils::resource_helpers::{ - create_silo, grant_iam, object_create, + create_silo, grant_iam, object_create, object_get, object_put, }; +use nexus_test_utils::resource_helpers::{revoke_iam, test_params}; use nexus_test_utils::{ TEST_SUITE_PASSWORD, load_test_config, test_setup_with_config, }; use nexus_test_utils_macros::nexus_test; use nexus_types::external_api::params::{self, ProjectCreate}; -use nexus_types::external_api::shared::{SiloIdentityMode, SiloRole}; +use nexus_types::external_api::shared::{ + FleetRole, IdentityType, SiloIdentityMode, SiloRole, +}; use nexus_types::external_api::{shared, views}; -use omicron_common::api::external::{Error, IdentityMetadataCreateParams}; +use omicron_common::api::external::{ + Error, IdentityMetadataCreateParams, LookupType, +}; use omicron_sled_agent::sim; use omicron_test_utils::dev::poll::{CondCheckError, wait_for_condition}; @@ -426,14 +432,7 @@ async fn test_session_me(cptestctx: &ControlPlaneTestContext) { .expect("failed to 401 on unauthed request"); // now make same request with auth - let priv_user = NexusRequest::object_get(testctx, "/v1/me") - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("failed to get current user") - .parsed_body::() - .unwrap(); - + let priv_user = get_priv_me(testctx).await; assert_eq!( priv_user, views::CurrentUser { @@ -442,15 +441,13 @@ async fn test_session_me(cptestctx: &ControlPlaneTestContext) { display_name: USER_TEST_PRIVILEGED.external_id.clone(), silo_id: DEFAULT_SILO.id(), }, - silo_name: DEFAULT_SILO.name().clone() + silo_name: DEFAULT_SILO.name().clone(), + fleet_role: Some("admin".to_string()), + silo_role: Some("admin".to_string()), } ); - let unpriv_user = NexusRequest::object_get(testctx, "/v1/me") - .authn_as(AuthnMode::UnprivilegedUser) - .execute_and_parse_unwrap::() - .await; - + let unpriv_user = get_unpriv_me(testctx).await; assert_eq!( unpriv_user, views::CurrentUser { @@ -459,9 +456,144 @@ async fn test_session_me(cptestctx: &ControlPlaneTestContext) { display_name: USER_TEST_UNPRIVILEGED.external_id.clone(), silo_id: DEFAULT_SILO.id(), }, - silo_name: DEFAULT_SILO.name().clone() + silo_name: DEFAULT_SILO.name().clone(), + fleet_role: None, + silo_role: None, } ); + + // add collab role to unpriv and check response again + let silo_url = format!("/v1/system/silos/{}", DEFAULT_SILO.identity().name); + grant_iam( + testctx, + &silo_url, + SiloRole::Collaborator, + USER_TEST_UNPRIVILEGED.id(), + AuthnMode::PrivilegedUser, + ) + .await; + + let unpriv_user = get_unpriv_me(testctx).await; + assert_eq!(unpriv_user.fleet_role, None); + assert_eq!(unpriv_user.silo_role, Some("collaborator".to_string())); + + // now add silo admin to the same user. they will now technically have + // both admin and collaborator on the silo, which means their effective role + // is admin + grant_iam( + testctx, + &silo_url, + SiloRole::Admin, + USER_TEST_UNPRIVILEGED.id(), + AuthnMode::PrivilegedUser, + ) + .await; + + // since we are picking the strongest role on the resource, it should now + // say admin instead of collaborator + let unpriv_user = get_unpriv_me(testctx).await; + assert_eq!(unpriv_user.silo_role, Some("admin".to_string())); + + // now add fleet viewer + grant_iam( + testctx, + "/v1/system", // fleet + FleetRole::Viewer, + USER_TEST_UNPRIVILEGED.id(), + AuthnMode::PrivilegedUser, + ) + .await; + + let unpriv_user = get_unpriv_me(testctx).await; + assert_eq!(unpriv_user.fleet_role, Some("viewer".to_string())); + assert_eq!(unpriv_user.silo_role, Some("admin".to_string())); + + // take admin back away and we should be back to collaborator + revoke_iam( + testctx, + &silo_url, + SiloRole::Admin, + USER_TEST_UNPRIVILEGED.id(), + AuthnMode::PrivilegedUser, + ) + .await; + + let unpriv_user = get_unpriv_me(testctx).await; + assert_eq!(unpriv_user.fleet_role, Some("viewer".to_string())); + assert_eq!(unpriv_user.silo_role, Some("collaborator".to_string())); + + // now let's make sure group memberships are reflected in effective role + let nexus = &cptestctx.server.server_context().nexus; + let log = cptestctx.logctx.log.new(o!()); + let opctx = OpContext::for_tests(log.new(o!()), nexus.datastore().clone()); + + let authz_silo = authz::Silo::new( + authz::FLEET, + DEFAULT_SILO_ID, + LookupType::ById(DEFAULT_SILO_ID), + ); + + let group_name = "group1".to_string(); + let group = nexus + .silo_group_lookup_or_create_by_name(&opctx, &authz_silo, &group_name) + .await + .expect("Group created"); + + // Now add unprivileged user to the group + let authz_silo_user = authz::SiloUser::new( + authz_silo, + USER_TEST_UNPRIVILEGED.id(), + LookupType::ById(USER_TEST_UNPRIVILEGED.id()), + ); + nexus + .datastore() + .silo_group_membership_replace_for_user( + &opctx, + &authz_silo_user, + vec![group.id()], + ) + .await + .expect("Failed to set user group memberships"); + + // expect no change in roles because the group has no role + let unpriv_user = get_unpriv_me(testctx).await; + assert_eq!(unpriv_user.fleet_role, Some("viewer".to_string())); + assert_eq!(unpriv_user.silo_role, Some("collaborator".to_string())); + + // assign admin role to the group + let silo_policy_url = format!("{}/policy", silo_url); + let existing_policy: shared::Policy = + object_get(testctx, &silo_policy_url).await; + let new_role_assignment = shared::RoleAssignment { + identity_type: IdentityType::SiloGroup, + identity_id: group.id(), + role_name: SiloRole::Admin, + }; + let mut new_role_assignments = existing_policy.role_assignments; + new_role_assignments.push(new_role_assignment); + + let new_policy = shared::Policy { role_assignments: new_role_assignments }; + let _: shared::Policy = + object_put(testctx, &silo_policy_url, &new_policy).await; + + // now the user should have admin from the group + let unpriv_user = get_unpriv_me(testctx).await; + assert_eq!(unpriv_user.fleet_role, Some("viewer".to_string())); + assert_eq!(unpriv_user.silo_role, Some("admin".to_string())); +} + +async fn get_unpriv_me(testctx: &ClientTestContext) -> views::CurrentUser { + NexusRequest::object_get(testctx, "/v1/me") + .authn_as(AuthnMode::UnprivilegedUser) + .execute_and_parse_unwrap() + .await +} + +async fn get_priv_me(testctx: &ClientTestContext) -> views::CurrentUser { + NexusRequest::object_get(testctx, "/v1/me") + .authn_as(AuthnMode::PrivilegedUser) + .execute_and_parse_unwrap() + .await } #[nexus_test] diff --git a/nexus/types/src/external_api/shared.rs b/nexus/types/src/external_api/shared.rs index 151633d52ba..db7e249e0e5 100644 --- a/nexus/types/src/external_api/shared.rs +++ b/nexus/types/src/external_api/shared.rs @@ -94,6 +94,7 @@ pub struct RoleAssignment { Deserialize, EnumIter, Eq, + FromStr, Ord, PartialEq, PartialOrd, diff --git a/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index 88c1d177ddb..2b14d66c08c 100644 --- a/nexus/types/src/external_api/views.rs +++ b/nexus/types/src/external_api/views.rs @@ -924,15 +924,32 @@ pub struct User { // SESSION -// Add silo name to User because the console needs to display it /// Info about the current user #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, JsonSchema)] pub struct CurrentUser { #[serde(flatten)] pub user: User, - /** Name of the silo to which this user belongs. */ + /// Name of the silo to which this user belongs pub silo_name: Name, + + /// This user's effective IAM role on the fleet resource. This determines + /// the user's level of access to system-level resources under the + /// `/v1/system/*` endpoints. + /// + /// If the user has multiple role assignments on a resource, for example, + /// an `admin` role assigned directly to the user and a `viewer` role + /// inherited through a group membership, the strongest role (`admin`) is + /// the effective role. A null value means no role is assigned. + pub fleet_role: Option, + /// This user's effective IAM role on their silo. This determines the user's + /// level of access to the silo itself and resources within it. + /// + /// If the user has multiple role assignments on a resource, for example, + /// an `admin` role assigned directly to the user and a `viewer` role + /// inherited through a group membership, the strongest role (`admin`) is + /// the effective role. A null value means no role is assigned. + pub silo_role: Option, } // SILO GROUPS diff --git a/openapi/nexus.json b/openapi/nexus.json index 216744207d5..6424158253f 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -16173,6 +16173,11 @@ "description": "Human-readable name that can identify the user", "type": "string" }, + "fleet_role": { + "nullable": true, + "description": "This user's effective IAM role on the fleet resource. This determines the user's level of access to system-level resources under the `/v1/system/*` endpoints.\n\nIf the user has multiple role assignments on a resource, for example, an `admin` role assigned directly to the user and a `viewer` role inherited through a group membership, the strongest role (`admin`) is the effective role. A null value means no role is assigned.", + "type": "string" + }, "id": { "type": "string", "format": "uuid" @@ -16183,12 +16188,17 @@ "format": "uuid" }, "silo_name": { - "description": "Name of the silo to which this user belongs.", + "description": "Name of the silo to which this user belongs", "allOf": [ { "$ref": "#/components/schemas/Name" } ] + }, + "silo_role": { + "nullable": true, + "description": "This user's effective IAM role on their silo. This determines the user's level of access to the silo itself and resources within it.\n\nIf the user has multiple role assignments on a resource, for example, an `admin` role assigned directly to the user and a `viewer` role inherited through a group membership, the strongest role (`admin`) is the effective role. A null value means no role is assigned.", + "type": "string" } }, "required": [