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

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
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
58 changes: 58 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,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
Comment on lines +671 to +672
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, how would that work? Is that a question you want to answer before merging this branch?

Copy link
Contributor Author

@david-crespo david-crespo Jul 15, 2025

Choose a reason for hiding this comment

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

Status quo is the single resource version (I meant resource from the POV of authz). I think it would be simple to have two instead, I would just make a second one and name one tokens and one sessions, and use each as appropriate. Since that can be done any time we need to distinguish the two (we may never have to) I am inclined to solve this by deleting the comment.


/// 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<Self> {
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;

Expand Down
20 changes: 20 additions & 0 deletions nexus/auth/src/authz/omicron.polar
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a little funny because I'm using list_children to determine whether you can list sessions and tokens (only self and silo admin can) but then I'm using modify on the list itself to determine whether you can do the logout delete-all operation. It works fine, but it goes slightly against the grain of how I know it's supposed to work. We just don't have many delete all type things.

Copy link
Member

Choose a reason for hiding this comment

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

Hmm, that seems resonable to me, though I'm far from the most knowledgeable about how we tend to model stuff in Polar... @davepacheco might have an opinion?

}
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" ];
Expand Down
1 change: 1 addition & 0 deletions nexus/auth/src/authz/oso_generic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result<OsoInit, anyhow::Error> {
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(),
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,
}
}
}
54 changes: 54 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,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
Expand Down Expand Up @@ -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<ConsoleSession> {
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.
Comment on lines +178 to +184
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the major outstanding issue. I will probably make the fix a separate PR on top of this one because it's an actual change, not just an addition.

Copy link
Member

Choose a reason for hiding this comment

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

Mm, I like the idea of the proposed refactor --- generally it seems nicer to represent things as deadlines, I think. I agree that it's a big enough change that it could be done subsequently. In the meantime, might we want to cruft together a thing that filters such sessions out of the returned Vec in Rust code?

Do you think it's worth opening an issue to track that, in addition to/instead of the TODO comment?

.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| ())
}
}
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,
user_authn_list: authz::SiloUserAuthnList,
pagparams: &DataPageParams<'_, Uuid>,
) -> ListResultVec<DeviceAccessToken> {
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,
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,
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)
Copy link
Member

Choose a reason for hiding this comment

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

Occurs to me we could do a soft-delete-like thing by mutating the expiration time to now, though that feels kind of sketchy to me as the soft-delete would disturb the record of what that token was originally supposed to be... just hard deleting them is probably less goofy!

.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| ())
}
}
17 changes: 17 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,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())
}
}
3 changes: 2 additions & 1 deletion nexus/db-queries/src/policy_test/resources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
));
Expand All @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions nexus/db-queries/tests/output/authz-roles.out
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions nexus/external-api/output/nexus_tags.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading