Skip to content

Silo admin endpoints for user logout + listing tokens and sessions #8479

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Jul 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions nexus/auth/src/authz/api_resources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,118 @@ impl AuthorizedResource for SiloUserList {
}
}

// Note the session list and the token list have exactly the same behavior

/// Synthetic resource for managing a user's sessions
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SiloUserSessionList(SiloUser);

impl SiloUserSessionList {
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 SiloUserSessionList {
fn get_polar_class_builder() -> oso::ClassBuilder<Self> {
oso::Class::builder().with_equality_check().add_attribute_getter(
"silo_user",
|user_sessions: &SiloUserSessionList| {
user_sessions.silo_user().clone()
},
)
}
}

impl AuthorizedResource for SiloUserSessionList {
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()
}
}

/// Synthetic resource for managing a user's tokens
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SiloUserTokenList(SiloUser);

impl SiloUserTokenList {
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 SiloUserTokenList {
fn get_polar_class_builder() -> oso::ClassBuilder<Self> {
oso::Class::builder().with_equality_check().add_attribute_getter(
"silo_user",
|user_sessions: &SiloUserTokenList| {
user_sessions.silo_user().clone()
},
)
}
}

impl AuthorizedResource for SiloUserTokenList {
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;

Expand Down
40 changes: 40 additions & 0 deletions nexus/auth/src/authz/omicron.polar
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,46 @@ resource ConsoleSessionList {
has_relation(fleet: Fleet, "parent_fleet", collection: ConsoleSessionList)
if collection.fleet = fleet;

# Allow silo admins to delete and list user sessions
resource SiloUserSessionList {
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 sessions.
"list_children" if "admin" on "parent_silo";
}
has_relation(silo: Silo, "parent_silo", authn_list: SiloUserSessionList)
if authn_list.silo_user.silo = silo;

# give users 'modify' and 'list_children' on their own sessions
has_permission(actor: AuthenticatedActor, "modify", authn_list: SiloUserSessionList)
if actor.equals_silo_user(authn_list.silo_user);
has_permission(actor: AuthenticatedActor, "list_children", authn_list: SiloUserSessionList)
if actor.equals_silo_user(authn_list.silo_user);

# Allow silo admins to delete and list user access tokens
resource SiloUserTokenList {
permissions = [ "modify", "list_children" ];
relations = { parent_silo: Silo };

# A silo admin can modify (e.g., delete) a user's tokens.
"modify" if "admin" on "parent_silo";

# A silo admin can list a user's tokens.
"list_children" if "admin" on "parent_silo";
}
has_relation(silo: Silo, "parent_silo", authn_list: SiloUserTokenList)
if authn_list.silo_user.silo = silo;

# give users 'modify' and 'list_children' on their own tokens
has_permission(actor: AuthenticatedActor, "modify", authn_list: SiloUserTokenList)
if actor.equals_silo_user(authn_list.silo_user);
has_permission(actor: AuthenticatedActor, "list_children", authn_list: SiloUserTokenList)
if actor.equals_silo_user(authn_list.silo_user);

# Describes the policy for creating and managing device authorization requests.
resource DeviceAuthRequestList {
permissions = [ "create_child" ];
Expand Down
2 changes: 2 additions & 0 deletions nexus/auth/src/authz/oso_generic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result<OsoInit, anyhow::Error> {
SiloCertificateList::get_polar_class(),
SiloIdentityProviderList::get_polar_class(),
SiloUserList::get_polar_class(),
SiloUserSessionList::get_polar_class(),
SiloUserTokenList::get_polar_class(),
UpdateTrustRootList::get_polar_class(),
TargetReleaseConfig::get_polar_class(),
AlertClassList::get_polar_class(),
Expand Down
14 changes: 12 additions & 2 deletions nexus/db-model/src/console_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -38,3 +38,13 @@ impl ConsoleSession {
self.id.0
}
}

impl From<ConsoleSession> 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,
}
}
}
63 changes: 63 additions & 0 deletions nexus/db-queries/src/db/datastore/console_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,26 @@ 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::TimeDelta;
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
Expand Down Expand Up @@ -154,4 +161,60 @@ impl DataStore {
))
})
}

/// List console sessions for a specific user
///
/// Have to pass down TTLs because they come from the nexus server config.
pub async fn silo_user_session_list(
&self,
opctx: &OpContext,
authn_list: authz::SiloUserSessionList,
pagparams: &DataPageParams<'_, Uuid>,
idle_ttl: TimeDelta,
abs_ttl: TimeDelta,
Comment on lines +173 to +174
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpicky, sorry: I really don't like when a function has two or more positional arguments of the same type, because when you call the function, you have to remember which is which, and it's easy to transpose them etc by mistake. It might be nicer to have a quick

#[derive(Copy, Clone)]
pub struct SessionTtls {
    pub idle: TimeDelta,
    pub abs: TimeDelta,
}

so they're distinguished by name at the call site instead of by which order they're passed in?

Copy link
Contributor Author

@david-crespo david-crespo Jul 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I thought about doing this to make it all less sketchy-feeling. Will do (maybe in a followup PR so I can get this merged ASAP).

) -> ListResultVec<ConsoleSession> {
opctx.authorize(authz::Action::ListChildren, &authn_list).await?;

let user_id = authn_list.silo_user().id();

// HACK: unlike with tokens, we do not have expiration time here,
// so we can't filter out expired sessions by comparing to now. The
// real way to do this would be to change this to time_expires_idle
// and time_expires_abs and just compare them to now directly. Then
// we would not have to pass the TTLs down from the handler.
let now = Utc::now();

use nexus_db_schema::schema::console_session::dsl;
paginated(dsl::console_session, dsl::id, &pagparams)
.filter(dsl::silo_user_id.eq(user_id))
// session is not expired according to abs timeout
.filter(dsl::time_created.ge(now - abs_ttl))
// session is also not expired according to idle timeout
.filter(dsl::time_last_used.ge(now - idle_ttl))
.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::SiloUserSessionList,
) -> 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| ())
}
}
49 changes: 49 additions & 0 deletions nexus/db-queries/src/db/datastore/device_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
authz_token_list: authz::SiloUserTokenList,
pagparams: &DataPageParams<'_, Uuid>,
) -> ListResultVec<DeviceAccessToken> {
opctx.authorize(authz::Action::ListChildren, &authz_token_list).await?;

let silo_user_id = authz_token_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,
Expand Down Expand Up @@ -241,4 +268,26 @@ impl DataStore {

Ok(())
}

/// Delete all tokens for the user
pub async fn silo_user_tokens_delete(
&self,
opctx: &OpContext,
authz_token_list: &authz::SiloUserTokenList,
) -> 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, authz_token_list).await?;

use nexus_db_schema::schema::device_access_token;
diesel::delete(device_access_token::table)
.filter(
device_access_token::silo_user_id
.eq(authz_token_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| ())
}
}
34 changes: 34 additions & 0 deletions nexus/db-queries/src/policy_test/resource_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,3 +345,37 @@ impl DynAuthorizedResource for authz::SiloUserList {
format!("{}: user list", self.silo().resource_name())
}
}

impl DynAuthorizedResource for authz::SiloUserSessionList {
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())
}
}

impl DynAuthorizedResource for authz::SiloUserTokenList {
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!("{}: token list", self.silo_user().resource_name())
}
}
Loading
Loading