Skip to content

Commit 31b49ec

Browse files
authored
Silo admin endpoints for user logout + listing tokens and sessions (#8479)
The idea here is that to disable a user's access to the system, admins first disable that user's ability to log in on the IdP side and then hit this endpoint to remove all of their existing credentials on our end. The centerpiece is the logout endpoint, but I added the endpoints for listing sessions and tokens because someone pointed out you really want to see those come back empty after logout. They're also kind of useful anyway. Then I added `user_view` just because it wouldn't make sense to have token and session list endpoints hanging off `/v1/users/{user_id}` without having that defined. - [x] Add `/v1/users/{user_id}/logout` that deletes all of the user's tokens and sessions - [x] Add new authz resource `SiloUserAuthnList` letting us authorize that action for silo admins specifically (can't use silo modify because fleet collaborator and admin get that on all silos) - [x] Update IAM policy test - [x] Test that logout deletes tokens and the right perms are enforced - [x] Test that logout deletes sessions and the right perms are enforced - [x] Add `user_view` and `user_token_list` and `user_session_list` endpoints for symmetry and to give the admin a warm fuzzy feeling when they see that the tokens and sessions are in fact gone (also makes testing a little cleaner) - [x] Fix session list including expired sessions (and test it) - [ ] Think about whether we need to do something about dueling admins issues, i.e., what if the person you're trying to disable are themselves a silo admin and they log everyone else out of the silo. The only solution I can think of off the top of my head is an operator-level version of this endpoint that can be used by a user outside of the silo in question.
1 parent cc1d652 commit 31b49ec

File tree

20 files changed

+1210
-10
lines changed

20 files changed

+1210
-10
lines changed

nexus/auth/src/authz/api_resources.rs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,118 @@ impl AuthorizedResource for SiloUserList {
668668
}
669669
}
670670

671+
// Note the session list and the token list have exactly the same behavior
672+
673+
/// Synthetic resource for managing a user's sessions
674+
#[derive(Clone, Debug, Eq, PartialEq)]
675+
pub struct SiloUserSessionList(SiloUser);
676+
677+
impl SiloUserSessionList {
678+
pub fn new(silo_user: SiloUser) -> Self {
679+
Self(silo_user)
680+
}
681+
682+
pub fn silo_user(&self) -> &SiloUser {
683+
&self.0
684+
}
685+
686+
pub fn silo(&self) -> &Silo {
687+
&self.0.parent
688+
}
689+
}
690+
691+
impl oso::PolarClass for SiloUserSessionList {
692+
fn get_polar_class_builder() -> oso::ClassBuilder<Self> {
693+
oso::Class::builder().with_equality_check().add_attribute_getter(
694+
"silo_user",
695+
|user_sessions: &SiloUserSessionList| {
696+
user_sessions.silo_user().clone()
697+
},
698+
)
699+
}
700+
}
701+
702+
impl AuthorizedResource for SiloUserSessionList {
703+
fn load_roles<'fut>(
704+
&'fut self,
705+
opctx: &'fut OpContext,
706+
authn: &'fut authn::Context,
707+
roleset: &'fut mut RoleSet,
708+
) -> futures::future::BoxFuture<'fut, Result<(), Error>> {
709+
// To check for silo admin, we need to load roles from the parent silo.
710+
self.silo_user().parent.load_roles(opctx, authn, roleset)
711+
}
712+
713+
fn on_unauthorized(
714+
&self,
715+
_: &Authz,
716+
error: Error,
717+
_: AnyActor,
718+
_: Action,
719+
) -> Error {
720+
error
721+
}
722+
723+
fn polar_class(&self) -> oso::Class {
724+
Self::get_polar_class()
725+
}
726+
}
727+
728+
/// Synthetic resource for managing a user's tokens
729+
#[derive(Clone, Debug, Eq, PartialEq)]
730+
pub struct SiloUserTokenList(SiloUser);
731+
732+
impl SiloUserTokenList {
733+
pub fn new(silo_user: SiloUser) -> Self {
734+
Self(silo_user)
735+
}
736+
737+
pub fn silo_user(&self) -> &SiloUser {
738+
&self.0
739+
}
740+
741+
pub fn silo(&self) -> &Silo {
742+
&self.0.parent
743+
}
744+
}
745+
746+
impl oso::PolarClass for SiloUserTokenList {
747+
fn get_polar_class_builder() -> oso::ClassBuilder<Self> {
748+
oso::Class::builder().with_equality_check().add_attribute_getter(
749+
"silo_user",
750+
|user_sessions: &SiloUserTokenList| {
751+
user_sessions.silo_user().clone()
752+
},
753+
)
754+
}
755+
}
756+
757+
impl AuthorizedResource for SiloUserTokenList {
758+
fn load_roles<'fut>(
759+
&'fut self,
760+
opctx: &'fut OpContext,
761+
authn: &'fut authn::Context,
762+
roleset: &'fut mut RoleSet,
763+
) -> futures::future::BoxFuture<'fut, Result<(), Error>> {
764+
// To check for silo admin, we need to load roles from the parent silo.
765+
self.silo_user().parent.load_roles(opctx, authn, roleset)
766+
}
767+
768+
fn on_unauthorized(
769+
&self,
770+
_: &Authz,
771+
error: Error,
772+
_: AnyActor,
773+
_: Action,
774+
) -> Error {
775+
error
776+
}
777+
778+
fn polar_class(&self) -> oso::Class {
779+
Self::get_polar_class()
780+
}
781+
}
782+
671783
#[derive(Clone, Copy, Debug)]
672784
pub struct UpdateTrustRootList;
673785

nexus/auth/src/authz/omicron.polar

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,46 @@ resource ConsoleSessionList {
450450
has_relation(fleet: Fleet, "parent_fleet", collection: ConsoleSessionList)
451451
if collection.fleet = fleet;
452452

453+
# Allow silo admins to delete and list user sessions
454+
resource SiloUserSessionList {
455+
permissions = [ "modify", "list_children" ];
456+
relations = { parent_silo: Silo };
457+
458+
# A silo admin can modify (e.g., delete) a user's sessions.
459+
"modify" if "admin" on "parent_silo";
460+
461+
# A silo admin can list a user's sessions.
462+
"list_children" if "admin" on "parent_silo";
463+
}
464+
has_relation(silo: Silo, "parent_silo", authn_list: SiloUserSessionList)
465+
if authn_list.silo_user.silo = silo;
466+
467+
# give users 'modify' and 'list_children' on their own sessions
468+
has_permission(actor: AuthenticatedActor, "modify", authn_list: SiloUserSessionList)
469+
if actor.equals_silo_user(authn_list.silo_user);
470+
has_permission(actor: AuthenticatedActor, "list_children", authn_list: SiloUserSessionList)
471+
if actor.equals_silo_user(authn_list.silo_user);
472+
473+
# Allow silo admins to delete and list user access tokens
474+
resource SiloUserTokenList {
475+
permissions = [ "modify", "list_children" ];
476+
relations = { parent_silo: Silo };
477+
478+
# A silo admin can modify (e.g., delete) a user's tokens.
479+
"modify" if "admin" on "parent_silo";
480+
481+
# A silo admin can list a user's tokens.
482+
"list_children" if "admin" on "parent_silo";
483+
}
484+
has_relation(silo: Silo, "parent_silo", authn_list: SiloUserTokenList)
485+
if authn_list.silo_user.silo = silo;
486+
487+
# give users 'modify' and 'list_children' on their own tokens
488+
has_permission(actor: AuthenticatedActor, "modify", authn_list: SiloUserTokenList)
489+
if actor.equals_silo_user(authn_list.silo_user);
490+
has_permission(actor: AuthenticatedActor, "list_children", authn_list: SiloUserTokenList)
491+
if actor.equals_silo_user(authn_list.silo_user);
492+
453493
# Describes the policy for creating and managing device authorization requests.
454494
resource DeviceAuthRequestList {
455495
permissions = [ "create_child" ];

nexus/auth/src/authz/oso_generic.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result<OsoInit, anyhow::Error> {
114114
SiloCertificateList::get_polar_class(),
115115
SiloIdentityProviderList::get_polar_class(),
116116
SiloUserList::get_polar_class(),
117+
SiloUserSessionList::get_polar_class(),
118+
SiloUserTokenList::get_polar_class(),
117119
UpdateTrustRootList::get_polar_class(),
118120
TargetReleaseConfig::get_polar_class(),
119121
AlertClassList::get_polar_class(),

nexus/db-model/src/console_session.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44

55
use chrono::{DateTime, Utc};
66
use nexus_db_schema::schema::console_session;
7-
use omicron_uuid_kinds::ConsoleSessionKind;
8-
use omicron_uuid_kinds::ConsoleSessionUuid;
7+
use nexus_types::external_api::views;
8+
use omicron_uuid_kinds::{ConsoleSessionKind, ConsoleSessionUuid, GenericUuid};
99
use uuid::Uuid;
1010

1111
use crate::typed_uuid::DbTypedUuid;
@@ -38,3 +38,13 @@ impl ConsoleSession {
3838
self.id.0
3939
}
4040
}
41+
42+
impl From<ConsoleSession> for views::ConsoleSession {
43+
fn from(session: ConsoleSession) -> Self {
44+
Self {
45+
id: session.id.into_untyped_uuid(),
46+
time_created: session.time_created,
47+
time_last_used: session.time_last_used,
48+
}
49+
}
50+
}

nexus/db-queries/src/db/datastore/console_session.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,26 @@ use crate::authn;
99
use crate::authz;
1010
use crate::context::OpContext;
1111
use crate::db::model::ConsoleSession;
12+
use crate::db::pagination::paginated;
1213
use async_bb8_diesel::AsyncRunQueryDsl;
14+
use chrono::TimeDelta;
1315
use chrono::Utc;
1416
use diesel::prelude::*;
17+
use nexus_db_errors::ErrorHandler;
18+
use nexus_db_errors::public_error_from_diesel;
1519
use nexus_db_lookup::LookupPath;
1620
use nexus_db_schema::schema::console_session;
1721
use omicron_common::api::external::CreateResult;
22+
use omicron_common::api::external::DataPageParams;
1823
use omicron_common::api::external::DeleteResult;
1924
use omicron_common::api::external::Error;
25+
use omicron_common::api::external::ListResultVec;
2026
use omicron_common::api::external::LookupResult;
2127
use omicron_common::api::external::LookupType;
2228
use omicron_common::api::external::ResourceType;
2329
use omicron_common::api::external::UpdateResult;
2430
use omicron_uuid_kinds::GenericUuid;
31+
use uuid::Uuid;
2532

2633
impl DataStore {
2734
/// Look up session by token. The token is a kind of password, so simply
@@ -154,4 +161,60 @@ impl DataStore {
154161
))
155162
})
156163
}
164+
165+
/// List console sessions for a specific user
166+
///
167+
/// Have to pass down TTLs because they come from the nexus server config.
168+
pub async fn silo_user_session_list(
169+
&self,
170+
opctx: &OpContext,
171+
authn_list: authz::SiloUserSessionList,
172+
pagparams: &DataPageParams<'_, Uuid>,
173+
idle_ttl: TimeDelta,
174+
abs_ttl: TimeDelta,
175+
) -> ListResultVec<ConsoleSession> {
176+
opctx.authorize(authz::Action::ListChildren, &authn_list).await?;
177+
178+
let user_id = authn_list.silo_user().id();
179+
180+
// HACK: unlike with tokens, we do not have expiration time here,
181+
// so we can't filter out expired sessions by comparing to now. The
182+
// real way to do this would be to change this to time_expires_idle
183+
// and time_expires_abs and just compare them to now directly. Then
184+
// we would not have to pass the TTLs down from the handler.
185+
let now = Utc::now();
186+
187+
use nexus_db_schema::schema::console_session::dsl;
188+
paginated(dsl::console_session, dsl::id, &pagparams)
189+
.filter(dsl::silo_user_id.eq(user_id))
190+
// session is not expired according to abs timeout
191+
.filter(dsl::time_created.ge(now - abs_ttl))
192+
// session is also not expired according to idle timeout
193+
.filter(dsl::time_last_used.ge(now - idle_ttl))
194+
.select(ConsoleSession::as_select())
195+
.load_async(&*self.pool_connection_authorized(opctx).await?)
196+
.await
197+
.map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))
198+
}
199+
200+
/// Delete all session for the user
201+
pub async fn silo_user_sessions_delete(
202+
&self,
203+
opctx: &OpContext,
204+
authn_list: &authz::SiloUserSessionList,
205+
) -> Result<(), Error> {
206+
// authz policy enforces that the opctx actor is a silo admin on the
207+
// target user's own silo in particular
208+
opctx.authorize(authz::Action::Modify, authn_list).await?;
209+
210+
let user_id = authn_list.silo_user().id();
211+
212+
use nexus_db_schema::schema::console_session;
213+
diesel::delete(console_session::table)
214+
.filter(console_session::silo_user_id.eq(user_id))
215+
.execute_async(&*self.pool_connection_authorized(opctx).await?)
216+
.await
217+
.map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))
218+
.map(|_x| ())
219+
}
157220
}

nexus/db-queries/src/db/datastore/device_auth.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,33 @@ impl DataStore {
214214
.map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))
215215
}
216216

217+
/// List device access tokens for a specific user
218+
pub async fn silo_user_token_list(
219+
&self,
220+
opctx: &OpContext,
221+
authz_token_list: authz::SiloUserTokenList,
222+
pagparams: &DataPageParams<'_, Uuid>,
223+
) -> ListResultVec<DeviceAccessToken> {
224+
opctx.authorize(authz::Action::ListChildren, &authz_token_list).await?;
225+
226+
let silo_user_id = authz_token_list.silo_user().id();
227+
228+
use nexus_db_schema::schema::device_access_token::dsl;
229+
paginated(dsl::device_access_token, dsl::id, &pagparams)
230+
.filter(dsl::silo_user_id.eq(silo_user_id))
231+
// we don't have time_deleted on tokens. unfortunately this is not
232+
// indexed well. maybe it can be!
233+
.filter(
234+
dsl::time_expires
235+
.is_null()
236+
.or(dsl::time_expires.gt(Utc::now())),
237+
)
238+
.select(DeviceAccessToken::as_select())
239+
.load_async(&*self.pool_connection_authorized(opctx).await?)
240+
.await
241+
.map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))
242+
}
243+
217244
pub async fn current_user_token_delete(
218245
&self,
219246
opctx: &OpContext,
@@ -241,4 +268,26 @@ impl DataStore {
241268

242269
Ok(())
243270
}
271+
272+
/// Delete all tokens for the user
273+
pub async fn silo_user_tokens_delete(
274+
&self,
275+
opctx: &OpContext,
276+
authz_token_list: &authz::SiloUserTokenList,
277+
) -> Result<(), Error> {
278+
// authz policy enforces that the opctx actor is a silo admin on the
279+
// target user's own silo in particular
280+
opctx.authorize(authz::Action::Modify, authz_token_list).await?;
281+
282+
use nexus_db_schema::schema::device_access_token;
283+
diesel::delete(device_access_token::table)
284+
.filter(
285+
device_access_token::silo_user_id
286+
.eq(authz_token_list.silo_user().id()),
287+
)
288+
.execute_async(&*self.pool_connection_authorized(opctx).await?)
289+
.await
290+
.map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))
291+
.map(|_x| ())
292+
}
244293
}

nexus/db-queries/src/policy_test/resource_builder.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,3 +345,37 @@ impl DynAuthorizedResource for authz::SiloUserList {
345345
format!("{}: user list", self.silo().resource_name())
346346
}
347347
}
348+
349+
impl DynAuthorizedResource for authz::SiloUserSessionList {
350+
fn do_authorize<'a, 'b>(
351+
&'a self,
352+
opctx: &'b OpContext,
353+
action: authz::Action,
354+
) -> BoxFuture<'a, Result<(), Error>>
355+
where
356+
'b: 'a,
357+
{
358+
opctx.authorize(action, self).boxed()
359+
}
360+
361+
fn resource_name(&self) -> String {
362+
format!("{}: session list", self.silo_user().resource_name())
363+
}
364+
}
365+
366+
impl DynAuthorizedResource for authz::SiloUserTokenList {
367+
fn do_authorize<'a, 'b>(
368+
&'a self,
369+
opctx: &'b OpContext,
370+
action: authz::Action,
371+
) -> BoxFuture<'a, Result<(), Error>>
372+
where
373+
'b: 'a,
374+
{
375+
opctx.authorize(action, self).boxed()
376+
}
377+
378+
fn resource_name(&self) -> String {
379+
format!("{}: token list", self.silo_user().resource_name())
380+
}
381+
}

0 commit comments

Comments
 (0)