From dbb6937f12ed3ccd4c91688e2e4572b22ab1e0ca Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 27 Jun 2025 13:23:35 -0500 Subject: [PATCH 01/14] endpoint for silo admin to expire all user's tokens and sessions --- .../src/db/datastore/console_session.rs | 21 ++++++++++++ .../src/db/datastore/device_auth.rs | 19 +++++++++++ nexus/external-api/output/nexus_tags.txt | 1 + nexus/external-api/src/lib.rs | 11 +++++++ nexus/src/app/silo.rs | 22 +++++++++++++ nexus/src/external_api/http_entrypoints.rs | 20 ++++++++++++ nexus/tests/integration_tests/endpoints.rs | 8 +++++ nexus/types/src/external_api/params.rs | 1 + openapi/nexus.json | 32 +++++++++++++++++++ 9 files changed, 135 insertions(+) diff --git a/nexus/db-queries/src/db/datastore/console_session.rs b/nexus/db-queries/src/db/datastore/console_session.rs index 961566916b7..a11644e7dfc 100644 --- a/nexus/db-queries/src/db/datastore/console_session.rs +++ b/nexus/db-queries/src/db/datastore/console_session.rs @@ -12,6 +12,8 @@ use crate::db::model::ConsoleSession; use async_bb8_diesel::AsyncRunQueryDsl; use chrono::Utc; use diesel::prelude::*; +use nexus_db_errors::ErrorHandler; +use nexus_db_errors::public_error_from_diesel; use nexus_db_lookup::LookupPath; use nexus_db_schema::schema::console_session; use omicron_common::api::external::CreateResult; @@ -154,4 +156,23 @@ impl DataStore { )) }) } + + /// Delete all session for the user + pub async fn silo_user_sessions_delete( + &self, + opctx: &OpContext, + user: &authz::SiloUser, + ) -> Result<(), Error> { + // TODO: check for silo admin on opctx + // TODO: ensure this can only be used in current silo + // TODO: think about dueling admins problem + + use nexus_db_schema::schema::console_session; + diesel::delete(console_session::table) + .filter(console_session::silo_user_id.eq(user.id())) + .execute_async(&*self.pool_connection_authorized(opctx).await?) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) + .map(|_x| ()) + } } diff --git a/nexus/db-queries/src/db/datastore/device_auth.rs b/nexus/db-queries/src/db/datastore/device_auth.rs index e81ea997d2b..2bf26240ac4 100644 --- a/nexus/db-queries/src/db/datastore/device_auth.rs +++ b/nexus/db-queries/src/db/datastore/device_auth.rs @@ -241,4 +241,23 @@ impl DataStore { Ok(()) } + + /// Delete all tokens for the user + pub async fn silo_user_tokens_delete( + &self, + opctx: &OpContext, + user: &authz::SiloUser, + ) -> Result<(), Error> { + // TODO: check for silo admin on opctx + // TODO: ensure this can only be used in current silo + // TODO: think about dueling admins problem + + use nexus_db_schema::schema::device_access_token; + diesel::delete(device_access_token::table) + .filter(device_access_token::silo_user_id.eq(user.id())) + .execute_async(&*self.pool_connection_authorized(opctx).await?) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) + .map(|_x| ()) + } } diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index c7def7557d3..147ee71256f 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -157,6 +157,7 @@ group_view GET /v1/groups/{group_id} policy_update PUT /v1/policy policy_view GET /v1/policy user_list GET /v1/users +user_logout POST /v1/users/{user_id}/logout utilization_view GET /v1/utilization API operations found with tag "snapshots" diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 4361ea4ed38..44d0810d647 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -3076,6 +3076,17 @@ pub trait NexusExternalApi { query_params: Query>, ) -> Result>, HttpError>; + /// Expire all of user's tokens and sessions + #[endpoint { + method = POST, + path = "/v1/users/{user_id}/logout", + tags = ["silos"], + }] + async fn user_logout( + rqctx: RequestContext, + path_params: Path, + ) -> Result; + // Silo groups /// List groups diff --git a/nexus/src/app/silo.rs b/nexus/src/app/silo.rs index e41afde1ba7..af178717d24 100644 --- a/nexus/src/app/silo.rs +++ b/nexus/src/app/silo.rs @@ -313,6 +313,28 @@ impl super::Nexus { Ok(db_silo_user) } + /// Fetch a user in a Silo + pub(crate) async fn current_silo_user_logout( + &self, + opctx: &OpContext, + silo_user_id: Uuid, + ) -> UpdateResult<()> { + let (_, authz_silo_user, _) = LookupPath::new(opctx, self.datastore()) + .silo_user_id(silo_user_id) + .fetch() + .await?; + + self.datastore() + .silo_user_tokens_delete(opctx, &authz_silo_user) + .await?; + + self.datastore() + .silo_user_sessions_delete(opctx, &authz_silo_user) + .await?; + + Ok(()) + } + // The "local" identity provider (available only in `LocalOnly` Silos) /// Helper function for looking up a LocalOnly Silo by name diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index d2ba100882a..12009335c85 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -6860,6 +6860,26 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } + async fn user_logout( + rqctx: RequestContext, + path_params: Path, + ) -> Result { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.context.nexus; + let path = path_params.into_inner(); + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; + nexus.current_silo_user_logout(&opctx, path.user_id).await?; + Ok(HttpResponseUpdatedNoContent()) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + // Silo groups async fn group_list( diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 0718f1c36fe..9f2ed191d93 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -1675,6 +1675,14 @@ pub static VERIFY_ENDPOINTS: LazyLock> = unprivileged_access: UnprivilegedAccess::ReadOnly, allowed_methods: vec![AllowedMethod::Get], }, + VerifyEndpoint { + url: "/v1/users/af47bf12-2eab-4892-8a2f-c064a812c884/logout", + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![AllowedMethod::Post(serde_json::json!( + {} + ))], + }, VerifyEndpoint { url: "/v1/groups", visibility: Visibility::Public, diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index 05e1adc30aa..9db746cecd3 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -96,6 +96,7 @@ path_param!(CertificatePath, certificate, "certificate"); id_path_param!(SupportBundlePath, bundle_id, "support bundle"); id_path_param!(GroupPath, group_id, "group"); +id_path_param!(UserPath, user_id, "user"); id_path_param!(TokenPath, token_id, "token"); id_path_param!(TufTrustRootPath, trust_root_id, "trust root"); diff --git a/openapi/nexus.json b/openapi/nexus.json index 6584f45bb5b..75ac7f965d4 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -11577,6 +11577,38 @@ } } }, + "/v1/users/{user_id}/logout": { + "post": { + "tags": [ + "silos" + ], + "summary": "Expire all of user's tokens and sessions", + "operationId": "user_logout", + "parameters": [ + { + "in": "path", + "name": "user_id", + "description": "ID of the user", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/v1/utilization": { "get": { "tags": [ From b00d8ba2717369adffe3f925cd3531d180a06d73 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 1 Jul 2025 16:57:54 -0500 Subject: [PATCH 02/14] basic authz allowing silo admin and self to hit logout endpoint --- nexus/auth/src/authz/api_resources.rs | 52 +++++++ nexus/auth/src/authz/omicron.polar | 15 ++ nexus/auth/src/authz/oso_generic.rs | 1 + nexus/src/app/silo.rs | 5 + nexus/tests/integration_tests/device_auth.rs | 149 ++++++++++++++++++- 5 files changed, 215 insertions(+), 7 deletions(-) diff --git a/nexus/auth/src/authz/api_resources.rs b/nexus/auth/src/authz/api_resources.rs index 52bcc0049e7..6ed499e4e7e 100644 --- a/nexus/auth/src/authz/api_resources.rs +++ b/nexus/auth/src/authz/api_resources.rs @@ -668,6 +668,58 @@ impl AuthorizedResource for SiloUserList { } } +// TODO: does it make sense to use a single authz resource to represent +// both user sessions and tokens? seems silly to have two identical ones + +/// Synthetic resource for managing a user's sessions and tokens +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserSessions(SiloUser); + +impl UserSessions { + pub fn new(silo_user: SiloUser) -> Self { + Self(silo_user) + } + + pub fn silo_user(&self) -> &SiloUser { + &self.0 + } +} + +impl oso::PolarClass for UserSessions { + fn get_polar_class_builder() -> oso::ClassBuilder { + oso::Class::builder().with_equality_check().add_attribute_getter( + "silo_user", + |user_sessions: &UserSessions| user_sessions.silo_user().clone(), + ) + } +} + +impl AuthorizedResource for UserSessions { + fn load_roles<'fut>( + &'fut self, + opctx: &'fut OpContext, + authn: &'fut authn::Context, + roleset: &'fut mut RoleSet, + ) -> futures::future::BoxFuture<'fut, Result<(), Error>> { + // To check for silo admin, we need to load roles from the parent silo. + self.silo_user().parent.load_roles(opctx, authn, roleset) + } + + fn on_unauthorized( + &self, + _: &Authz, + error: Error, + _: AnyActor, + _: Action, + ) -> Error { + error + } + + fn polar_class(&self) -> oso::Class { + Self::get_polar_class() + } +} + #[derive(Clone, Copy, Debug)] pub struct UpdateTrustRootList; diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index 03346f13803..c88e3d79e1d 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -450,6 +450,21 @@ resource ConsoleSessionList { has_relation(fleet: Fleet, "parent_fleet", collection: ConsoleSessionList) if collection.fleet = fleet; +# Allow silo admins to delete user sessions +resource UserSessions { + permissions = [ "modify" ]; + relations = { parent_silo: Silo }; + + # A silo admin can modify (e.g., delete) a user's sessions. + "modify" if "admin" on "parent_silo"; +} +has_relation(silo: Silo, "parent_silo", sessions: UserSessions) + if sessions.silo_user.silo = silo; + +# also give users 'modify' on their own sessions +has_permission(actor: AuthenticatedActor, "modify", sessions: UserSessions) + if actor.equals_silo_user(sessions.silo_user); + # Describes the policy for creating and managing device authorization requests. resource DeviceAuthRequestList { permissions = [ "create_child" ]; diff --git a/nexus/auth/src/authz/oso_generic.rs b/nexus/auth/src/authz/oso_generic.rs index 27a38c9c9d1..c61c2d0bab3 100644 --- a/nexus/auth/src/authz/oso_generic.rs +++ b/nexus/auth/src/authz/oso_generic.rs @@ -115,6 +115,7 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result { SiloIdentityProviderList::get_polar_class(), SiloUserList::get_polar_class(), UpdateTrustRootList::get_polar_class(), + UserSessions::get_polar_class(), TargetReleaseConfig::get_polar_class(), AlertClassList::get_polar_class(), ]; diff --git a/nexus/src/app/silo.rs b/nexus/src/app/silo.rs index af178717d24..7576a6e1425 100644 --- a/nexus/src/app/silo.rs +++ b/nexus/src/app/silo.rs @@ -324,6 +324,11 @@ impl super::Nexus { .fetch() .await?; + let authz_user_sessions = + authz::UserSessions::new(authz_silo_user.clone()); + // TODO: would rather do this check in the datastore functions + opctx.authorize(authz::Action::Modify, &authz_user_sessions).await?; + self.datastore() .silo_user_tokens_delete(opctx, &authz_silo_user) .await?; diff --git a/nexus/tests/integration_tests/device_auth.rs b/nexus/tests/integration_tests/device_auth.rs index 0ff00d768fc..beeb36174be 100644 --- a/nexus/tests/integration_tests/device_auth.rs +++ b/nexus/tests/integration_tests/device_auth.rs @@ -12,7 +12,8 @@ use nexus_db_queries::db::fixed_data::silo::DEFAULT_SILO; use nexus_db_queries::db::identity::{Asset, Resource}; use nexus_test_utils::http_testing::TestResponse; use nexus_test_utils::resource_helpers::{ - object_delete_error, object_get, object_put, object_put_error, + create_local_user, object_delete_error, object_get, object_put, + object_put_error, test_params, }; use nexus_test_utils::{ http_testing::{AuthnMode, NexusRequest, RequestBuilder}, @@ -28,7 +29,7 @@ use nexus_types::external_api::{ }; use http::{StatusCode, header, method::Method}; -use oxide_client::types::SiloRole; +use oxide_client::types::{FleetRole, SiloRole}; use serde::Deserialize; use tokio::time::{Duration, sleep}; use uuid::Uuid; @@ -245,6 +246,7 @@ async fn test_device_auth_flow(cptestctx: &ControlPlaneTestContext) { /// as a string async fn get_device_token( testctx: &ClientTestContext, + authn_mode: AuthnMode, ) -> DeviceAccessTokenGrant { let client_id = Uuid::new_v4(); let authn_params = DeviceAuthRequest { client_id, ttl_seconds: None }; @@ -272,7 +274,7 @@ async fn get_device_token( .body(Some(&confirm_params)) .expect_status(Some(StatusCode::NO_CONTENT)), ) - .authn_as(AuthnMode::PrivilegedUser) + .authn_as(authn_mode.clone()) .execute() .await .expect("failed to confirm"); @@ -290,7 +292,7 @@ async fn get_device_token( .body_urlencoded(Some(&token_params)) .expect_status(Some(StatusCode::OK)), ) - .authn_as(AuthnMode::PrivilegedUser) + .authn_as(authn_mode) .execute() .await .expect("failed to get token") @@ -311,7 +313,8 @@ async fn test_device_token_expiration(cptestctx: &ControlPlaneTestContext) { // get a token for the privileged user. default silo max token expiration // is null, so tokens don't expire - let initial_token_grant = get_device_token(testctx).await; + let initial_token_grant = + get_device_token(testctx, AuthnMode::PrivilegedUser).await; let initial_token = initial_token_grant.access_token; // now there is a token in the list @@ -381,7 +384,8 @@ async fn test_device_token_expiration(cptestctx: &ControlPlaneTestContext) { assert_eq!(settings.device_token_max_ttl_seconds, Some(3)); // create token again (this one will have the 3-second expiration) - let expiring_token_grant = get_device_token(testctx).await; + let expiring_token_grant = + get_device_token(testctx, AuthnMode::PrivilegedUser).await; // check that expiration time is there and in the right range let exp = expiring_token_grant @@ -624,11 +628,142 @@ async fn test_device_token_request_ttl(cptestctx: &ControlPlaneTestContext) { .expect("token should be expired"); } +#[nexus_test] +async fn test_admin_logout_deletes_tokens(cptestctx: &ControlPlaneTestContext) { + let testctx = &cptestctx.external_client; + + // create a user have a user ID on hand to use in the authn_as + let silo_url = "/v1/system/silos/test-suite-silo"; + let test_suite_silo: views::Silo = object_get(testctx, silo_url).await; + let user1 = create_local_user( + testctx, + &test_suite_silo, + &"user1".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + let user2 = create_local_user( + testctx, + &test_suite_silo, + &"user2".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .await; + + // TODO: we are using the fetch my tokens endpoint, authed as user1, to + // check the tokens, but we will likely have a list tokens for user endpoint + // (accessible to silo admins only) so they can feel good about there being + // no tokens or sessions for a given user + + // no tokens for user 1 yet + let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await; + assert!(tokens.is_empty()); + + // create a token for user1 + get_device_token(testctx, AuthnMode::SiloUser(user1.id)).await; + + // now there is a token for user1 + let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await; + assert_eq!(tokens.len(), 1); + + let logout_url = format!("/v1/users/{}/logout", user1.id); + + // user 2 cannot hit the logout endpoint for user 1 + NexusRequest::new( + RequestBuilder::new(testctx, Method::POST, &logout_url) + .body(Some(&serde_json::json!({}))) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(user2.id)) + .execute() + .await + .expect("User has no perms, can't delete another user's tokens"); + + let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await; + assert_eq!(tokens.len(), 1); + + // user 1 can hit the logout endpoint for themselves + NexusRequest::new( + RequestBuilder::new(testctx, Method::POST, &logout_url) + .body(Some(&serde_json::json!({}))) + .expect_status(Some(StatusCode::NO_CONTENT)), + ) + .authn_as(AuthnMode::SiloUser(user1.id)) + .execute() + .await + .expect("User 1 should be able to delete their own tokens"); + + let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await; + assert!(tokens.is_empty()); + + // create another couple of tokens for user1 + get_device_token(testctx, AuthnMode::SiloUser(user1.id)).await; + get_device_token(testctx, AuthnMode::SiloUser(user1.id)).await; + + let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await; + assert_eq!(tokens.len(), 2); + + // make user 2 fleet admin to show that fleet admin does not inherit + // the appropriate role due to being fleet admin alone + grant_iam( + testctx, + "/v1/system", + FleetRole::Admin, + user2.id, + AuthnMode::PrivilegedUser, + ) + .await; + + NexusRequest::new( + RequestBuilder::new(testctx, Method::POST, &logout_url) + .body(Some(&serde_json::json!({}))) + .expect_status(Some(StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(user2.id)) + .execute() + .await + .expect("Fleet admin is not sufficient to delete another user's tokens"); + + let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await; + assert_eq!(tokens.len(), 2); + + // make user 2 a silo admin so they can delete user 1's tokens + grant_iam( + testctx, + silo_url, + SiloRole::Admin, + user2.id, + AuthnMode::PrivilegedUser, + ) + .await; + + NexusRequest::new( + RequestBuilder::new(testctx, Method::POST, &logout_url) + .body(Some(&serde_json::json!({}))) + .expect_status(Some(StatusCode::NO_CONTENT)), + ) + .authn_as(AuthnMode::SiloUser(user1.id)) + .execute() + .await + .expect("Silo admin should be able to delete user 1's tokens"); + + // they're gone! + let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await; + assert!(tokens.is_empty()); +} + async fn get_tokens_priv( testctx: &ClientTestContext, +) -> Vec { + get_tokens_as(testctx, AuthnMode::PrivilegedUser).await +} + +async fn get_tokens_as( + testctx: &ClientTestContext, + authn_mode: AuthnMode, ) -> Vec { NexusRequest::object_get(testctx, "/v1/me/access-tokens") - .authn_as(AuthnMode::PrivilegedUser) + .authn_as(authn_mode) .execute_and_parse_unwrap::>() .await .items From 0419e331e655fe5ab1711127844dc377a05f6f76 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Jul 2025 12:30:04 -0500 Subject: [PATCH 03/14] update policy test for session list --- nexus/auth/src/authz/api_resources.rs | 4 +++ .../src/policy_test/resource_builder.rs | 17 +++++++++++ nexus/db-queries/src/policy_test/resources.rs | 3 +- nexus/db-queries/tests/output/authz-roles.out | 28 +++++++++++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/nexus/auth/src/authz/api_resources.rs b/nexus/auth/src/authz/api_resources.rs index 6ed499e4e7e..f9f6b974fae 100644 --- a/nexus/auth/src/authz/api_resources.rs +++ b/nexus/auth/src/authz/api_resources.rs @@ -683,6 +683,10 @@ impl UserSessions { pub fn silo_user(&self) -> &SiloUser { &self.0 } + + pub fn silo(&self) -> &Silo { + &self.0.parent + } } impl oso::PolarClass for UserSessions { diff --git a/nexus/db-queries/src/policy_test/resource_builder.rs b/nexus/db-queries/src/policy_test/resource_builder.rs index 0826226128b..8623d0e3d1d 100644 --- a/nexus/db-queries/src/policy_test/resource_builder.rs +++ b/nexus/db-queries/src/policy_test/resource_builder.rs @@ -345,3 +345,20 @@ impl DynAuthorizedResource for authz::SiloUserList { format!("{}: user list", self.silo().resource_name()) } } + +impl DynAuthorizedResource for authz::UserSessions { + fn do_authorize<'a, 'b>( + &'a self, + opctx: &'b OpContext, + action: authz::Action, + ) -> BoxFuture<'a, Result<(), Error>> + where + 'b: 'a, + { + opctx.authorize(action, self).boxed() + } + + fn resource_name(&self) -> String { + format!("{}: session list", self.silo_user().resource_name()) + } +} diff --git a/nexus/db-queries/src/policy_test/resources.rs b/nexus/db-queries/src/policy_test/resources.rs index c9293df7ddc..dc745b0b3a1 100644 --- a/nexus/db-queries/src/policy_test/resources.rs +++ b/nexus/db-queries/src/policy_test/resources.rs @@ -265,7 +265,7 @@ async fn make_silo( builder.new_resource(silo_user.clone()); let ssh_key_id = Uuid::new_v4(); builder.new_resource(authz::SshKey::new( - silo_user, + silo_user.clone(), ssh_key_id, LookupType::ByName(format!("{}-user-ssh-key", silo_name)), )); @@ -281,6 +281,7 @@ async fn make_silo( silo_image_id, LookupType::ByName(format!("{}-silo-image", silo_name)), )); + builder.new_resource(authz::UserSessions::new(silo_user)); // Image is a special case in that this resource is technically just a // pass-through for `SiloImage` and `ProjectImage` resources. diff --git a/nexus/db-queries/tests/output/authz-roles.out b/nexus/db-queries/tests/output/authz-roles.out index 23f3097a04c..157bc65800d 100644 --- a/nexus/db-queries/tests/output/authz-roles.out +++ b/nexus/db-queries/tests/output/authz-roles.out @@ -306,6 +306,20 @@ resource: SiloImage "silo1-silo-image" silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! +resource: SiloUser "silo1-user": session list + + USER Q R LC RP M MP CC D + fleet-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + fleet-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + fleet-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-admin ✘ ✘ ✘ ✘ ✔ ✔ ✘ ✔ + silo1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + unauthenticated ! ! ! ! ! ! ! ! + resource: Image "silo1-image" USER Q R LC RP M MP CC D @@ -866,6 +880,20 @@ resource: SiloImage "silo2-silo-image" silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! +resource: SiloUser "silo2-user": session list + + USER Q R LC RP M MP CC D + fleet-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + fleet-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + fleet-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + unauthenticated ! ! ! ! ! ! ! ! + resource: Image "silo2-image" USER Q R LC RP M MP CC D From 041313dea19ea0788d4a90d66f90de0d4bac8298 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Jul 2025 13:30:46 -0500 Subject: [PATCH 04/14] UserSessions -> SiloUserAuthnList --- nexus/auth/src/authz/api_resources.rs | 16 +++++++++------- nexus/auth/src/authz/omicron.polar | 6 +++--- nexus/auth/src/authz/oso_generic.rs | 2 +- .../src/policy_test/resource_builder.rs | 2 +- nexus/db-queries/src/policy_test/resources.rs | 2 +- nexus/src/app/silo.rs | 2 +- 6 files changed, 16 insertions(+), 14 deletions(-) diff --git a/nexus/auth/src/authz/api_resources.rs b/nexus/auth/src/authz/api_resources.rs index f9f6b974fae..4105a4be349 100644 --- a/nexus/auth/src/authz/api_resources.rs +++ b/nexus/auth/src/authz/api_resources.rs @@ -668,14 +668,14 @@ impl AuthorizedResource for SiloUserList { } } -// TODO: does it make sense to use a single authz resource to represent -// both user sessions and tokens? seems silly to have two identical ones +// TODO: does it make sense to use a single resource to represent both user +// sessions and tokens? it seems silly to have two identical ones /// Synthetic resource for managing a user's sessions and tokens #[derive(Clone, Debug, Eq, PartialEq)] -pub struct UserSessions(SiloUser); +pub struct SiloUserAuthnList(SiloUser); -impl UserSessions { +impl SiloUserAuthnList { pub fn new(silo_user: SiloUser) -> Self { Self(silo_user) } @@ -689,16 +689,18 @@ impl UserSessions { } } -impl oso::PolarClass for UserSessions { +impl oso::PolarClass for SiloUserAuthnList { fn get_polar_class_builder() -> oso::ClassBuilder { oso::Class::builder().with_equality_check().add_attribute_getter( "silo_user", - |user_sessions: &UserSessions| user_sessions.silo_user().clone(), + |user_sessions: &SiloUserAuthnList| { + user_sessions.silo_user().clone() + }, ) } } -impl AuthorizedResource for UserSessions { +impl AuthorizedResource for SiloUserAuthnList { fn load_roles<'fut>( &'fut self, opctx: &'fut OpContext, diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index c88e3d79e1d..493283ec1a3 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -451,18 +451,18 @@ has_relation(fleet: Fleet, "parent_fleet", collection: ConsoleSessionList) if collection.fleet = fleet; # Allow silo admins to delete user sessions -resource UserSessions { +resource SiloUserAuthnList { permissions = [ "modify" ]; relations = { parent_silo: Silo }; # A silo admin can modify (e.g., delete) a user's sessions. "modify" if "admin" on "parent_silo"; } -has_relation(silo: Silo, "parent_silo", sessions: UserSessions) +has_relation(silo: Silo, "parent_silo", sessions: SiloUserAuthnList) if sessions.silo_user.silo = silo; # also give users 'modify' on their own sessions -has_permission(actor: AuthenticatedActor, "modify", sessions: UserSessions) +has_permission(actor: AuthenticatedActor, "modify", sessions: SiloUserAuthnList) if actor.equals_silo_user(sessions.silo_user); # Describes the policy for creating and managing device authorization requests. diff --git a/nexus/auth/src/authz/oso_generic.rs b/nexus/auth/src/authz/oso_generic.rs index c61c2d0bab3..d833e5d58ba 100644 --- a/nexus/auth/src/authz/oso_generic.rs +++ b/nexus/auth/src/authz/oso_generic.rs @@ -113,9 +113,9 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result { DeviceAuthRequestList::get_polar_class(), SiloCertificateList::get_polar_class(), SiloIdentityProviderList::get_polar_class(), + SiloUserAuthnList::get_polar_class(), SiloUserList::get_polar_class(), UpdateTrustRootList::get_polar_class(), - UserSessions::get_polar_class(), TargetReleaseConfig::get_polar_class(), AlertClassList::get_polar_class(), ]; diff --git a/nexus/db-queries/src/policy_test/resource_builder.rs b/nexus/db-queries/src/policy_test/resource_builder.rs index 8623d0e3d1d..0e0fb230f69 100644 --- a/nexus/db-queries/src/policy_test/resource_builder.rs +++ b/nexus/db-queries/src/policy_test/resource_builder.rs @@ -346,7 +346,7 @@ impl DynAuthorizedResource for authz::SiloUserList { } } -impl DynAuthorizedResource for authz::UserSessions { +impl DynAuthorizedResource for authz::SiloUserAuthnList { fn do_authorize<'a, 'b>( &'a self, opctx: &'b OpContext, diff --git a/nexus/db-queries/src/policy_test/resources.rs b/nexus/db-queries/src/policy_test/resources.rs index dc745b0b3a1..3684e3e989a 100644 --- a/nexus/db-queries/src/policy_test/resources.rs +++ b/nexus/db-queries/src/policy_test/resources.rs @@ -281,7 +281,7 @@ async fn make_silo( silo_image_id, LookupType::ByName(format!("{}-silo-image", silo_name)), )); - builder.new_resource(authz::UserSessions::new(silo_user)); + builder.new_resource(authz::SiloUserAuthnList::new(silo_user)); // Image is a special case in that this resource is technically just a // pass-through for `SiloImage` and `ProjectImage` resources. diff --git a/nexus/src/app/silo.rs b/nexus/src/app/silo.rs index 7576a6e1425..ed43409e3b4 100644 --- a/nexus/src/app/silo.rs +++ b/nexus/src/app/silo.rs @@ -325,7 +325,7 @@ impl super::Nexus { .await?; let authz_user_sessions = - authz::UserSessions::new(authz_silo_user.clone()); + authz::SiloUserAuthnList::new(authz_silo_user.clone()); // TODO: would rather do this check in the datastore functions opctx.authorize(authz::Action::Modify, &authz_user_sessions).await?; From 42d7fd33d431cd43d8557640c677019f2908a745 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Jul 2025 13:41:45 -0500 Subject: [PATCH 05/14] do authz checks inside datastore delete functions --- .../db-queries/src/db/datastore/console_session.rs | 12 +++++++----- nexus/db-queries/src/db/datastore/device_auth.rs | 13 ++++++++----- nexus/src/app/silo.rs | 8 +++----- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/nexus/db-queries/src/db/datastore/console_session.rs b/nexus/db-queries/src/db/datastore/console_session.rs index a11644e7dfc..60f59c7598d 100644 --- a/nexus/db-queries/src/db/datastore/console_session.rs +++ b/nexus/db-queries/src/db/datastore/console_session.rs @@ -161,15 +161,17 @@ impl DataStore { pub async fn silo_user_sessions_delete( &self, opctx: &OpContext, - user: &authz::SiloUser, + authn_list: &authz::SiloUserAuthnList, ) -> Result<(), Error> { - // TODO: check for silo admin on opctx - // TODO: ensure this can only be used in current silo - // TODO: think about dueling admins problem + // authz policy enforces that the opctx actor is a silo admin on the + // target user's own silo in particular + opctx.authorize(authz::Action::Modify, authn_list).await?; use nexus_db_schema::schema::console_session; diesel::delete(console_session::table) - .filter(console_session::silo_user_id.eq(user.id())) + .filter( + console_session::silo_user_id.eq(authn_list.silo_user().id()), + ) .execute_async(&*self.pool_connection_authorized(opctx).await?) .await .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) diff --git a/nexus/db-queries/src/db/datastore/device_auth.rs b/nexus/db-queries/src/db/datastore/device_auth.rs index 2bf26240ac4..6783d6b410f 100644 --- a/nexus/db-queries/src/db/datastore/device_auth.rs +++ b/nexus/db-queries/src/db/datastore/device_auth.rs @@ -246,15 +246,18 @@ impl DataStore { pub async fn silo_user_tokens_delete( &self, opctx: &OpContext, - user: &authz::SiloUser, + authn_list: &authz::SiloUserAuthnList, ) -> Result<(), Error> { - // TODO: check for silo admin on opctx - // TODO: ensure this can only be used in current silo - // TODO: think about dueling admins problem + // authz policy enforces that the opctx actor is a silo admin on the + // target user's own silo in particular + opctx.authorize(authz::Action::Modify, authn_list).await?; use nexus_db_schema::schema::device_access_token; diesel::delete(device_access_token::table) - .filter(device_access_token::silo_user_id.eq(user.id())) + .filter( + device_access_token::silo_user_id + .eq(authn_list.silo_user().id()), + ) .execute_async(&*self.pool_connection_authorized(opctx).await?) .await .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) diff --git a/nexus/src/app/silo.rs b/nexus/src/app/silo.rs index ed43409e3b4..d51da814106 100644 --- a/nexus/src/app/silo.rs +++ b/nexus/src/app/silo.rs @@ -324,17 +324,15 @@ impl super::Nexus { .fetch() .await?; - let authz_user_sessions = + let authz_authn_list = authz::SiloUserAuthnList::new(authz_silo_user.clone()); - // TODO: would rather do this check in the datastore functions - opctx.authorize(authz::Action::Modify, &authz_user_sessions).await?; self.datastore() - .silo_user_tokens_delete(opctx, &authz_silo_user) + .silo_user_tokens_delete(opctx, &authz_authn_list) .await?; self.datastore() - .silo_user_sessions_delete(opctx, &authz_silo_user) + .silo_user_sessions_delete(opctx, &authz_authn_list) .await?; Ok(()) From d80a4d6cb3b4fd146ae03c08db10ea5232402342 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Jul 2025 14:39:45 -0500 Subject: [PATCH 06/14] user_view endpoint --- .gitignore | 1 + nexus/external-api/output/nexus_tags.txt | 1 + nexus/external-api/src/lib.rs | 11 ++++++ nexus/src/app/silo.rs | 17 +++++++- nexus/src/external_api/http_entrypoints.rs | 22 +++++++++++ nexus/tests/integration_tests/endpoints.rs | 13 ++++++- nexus/tests/integration_tests/unauthorized.rs | 2 + openapi/nexus.json | 39 +++++++++++++++++++ 8 files changed, 104 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 6e4e0eb42a1..e67712f323a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ tags .img/* connectivity-report.json *.local +CLAUDE.md diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index 147ee71256f..965b0902722 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -158,6 +158,7 @@ policy_update PUT /v1/policy policy_view GET /v1/policy user_list GET /v1/users user_logout POST /v1/users/{user_id}/logout +user_view GET /v1/users/{user_id} utilization_view GET /v1/utilization API operations found with tag "snapshots" diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 44d0810d647..f1964570b9a 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -3076,6 +3076,17 @@ pub trait NexusExternalApi { query_params: Query>, ) -> Result>, HttpError>; + /// Fetch user + #[endpoint { + method = GET, + path = "/v1/users/{user_id}", + tags = ["silos"], + }] + async fn user_view( + rqctx: RequestContext, + path_params: Path, + ) -> Result, HttpError>; + /// Expire all of user's tokens and sessions #[endpoint { method = POST, diff --git a/nexus/src/app/silo.rs b/nexus/src/app/silo.rs index d51da814106..f0f8dceca43 100644 --- a/nexus/src/app/silo.rs +++ b/nexus/src/app/silo.rs @@ -313,7 +313,7 @@ impl super::Nexus { Ok(db_silo_user) } - /// Fetch a user in a Silo + /// Delete all of user's tokens and sessions pub(crate) async fn current_silo_user_logout( &self, opctx: &OpContext, @@ -338,6 +338,21 @@ impl super::Nexus { Ok(()) } + /// Fetch a user in a Silo + pub(crate) async fn current_silo_user_lookup( + &self, + opctx: &OpContext, + silo_user_id: Uuid, + ) -> LookupResult<(authz::SiloUser, db::model::SiloUser)> { + let (_, authz_silo_user, db_silo_user) = + LookupPath::new(opctx, self.datastore()) + .silo_user_id(silo_user_id) + .fetch_for(authz::Action::Read) + .await?; + + Ok((authz_silo_user, db_silo_user)) + } + // The "local" identity provider (available only in `LocalOnly` Silos) /// Helper function for looking up a LocalOnly Silo by name diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 12009335c85..84a30b49507 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -6860,6 +6860,28 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } + async fn user_view( + rqctx: RequestContext, + path_params: Path, + ) -> Result, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.context.nexus; + let path = path_params.into_inner(); + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; + let (.., user) = nexus + .current_silo_user_lookup(&opctx, path.user_id) + .await?; + Ok(HttpResponseOk(user.into())) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + async fn user_logout( rqctx: RequestContext, path_params: Path, diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 9f2ed191d93..85748fbc315 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -139,6 +139,11 @@ pub static DEMO_SILO_USERS_LIST_URL: LazyLock = LazyLock::new(|| { pub static DEMO_SILO_USER_ID_GET_URL: LazyLock = LazyLock::new(|| { format!("/v1/system/users/{{id}}?silo={}", DEFAULT_SILO.identity().name,) }); +pub static DEMO_SILO_USER_ID_IN_SILO_URL: LazyLock = + LazyLock::new(|| "/v1/users/{id}".to_string()); +pub static DEMO_SILO_USER_LOGOUT_URL: LazyLock = + LazyLock::new(|| "/v1/users/{id}/logout".to_string()); + pub static DEMO_SILO_USER_ID_DELETE_URL: LazyLock = LazyLock::new(|| { format!( @@ -1676,8 +1681,14 @@ pub static VERIFY_ENDPOINTS: LazyLock> = allowed_methods: vec![AllowedMethod::Get], }, VerifyEndpoint { - url: "/v1/users/af47bf12-2eab-4892-8a2f-c064a812c884/logout", + url: &DEMO_SILO_USER_ID_IN_SILO_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::ReadOnly, + allowed_methods: vec![AllowedMethod::Get], + }, + VerifyEndpoint { + url: &DEMO_SILO_USER_LOGOUT_URL, + visibility: Visibility::Public, unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::Post(serde_json::json!( {} diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index 148ebf0bee3..668e031494c 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -250,6 +250,8 @@ static SETUP_REQUESTS: LazyLock> = LazyLock::new(|| { &*DEMO_SILO_USER_ID_GET_URL, &*DEMO_SILO_USER_ID_DELETE_URL, &*DEMO_SILO_USER_ID_SET_PASSWORD_URL, + &*DEMO_SILO_USER_ID_IN_SILO_URL, + &*DEMO_SILO_USER_LOGOUT_URL, ], }, // Create the default IP pool diff --git a/openapi/nexus.json b/openapi/nexus.json index 75ac7f965d4..88958609057 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -11577,6 +11577,45 @@ } } }, + "/v1/users/{user_id}": { + "get": { + "tags": [ + "silos" + ], + "summary": "Fetch user", + "operationId": "user_view", + "parameters": [ + { + "in": "path", + "name": "user_id", + "description": "ID of the user", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/v1/users/{user_id}/logout": { "post": { "tags": [ From 653bd2973e6a6461431890e70368e119451bd7f4 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Jul 2025 15:53:14 -0500 Subject: [PATCH 07/14] user token list endpoint --- nexus/auth/src/authz/omicron.polar | 12 +++- .../src/db/datastore/device_auth.rs | 27 ++++++++ nexus/external-api/output/nexus_tags.txt | 1 + nexus/external-api/src/lib.rs | 12 ++++ nexus/src/app/silo.rs | 20 ++++++ nexus/src/external_api/http_entrypoints.rs | 32 +++++++++ nexus/tests/integration_tests/endpoints.rs | 8 +++ nexus/tests/integration_tests/unauthorized.rs | 1 + openapi/nexus.json | 69 +++++++++++++++++++ 9 files changed, 179 insertions(+), 3 deletions(-) diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index 493283ec1a3..1b1d7c602bd 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -450,21 +450,27 @@ resource ConsoleSessionList { has_relation(fleet: Fleet, "parent_fleet", collection: ConsoleSessionList) if collection.fleet = fleet; -# Allow silo admins to delete user sessions +# Allow silo admins to delete user sessions and list user tokens resource SiloUserAuthnList { - permissions = [ "modify" ]; + permissions = [ "modify", "list_children" ]; relations = { parent_silo: Silo }; # A silo admin can modify (e.g., delete) a user's sessions. "modify" if "admin" on "parent_silo"; + + # A silo admin can list a user's tokens and sessions. + "list_children" if "admin" on "parent_silo"; } has_relation(silo: Silo, "parent_silo", sessions: SiloUserAuthnList) if sessions.silo_user.silo = silo; -# also give users 'modify' on their own sessions +# also give users 'modify' and 'list_children' on their own sessions has_permission(actor: AuthenticatedActor, "modify", sessions: SiloUserAuthnList) if actor.equals_silo_user(sessions.silo_user); +has_permission(actor: AuthenticatedActor, "list_children", sessions: SiloUserAuthnList) + if actor.equals_silo_user(sessions.silo_user); + # Describes the policy for creating and managing device authorization requests. resource DeviceAuthRequestList { permissions = [ "create_child" ]; diff --git a/nexus/db-queries/src/db/datastore/device_auth.rs b/nexus/db-queries/src/db/datastore/device_auth.rs index 6783d6b410f..3b4b834f8b2 100644 --- a/nexus/db-queries/src/db/datastore/device_auth.rs +++ b/nexus/db-queries/src/db/datastore/device_auth.rs @@ -214,6 +214,33 @@ impl DataStore { .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) } + /// List device access tokens for a specific user + pub async fn silo_user_token_list( + &self, + opctx: &OpContext, + user_authn_list: authz::SiloUserAuthnList, + pagparams: &DataPageParams<'_, Uuid>, + ) -> ListResultVec { + opctx.authorize(authz::Action::ListChildren, &user_authn_list).await?; + + let silo_user_id = user_authn_list.silo_user().id(); + + use nexus_db_schema::schema::device_access_token::dsl; + paginated(dsl::device_access_token, dsl::id, &pagparams) + .filter(dsl::silo_user_id.eq(silo_user_id)) + // we don't have time_deleted on tokens. unfortunately this is not + // indexed well. maybe it can be! + .filter( + dsl::time_expires + .is_null() + .or(dsl::time_expires.gt(Utc::now())), + ) + .select(DeviceAccessToken::as_select()) + .load_async(&*self.pool_connection_authorized(opctx).await?) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) + } + pub async fn current_user_token_delete( &self, opctx: &OpContext, diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index 965b0902722..40a9bfd1260 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -158,6 +158,7 @@ policy_update PUT /v1/policy policy_view GET /v1/policy user_list GET /v1/users user_logout POST /v1/users/{user_id}/logout +user_token_list GET /v1/users/{user_id}/access-tokens user_view GET /v1/users/{user_id} utilization_view GET /v1/utilization diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index f1964570b9a..ffe79b1fbf8 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -3087,6 +3087,18 @@ pub trait NexusExternalApi { path_params: Path, ) -> Result, HttpError>; + /// List user's device access tokens + #[endpoint { + method = GET, + path = "/v1/users/{user_id}/access-tokens", + tags = ["silos"], + }] + async fn user_token_list( + rqctx: RequestContext, + path_params: Path, + query_params: Query, + ) -> Result>, HttpError>; + /// Expire all of user's tokens and sessions #[endpoint { method = POST, diff --git a/nexus/src/app/silo.rs b/nexus/src/app/silo.rs index f0f8dceca43..cf7e82ccb72 100644 --- a/nexus/src/app/silo.rs +++ b/nexus/src/app/silo.rs @@ -353,6 +353,26 @@ impl super::Nexus { Ok((authz_silo_user, db_silo_user)) } + /// List device access tokens for a user in a Silo + pub(crate) async fn silo_user_token_list( + &self, + opctx: &OpContext, + silo_user_id: Uuid, + pagparams: &DataPageParams<'_, Uuid>, + ) -> ListResultVec { + let (_, authz_silo_user, _db_silo_user) = + LookupPath::new(opctx, self.datastore()) + .silo_user_id(silo_user_id) + .fetch_for(authz::Action::Read) + .await?; + + let user_authn_list = authz::SiloUserAuthnList::new(authz_silo_user); + + self.datastore() + .silo_user_token_list(opctx, user_authn_list, pagparams) + .await + } + // The "local" identity provider (available only in `LocalOnly` Silos) /// Helper function for looking up a LocalOnly Silo by name diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 84a30b49507..909b1e4be04 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -6882,6 +6882,38 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } + async fn user_token_list( + rqctx: RequestContext, + path_params: Path, + query_params: Query, + ) -> Result>, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.context.nexus; + let path = path_params.into_inner(); + let query = query_params.into_inner(); + let pag_params = data_page_params_for(&rqctx, &query)?; + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; + let tokens = nexus + .silo_user_token_list(&opctx, path.user_id, &pag_params) + .await? + .into_iter() + .map(views::DeviceAccessToken::from) + .collect(); + Ok(HttpResponseOk(ScanById::results_page( + &query, + tokens, + &marker_for_id, + )?)) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + async fn user_logout( rqctx: RequestContext, path_params: Path, diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 85748fbc315..756a8821261 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -141,6 +141,8 @@ pub static DEMO_SILO_USER_ID_GET_URL: LazyLock = LazyLock::new(|| { }); pub static DEMO_SILO_USER_ID_IN_SILO_URL: LazyLock = LazyLock::new(|| "/v1/users/{id}".to_string()); +pub static DEMO_SILO_USER_TOKEN_LIST_URL: LazyLock = + LazyLock::new(|| "/v1/users/{id}/access-tokens".to_string()); pub static DEMO_SILO_USER_LOGOUT_URL: LazyLock = LazyLock::new(|| "/v1/users/{id}/logout".to_string()); @@ -1686,6 +1688,12 @@ pub static VERIFY_ENDPOINTS: LazyLock> = unprivileged_access: UnprivilegedAccess::ReadOnly, allowed_methods: vec![AllowedMethod::Get], }, + VerifyEndpoint { + url: &DEMO_SILO_USER_TOKEN_LIST_URL, + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![AllowedMethod::Get], + }, VerifyEndpoint { url: &DEMO_SILO_USER_LOGOUT_URL, visibility: Visibility::Public, diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index 668e031494c..c45a031e20a 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -251,6 +251,7 @@ static SETUP_REQUESTS: LazyLock> = LazyLock::new(|| { &*DEMO_SILO_USER_ID_DELETE_URL, &*DEMO_SILO_USER_ID_SET_PASSWORD_URL, &*DEMO_SILO_USER_ID_IN_SILO_URL, + &*DEMO_SILO_USER_TOKEN_LIST_URL, &*DEMO_SILO_USER_LOGOUT_URL, ], }, diff --git a/openapi/nexus.json b/openapi/nexus.json index 88958609057..0be126afcd2 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -11616,6 +11616,75 @@ } } }, + "/v1/users/{user_id}/access-tokens": { + "get": { + "tags": [ + "silos" + ], + "summary": "List user's device access tokens", + "operationId": "user_token_list", + "parameters": [ + { + "in": "path", + "name": "user_id", + "description": "ID of the user", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + } + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retrieve the subsequent page", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/IdSortMode" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceAccessTokenResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [] + } + } + }, "/v1/users/{user_id}/logout": { "post": { "tags": [ From bdc9d5934012553f88f1969c9a575a91841bc079 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Jul 2025 16:20:31 -0500 Subject: [PATCH 08/14] use token list endpoint in integration test --- nexus/tests/integration_tests/device_auth.rs | 31 ++++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/nexus/tests/integration_tests/device_auth.rs b/nexus/tests/integration_tests/device_auth.rs index beeb36174be..466d455c6bf 100644 --- a/nexus/tests/integration_tests/device_auth.rs +++ b/nexus/tests/integration_tests/device_auth.rs @@ -650,20 +650,15 @@ async fn test_admin_logout_deletes_tokens(cptestctx: &ControlPlaneTestContext) { ) .await; - // TODO: we are using the fetch my tokens endpoint, authed as user1, to - // check the tokens, but we will likely have a list tokens for user endpoint - // (accessible to silo admins only) so they can feel good about there being - // no tokens or sessions for a given user - // no tokens for user 1 yet - let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await; + let tokens = get_user_tokens(testctx, user1.id).await; assert!(tokens.is_empty()); // create a token for user1 get_device_token(testctx, AuthnMode::SiloUser(user1.id)).await; // now there is a token for user1 - let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await; + let tokens = get_user_tokens(testctx, user1.id).await; assert_eq!(tokens.len(), 1); let logout_url = format!("/v1/users/{}/logout", user1.id); @@ -679,7 +674,7 @@ async fn test_admin_logout_deletes_tokens(cptestctx: &ControlPlaneTestContext) { .await .expect("User has no perms, can't delete another user's tokens"); - let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await; + let tokens = get_user_tokens(testctx, user1.id).await; assert_eq!(tokens.len(), 1); // user 1 can hit the logout endpoint for themselves @@ -693,14 +688,14 @@ async fn test_admin_logout_deletes_tokens(cptestctx: &ControlPlaneTestContext) { .await .expect("User 1 should be able to delete their own tokens"); - let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await; + let tokens = get_user_tokens(testctx, user1.id).await; assert!(tokens.is_empty()); // create another couple of tokens for user1 get_device_token(testctx, AuthnMode::SiloUser(user1.id)).await; get_device_token(testctx, AuthnMode::SiloUser(user1.id)).await; - let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await; + let tokens = get_user_tokens(testctx, user1.id).await; assert_eq!(tokens.len(), 2); // make user 2 fleet admin to show that fleet admin does not inherit @@ -724,7 +719,7 @@ async fn test_admin_logout_deletes_tokens(cptestctx: &ControlPlaneTestContext) { .await .expect("Fleet admin is not sufficient to delete another user's tokens"); - let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await; + let tokens = get_user_tokens(testctx, user1.id).await; assert_eq!(tokens.len(), 2); // make user 2 a silo admin so they can delete user 1's tokens @@ -748,22 +743,26 @@ async fn test_admin_logout_deletes_tokens(cptestctx: &ControlPlaneTestContext) { .expect("Silo admin should be able to delete user 1's tokens"); // they're gone! - let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await; + let tokens = get_user_tokens(testctx, user1.id).await; assert!(tokens.is_empty()); } async fn get_tokens_priv( testctx: &ClientTestContext, ) -> Vec { - get_tokens_as(testctx, AuthnMode::PrivilegedUser).await + NexusRequest::object_get(testctx, "/v1/me/access-tokens") + .authn_as(AuthnMode::PrivilegedUser) + .execute_and_parse_unwrap::>() + .await + .items } -async fn get_tokens_as( +async fn get_user_tokens( testctx: &ClientTestContext, - authn_mode: AuthnMode, + user_id: Uuid, ) -> Vec { NexusRequest::object_get(testctx, "/v1/me/access-tokens") - .authn_as(authn_mode) + .authn_as(AuthnMode::SiloUser(user_id)) .execute_and_parse_unwrap::>() .await .items From e475578f76bfd1de8604670dff904d5ba2f2ab3d Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Jul 2025 16:21:14 -0500 Subject: [PATCH 09/14] session list endpoint --- nexus/db-model/src/console_session.rs | 14 ++- .../src/db/datastore/console_session.rs | 24 ++++ nexus/external-api/output/nexus_tags.txt | 1 + nexus/external-api/src/lib.rs | 12 ++ nexus/src/app/silo.rs | 20 +++ nexus/src/external_api/http_entrypoints.rs | 32 +++++ nexus/tests/integration_tests/endpoints.rs | 8 ++ nexus/tests/integration_tests/unauthorized.rs | 1 + nexus/types/src/external_api/views.rs | 15 +++ openapi/nexus.json | 114 ++++++++++++++++++ 10 files changed, 239 insertions(+), 2 deletions(-) diff --git a/nexus/db-model/src/console_session.rs b/nexus/db-model/src/console_session.rs index c4e5f31e234..6332cb26638 100644 --- a/nexus/db-model/src/console_session.rs +++ b/nexus/db-model/src/console_session.rs @@ -4,8 +4,8 @@ use chrono::{DateTime, Utc}; use nexus_db_schema::schema::console_session; -use omicron_uuid_kinds::ConsoleSessionKind; -use omicron_uuid_kinds::ConsoleSessionUuid; +use nexus_types::external_api::views; +use omicron_uuid_kinds::{ConsoleSessionKind, ConsoleSessionUuid, GenericUuid}; use uuid::Uuid; use crate::typed_uuid::DbTypedUuid; @@ -38,3 +38,13 @@ impl ConsoleSession { self.id.0 } } + +impl From for views::ConsoleSession { + fn from(session: ConsoleSession) -> Self { + Self { + id: session.id.into_untyped_uuid(), + time_created: session.time_created, + time_last_used: session.time_last_used, + } + } +} diff --git a/nexus/db-queries/src/db/datastore/console_session.rs b/nexus/db-queries/src/db/datastore/console_session.rs index 60f59c7598d..566fe2d16ba 100644 --- a/nexus/db-queries/src/db/datastore/console_session.rs +++ b/nexus/db-queries/src/db/datastore/console_session.rs @@ -9,6 +9,7 @@ use crate::authn; use crate::authz; use crate::context::OpContext; use crate::db::model::ConsoleSession; +use crate::db::pagination::paginated; use async_bb8_diesel::AsyncRunQueryDsl; use chrono::Utc; use diesel::prelude::*; @@ -17,13 +18,16 @@ use nexus_db_errors::public_error_from_diesel; use nexus_db_lookup::LookupPath; use nexus_db_schema::schema::console_session; 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::ListResultVec; use omicron_common::api::external::LookupResult; use omicron_common::api::external::LookupType; use omicron_common::api::external::ResourceType; use omicron_common::api::external::UpdateResult; use omicron_uuid_kinds::GenericUuid; +use uuid::Uuid; impl DataStore { /// Look up session by token. The token is a kind of password, so simply @@ -157,6 +161,26 @@ impl DataStore { }) } + /// List console sessions for a specific user + pub async fn silo_user_session_list( + &self, + opctx: &OpContext, + user_authn_list: authz::SiloUserAuthnList, + pagparams: &DataPageParams<'_, Uuid>, + ) -> ListResultVec { + opctx.authorize(authz::Action::ListChildren, &user_authn_list).await?; + + let silo_user_id = user_authn_list.silo_user().id(); + + use nexus_db_schema::schema::console_session::dsl; + paginated(dsl::console_session, dsl::id, &pagparams) + .filter(dsl::silo_user_id.eq(silo_user_id)) + .select(ConsoleSession::as_select()) + .load_async(&*self.pool_connection_authorized(opctx).await?) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) + } + /// Delete all session for the user pub async fn silo_user_sessions_delete( &self, diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index 40a9bfd1260..54262eb34a5 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -158,6 +158,7 @@ policy_update PUT /v1/policy policy_view GET /v1/policy user_list GET /v1/users user_logout POST /v1/users/{user_id}/logout +user_session_list GET /v1/users/{user_id}/sessions user_token_list GET /v1/users/{user_id}/access-tokens user_view GET /v1/users/{user_id} utilization_view GET /v1/utilization diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index ffe79b1fbf8..19eb296ad5e 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -3099,6 +3099,18 @@ pub trait NexusExternalApi { query_params: Query, ) -> Result>, HttpError>; + /// List user's console sessions + #[endpoint { + method = GET, + path = "/v1/users/{user_id}/sessions", + tags = ["silos"], + }] + async fn user_session_list( + rqctx: RequestContext, + path_params: Path, + query_params: Query, + ) -> Result>, HttpError>; + /// Expire all of user's tokens and sessions #[endpoint { method = POST, diff --git a/nexus/src/app/silo.rs b/nexus/src/app/silo.rs index cf7e82ccb72..0f176a2c69a 100644 --- a/nexus/src/app/silo.rs +++ b/nexus/src/app/silo.rs @@ -373,6 +373,26 @@ impl super::Nexus { .await } + /// List console sessions for a user in a Silo + pub(crate) async fn silo_user_session_list( + &self, + opctx: &OpContext, + silo_user_id: Uuid, + pagparams: &DataPageParams<'_, Uuid>, + ) -> ListResultVec { + let (_, authz_silo_user, _db_silo_user) = + LookupPath::new(opctx, self.datastore()) + .silo_user_id(silo_user_id) + .fetch_for(authz::Action::Read) + .await?; + + let user_authn_list = authz::SiloUserAuthnList::new(authz_silo_user); + + self.datastore() + .silo_user_session_list(opctx, user_authn_list, pagparams) + .await + } + // The "local" identity provider (available only in `LocalOnly` Silos) /// Helper function for looking up a LocalOnly Silo by name diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 909b1e4be04..faf8114ec17 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -6914,6 +6914,38 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } + async fn user_session_list( + rqctx: RequestContext, + path_params: Path, + query_params: Query, + ) -> Result>, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.context.nexus; + let path = path_params.into_inner(); + let query = query_params.into_inner(); + let pag_params = data_page_params_for(&rqctx, &query)?; + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; + let sessions = nexus + .silo_user_session_list(&opctx, path.user_id, &pag_params) + .await? + .into_iter() + .map(views::ConsoleSession::from) + .collect(); + Ok(HttpResponseOk(ScanById::results_page( + &query, + sessions, + &marker_for_id, + )?)) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + async fn user_logout( rqctx: RequestContext, path_params: Path, diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 756a8821261..d2fa3cc3f43 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -143,6 +143,8 @@ pub static DEMO_SILO_USER_ID_IN_SILO_URL: LazyLock = LazyLock::new(|| "/v1/users/{id}".to_string()); pub static DEMO_SILO_USER_TOKEN_LIST_URL: LazyLock = LazyLock::new(|| "/v1/users/{id}/access-tokens".to_string()); +pub static DEMO_SILO_USER_SESSION_LIST_URL: LazyLock = + LazyLock::new(|| "/v1/users/{id}/sessions".to_string()); pub static DEMO_SILO_USER_LOGOUT_URL: LazyLock = LazyLock::new(|| "/v1/users/{id}/logout".to_string()); @@ -1694,6 +1696,12 @@ pub static VERIFY_ENDPOINTS: LazyLock> = unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::Get], }, + VerifyEndpoint { + url: &DEMO_SILO_USER_SESSION_LIST_URL, + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![AllowedMethod::Get], + }, VerifyEndpoint { url: &DEMO_SILO_USER_LOGOUT_URL, visibility: Visibility::Public, diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index c45a031e20a..08a6b4c15c0 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -252,6 +252,7 @@ static SETUP_REQUESTS: LazyLock> = LazyLock::new(|| { &*DEMO_SILO_USER_ID_SET_PASSWORD_URL, &*DEMO_SILO_USER_ID_IN_SILO_URL, &*DEMO_SILO_USER_TOKEN_LIST_URL, + &*DEMO_SILO_USER_SESSION_LIST_URL, &*DEMO_SILO_USER_LOGOUT_URL, ], }, diff --git a/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index 728d4ddadbe..9bd8ab5cc12 100644 --- a/nexus/types/src/external_api/views.rs +++ b/nexus/types/src/external_api/views.rs @@ -1000,6 +1000,21 @@ impl SimpleIdentity for DeviceAccessToken { } } +/// View of a console session +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq)] +pub struct ConsoleSession { + /// A unique, immutable, system-controlled identifier for the session + pub id: Uuid, + pub time_created: DateTime, + pub time_last_used: DateTime, +} + +impl SimpleIdentity for ConsoleSession { + fn id(&self) -> Uuid { + self.id + } +} + // OAUTH 2.0 DEVICE AUTHORIZATION REQUESTS & TOKENS /// Response to an initial device authorization request. diff --git a/openapi/nexus.json b/openapi/nexus.json index 0be126afcd2..bbb59f463d0 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -11717,6 +11717,75 @@ } } }, + "/v1/users/{user_id}/sessions": { + "get": { + "tags": [ + "silos" + ], + "summary": "List user's console sessions", + "operationId": "user_session_list", + "parameters": [ + { + "in": "path", + "name": "user_id", + "description": "ID of the user", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + } + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retrieve the subsequent page", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/IdSortMode" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConsoleSessionResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [] + } + } + }, "/v1/utilization": { "get": { "tags": [ @@ -16305,6 +16374,51 @@ "items" ] }, + "ConsoleSession": { + "description": "View of a console session", + "type": "object", + "properties": { + "id": { + "description": "A unique, immutable, system-controlled identifier for the session", + "type": "string", + "format": "uuid" + }, + "time_created": { + "type": "string", + "format": "date-time" + }, + "time_last_used": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "time_created", + "time_last_used" + ] + }, + "ConsoleSessionResultsPage": { + "description": "A single page of results", + "type": "object", + "properties": { + "items": { + "description": "list of items on this page of results", + "type": "array", + "items": { + "$ref": "#/components/schemas/ConsoleSession" + } + }, + "next_page": { + "nullable": true, + "description": "token used to fetch the next page of results (if any)", + "type": "string" + } + }, + "required": [ + "items" + ] + }, "Cumulativedouble": { "description": "A cumulative or counter data type.", "type": "object", From b7d6372aa45a92d612eb81be170d70dd7a5bea36 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Jul 2025 17:00:04 -0500 Subject: [PATCH 10/14] also test sessions in the device_auth logout test --- nexus/tests/integration_tests/device_auth.rs | 82 ++++++++++++++++---- 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/nexus/tests/integration_tests/device_auth.rs b/nexus/tests/integration_tests/device_auth.rs index 466d455c6bf..d1628da0796 100644 --- a/nexus/tests/integration_tests/device_auth.rs +++ b/nexus/tests/integration_tests/device_auth.rs @@ -629,7 +629,9 @@ async fn test_device_token_request_ttl(cptestctx: &ControlPlaneTestContext) { } #[nexus_test] -async fn test_admin_logout_deletes_tokens(cptestctx: &ControlPlaneTestContext) { +async fn test_admin_logout_deletes_tokens_and_sessions( + cptestctx: &ControlPlaneTestContext, +) { let testctx = &cptestctx.external_client; // create a user have a user ID on hand to use in the authn_as @@ -639,7 +641,7 @@ async fn test_admin_logout_deletes_tokens(cptestctx: &ControlPlaneTestContext) { testctx, &test_suite_silo, &"user1".parse().unwrap(), - test_params::UserPassword::LoginDisallowed, + test_params::UserPassword::Password("password1".to_string()), ) .await; let user2 = create_local_user( @@ -650,16 +652,22 @@ async fn test_admin_logout_deletes_tokens(cptestctx: &ControlPlaneTestContext) { ) .await; - // no tokens for user 1 yet - let tokens = get_user_tokens(testctx, user1.id).await; + // no tokens or sessions for user 1 yet + let tokens = list_user_tokens(testctx, user1.id).await; assert!(tokens.is_empty()); + let sessions = list_user_sessions(testctx, user1.id).await; + assert!(sessions.is_empty()); - // create a token for user1 + // create a token and session for user1 get_device_token(testctx, AuthnMode::SiloUser(user1.id)).await; + create_session_for_user(testctx, "test-suite-silo", "user1", "password1") + .await; - // now there is a token for user1 - let tokens = get_user_tokens(testctx, user1.id).await; + // now there is a token and session for user1 + let tokens = list_user_tokens(testctx, user1.id).await; assert_eq!(tokens.len(), 1); + let sessions = list_user_sessions(testctx, user1.id).await; + assert_eq!(sessions.len(), 1); let logout_url = format!("/v1/users/{}/logout", user1.id); @@ -674,8 +682,10 @@ async fn test_admin_logout_deletes_tokens(cptestctx: &ControlPlaneTestContext) { .await .expect("User has no perms, can't delete another user's tokens"); - let tokens = get_user_tokens(testctx, user1.id).await; + let tokens = list_user_tokens(testctx, user1.id).await; assert_eq!(tokens.len(), 1); + let sessions = list_user_sessions(testctx, user1.id).await; + assert_eq!(sessions.len(), 1); // user 1 can hit the logout endpoint for themselves NexusRequest::new( @@ -688,15 +698,23 @@ async fn test_admin_logout_deletes_tokens(cptestctx: &ControlPlaneTestContext) { .await .expect("User 1 should be able to delete their own tokens"); - let tokens = get_user_tokens(testctx, user1.id).await; + let tokens = list_user_tokens(testctx, user1.id).await; assert!(tokens.is_empty()); + let sessions = list_user_sessions(testctx, user1.id).await; + assert!(sessions.is_empty()); - // create another couple of tokens for user1 + // create another couple of tokens and sessions for user1 get_device_token(testctx, AuthnMode::SiloUser(user1.id)).await; get_device_token(testctx, AuthnMode::SiloUser(user1.id)).await; + create_session_for_user(testctx, "test-suite-silo", "user1", "password1") + .await; + create_session_for_user(testctx, "test-suite-silo", "user1", "password1") + .await; - let tokens = get_user_tokens(testctx, user1.id).await; + let tokens = list_user_tokens(testctx, user1.id).await; assert_eq!(tokens.len(), 2); + let sessions = list_user_sessions(testctx, user1.id).await; + assert_eq!(sessions.len(), 2); // make user 2 fleet admin to show that fleet admin does not inherit // the appropriate role due to being fleet admin alone @@ -719,8 +737,10 @@ async fn test_admin_logout_deletes_tokens(cptestctx: &ControlPlaneTestContext) { .await .expect("Fleet admin is not sufficient to delete another user's tokens"); - let tokens = get_user_tokens(testctx, user1.id).await; + let tokens = list_user_tokens(testctx, user1.id).await; assert_eq!(tokens.len(), 2); + let sessions = list_user_sessions(testctx, user1.id).await; + assert_eq!(sessions.len(), 2); // make user 2 a silo admin so they can delete user 1's tokens grant_iam( @@ -743,8 +763,10 @@ async fn test_admin_logout_deletes_tokens(cptestctx: &ControlPlaneTestContext) { .expect("Silo admin should be able to delete user 1's tokens"); // they're gone! - let tokens = get_user_tokens(testctx, user1.id).await; + let tokens = list_user_tokens(testctx, user1.id).await; assert!(tokens.is_empty()); + let sessions = list_user_sessions(testctx, user1.id).await; + assert!(sessions.is_empty()); } async fn get_tokens_priv( @@ -757,7 +779,7 @@ async fn get_tokens_priv( .items } -async fn get_user_tokens( +async fn list_user_tokens( testctx: &ClientTestContext, user_id: Uuid, ) -> Vec { @@ -768,6 +790,38 @@ async fn get_user_tokens( .items } +async fn list_user_sessions( + testctx: &ClientTestContext, + user_id: Uuid, +) -> Vec { + let url = format!("/v1/users/{}/sessions", user_id); + NexusRequest::object_get(testctx, &url) + .authn_as(AuthnMode::SiloUser(user_id)) + .execute_and_parse_unwrap::>() + .await + .items +} + +async fn create_session_for_user( + testctx: &ClientTestContext, + silo_name: &str, + username: &str, + password: &str, +) { + let url = format!("/v1/login/{}/local", silo_name); + let credentials = test_params::UsernamePasswordCredentials { + username: username.parse().unwrap(), + password: password.to_string(), + }; + let _login = RequestBuilder::new(&testctx, Method::POST, &url) + .body(Some(&credentials)) + .expect_status(Some(StatusCode::NO_CONTENT)) + .execute() + .await + .expect("failed to log in"); + // We don't need to extract the token, just creating the session is enough +} + async fn get_tokens_unpriv( testctx: &ClientTestContext, ) -> Vec { From 17110eb3e51ce34ca3c5033b5d2944e42e12a619 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Jul 2025 17:05:21 -0500 Subject: [PATCH 11/14] cargo fmt --- nexus/src/external_api/http_entrypoints.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index faf8114ec17..2f1bc3552fa 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -6870,9 +6870,8 @@ impl NexusExternalApi for NexusExternalApiImpl { let path = path_params.into_inner(); let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - let (.., user) = nexus - .current_silo_user_lookup(&opctx, path.user_id) - .await?; + let (.., user) = + nexus.current_silo_user_lookup(&opctx, path.user_id).await?; Ok(HttpResponseOk(user.into())) }; apictx @@ -6886,7 +6885,8 @@ impl NexusExternalApi for NexusExternalApiImpl { rqctx: RequestContext, path_params: Path, query_params: Query, - ) -> Result>, HttpError> { + ) -> Result>, HttpError> + { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; @@ -6918,7 +6918,8 @@ impl NexusExternalApi for NexusExternalApiImpl { rqctx: RequestContext, path_params: Path, query_params: Query, - ) -> Result>, HttpError> { + ) -> Result>, HttpError> + { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; From 898ef72e839c2df9fa8fe65f30bedbda0333d0c8 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Jul 2025 17:31:48 -0500 Subject: [PATCH 12/14] self-review tweaks --- .gitignore | 1 - nexus/auth/src/authz/omicron.polar | 17 +++++++-------- .../src/db/datastore/console_session.rs | 21 ++++++++++++------- nexus/src/app/silo.rs | 6 +++--- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index e67712f323a..6e4e0eb42a1 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,3 @@ tags .img/* connectivity-report.json *.local -CLAUDE.md diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index 1b1d7c602bd..e5459eb8272 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -461,15 +461,14 @@ resource SiloUserAuthnList { # A silo admin can list a user's tokens and sessions. "list_children" if "admin" on "parent_silo"; } -has_relation(silo: Silo, "parent_silo", sessions: SiloUserAuthnList) - if sessions.silo_user.silo = silo; - -# also give users 'modify' and 'list_children' on their own sessions -has_permission(actor: AuthenticatedActor, "modify", sessions: SiloUserAuthnList) - if actor.equals_silo_user(sessions.silo_user); - -has_permission(actor: AuthenticatedActor, "list_children", sessions: SiloUserAuthnList) - if actor.equals_silo_user(sessions.silo_user); +has_relation(silo: Silo, "parent_silo", authn_list: SiloUserAuthnList) + if authn_list.silo_user.silo = silo; + +# give users 'modify' and 'list_children' on their own tokens and sessions +has_permission(actor: AuthenticatedActor, "modify", authn_list: SiloUserAuthnList) + if actor.equals_silo_user(authn_list.silo_user); +has_permission(actor: AuthenticatedActor, "list_children", authn_list: SiloUserAuthnList) + if actor.equals_silo_user(authn_list.silo_user); # Describes the policy for creating and managing device authorization requests. resource DeviceAuthRequestList { diff --git a/nexus/db-queries/src/db/datastore/console_session.rs b/nexus/db-queries/src/db/datastore/console_session.rs index 566fe2d16ba..cbdb1321b4f 100644 --- a/nexus/db-queries/src/db/datastore/console_session.rs +++ b/nexus/db-queries/src/db/datastore/console_session.rs @@ -165,16 +165,23 @@ impl DataStore { pub async fn silo_user_session_list( &self, opctx: &OpContext, - user_authn_list: authz::SiloUserAuthnList, + authn_list: authz::SiloUserAuthnList, pagparams: &DataPageParams<'_, Uuid>, ) -> ListResultVec { - opctx.authorize(authz::Action::ListChildren, &user_authn_list).await?; + opctx.authorize(authz::Action::ListChildren, &authn_list).await?; - let silo_user_id = user_authn_list.silo_user().id(); + let user_id = authn_list.silo_user().id(); use nexus_db_schema::schema::console_session::dsl; paginated(dsl::console_session, dsl::id, &pagparams) - .filter(dsl::silo_user_id.eq(silo_user_id)) + .filter(dsl::silo_user_id.eq(user_id)) + // TODO: unlike with tokens, we do not have expiration time here, + // so we can't filter out expired sessions by comparing to now. In + // the authn code, this works by dynamically comparing the created + // and last used times against now + idle/absolute TTL. We may + // have to do that here but it's kind of sad. It might be nicer to + // make sessions work more like tokens and put idle and absolute + // expiration time right there in the table at session create time. .select(ConsoleSession::as_select()) .load_async(&*self.pool_connection_authorized(opctx).await?) .await @@ -191,11 +198,11 @@ impl DataStore { // target user's own silo in particular opctx.authorize(authz::Action::Modify, authn_list).await?; + let user_id = authn_list.silo_user().id(); + use nexus_db_schema::schema::console_session; diesel::delete(console_session::table) - .filter( - console_session::silo_user_id.eq(authn_list.silo_user().id()), - ) + .filter(console_session::silo_user_id.eq(user_id)) .execute_async(&*self.pool_connection_authorized(opctx).await?) .await .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) diff --git a/nexus/src/app/silo.rs b/nexus/src/app/silo.rs index 0f176a2c69a..8dc0f5aa6e3 100644 --- a/nexus/src/app/silo.rs +++ b/nexus/src/app/silo.rs @@ -347,7 +347,7 @@ impl super::Nexus { let (_, authz_silo_user, db_silo_user) = LookupPath::new(opctx, self.datastore()) .silo_user_id(silo_user_id) - .fetch_for(authz::Action::Read) + .fetch() .await?; Ok((authz_silo_user, db_silo_user)) @@ -363,7 +363,7 @@ impl super::Nexus { let (_, authz_silo_user, _db_silo_user) = LookupPath::new(opctx, self.datastore()) .silo_user_id(silo_user_id) - .fetch_for(authz::Action::Read) + .fetch() .await?; let user_authn_list = authz::SiloUserAuthnList::new(authz_silo_user); @@ -383,7 +383,7 @@ impl super::Nexus { let (_, authz_silo_user, _db_silo_user) = LookupPath::new(opctx, self.datastore()) .silo_user_id(silo_user_id) - .fetch_for(authz::Action::Read) + .fetch() .await?; let user_authn_list = authz::SiloUserAuthnList::new(authz_silo_user); From 0a436e955e01b420415f0f537334fc4f7f51f4b7 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Jul 2025 18:06:57 -0500 Subject: [PATCH 13/14] tweak endpoint summaries --- nexus/external-api/src/lib.rs | 4 ++-- openapi/nexus.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 19eb296ad5e..44585f664ac 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -3087,7 +3087,7 @@ pub trait NexusExternalApi { path_params: Path, ) -> Result, HttpError>; - /// List user's device access tokens + /// List user's access tokens #[endpoint { method = GET, path = "/v1/users/{user_id}/access-tokens", @@ -3111,7 +3111,7 @@ pub trait NexusExternalApi { query_params: Query, ) -> Result>, HttpError>; - /// Expire all of user's tokens and sessions + /// Delete all of user's tokens and sessions #[endpoint { method = POST, path = "/v1/users/{user_id}/logout", diff --git a/openapi/nexus.json b/openapi/nexus.json index bbb59f463d0..9c4312307f4 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -11621,7 +11621,7 @@ "tags": [ "silos" ], - "summary": "List user's device access tokens", + "summary": "List user's access tokens", "operationId": "user_token_list", "parameters": [ { @@ -11690,7 +11690,7 @@ "tags": [ "silos" ], - "summary": "Expire all of user's tokens and sessions", + "summary": "Delete all of user's tokens and sessions", "operationId": "user_logout", "parameters": [ { From 10f014e069fbbf37c7c0705a20cfd23f0544a462 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 3 Jul 2025 12:20:50 -0500 Subject: [PATCH 14/14] fix iam policy snapshot test failure --- nexus/db-queries/src/policy_test/resource_builder.rs | 2 +- nexus/db-queries/tests/output/authz-roles.out | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nexus/db-queries/src/policy_test/resource_builder.rs b/nexus/db-queries/src/policy_test/resource_builder.rs index 0e0fb230f69..a9b89bf7269 100644 --- a/nexus/db-queries/src/policy_test/resource_builder.rs +++ b/nexus/db-queries/src/policy_test/resource_builder.rs @@ -359,6 +359,6 @@ impl DynAuthorizedResource for authz::SiloUserAuthnList { } fn resource_name(&self) -> String { - format!("{}: session list", self.silo_user().resource_name()) + format!("{}: authn list", self.silo_user().resource_name()) } } diff --git a/nexus/db-queries/tests/output/authz-roles.out b/nexus/db-queries/tests/output/authz-roles.out index 157bc65800d..c22f5dc27af 100644 --- a/nexus/db-queries/tests/output/authz-roles.out +++ b/nexus/db-queries/tests/output/authz-roles.out @@ -306,13 +306,13 @@ resource: SiloImage "silo1-silo-image" silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! -resource: SiloUser "silo1-user": session list +resource: SiloUser "silo1-user": authn list USER Q R LC RP M MP CC D fleet-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ fleet-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ fleet-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ - silo1-admin ✘ ✘ ✘ ✘ ✔ ✔ ✘ ✔ + silo1-admin ✘ ✘ ✔ ✘ ✔ ✔ ✘ ✔ silo1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ silo1-proj1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ @@ -880,7 +880,7 @@ resource: SiloImage "silo2-silo-image" silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! -resource: SiloUser "silo2-user": session list +resource: SiloUser "silo2-user": authn list USER Q R LC RP M MP CC D fleet-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘