diff --git a/nexus/src/app/silo.rs b/nexus/src/app/silo.rs index 8c8a57fcd94..d732a47d8b1 100644 --- a/nexus/src/app/silo.rs +++ b/nexus/src/app/silo.rs @@ -806,4 +806,12 @@ impl super::Nexus { .await?; Ok(saml_identity_provider) } + + pub fn silo_group_lookup<'a>( + &'a self, + opctx: &'a OpContext, + group_id: &'a Uuid, + ) -> db::lookup::SiloGroup<'a> { + LookupPath::new(opctx, &self.db_datastore).silo_group_id(*group_id) + } } diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 108163b6998..1e3a9c9d5df 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -363,6 +363,7 @@ pub fn external_api() -> NexusApiDescription { api.register(silo_user_view)?; api.register(group_list)?; api.register(group_list_v1)?; + api.register(group_view)?; // Console API operations api.register(console_api::login_begin)?; @@ -7562,6 +7563,28 @@ async fn group_list_v1( apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } +/// Fetch group +#[endpoint { + method = GET, + path = "/v1/groups/{group}", + tags = ["silos"], +}] +async fn group_view( + rqctx: RequestContext>, + path_params: Path, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let opctx = OpContext::for_external_api(&rqctx).await?; + let (.., group) = + nexus.silo_group_lookup(&opctx, &path.group).fetch().await?; + Ok(HttpResponseOk(group.into())) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + // Built-in (system) users /// List built-in users diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 91922393dcd..4a36fed08c4 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -809,6 +809,16 @@ lazy_static! { ], }, + VerifyEndpoint { + // non-existent UUID that will 404 + url: "/v1/groups/8d90b9a5-1cea-4a2b-9af4-71467dd33a04", + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::ReadOnly, + allowed_methods: vec![ + AllowedMethod::GetNonexistent, + ], + }, + VerifyEndpoint { url: &DEMO_SILO_USERS_LIST_URL, visibility: Visibility::Public, diff --git a/nexus/tests/integration_tests/silo_users.rs b/nexus/tests/integration_tests/silo_users.rs index 3d4590e8d6a..4c85b9fb370 100644 --- a/nexus/tests/integration_tests/silo_users.rs +++ b/nexus/tests/integration_tests/silo_users.rs @@ -47,25 +47,31 @@ async fn test_silo_group_users(cptestctx: &ControlPlaneTestContext) { authz::Silo::new(authz::FLEET, *SILO_ID, LookupType::ById(*SILO_ID)); // create a group - let group = nexus - .silo_group_lookup_or_create_by_name( - &opctx, - &authz_silo, - &"group1".to_string(), - ) + let group_name = "group1".to_string(); + nexus + .silo_group_lookup_or_create_by_name(&opctx, &authz_silo, &group_name) .await - .unwrap(); + .expect("Group created"); // now we have a group let groups = objects_list_page_authz::(client, &"/v1/groups").await; - let group_names: Vec<&str> = - groups.items.iter().map(|g| g.display_name.as_str()).collect(); - assert_same_items(group_names, vec!["group1"]); + assert_eq!(groups.items.len(), 1); - let group_users_url = format!("/v1/users?group={}", group.id()); + let group = groups.items.get(0).unwrap(); + assert_eq!(group.display_name, group_name); - // we can now fetch the group by ID and get an empty list of users + // we can fetch that group by ID + let group_url = format!("/v1/groups/{}", group.id); + let group = NexusRequest::object_get(&client, &group_url) + .authn_as(AuthnMode::PrivilegedUser) + .execute_and_parse_unwrap::() + .await; + assert_eq!(group.display_name, group_name); + + let group_users_url = format!("/v1/users?group={}", group.id); + + // we can now fetch the group's user list and get an empty list of users let group_users = objects_list_page_authz::(client, &group_users_url).await; @@ -84,7 +90,7 @@ async fn test_silo_group_users(cptestctx: &ControlPlaneTestContext) { .silo_group_membership_replace_for_user( &opctx, &authz_silo_user, - vec![group.id()], + vec![group.id], ) .await .expect("Failed to set user group memberships"); @@ -114,6 +120,20 @@ async fn test_silo_group_users_bad_group_id( expect_failure(&client, &"/v1/users?group=", StatusCode::BAD_REQUEST).await; } +#[nexus_test] +async fn test_silo_group_detail_bad_group_id( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + // 404 on UUID that doesn't exist + let nonexistent_group = format!("/v1/groups/{}", Uuid::new_v4()); + expect_failure(&client, &nonexistent_group, StatusCode::NOT_FOUND).await; + + // 400 on non-UUID identifier + expect_failure(&client, &"/v1/groups/abc", StatusCode::BAD_REQUEST).await; +} + async fn expect_failure( client: &ClientTestContext, url: &str, diff --git a/nexus/tests/output/nexus_tags.txt b/nexus/tests/output/nexus_tags.txt index a814170f6c0..c1157b31f00 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -140,6 +140,7 @@ API operations found with tag "silos" OPERATION ID URL PATH group_list /groups group_list_v1 /v1/groups +group_view /v1/groups/{group} policy_update /policy policy_update_v1 /v1/policy policy_view /policy diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index db044e8fdf6..db1d50413ba 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -71,6 +71,15 @@ pub struct SnapshotPath { pub snapshot: NameOrId, } +// Only by ID because groups have an `external_id` instead of a name and +// therefore don't implement `ObjectIdentity`, which makes lookup by name +// inconvenient. We should figure this out more generally, as there are several +// resources like this. +#[derive(Deserialize, JsonSchema)] +pub struct GroupPath { + pub group: Uuid, +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] pub struct OrganizationSelector { pub organization: NameOrId, diff --git a/openapi/nexus.json b/openapi/nexus.json index b3764973b6c..8d38eeabe4c 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -8066,6 +8066,44 @@ "x-dropshot-pagination": true } }, + "/v1/groups/{group}": { + "get": { + "tags": [ + "silos" + ], + "summary": "Fetch group", + "operationId": "group_view", + "parameters": [ + { + "in": "path", + "name": "group", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Group" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/v1/instances": { "get": { "tags": [