Skip to content

Commit 276db6b

Browse files
committed
basic authz allowing silo admin and self to hit logout endpoint
1 parent cc2fae5 commit 276db6b

File tree

5 files changed

+215
-7
lines changed

5 files changed

+215
-7
lines changed

nexus/auth/src/authz/api_resources.rs

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

671+
// TODO: does it make sense to use a single authz resource to represent
672+
// both user sessions and tokens? seems silly to have two identical ones
673+
674+
/// Synthetic resource for managing a user's sessions and tokens
675+
#[derive(Clone, Debug, Eq, PartialEq)]
676+
pub struct UserSessions(SiloUser);
677+
678+
impl UserSessions {
679+
pub fn new(silo_user: SiloUser) -> Self {
680+
Self(silo_user)
681+
}
682+
683+
pub fn silo_user(&self) -> &SiloUser {
684+
&self.0
685+
}
686+
}
687+
688+
impl oso::PolarClass for UserSessions {
689+
fn get_polar_class_builder() -> oso::ClassBuilder<Self> {
690+
oso::Class::builder().with_equality_check().add_attribute_getter(
691+
"silo_user",
692+
|user_sessions: &UserSessions| user_sessions.silo_user().clone(),
693+
)
694+
}
695+
}
696+
697+
impl AuthorizedResource for UserSessions {
698+
fn load_roles<'fut>(
699+
&'fut self,
700+
opctx: &'fut OpContext,
701+
authn: &'fut authn::Context,
702+
roleset: &'fut mut RoleSet,
703+
) -> futures::future::BoxFuture<'fut, Result<(), Error>> {
704+
// To check for silo admin, we need to load roles from the parent silo.
705+
self.silo_user().parent.load_roles(opctx, authn, roleset)
706+
}
707+
708+
fn on_unauthorized(
709+
&self,
710+
_: &Authz,
711+
error: Error,
712+
_: AnyActor,
713+
_: Action,
714+
) -> Error {
715+
error
716+
}
717+
718+
fn polar_class(&self) -> oso::Class {
719+
Self::get_polar_class()
720+
}
721+
}
722+
671723
/// System software target version configuration
672724
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
673725
pub struct TargetReleaseConfig;

nexus/auth/src/authz/omicron.polar

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,21 @@ resource ConsoleSessionList {
440440
has_relation(fleet: Fleet, "parent_fleet", collection: ConsoleSessionList)
441441
if collection.fleet = fleet;
442442

443+
# Allow silo admins to delete user sessions
444+
resource UserSessions {
445+
permissions = [ "modify" ];
446+
relations = { parent_silo: Silo };
447+
448+
# A silo admin can modify (e.g., delete) a user's sessions.
449+
"modify" if "admin" on "parent_silo";
450+
}
451+
has_relation(silo: Silo, "parent_silo", sessions: UserSessions)
452+
if sessions.silo_user.silo = silo;
453+
454+
# also give users 'modify' on their own sessions
455+
has_permission(actor: AuthenticatedActor, "modify", sessions: UserSessions)
456+
if actor.equals_silo_user(sessions.silo_user);
457+
443458
# Describes the policy for creating and managing device authorization requests.
444459
resource DeviceAuthRequestList {
445460
permissions = [ "create_child" ];

nexus/auth/src/authz/oso_generic.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ 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+
UserSessions::get_polar_class(),
117118
TargetReleaseConfig::get_polar_class(),
118119
AlertClassList::get_polar_class(),
119120
];

nexus/src/app/silo.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,11 @@ impl super::Nexus {
324324
.fetch()
325325
.await?;
326326

327+
let authz_user_sessions =
328+
authz::UserSessions::new(authz_silo_user.clone());
329+
// TODO: would rather do this check in the datastore functions
330+
opctx.authorize(authz::Action::Modify, &authz_user_sessions).await?;
331+
327332
self.datastore()
328333
.silo_user_tokens_delete(opctx, &authz_silo_user)
329334
.await?;

nexus/tests/integration_tests/device_auth.rs

Lines changed: 142 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ use nexus_db_queries::db::fixed_data::silo::DEFAULT_SILO;
1212
use nexus_db_queries::db::identity::{Asset, Resource};
1313
use nexus_test_utils::http_testing::TestResponse;
1414
use nexus_test_utils::resource_helpers::{
15-
object_delete_error, object_get, object_put, object_put_error,
15+
create_local_user, object_delete_error, object_get, object_put,
16+
object_put_error, test_params,
1617
};
1718
use nexus_test_utils::{
1819
http_testing::{AuthnMode, NexusRequest, RequestBuilder},
@@ -28,7 +29,7 @@ use nexus_types::external_api::{
2829
};
2930

3031
use http::{StatusCode, header, method::Method};
31-
use oxide_client::types::SiloRole;
32+
use oxide_client::types::{FleetRole, SiloRole};
3233
use serde::Deserialize;
3334
use tokio::time::{Duration, sleep};
3435
use uuid::Uuid;
@@ -245,6 +246,7 @@ async fn test_device_auth_flow(cptestctx: &ControlPlaneTestContext) {
245246
/// as a string
246247
async fn get_device_token(
247248
testctx: &ClientTestContext,
249+
authn_mode: AuthnMode,
248250
) -> DeviceAccessTokenGrant {
249251
let client_id = Uuid::new_v4();
250252
let authn_params = DeviceAuthRequest { client_id, ttl_seconds: None };
@@ -272,7 +274,7 @@ async fn get_device_token(
272274
.body(Some(&confirm_params))
273275
.expect_status(Some(StatusCode::NO_CONTENT)),
274276
)
275-
.authn_as(AuthnMode::PrivilegedUser)
277+
.authn_as(authn_mode.clone())
276278
.execute()
277279
.await
278280
.expect("failed to confirm");
@@ -290,7 +292,7 @@ async fn get_device_token(
290292
.body_urlencoded(Some(&token_params))
291293
.expect_status(Some(StatusCode::OK)),
292294
)
293-
.authn_as(AuthnMode::PrivilegedUser)
295+
.authn_as(authn_mode)
294296
.execute()
295297
.await
296298
.expect("failed to get token")
@@ -311,7 +313,8 @@ async fn test_device_token_expiration(cptestctx: &ControlPlaneTestContext) {
311313

312314
// get a token for the privileged user. default silo max token expiration
313315
// is null, so tokens don't expire
314-
let initial_token_grant = get_device_token(testctx).await;
316+
let initial_token_grant =
317+
get_device_token(testctx, AuthnMode::PrivilegedUser).await;
315318
let initial_token = initial_token_grant.access_token;
316319

317320
// now there is a token in the list
@@ -381,7 +384,8 @@ async fn test_device_token_expiration(cptestctx: &ControlPlaneTestContext) {
381384
assert_eq!(settings.device_token_max_ttl_seconds, Some(3));
382385

383386
// create token again (this one will have the 3-second expiration)
384-
let expiring_token_grant = get_device_token(testctx).await;
387+
let expiring_token_grant =
388+
get_device_token(testctx, AuthnMode::PrivilegedUser).await;
385389

386390
// check that expiration time is there and in the right range
387391
let exp = expiring_token_grant
@@ -624,11 +628,142 @@ async fn test_device_token_request_ttl(cptestctx: &ControlPlaneTestContext) {
624628
.expect("token should be expired");
625629
}
626630

631+
#[nexus_test]
632+
async fn test_admin_logout_deletes_tokens(cptestctx: &ControlPlaneTestContext) {
633+
let testctx = &cptestctx.external_client;
634+
635+
// create a user have a user ID on hand to use in the authn_as
636+
let silo_url = "/v1/system/silos/test-suite-silo";
637+
let test_suite_silo: views::Silo = object_get(testctx, silo_url).await;
638+
let user1 = create_local_user(
639+
testctx,
640+
&test_suite_silo,
641+
&"user1".parse().unwrap(),
642+
test_params::UserPassword::LoginDisallowed,
643+
)
644+
.await;
645+
let user2 = create_local_user(
646+
testctx,
647+
&test_suite_silo,
648+
&"user2".parse().unwrap(),
649+
test_params::UserPassword::LoginDisallowed,
650+
)
651+
.await;
652+
653+
// TODO: we are using the fetch my tokens endpoint, authed as user1, to
654+
// check the tokens, but we will likely have a list tokens for user endpoint
655+
// (accessible to silo admins only) so they can feel good about there being
656+
// no tokens or sessions for a given user
657+
658+
// no tokens for user 1 yet
659+
let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await;
660+
assert!(tokens.is_empty());
661+
662+
// create a token for user1
663+
get_device_token(testctx, AuthnMode::SiloUser(user1.id)).await;
664+
665+
// now there is a token for user1
666+
let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await;
667+
assert_eq!(tokens.len(), 1);
668+
669+
let logout_url = format!("/v1/users/{}/logout", user1.id);
670+
671+
// user 2 cannot hit the logout endpoint for user 1
672+
NexusRequest::new(
673+
RequestBuilder::new(testctx, Method::POST, &logout_url)
674+
.body(Some(&serde_json::json!({})))
675+
.expect_status(Some(StatusCode::FORBIDDEN)),
676+
)
677+
.authn_as(AuthnMode::SiloUser(user2.id))
678+
.execute()
679+
.await
680+
.expect("User has no perms, can't delete another user's tokens");
681+
682+
let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await;
683+
assert_eq!(tokens.len(), 1);
684+
685+
// user 1 can hit the logout endpoint for themselves
686+
NexusRequest::new(
687+
RequestBuilder::new(testctx, Method::POST, &logout_url)
688+
.body(Some(&serde_json::json!({})))
689+
.expect_status(Some(StatusCode::NO_CONTENT)),
690+
)
691+
.authn_as(AuthnMode::SiloUser(user1.id))
692+
.execute()
693+
.await
694+
.expect("User 1 should be able to delete their own tokens");
695+
696+
let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await;
697+
assert!(tokens.is_empty());
698+
699+
// create another couple of tokens for user1
700+
get_device_token(testctx, AuthnMode::SiloUser(user1.id)).await;
701+
get_device_token(testctx, AuthnMode::SiloUser(user1.id)).await;
702+
703+
let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await;
704+
assert_eq!(tokens.len(), 2);
705+
706+
// make user 2 fleet admin to show that fleet admin does not inherit
707+
// the appropriate role due to being fleet admin alone
708+
grant_iam(
709+
testctx,
710+
"/v1/system",
711+
FleetRole::Admin,
712+
user2.id,
713+
AuthnMode::PrivilegedUser,
714+
)
715+
.await;
716+
717+
NexusRequest::new(
718+
RequestBuilder::new(testctx, Method::POST, &logout_url)
719+
.body(Some(&serde_json::json!({})))
720+
.expect_status(Some(StatusCode::FORBIDDEN)),
721+
)
722+
.authn_as(AuthnMode::SiloUser(user2.id))
723+
.execute()
724+
.await
725+
.expect("Fleet admin is not sufficient to delete another user's tokens");
726+
727+
let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await;
728+
assert_eq!(tokens.len(), 2);
729+
730+
// make user 2 a silo admin so they can delete user 1's tokens
731+
grant_iam(
732+
testctx,
733+
silo_url,
734+
SiloRole::Admin,
735+
user2.id,
736+
AuthnMode::PrivilegedUser,
737+
)
738+
.await;
739+
740+
NexusRequest::new(
741+
RequestBuilder::new(testctx, Method::POST, &logout_url)
742+
.body(Some(&serde_json::json!({})))
743+
.expect_status(Some(StatusCode::NO_CONTENT)),
744+
)
745+
.authn_as(AuthnMode::SiloUser(user1.id))
746+
.execute()
747+
.await
748+
.expect("Silo admin should be able to delete user 1's tokens");
749+
750+
// they're gone!
751+
let tokens = get_tokens_as(testctx, AuthnMode::SiloUser(user1.id)).await;
752+
assert!(tokens.is_empty());
753+
}
754+
627755
async fn get_tokens_priv(
628756
testctx: &ClientTestContext,
757+
) -> Vec<views::DeviceAccessToken> {
758+
get_tokens_as(testctx, AuthnMode::PrivilegedUser).await
759+
}
760+
761+
async fn get_tokens_as(
762+
testctx: &ClientTestContext,
763+
authn_mode: AuthnMode,
629764
) -> Vec<views::DeviceAccessToken> {
630765
NexusRequest::object_get(testctx, "/v1/me/access-tokens")
631-
.authn_as(AuthnMode::PrivilegedUser)
766+
.authn_as(authn_mode)
632767
.execute_and_parse_unwrap::<ResultsPage<views::DeviceAccessToken>>()
633768
.await
634769
.items

0 commit comments

Comments
 (0)