From 62148001d8a1af42ac0e8022b58fc08ee01c0c05 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 22:52:57 +0000 Subject: [PATCH 1/2] ogar-auth + ogar-rbac: a delegated login keeps the user's authority AuthContext records an RFC 8693 delegation: the acting party (the outermost act claim, as the provider asserted it) and the earlier actors, most recent first. AuthenticatedUser::acting_party reads it. The identity envelope, the roles and every authorization decision stay the user's; a kiosk login cannot be delegated. IdentityActors records the acting party for audit and gives it no role under any name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-auth/src/user.rs | 87 ++++++++++++++++++++++++++++++++++++ crates/ogar-rbac/src/lib.rs | 39 +++++++++++++++- 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/crates/ogar-auth/src/user.rs b/crates/ogar-auth/src/user.rs index 373c69e1..66d1fc69 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,30 @@ impl AuthContext { channel: AuthChannel::Local, provider: ProviderId::LOCAL, strength, + acting: None, + prior_actors: Vec::new(), + } + } + + /// This login with a delegation recorded: the session acts with the + /// user's authority on behalf of `acting` (the outermost RFC 8693 `act`); + /// `prior` are the nested actors, most recent first. Roles and every + /// authorization decision stay the user's. + /// + /// # Errors + /// + /// [`NotDelegable::Unauthenticated`] for a kiosk context. + pub fn delegated( + mut self, + acting: AuthBinding, + prior: impl IntoIterator, + ) -> Result { + if !self.authenticated { + 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 +239,8 @@ impl AuthContext { channel: AuthChannel::Web, provider, strength, + acting: None, + prior_actors: Vec::new(), } } } @@ -251,6 +293,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 +408,44 @@ 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) + ); + } + /// 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"); From f583268e9f433255d388838883a4b7a75597f387 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 23:06:50 +0000 Subject: [PATCH 2/2] ogar-auth: refuse delegation on a kiosk channel; state who acts for whom delegated() checked only the public authenticated flag, so a kiosk context with the flag set to true could be delegated; the kiosk channel is now refused too. The doc said the session acts on behalf of the acting party; it is the acting party that acts on the user's behalf, with the user's authority. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-auth/src/user.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/ogar-auth/src/user.rs b/crates/ogar-auth/src/user.rs index 66d1fc69..b3172675 100644 --- a/crates/ogar-auth/src/user.rs +++ b/crates/ogar-auth/src/user.rs @@ -210,20 +210,22 @@ impl AuthContext { } } - /// This login with a delegation recorded: the session acts with the - /// user's authority on behalf of `acting` (the outermost RFC 8693 `act`); - /// `prior` are the nested actors, most recent first. Roles and every + /// 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 a kiosk context. + /// [`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 { + if !self.authenticated || self.channel == AuthChannel::Kiosk { return Err(NotDelegable::Unauthenticated); } self.acting = Some(acting); @@ -444,6 +446,15 @@ mod tests { 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.