diff --git a/nexus/src/app/iam.rs b/nexus/src/app/iam.rs index a5ae495bacd..2e28c9ac03b 100644 --- a/nexus/src/app/iam.rs +++ b/nexus/src/app/iam.rs @@ -90,6 +90,13 @@ impl super::Nexus { Ok(db_silo_user) } + pub async fn silo_user_fetch_groups_for_self( + &self, + opctx: &OpContext, + ) -> ListResultVec { + self.db_datastore.silo_group_membership_for_self(opctx).await + } + // Silo groups pub async fn silo_groups_list( diff --git a/nexus/src/db/datastore/silo_group.rs b/nexus/src/db/datastore/silo_group.rs index 014a672a1a0..5d83d0af99e 100644 --- a/nexus/src/db/datastore/silo_group.rs +++ b/nexus/src/db/datastore/silo_group.rs @@ -23,6 +23,7 @@ use omicron_common::api::external::CreateResult; use omicron_common::api::external::DataPageParams; use omicron_common::api::external::DeleteResult; use omicron_common::api::external::Error; +use omicron_common::api::external::InternalContext; use omicron_common::api::external::ListResultVec; use omicron_common::api::external::LookupResult; use omicron_common::api::external::UpdateResult; @@ -105,6 +106,27 @@ impl DataStore { .map_err(|e| public_error_from_diesel_pool(e, ErrorHandler::Server)) } + pub async fn silo_group_membership_for_self( + &self, + opctx: &OpContext, + ) -> ListResultVec { + // Similar to session_hard_delete (see comment there), we do not do a + // typical authz check, instead effectively encoding the policy here + // that any user is allowed to fetch their own group memberships + let &actor = opctx + .authn + .actor_required() + .internal_context("fetching current user's group memberships")?; + + use db::schema::silo_group_membership::dsl; + dsl::silo_group_membership + .filter(dsl::silo_user_id.eq(actor.actor_id())) + .select(SiloGroupMembership::as_returning()) + .get_results_async(self.pool_authorized(opctx).await?) + .await + .map_err(|e| public_error_from_diesel_pool(e, ErrorHandler::Server)) + } + /// Update a silo user's group membership: /// /// - add the user to groups they are supposed to be a member of, and diff --git a/nexus/src/external_api/console_api.rs b/nexus/src/external_api/console_api.rs index ff35d570c28..1886626bce6 100644 --- a/nexus/src/external_api/console_api.rs +++ b/nexus/src/external_api/console_api.rs @@ -592,7 +592,7 @@ pub async fn login_begin( }] pub async fn session_me( rqctx: Arc>>, -) -> Result, HttpError> { +) -> Result, HttpError> { let apictx = rqctx.context(); let nexus = &apictx.nexus; let handler = async { @@ -601,7 +601,13 @@ pub async fn session_me( // not clear what the advantage would be. let opctx = OpContext::for_external_api(&rqctx).await?; let user = nexus.silo_user_fetch_self(&opctx).await?; - Ok(HttpResponseOk(user.into())) + let groups = nexus.silo_user_fetch_groups_for_self(&opctx).await?; + Ok(HttpResponseOk(views::SessionMe { + id: user.id(), + display_name: user.external_id, + silo_id: user.silo_id, + group_ids: groups.iter().map(|g| g.silo_group_id).collect(), + })) }; apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } diff --git a/nexus/tests/integration_tests/console_api.rs b/nexus/tests/integration_tests/console_api.rs index ad4908dcca0..42c93e7dafb 100644 --- a/nexus/tests/integration_tests/console_api.rs +++ b/nexus/tests/integration_tests/console_api.rs @@ -335,33 +335,34 @@ async fn test_session_me(cptestctx: &ControlPlaneTestContext) { .execute() .await .expect("failed to get current user") - .parsed_body::() + .parsed_body::() .unwrap(); assert_eq!( priv_user, - views::User { + views::SessionMe { id: USER_TEST_PRIVILEGED.id(), display_name: USER_TEST_PRIVILEGED.external_id.clone(), silo_id: DEFAULT_SILO.id(), + group_ids: vec![], } ); - // make sure it returns different things for different users let unpriv_user = NexusRequest::object_get(testctx, "/session/me") .authn_as(AuthnMode::UnprivilegedUser) .execute() .await .expect("failed to get current user") - .parsed_body::() + .parsed_body::() .unwrap(); assert_eq!( unpriv_user, - views::User { + views::SessionMe { id: USER_TEST_UNPRIVILEGED.id(), display_name: USER_TEST_UNPRIVILEGED.external_id.clone(), silo_id: DEFAULT_SILO.id(), + group_ids: vec![], } ); } diff --git a/nexus/tests/integration_tests/saml.rs b/nexus/tests/integration_tests/saml.rs index 55746338120..b3542e88767 100644 --- a/nexus/tests/integration_tests/saml.rs +++ b/nexus/tests/integration_tests/saml.rs @@ -2,6 +2,8 @@ // 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 std::fmt::Debug; + use nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder}; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_nexus::authn::silos::{ @@ -19,7 +21,9 @@ use nexus_test_utils::resource_helpers::{create_silo, object_create}; use nexus_test_utils::ControlPlaneTestContext; use nexus_test_utils_macros::nexus_test; +use dropshot::ResultsPage; use httptest::{matchers::*, responders::*, Expectation, Server}; +use uuid::Uuid; // Valid SAML IdP entity descriptor from https://en.wikipedia.org/wiki/SAML_metadata#Identity_provider_metadata // note: no signing keys @@ -959,7 +963,7 @@ async fn test_post_saml_response(cptestctx: &ControlPlaneTestContext) { signing_keypair: None, - group_attribute_name: None, + group_attribute_name: Some("groups".into()), }, ) .await; @@ -984,7 +988,7 @@ async fn test_post_saml_response(cptestctx: &ControlPlaneTestContext) { ) .raw_body(Some( serde_urlencoded::to_string(SamlLoginPost { - saml_response: base64::encode(SAML_RESPONSE), + saml_response: base64::encode(SAML_RESPONSE_WITH_GROUPS), relay_state: None, }) .unwrap(), @@ -1006,12 +1010,29 @@ async fn test_post_saml_response(cptestctx: &ControlPlaneTestContext) { .await .expect("expected success"); - let _session_user: views::User = NexusRequest::new( + let session_cookie_value = + result.headers["Set-Cookie"].to_str().unwrap().to_string(); + + let groups: ResultsPage = NexusRequest::new( + RequestBuilder::new(client, Method::GET, "/groups") + .header(http::header::COOKIE, session_cookie_value.clone()) + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .expect("expected success") + .parsed_body() + .unwrap(); + + let silo_group_names: Vec<&str> = + groups.items.iter().map(|g| g.display_name.as_str()).collect(); + let silo_group_ids: Vec = groups.items.iter().map(|g| g.id).collect(); + + assert_same_items(silo_group_names, vec!["SRE", "Admins"]); + + let session_me: views::SessionMe = NexusRequest::new( RequestBuilder::new(client, Method::GET, "/session/me") - .header( - http::header::COOKIE, - result.headers["Set-Cookie"].to_str().unwrap().to_string(), - ) + .header(http::header::COOKIE, session_cookie_value) .expect_status(Some(StatusCode::OK)), ) .execute() @@ -1019,6 +1040,17 @@ async fn test_post_saml_response(cptestctx: &ControlPlaneTestContext) { .expect("expected success") .parsed_body() .unwrap(); + + assert_eq!(session_me.display_name, "some@customer.com"); + assert_same_items(session_me.group_ids, silo_group_ids); +} + +/// Order-agnostic vec equality +fn assert_same_items(v1: Vec, v2: Vec) { + assert_eq!(v1.len(), v2.len(), "{:?} and {:?} don't match", v1, v2); + for item in v1.iter() { + assert!(v2.contains(item), "{:?} and {:?} don't match", v1, v2); + } } // Test correct SAML response with relay state diff --git a/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index c160a12f3db..e5e472d696c 100644 --- a/nexus/types/src/external_api/views.rs +++ b/nexus/types/src/external_api/views.rs @@ -322,6 +322,19 @@ pub struct User { pub silo_id: Uuid, } +/// Client view of a [`User`] and their groups +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, JsonSchema)] +pub struct SessionMe { + pub id: Uuid, + /** Human-readable name that can identify the user */ + pub display_name: String, + + /** Uuid of the silo to which this user belongs */ + pub silo_id: Uuid, + + pub group_ids: Vec, +} + // SILO GROUPS /// Client view of a [`Group`] diff --git a/openapi/nexus.json b/openapi/nexus.json index 9babf1f1f30..9363e9ad8e9 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -5202,7 +5202,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/User" + "$ref": "#/components/schemas/SessionMe" } } } @@ -11002,6 +11002,38 @@ "technical_contact_email" ] }, + "SessionMe": { + "description": "Client view of a [`User`] and their groups", + "type": "object", + "properties": { + "display_name": { + "description": "Human-readable name that can identify the user", + "type": "string" + }, + "group_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "id": { + "type": "string", + "format": "uuid" + }, + "silo_id": { + "description": "Uuid of the silo to which this user belongs", + "type": "string", + "format": "uuid" + } + }, + "required": [ + "display_name", + "group_ids", + "id", + "silo_id" + ] + }, "Silo": { "description": "Client view of a ['Silo']", "type": "object",