diff --git a/crates/ogar-auth/src/user.rs b/crates/ogar-auth/src/user.rs index 373c69e1..b3172675 100644 --- a/crates/ogar-auth/src/user.rs +++ b/crates/ogar-auth/src/user.rs @@ -166,6 +166,21 @@ pub struct AuthContext { pub provider: ProviderId, /// How strong the assertion was. pub strength: AuthStrength, + /// The party acting on the user's behalf in this session, when the + /// login is delegated: the outermost RFC 8693 `act` claim, as the + /// provider asserted it. `None` for an undelegated login. + pub acting: Option, + /// Earlier actors from nested `act` claims, most recent first. + /// Informational only: never part of an access decision (RFC 8693 §4.1). + pub prior_actors: Vec, +} + +/// Why a login cannot carry a delegation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotDelegable { + /// No credential verified (kiosk): there is no exchanged token whose + /// `act` claim could name an actor. + Unauthenticated, } impl AuthContext { @@ -177,6 +192,8 @@ impl AuthContext { channel: AuthChannel::Kiosk, provider: ProviderId::KIOSK, strength: AuthStrength::None, + acting: None, + prior_actors: Vec::new(), } } @@ -188,7 +205,32 @@ impl AuthContext { channel: AuthChannel::Local, provider: ProviderId::LOCAL, strength, + acting: None, + prior_actors: Vec::new(), + } + } + + /// This login with a delegation recorded: `acting` (the outermost RFC + /// 8693 `act`) performs the session's requests on the user's behalf, with + /// the user's authority; `prior` are the nested actors, most recent first. Roles and every + /// authorization decision stay the user's. + /// + /// # Errors + /// + /// [`NotDelegable::Unauthenticated`] for an unauthenticated or kiosk + /// context. Both are checked: `authenticated` is a public field, so a + /// kiosk-channel context is refused even if it was set to `true`. + pub fn delegated( + mut self, + acting: AuthBinding, + prior: impl IntoIterator, + ) -> Result { + if !self.authenticated || self.channel == AuthChannel::Kiosk { + return Err(NotDelegable::Unauthenticated); } + self.acting = Some(acting); + self.prior_actors = prior.into_iter().collect(); + Ok(self) } /// A verified federated login, asserted by `provider`. @@ -199,6 +241,8 @@ impl AuthContext { channel: AuthChannel::Web, provider, strength, + acting: None, + prior_actors: Vec::new(), } } } @@ -251,6 +295,13 @@ impl AuthenticatedUser { self.user.roles.clone(), ) } + + /// The party performing this session's requests: the delegated actor + /// when the login is delegated, `None` when the user acts as itself. + #[must_use] + pub fn acting_party(&self) -> Option<&AuthBinding> { + self.auth.acting.as_ref() + } } /// Resolve external identity bindings to canonical users. @@ -359,6 +410,53 @@ mod tests { ); } + /// RFC 8693 delegation: bob's session acts with user 42's authority. The + /// envelope handed to authorization is user 42's, unchanged; bob is + /// recorded as the acting party, and the earlier actor only rides along. + #[test] + fn a_delegated_login_keeps_the_users_authority() { + let s = store(); + let u = s.user(UserId(42)).expect("user exists").clone(); + let plain = AuthenticatedUser { + user: u.clone(), + auth: AuthContext::federated(ZITADEL, AuthStrength::MultiFactor), + }; + let delegated = AuthenticatedUser { + user: u, + auth: AuthContext::federated(ZITADEL, AuthStrength::MultiFactor) + .delegated( + AuthBinding::new(ZITADEL, "bob"), + [AuthBinding::new(ZITADEL, "svc")], + ) + .expect("an authenticated login can be delegated"), + }; + assert_eq!(plain.acting_party(), None); + assert_eq!( + delegated.acting_party(), + Some(&AuthBinding::new(ZITADEL, "bob")) + ); + assert_eq!(delegated.auth.prior_actors.len(), 1); + assert_eq!(delegated.actor_context(), plain.actor_context()); + } + + /// A kiosk session has no exchanged token, so nothing can act through it. + #[test] + fn a_kiosk_login_cannot_be_delegated() { + assert_eq!( + AuthContext::kiosk().delegated(AuthBinding::new(ZITADEL, "bob"), []), + Err(NotDelegable::Unauthenticated) + ); + let forced = AuthContext { + authenticated: true, + ..AuthContext::kiosk() + }; + assert_eq!( + forced.delegated(AuthBinding::new(ZITADEL, "bob"), []), + Err(NotDelegable::Unauthenticated), + "a kiosk channel is refused whatever the flag says" + ); + } + /// An unknown binding resolves to nothing — never to a default user. #[test] fn unknown_binding_resolves_to_nothing() { diff --git a/crates/ogar-rbac/src/lib.rs b/crates/ogar-rbac/src/lib.rs index a9a41757..b60f92cf 100644 --- a/crates/ogar-rbac/src/lib.rs +++ b/crates/ogar-rbac/src/lib.rs @@ -65,7 +65,7 @@ use lance_graph_contract::rbac_plug::{ }; use lance_graph_rbac::authorize::{MembershipDecision, authorize_memberships}; use lance_graph_rbac::authorize::{ScopedDecision, authorize_scoped}; -use ogar_auth::user::AuthenticatedUser; +use ogar_auth::user::{AuthBinding, AuthenticatedUser}; /// Where this authority reads its grant data. /// @@ -216,9 +216,17 @@ impl RbacAuthority for OgarRbac { /// /// Built per request from the authenticated user: it answers for that one /// subject and knows no other actor. +/// +/// # Delegation +/// +/// A delegated login (RFC 8693 `act`) authorizes as the user whose authority +/// the token carries, never as the party acting with it: the acting party is +/// recorded ([`acting_party`](IdentityActors::acting_party)) and holds no +/// role here, under any name. #[derive(Debug, Clone, PartialEq, Eq)] pub struct IdentityActors { subject: String, + acting: Option, tenant: u64, roles: Vec, unplugged: Vec, @@ -243,12 +251,20 @@ impl IdentityActors { } Self { subject: identity.user.subject.clone(), + acting: identity.acting_party().cloned(), tenant: identity.user.tenant, roles, unplugged, } } + /// The party acting on the user's behalf, when the login is delegated. + /// For audit: it never contributes a role. + #[must_use] + pub fn acting_party(&self) -> Option<&AuthBinding> { + self.acting.as_ref() + } + /// The user's roles the plug did not declare — dropped, so they grant /// nothing here. For audit and diagnostics. #[must_use] @@ -724,6 +740,27 @@ mod tests { ); } + // A delegated login (RFC 8693 `act`): bob acts with dr-house's authority. + // The decision is dr-house's; bob is recorded and holds nothing himself. + #[test] + fn a_delegated_identity_is_authorized_as_the_user_not_the_actor() { + let binding = authority().bind(&PLUG).expect("green bind"); + let mut user = identity(&["cashier"], 7); + user.auth = user + .auth + .delegated(AuthBinding::new(ZITADEL, "bob"), []) + .expect("authenticated"); + let d = authorize_identity(&binding, &user, patient_class(), read()); + assert_eq!(d.decision, AccessDecision::Allow); + let actors = IdentityActors::new(&user, &binding); + assert_eq!( + actors.acting_party(), + Some(&AuthBinding::new(ZITADEL, "bob")) + ); + assert!(actors.roles_of("bob").is_empty()); + assert_eq!(actors.roles_of("dr-house"), &["cashier"]); + } + #[test] fn a_user_with_no_plugged_role_is_denied() { let binding = authority().bind(&PLUG).expect("green bind");