Skip to content
Merged
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
98 changes: 98 additions & 0 deletions crates/ogar-auth/src/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthBinding>,
/// 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<AuthBinding>,
}

/// 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 {
Expand All @@ -177,6 +192,8 @@ impl AuthContext {
channel: AuthChannel::Kiosk,
provider: ProviderId::KIOSK,
strength: AuthStrength::None,
acting: None,
prior_actors: Vec::new(),
}
}

Expand All @@ -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<Item = AuthBinding>,
) -> Result<Self, NotDelegable> {
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`.
Expand All @@ -199,6 +241,8 @@ impl AuthContext {
channel: AuthChannel::Web,
provider,
strength,
acting: None,
prior_actors: Vec::new(),
}
}
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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() {
Expand Down
39 changes: 38 additions & 1 deletion crates/ogar-rbac/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -216,9 +216,17 @@ impl<S: GrantSource> RbacAuthority for OgarRbac<S> {
///
/// 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<AuthBinding>,
tenant: u64,
roles: Vec<RoleId>,
unplugged: Vec<String>,
Expand All @@ -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]
Expand Down Expand Up @@ -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");
Expand Down
Loading