diff --git a/nexus/auth/src/authz/api_resources.rs b/nexus/auth/src/authz/api_resources.rs index 52bcc0049e..4105a4be34 100644 --- a/nexus/auth/src/authz/api_resources.rs +++ b/nexus/auth/src/authz/api_resources.rs @@ -668,6 +668,64 @@ impl AuthorizedResource for SiloUserList { } } +// 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 SiloUserAuthnList(SiloUser); + +impl SiloUserAuthnList { + pub fn new(silo_user: SiloUser) -> Self { + Self(silo_user) + } + + pub fn silo_user(&self) -> &SiloUser { + &self.0 + } + + pub fn silo(&self) -> &Silo { + &self.0.parent + } +} + +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: &SiloUserAuthnList| { + user_sessions.silo_user().clone() + }, + ) + } +} + +impl AuthorizedResource for SiloUserAuthnList { + 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 03346f1380..e5459eb827 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -450,6 +450,26 @@ resource ConsoleSessionList { has_relation(fleet: Fleet, "parent_fleet", collection: ConsoleSessionList) if collection.fleet = fleet; +# Allow silo admins to delete user sessions and list user tokens +resource SiloUserAuthnList { + 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", 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 { permissions = [ "create_child" ]; diff --git a/nexus/auth/src/authz/oso_generic.rs b/nexus/auth/src/authz/oso_generic.rs index 27a38c9c9d..d833e5d58b 100644 --- a/nexus/auth/src/authz/oso_generic.rs +++ b/nexus/auth/src/authz/oso_generic.rs @@ -113,6 +113,7 @@ 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(), TargetReleaseConfig::get_polar_class(), diff --git a/nexus/db-model/src/console_session.rs b/nexus/db-model/src/console_session.rs index c4e5f31e23..6332cb2663 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 961566916b..cbdb1321b4 100644 --- a/nexus/db-queries/src/db/datastore/console_session.rs +++ b/nexus/db-queries/src/db/datastore/console_session.rs @@ -9,19 +9,25 @@ 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::*; +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; +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 @@ -154,4 +160,52 @@ impl DataStore { )) }) } + + /// List console sessions for a specific user + pub async fn silo_user_session_list( + &self, + opctx: &OpContext, + authn_list: authz::SiloUserAuthnList, + pagparams: &DataPageParams<'_, Uuid>, + ) -> ListResultVec { + opctx.authorize(authz::Action::ListChildren, &authn_list).await?; + + 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(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 + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) + } + + /// Delete all session for the user + pub async fn silo_user_sessions_delete( + &self, + opctx: &OpContext, + authn_list: &authz::SiloUserAuthnList, + ) -> Result<(), Error> { + // 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?; + + 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(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 e81ea997d2..3b4b834f8b 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, @@ -241,4 +268,26 @@ impl DataStore { Ok(()) } + + /// Delete all tokens for the user + pub async fn silo_user_tokens_delete( + &self, + opctx: &OpContext, + authn_list: &authz::SiloUserAuthnList, + ) -> Result<(), Error> { + // 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(authn_list.silo_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/policy_test/resource_builder.rs b/nexus/db-queries/src/policy_test/resource_builder.rs index 0826226128..a9b89bf726 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::SiloUserAuthnList { + 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!("{}: authn 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 c9293df7dd..3684e3e989 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::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/db-queries/tests/output/authz-roles.out b/nexus/db-queries/tests/output/authz-roles.out index 23f3097a04..c22f5dc27a 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": authn 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": authn 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 diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index c7def7557d..54262eb34a 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -157,6 +157,10 @@ 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 +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 API operations found with tag "snapshots" diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 4361ea4ed3..44585f664a 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -3076,6 +3076,52 @@ 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>; + + /// List user's 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>; + + /// 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>; + + /// Delete 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 e41afde1ba..8dc0f5aa6e 100644 --- a/nexus/src/app/silo.rs +++ b/nexus/src/app/silo.rs @@ -313,6 +313,86 @@ impl super::Nexus { Ok(db_silo_user) } + /// Delete all of user's tokens and sessions + 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?; + + let authz_authn_list = + authz::SiloUserAuthnList::new(authz_silo_user.clone()); + + self.datastore() + .silo_user_tokens_delete(opctx, &authz_authn_list) + .await?; + + self.datastore() + .silo_user_sessions_delete(opctx, &authz_authn_list) + .await?; + + 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() + .await?; + + 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() + .await?; + + let user_authn_list = authz::SiloUserAuthnList::new(authz_silo_user); + + self.datastore() + .silo_user_token_list(opctx, user_authn_list, pagparams) + .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() + .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 d2ba100882..2f1bc3552f 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -6860,6 +6860,113 @@ 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_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_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, + ) -> 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/device_auth.rs b/nexus/tests/integration_tests/device_auth.rs index 0ff00d768f..d1628da079 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,6 +628,147 @@ async fn test_device_token_request_ttl(cptestctx: &ControlPlaneTestContext) { .expect("token should be expired"); } +#[nexus_test] +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 + 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::Password("password1".to_string()), + ) + .await; + let user2 = create_local_user( + testctx, + &test_suite_silo, + &"user2".parse().unwrap(), + test_params::UserPassword::LoginDisallowed, + ) + .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 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 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); + + // 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 = 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( + 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 = 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 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 = 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 + 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 = 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( + 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 = 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( testctx: &ClientTestContext, ) -> Vec { @@ -634,6 +779,49 @@ async fn get_tokens_priv( .items } +async fn list_user_tokens( + testctx: &ClientTestContext, + user_id: Uuid, +) -> Vec { + NexusRequest::object_get(testctx, "/v1/me/access-tokens") + .authn_as(AuthnMode::SiloUser(user_id)) + .execute_and_parse_unwrap::>() + .await + .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 { diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 0718f1c36f..d2fa3cc3f4 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -139,6 +139,15 @@ 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_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()); + pub static DEMO_SILO_USER_ID_DELETE_URL: LazyLock = LazyLock::new(|| { format!( @@ -1675,6 +1684,32 @@ pub static VERIFY_ENDPOINTS: LazyLock> = unprivileged_access: UnprivilegedAccess::ReadOnly, allowed_methods: vec![AllowedMethod::Get], }, + VerifyEndpoint { + 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_TOKEN_LIST_URL, + visibility: Visibility::Public, + 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, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![AllowedMethod::Post(serde_json::json!( + {} + ))], + }, VerifyEndpoint { url: "/v1/groups", visibility: Visibility::Public, diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index 148ebf0bee..08a6b4c15c 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -250,6 +250,10 @@ 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_TOKEN_LIST_URL, + &*DEMO_SILO_USER_SESSION_LIST_URL, + &*DEMO_SILO_USER_LOGOUT_URL, ], }, // Create the default IP pool diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index 05e1adc30a..9db746cecd 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/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index 728d4ddadb..9bd8ab5cc1 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 6584f45bb5..9c4312307f 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -11577,6 +11577,215 @@ } } }, + "/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}/access-tokens": { + "get": { + "tags": [ + "silos" + ], + "summary": "List user's 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": [ + "silos" + ], + "summary": "Delete 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/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": [ @@ -16165,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",