From e3ed93840cf23d5c86a3f39d88389698b24f42d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 22:23:29 +0000 Subject: [PATCH] feat(ogar-auth): issuer-qualified external identities and a verified-login boundary (PR-00) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - identity: Issuer (byte-exact OIDC iss, no normalisation), ExternalSubject, ExternalIdentity keyed by (issuer, subject). - user: LocalUserStore keeps external identities in a unique index; try_insert / bind_external refuse duplicate user ids, subjects, bindings held by another user and duplicate identities; insert keeps its signature and panics on conflicting input; UserStore::resolve_external defaults to None (fail closed). AuthContext::federated and AuthenticatedUser are documented as descriptive, not proof. - federation: TrustedIssuers registry, AssertionVerifier seam for the provider-agnostic OIDC adapter (PR-01), establish() -> VerifiedLogin with private fields and no public constructor; resolution is (iss, sub) only, never email; the provider label comes from the registry. - Tests F1-F5 (ogar-auth integration tests + ogar-rbac federated module), compile_fail doctests for VerifiedLogin. Disable runs: subject-only lookup, dropped holder check, email fallback, public VerifiedLogin fields, provider in the identity envelope — each turns its test red. - docs/AUTH-EXTERNAL-IDENTITY.md (rules, compatibility, migration, trust boundary), docs/UPSTREAM-DEPS.md row with dependency SHAs. No class id, ActorContext, kiosk or local-auth behaviour change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMQJzQD5KvNm8BtbsWG --- crates/ogar-auth/src/federation.rs | 279 +++++++++++++++++ crates/ogar-auth/src/identity.rs | 241 +++++++++++++++ crates/ogar-auth/src/lib.rs | 43 +-- crates/ogar-auth/src/user.rs | 178 ++++++++++- crates/ogar-auth/tests/federation_boundary.rs | 291 ++++++++++++++++++ crates/ogar-rbac/src/lib.rs | 112 +++++++ docs/AUTH-EXTERNAL-IDENTITY.md | 75 +++++ docs/UPSTREAM-DEPS.md | 1 + 8 files changed, 1174 insertions(+), 46 deletions(-) create mode 100644 crates/ogar-auth/src/federation.rs create mode 100644 crates/ogar-auth/src/identity.rs create mode 100644 crates/ogar-auth/tests/federation_boundary.rs create mode 100644 docs/AUTH-EXTERNAL-IDENTITY.md diff --git a/crates/ogar-auth/src/federation.rs b/crates/ogar-auth/src/federation.rs new file mode 100644 index 00000000..c7744c3b --- /dev/null +++ b/crates/ogar-auth/src/federation.rs @@ -0,0 +1,279 @@ +//! `federation` — the federated-login boundary: issuer-scoped resolution and +//! the [`VerifiedLogin`] proof type. +//! +//! This module records the one invariant a federated adapter MUST honour so the +//! endgame is legible from the SDK, and so nobody re-derives it: +//! +//! > **A federated login and a local login converge on the SAME identity +//! > envelope before any authorization decision is made.** The IdP adapter's +//! > job ends at producing verified claims; [`establish`] maps them to the +//! > canonical user and the envelope is +//! > [`AuthenticatedUser::actor_context`](crate::user::AuthenticatedUser::actor_context) +//! > (mirrors `lance_graph_contract::auth::ActorContext`). Authorization code +//! > downstream is identical whether the actor authenticated locally or through +//! > an IdP; the IdP is a *source of the envelope*, never a fork in the +//! > authorization logic. +//! +//! # What lives here +//! +//! | Item | Role | +//! |---|---| +//! | [`TrustedIssuers`] | deployment-configured `Issuer → ProviderId` registry | +//! | [`AssertionVerifier`] | the seam a token/introspection adapter implements | +//! | [`VerifiedClaims`] | what a verifier returns — **not proof** | +//! | [`establish`] | verify → trust check → `(iss, sub)` resolution → [`VerifiedLogin`] | +//! | [`VerifiedLogin`] | the proof of a federated login | +//! +//! Token validation, JWKS fetch and claim parsing are still out of scope: they +//! belong to a sibling adapter implementing [`AssertionVerifier`]. This module +//! is provider-agnostic. +//! +//! # Resolution rules +//! +//! - A login resolves by the full `(issuer, subject)` pair through +//! [`UserStore::resolve_external`]. **Never** by email, by a provider label, +//! or by an [`AuthBinding`](crate::user::AuthBinding): an unbound identity is +//! [`LoginError::Unbound`], full stop. +//! - The [`ProviderId`] recorded on the login comes from the +//! [`TrustedIssuers`] registry the deployment configured, never from the +//! verifier's claims. +//! +//! # What visibility can and cannot guarantee +//! +//! [`VerifiedLogin`] has private fields and no public constructor, `From` or +//! `Default`, so safe code outside this module cannot fabricate one except +//! through [`establish`]. That is a *type-level* guarantee only. It does **not** +//! stop: +//! +//! - an [`AssertionVerifier`] implementation (in any crate) that lies — it can +//! return whatever [`VerifiedClaims`] it likes and [`establish`] will trust +//! it; and +//! - code that hand-builds an [`AuthenticatedUser`] (its fields are public) and +//! passes it to RBAC directly. +//! +//! Which verifier implementations are linked into a binary, and which code may +//! call RBAC with a hand-built user, is a **deployment trust boundary**, not +//! something this crate can enforce. +//! +//! ```compile_fail +//! // A VerifiedLogin cannot be built with a struct literal from outside. +//! use ogar_auth::federation::VerifiedLogin; +//! fn forge( +//! user: ogar_auth::user::AuthenticatedUser, +//! identity: ogar_auth::identity::ExternalIdentity, +//! ) -> VerifiedLogin { +//! VerifiedLogin { user, identity, verifier: "forged" } +//! } +//! ``` +//! +//! ```compile_fail +//! // Nor converted from an AuthenticatedUser. +//! fn forge(some_authenticated_user: ogar_auth::user::AuthenticatedUser) { +//! let _: ogar_auth::federation::VerifiedLogin = some_authenticated_user.into(); +//! } +//! ``` + +use std::collections::BTreeMap; + +use lance_graph_contract::auth::ActorContext; + +use crate::identity::{ExternalIdentity, Issuer}; +use crate::user::{AuthContext, AuthStrength, AuthenticatedUser, ProviderId, UserStore}; + +/// An issuer is already registered in [`TrustedIssuers`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IssuerConflict { + /// The issuer that was already present. + pub issuer: Issuer, +} + +impl core::fmt::Display for IssuerConflict { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "issuer {:?} is already registered", self.issuer.as_str()) + } +} + +impl std::error::Error for IssuerConflict {} + +/// The deployment's registry of trusted issuers and the provider label each +/// maps to. An issuer absent from it is not trusted, whatever a verifier says. +#[derive(Debug, Clone, Default)] +pub struct TrustedIssuers { + map: BTreeMap, +} + +impl TrustedIssuers { + /// An empty registry (trusts nothing). + #[must_use] + pub const fn new() -> Self { + Self { + map: BTreeMap::new(), + } + } + + /// Trust `issuer`, labelled `provider`. Never overwrites. + /// + /// # Errors + /// + /// [`IssuerConflict`] if the issuer is already registered. + pub fn insert(&mut self, issuer: Issuer, provider: ProviderId) -> Result<(), IssuerConflict> { + if self.map.contains_key(&issuer) { + return Err(IssuerConflict { issuer }); + } + self.map.insert(issuer, provider); + Ok(()) + } + + /// The provider label registered for `issuer`, if it is trusted. + #[must_use] + pub fn provider_of(&self, issuer: &Issuer) -> Option { + self.map.get(issuer).copied() + } +} + +/// Claims a verifier extracted from an assertion. +/// +/// Returned by a verifier; **NOT proof** of anything until [`establish`] has +/// checked the issuer and resolved the identity. `email` is a display hint, +/// never a linking key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedClaims { + /// The issuer-scoped identity the assertion names. + pub identity: ExternalIdentity, + /// How strongly the IdP says the user authenticated. + pub strength: AuthStrength, + /// A display hint only. Never used to find or link a user. + pub email: Option, +} + +/// The seam a token / introspection adapter implements. +/// +/// This crate defines no implementation. The verifier is trusted to have +/// validated the assertion (signature, audience, expiry…); see the module docs +/// for what that trust means. +pub trait AssertionVerifier { + /// The assertion the verifier consumes (e.g. a raw ID token string). + type Input: ?Sized; + /// The verifier's own failure type. + type Error; + /// A stable identifier for this verifier implementation, recorded on the + /// resulting [`VerifiedLogin`]. + const ID: &'static str; + + /// Validate `input` and extract its claims. + /// + /// # Errors + /// + /// The verifier's own error when the assertion is invalid. + fn verify( + &self, + trusted: &TrustedIssuers, + input: &Self::Input, + ) -> Result; +} + +/// Why [`establish`] refused a login. +#[derive(Debug)] +pub enum LoginError { + /// The verifier rejected the assertion. + Verify(E), + /// The asserted issuer is not in [`TrustedIssuers`]. + UntrustedIssuer, + /// No user is bound to the asserted `(issuer, subject)`. + Unbound, +} + +impl core::fmt::Display for LoginError { + /// Deliberately omits the verifier error and any claim contents. + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Verify(_) => f.write_str("assertion verification failed"), + Self::UntrustedIssuer => f.write_str("issuer is not trusted"), + Self::Unbound => f.write_str("no user is bound to this external identity"), + } + } +} + +impl std::error::Error for LoginError {} + +/// The proof of a federated login: the verifier accepted the assertion, the +/// issuer is trusted, and the `(issuer, subject)` is bound to a canonical user. +/// +/// Fields are private and there is no public constructor, `From` or `Default`; +/// the only producer is [`establish`]. See the module docs for what this does +/// and does not guarantee. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedLogin { + user: AuthenticatedUser, + identity: ExternalIdentity, + verifier: &'static str, +} + +impl VerifiedLogin { + /// The authenticated user this login established. + #[must_use] + pub fn authenticated_user(&self) -> &AuthenticatedUser { + &self.user + } + + /// Consume the proof, yielding the user for authorization. + #[must_use] + pub fn into_authenticated_user(self) -> AuthenticatedUser { + self.user + } + + /// The `(issuer, subject)` the login resolved by. + #[must_use] + pub fn identity(&self) -> &ExternalIdentity { + &self.identity + } + + /// The [`AssertionVerifier::ID`] that accepted the assertion. + #[must_use] + pub fn verifier_id(&self) -> &'static str { + self.verifier + } + + /// The canonical identity envelope (delegates to the user). + #[must_use] + pub fn actor_context(&self) -> ActorContext { + self.user.actor_context() + } +} + +/// Run a federated login: verify, check the issuer, resolve `(iss, sub)`. +/// +/// Order is fixed: the verifier runs first; the asserted issuer must then be in +/// `trusted`; the identity must then resolve through +/// [`UserStore::resolve_external`]. There is **no fallback** to email or to an +/// [`AuthBinding`](crate::user::AuthBinding). The provider label on the +/// resulting [`AuthContext`] comes from `trusted`, not from the claims. +/// +/// # Errors +/// +/// [`LoginError::Verify`] from the verifier; [`LoginError::UntrustedIssuer`] if +/// the issuer is unregistered; [`LoginError::Unbound`] if no user is bound to +/// the identity. +pub fn establish( + v: &V, + trusted: &TrustedIssuers, + input: &V::Input, + store: &S, +) -> Result> { + let claims = v.verify(trusted, input).map_err(LoginError::Verify)?; + let provider = trusted + .provider_of(claims.identity.issuer()) + .ok_or(LoginError::UntrustedIssuer)?; + let user = store + .resolve_external(&claims.identity) + .ok_or(LoginError::Unbound)? + .clone(); + Ok(VerifiedLogin { + user: AuthenticatedUser { + user, + auth: AuthContext::federated(provider, claims.strength), + }, + identity: claims.identity, + verifier: V::ID, + }) +} diff --git a/crates/ogar-auth/src/identity.rs b/crates/ogar-auth/src/identity.rs new file mode 100644 index 00000000..fefa1ceb --- /dev/null +++ b/crates/ogar-auth/src/identity.rs @@ -0,0 +1,241 @@ +//! `identity` — the key an external login resolves by: `(issuer, subject)`. +//! +//! An OIDC `sub` is only unique **within its issuer** (OpenID Connect Core +//! §2: the `iss` + `sub` pair is the stable identifier). Two issuers may hand +//! out the same `sub` to two different people. Resolving by `sub` alone — or by +//! a provider *label* plus `sub` — therefore conflates them. This module +//! provides the pair as a value so the store can key on both. +//! +//! # Byte-exact, no normalisation +//! +//! [`Issuer`] is compared **byte for byte**. There is no trailing-slash trim, +//! no case fold, no percent-decoding. `https://idp.example` and +//! `https://idp.example/` are two different issuers. This matches how OIDC +//! discovery requires the `iss` claim to equal the configured issuer string +//! exactly; any "helpful" normalisation here would be a place two distinct +//! issuers could be merged (or one split) without anyone configuring it. +//! +//! Validation ([`Issuer::new`]) only rejects strings that cannot be an issuer +//! identifier; it never rewrites the input. +//! +//! # What this module does NOT do +//! +//! It does not verify tokens, fetch JWKS, or decide which issuers are trusted +//! (see [`crate::federation::TrustedIssuers`]). Constructing an +//! [`ExternalIdentity`] proves nothing: it is a *name*, not a credential. + +use core::fmt; + +/// Why a string was refused as an [`Issuer`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InvalidIssuer { + /// The string was empty. + Empty, + /// The string does not start with `https://`. + NotHttps, + /// Nothing follows the `https://` prefix. + MissingHost, + /// The string contains whitespace or a control character. + ForbiddenCharacter, + /// The string contains `?` or `#` (an issuer has no query or fragment). + QueryOrFragment, +} + +impl fmt::Display for InvalidIssuer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let m = match self { + Self::Empty => "issuer is empty", + Self::NotHttps => "issuer must start with https://", + Self::MissingHost => "issuer has no host after https://", + Self::ForbiddenCharacter => "issuer contains whitespace or a control character", + Self::QueryOrFragment => "issuer must not contain a query or fragment", + }; + f.write_str(m) + } +} + +impl std::error::Error for InvalidIssuer {} + +/// An OIDC issuer identifier, held byte-exact. +/// +/// The field is private so every value has passed [`Issuer::new`]. Equality, +/// ordering and hashing are those of the underlying bytes: **no trailing-slash +/// trim, no case folding.** +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Issuer(String); + +impl Issuer { + /// Validate and wrap an issuer string without altering it. + /// + /// # Errors + /// + /// [`InvalidIssuer`] when the string is empty, has whitespace or control + /// characters, does not start with `https://`, has nothing after that + /// prefix, or contains `?` or `#`. + pub fn new(s: &str) -> Result { + if s.is_empty() { + return Err(InvalidIssuer::Empty); + } + if s.chars().any(|c| c.is_whitespace() || c.is_control()) { + return Err(InvalidIssuer::ForbiddenCharacter); + } + let Some(rest) = s.strip_prefix("https://") else { + return Err(InvalidIssuer::NotHttps); + }; + if rest.is_empty() { + return Err(InvalidIssuer::MissingHost); + } + if s.contains('?') || s.contains('#') { + return Err(InvalidIssuer::QueryOrFragment); + } + Ok(Self(s.to_owned())) + } + + /// The issuer exactly as supplied. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// An issuer-scoped subject (`sub`) — opaque, never parsed. +/// +/// Only meaningful together with its [`Issuer`]; see [`ExternalIdentity`]. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ExternalSubject(String); + +/// A subject string was empty. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EmptySubject; + +impl fmt::Display for EmptySubject { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("external subject is empty") + } +} + +impl std::error::Error for EmptySubject {} + +impl ExternalSubject { + /// Wrap a subject. Only emptiness is rejected; the bytes are kept as-is. + /// + /// # Errors + /// + /// [`EmptySubject`] for an empty string. + pub fn new(s: &str) -> Result { + if s.is_empty() { + Err(EmptySubject) + } else { + Ok(Self(s.to_owned())) + } + } + + /// The subject exactly as supplied. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// The stable external identity: `(issuer, subject)`. +/// +/// This — not a provider label, not an email — is what a federated login is +/// resolved by. Ordering and hashing are over `(issuer, subject)`. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ExternalIdentity { + issuer: Issuer, + subject: ExternalSubject, +} + +impl ExternalIdentity { + /// Pair an issuer with a subject. + #[must_use] + pub fn new(issuer: Issuer, subject: ExternalSubject) -> Self { + Self { issuer, subject } + } + + /// The issuer half. + #[must_use] + pub fn issuer(&self) -> &Issuer { + &self.issuer + } + + /// The subject half. + #[must_use] + pub fn subject(&self) -> &ExternalSubject { + &self.subject + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn iss(s: &str) -> Issuer { + Issuer::new(s).expect("valid issuer") + } + + #[test] + fn trailing_slash_issuer_is_a_different_issuer() { + assert_ne!(iss("https://idp.example"), iss("https://idp.example/")); + assert_eq!(iss("https://idp.example/").as_str(), "https://idp.example/"); + } + + #[test] + fn case_is_not_folded() { + assert_ne!(iss("https://IdP.example"), iss("https://idp.example")); + } + + #[test] + fn byte_identical_issuers_and_identities_match() { + let a = ExternalIdentity::new( + iss("https://idp.example/realm"), + ExternalSubject::new("u-1").unwrap(), + ); + let b = ExternalIdentity::new( + iss("https://idp.example/realm"), + ExternalSubject::new("u-1").unwrap(), + ); + assert_eq!(a, b); + let c = ExternalIdentity::new( + iss("https://idp.example/realm/"), + ExternalSubject::new("u-1").unwrap(), + ); + assert_ne!(a, c, "same sub under a different issuer string"); + } + + #[test] + fn invalid_issuers_are_rejected() { + assert_eq!(Issuer::new(""), Err(InvalidIssuer::Empty)); + assert_eq!( + Issuer::new("http://idp.example"), + Err(InvalidIssuer::NotHttps) + ); + assert_eq!(Issuer::new("idp.example"), Err(InvalidIssuer::NotHttps)); + assert_eq!(Issuer::new("https://"), Err(InvalidIssuer::MissingHost)); + assert_eq!( + Issuer::new("https://idp.example/a b"), + Err(InvalidIssuer::ForbiddenCharacter) + ); + assert_eq!( + Issuer::new("https://idp.example/\n"), + Err(InvalidIssuer::ForbiddenCharacter) + ); + assert_eq!( + Issuer::new("https://idp.example?x=1"), + Err(InvalidIssuer::QueryOrFragment) + ); + assert_eq!( + Issuer::new("https://idp.example#frag"), + Err(InvalidIssuer::QueryOrFragment) + ); + // stay-silent: a path-bearing https issuer is fine. + assert!(Issuer::new("https://idp.example/tenants/a").is_ok()); + } + + #[test] + fn empty_subject_is_rejected_anything_else_kept() { + assert_eq!(ExternalSubject::new(""), Err(EmptySubject)); + assert_eq!(ExternalSubject::new(" x ").unwrap().as_str(), " x "); + } +} diff --git a/crates/ogar-auth/src/lib.rs b/crates/ogar-auth/src/lib.rs index ef6e4388..4d689e35 100644 --- a/crates/ogar-auth/src/lib.rs +++ b/crates/ogar-auth/src/lib.rs @@ -41,13 +41,16 @@ //! Today ogar-auth is the *local* authentication substrate: passwords, second //! factor, legacy migration, envelopes. The next arm is **federated identity** //! — OGAR ↔ `OGIT[auth]` bound to an external IdP (OIDC / Zitadel / any -//! standards IAM). That surface is deliberately NOT built yet; it lands as a -//! sibling adapter (cf. the `ogar-adapter-*` family) that maps an OIDC -//! `IdToken` / introspection response onto the same identity envelope the -//! local path produces, so a consumer's authorization code is IdP-agnostic. -//! See [`federation`] for the reserved shape and the one invariant that seam -//! must honour. +//! standards IAM). The *boundary* is built: [`identity`] (issuer-scoped +//! `(iss, sub)`), and [`federation`] ([`federation::AssertionVerifier`], +//! [`federation::establish`], the [`federation::VerifiedLogin`] proof). The +//! token-validating adapter itself (JWKS, claim parsing) is NOT built here; it +//! lands as a sibling crate implementing `AssertionVerifier`, so a consumer's +//! authorization code stays IdP-agnostic. See [`federation`] for the invariant +//! and for what the types cannot guarantee. +pub mod federation; +pub mod identity; pub mod legacy; pub mod password; pub mod totp; @@ -86,31 +89,3 @@ impl std::error::Error for AuthError {} /// Result alias for the auth-specific primitives. pub type AuthResult = Result; - -/// The reserved federation seam — OGAR ↔ `OGIT[auth]` external-IdP binding. -/// -/// This module is intentionally a **contract stub**, not an implementation. -/// It records the one invariant a future OIDC / Zitadel adapter MUST honour so -/// the endgame is legible from the SDK today, and so nobody re-derives it: -/// -/// > **A federated login and a local login converge on the SAME identity -/// > envelope before any authorization decision is made.** The IdP adapter's -/// > job ends at producing `(subject, tenant, roles)` — the exact shape the -/// > local password + TOTP path already produces (mirrors -/// > `lance_graph_contract::auth::ActorContext`). Authorization code downstream -/// > is therefore identical whether the actor authenticated locally or through -/// > Zitadel; the IdP is a *source of the envelope*, never a fork in the -/// > authorization logic. -/// -/// The adapter itself (token validation, JWKS fetch, claim mapping) lands as a -/// sibling crate under the `ogar-adapter-*` convention when the endgame is -/// scheduled — it is out of scope for the local auth substrate this crate is. -/// -/// **The convergence point is now shipped**, as [`crate::user`]: an adapter's -/// job ends at producing an [`AuthBinding`](crate::user::AuthBinding), which -/// [`UserStore::resolve`](crate::user::UserStore::resolve) maps to the canonical -/// [`User`](crate::user::User); the envelope every path converges on is -/// [`AuthenticatedUser::actor_context`](crate::user::AuthenticatedUser::actor_context). -/// This module stays an empty marker: what is still out of scope here is -/// unchanged — token validation, JWKS fetch and claim parsing. -pub mod federation {} diff --git a/crates/ogar-auth/src/user.rs b/crates/ogar-auth/src/user.rs index b3172675..7ea85673 100644 --- a/crates/ogar-auth/src/user.rs +++ b/crates/ogar-auth/src/user.rs @@ -37,17 +37,23 @@ //! is the single place it is produced. //! //! ```text -//! ("zitadel", external_subject) ─┐ -//! ("entra", object_id) ─┼──► UserStore::resolve ──► User(42) -//! ("local", "dr-house") ─┘ │ -//! kiosk ──────────────────────────────────────────────────────┤ -//! ▼ -//! ActorContext -//! │ -//! ▼ -//! ogar-rbac +//! (iss, sub) ExternalIdentity ─┐ +//! (iss', sub') ExternalIdentity ─┼──► UserStore::resolve_external ──► User(42) +//! ("local", "dr-house") binding ─┘ (UserStore::resolve: local path) │ +//! kiosk ──────────────────────────────────────────────────────────────┤ +//! ▼ +//! ActorContext +//! │ +//! ▼ +//! ogar-rbac //! ``` //! +//! A federated login resolves by the issuer-scoped pair `(iss, sub)` (see +//! [`crate::identity`]), never by a provider label or an email. The proof that +//! an OIDC login happened is holding a [`crate::federation::VerifiedLogin`]; +//! [`AuthContext::federated`] and [`AuthenticatedUser`] are **descriptive** — +//! constructing one proves nothing. +//! //! # What this module deliberately does NOT do //! //! - **No token validation, no JWKS, no claim parsing.** Those belong in an @@ -60,9 +66,13 @@ //! the [`User`]; [`AuthContext`] describes *how* they logged in and never //! contributes a role. +use std::collections::BTreeMap; + use lance_graph_contract::auth::ActorContext; use lance_graph_contract::sla::TenantId; +use crate::identity::ExternalIdentity; + /// The canonical OGAR user id — `project_actor` (`0x0104`) as a value. /// /// Opaque and internal: an external subject is *bound* to one of these @@ -98,6 +108,12 @@ impl ProviderId { /// One external identity bound to a canonical [`User`]. /// +/// **Not a federated resolution key.** The `provider` is a free-form label, not +/// a trust root, and `external_subject` is not issuer-scoped. A federated +/// (OIDC) login resolves by [`crate::identity::ExternalIdentity`] through +/// [`UserStore::resolve_external`]; this type serves the local/kiosk path and +/// descriptive records (e.g. delegation actors). +/// /// A user may hold several: `("zitadel", "a1b2…")` and `("entra", "0000-…")` /// can both resolve to the same [`UserId`]. That is the point — it is what /// makes an IdP swap invisible to authorization. @@ -233,7 +249,12 @@ impl AuthContext { Ok(self) } - /// A verified federated login, asserted by `provider`. + /// A federated login context, asserted by `provider`. + /// + /// **Descriptive only.** This sets fields; it verifies nothing. Holding the + /// result (or an [`AuthenticatedUser`] built from it) is not proof that any + /// IdP authenticated anyone. The proof of an OIDC login is a + /// [`crate::federation::VerifiedLogin`]. #[must_use] pub const fn federated(provider: ProviderId, strength: AuthStrength) -> Self { Self { @@ -273,6 +294,11 @@ pub struct User { /// This is the type `ogar-rbac` consumes. It is the *only* sanctioned input to /// authorization, which is what makes "RBAC operates on the canonical user" /// checkable rather than aspirational. +/// +/// **Descriptive, not a proof.** Its fields are public, so any code can build +/// one. Constructing it proves nothing about how (or whether) the actor +/// authenticated; the proof of a federated login is a +/// [`crate::federation::VerifiedLogin`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuthenticatedUser { /// The canonical user. @@ -323,26 +349,149 @@ pub trait UserStore { /// Look a canonical user up directly. fn user(&self, id: UserId) -> Option<&User>; + + /// The canonical user an issuer-scoped external identity is bound to. + /// + /// The default is `None` (fail closed): a store that does not implement + /// external resolution resolves no federated login. Implementors must match + /// on the full `(issuer, subject)` pair and must never fall back to an + /// email, a provider label or an [`AuthBinding`]. + fn resolve_external(&self, _id: &crate::identity::ExternalIdentity) -> Option<&User> { + None + } } +/// Why a [`LocalUserStore`] mutation was refused. Carries identifiers only — +/// never secrets or key material. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StoreConflict { + /// A user with this id is already stored. + DuplicateUserId(UserId), + /// A user with this subject string is already stored. + DuplicateSubject(String), + /// The [`AuthBinding`] is already held by another user. + BindingHeldBy { + /// The user that holds it. + holder: UserId, + }, + /// The same [`AuthBinding`] appears twice on the user being inserted. + DuplicateBinding, + /// No user with this id exists. + UnknownUser(UserId), + /// The external identity is already bound to another user. + ExternalHeldBy { + /// The user that holds it. + holder: UserId, + }, + /// The external identity is already bound to this same user. + DuplicateExternal, +} + +impl core::fmt::Display for StoreConflict { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::DuplicateUserId(id) => write!(f, "user id {} already exists", id.0), + Self::DuplicateSubject(s) => write!(f, "user subject {s:?} already exists"), + Self::BindingHeldBy { holder } => { + write!(f, "binding already held by user {}", holder.0) + } + Self::DuplicateBinding => f.write_str("duplicate binding on the same user"), + Self::UnknownUser(id) => write!(f, "unknown user {}", id.0), + Self::ExternalHeldBy { holder } => { + write!(f, "external identity already bound to user {}", holder.0) + } + Self::DuplicateExternal => f.write_str("external identity already bound to this user"), + } + } +} + +impl std::error::Error for StoreConflict {} + /// An in-memory [`UserStore`] — the local/kiosk path, and the fixture an /// adapter-free build uses. Not a placeholder for a database so much as the /// honest shape of a single-tenant local deployment. #[derive(Debug, Clone, Default)] pub struct LocalUserStore { users: Vec, + /// `(issuer, subject)` → canonical user. Each identity maps to exactly one + /// user; see [`LocalUserStore::bind_external`]. + external: BTreeMap, } impl LocalUserStore { /// An empty store. #[must_use] pub const fn new() -> Self { - Self { users: Vec::new() } + Self { + users: Vec::new(), + external: BTreeMap::new(), + } + } + + /// Add a user, refusing anything that would make resolution ambiguous. + /// + /// # Errors + /// + /// [`StoreConflict`] when the user id or subject is already stored, when a + /// binding is already held by another user, or when the user lists the same + /// binding twice. The store is unchanged on error. + pub fn try_insert(&mut self, user: User) -> Result<(), StoreConflict> { + if self.users.iter().any(|u| u.id == user.id) { + return Err(StoreConflict::DuplicateUserId(user.id)); + } + if self.users.iter().any(|u| u.subject == user.subject) { + return Err(StoreConflict::DuplicateSubject(user.subject)); + } + for (i, b) in user.bindings.iter().enumerate() { + if let Some(holder) = self.users.iter().find(|u| u.bindings.contains(b)) { + return Err(StoreConflict::BindingHeldBy { holder: holder.id }); + } + if user.bindings[..i].contains(b) { + return Err(StoreConflict::DuplicateBinding); + } + } + self.users.push(user); + Ok(()) } /// Add a user. Later lookups resolve any of its bindings. + /// + /// # Panics + /// + /// Panics if [`try_insert`](Self::try_insert) would refuse the user + /// (duplicate id, subject or binding). Use `try_insert` for untrusted input. + #[track_caller] pub fn insert(&mut self, user: User) { - self.users.push(user); + self.try_insert(user) + .expect("LocalUserStore::insert: conflicting user"); + } + + /// Bind an issuer-scoped external identity to an existing user. + /// + /// Never overwrites: an identity is bound once, to one user. + /// + /// # Errors + /// + /// [`StoreConflict::UnknownUser`] if `user` is not stored; + /// [`StoreConflict::ExternalHeldBy`] if another user holds `id`; + /// [`StoreConflict::DuplicateExternal`] if `user` already holds it. + pub fn bind_external( + &mut self, + user: UserId, + id: ExternalIdentity, + ) -> Result<(), StoreConflict> { + if !self.users.iter().any(|u| u.id == user) { + return Err(StoreConflict::UnknownUser(user)); + } + if let Some(&holder) = self.external.get(&id) { + return Err(if holder == user { + StoreConflict::DuplicateExternal + } else { + StoreConflict::ExternalHeldBy { holder } + }); + } + self.external.insert(id, user); + Ok(()) } } @@ -354,6 +503,11 @@ impl UserStore for LocalUserStore { fn user(&self, id: UserId) -> Option<&User> { self.users.iter().find(|u| u.id == id) } + + fn resolve_external(&self, id: &ExternalIdentity) -> Option<&User> { + let uid = *self.external.get(id)?; + self.user(uid) + } } #[cfg(test)] diff --git a/crates/ogar-auth/tests/federation_boundary.rs b/crates/ogar-auth/tests/federation_boundary.rs new file mode 100644 index 00000000..7c094498 --- /dev/null +++ b/crates/ogar-auth/tests/federation_boundary.rs @@ -0,0 +1,291 @@ +//! Federation boundary: issuer-scoped resolution, store uniqueness, no email +//! linking, and the trust check. Integration test (separate crate) so only the +//! public surface is reachable — which is the point for `VerifiedLogin`. +//! +//! A hand-built `AuthContext::federated(..)` / `AuthenticatedUser` is possible +//! (public fields) but cannot become a `VerifiedLogin`; that is pinned by the +//! two `compile_fail` doctests in `ogar_auth::federation`. + +use ogar_auth::federation::{ + AssertionVerifier, LoginError, TrustedIssuers, VerifiedClaims, establish, +}; +use ogar_auth::identity::{ExternalIdentity, ExternalSubject, Issuer}; +use ogar_auth::user::{ + AuthBinding, AuthContext, AuthStrength, AuthenticatedUser, KeyRef, LocalUserStore, ProviderId, + StoreConflict, User, UserId, UserStore, +}; + +const ISS_A: &str = "https://idp-a.example"; +const ISS_B: &str = "https://idp-b.example"; +const PROV_A: ProviderId = ProviderId("idp-a"); +const PROV_B: ProviderId = ProviderId("idp-b"); + +fn ident(iss: &str, sub: &str) -> ExternalIdentity { + ExternalIdentity::new( + Issuer::new(iss).expect("issuer"), + ExternalSubject::new(sub).expect("subject"), + ) +} + +fn user(id: u64, subject: &str, bindings: Vec) -> User { + User { + id: UserId(id), + subject: subject.to_string(), + tenant: 7, + roles: vec!["physician".to_string()], + memberships: vec![], + bindings, + key_refs: vec![KeyRef(format!("kms://user/{id}"))], + } +} + +/// A verifier that returns whatever claims it was configured with. +struct StubVerifier { + claims: Result, +} + +impl StubVerifier { + fn ok(identity: ExternalIdentity, email: Option<&str>) -> Self { + Self { + claims: Ok(VerifiedClaims { + identity, + strength: AuthStrength::MultiFactor, + email: email.map(str::to_string), + }), + } + } +} + +impl AssertionVerifier for StubVerifier { + type Input = str; + type Error = &'static str; + const ID: &'static str = "stub"; + + fn verify(&self, _t: &TrustedIssuers, _input: &str) -> Result { + self.claims.clone() + } +} + +fn trusted() -> TrustedIssuers { + let mut t = TrustedIssuers::new(); + t.insert(Issuer::new(ISS_A).unwrap(), PROV_A).unwrap(); + t.insert(Issuer::new(ISS_B).unwrap(), PROV_B).unwrap(); + t +} + +// ── F1: the pair, not the sub ─────────────────────────────────────────────── + +#[test] +fn same_sub_different_issuers_resolve_to_distinct_users() { + let mut s = LocalUserStore::new(); + s.insert(user(1, "alice", vec![])); + s.insert(user(2, "bob", vec![])); + s.bind_external(UserId(1), ident(ISS_A, "same-sub")) + .unwrap(); + s.bind_external(UserId(2), ident(ISS_B, "same-sub")) + .unwrap(); + + let a = s.resolve_external(&ident(ISS_A, "same-sub")).expect("A"); + let b = s.resolve_external(&ident(ISS_B, "same-sub")).expect("B"); + assert_eq!(a.id, UserId(1)); + assert_eq!(b.id, UserId(2)); + assert_ne!(a.id, b.id, "sub alone must not decide the user"); +} + +#[test] +fn same_issuer_same_sub_resolves_to_its_one_user() { + let mut s = LocalUserStore::new(); + s.insert(user(1, "alice", vec![])); + s.bind_external(UserId(1), ident(ISS_A, "x")).unwrap(); + let first = s.resolve_external(&ident(ISS_A, "x")).map(|u| u.id); + let second = s.resolve_external(&ident(ISS_A, "x")).map(|u| u.id); + assert_eq!(first, Some(UserId(1))); + assert_eq!(first, second); + // a trailing-slash issuer is a different issuer and resolves to nothing + assert!( + s.resolve_external(&ident("https://idp-a.example/", "x")) + .is_none() + ); +} + +// ── F2: store uniqueness ──────────────────────────────────────────────────── + +#[test] +fn binding_held_by_another_user_is_rejected() { + let mut s = LocalUserStore::new(); + let shared = AuthBinding::new(ProviderId::LOCAL, "dr-house"); + s.try_insert(user(1, "alice", vec![shared.clone()])) + .unwrap(); + let err = s + .try_insert(user(2, "bob", vec![shared])) + .expect_err("binding already held"); + assert_eq!(err, StoreConflict::BindingHeldBy { holder: UserId(1) }); + assert!(s.user(UserId(2)).is_none(), "store unchanged on error"); +} + +#[test] +fn duplicate_external_within_user_is_rejected() { + let mut s = LocalUserStore::new(); + s.insert(user(1, "alice", vec![])); + s.insert(user(2, "bob", vec![])); + s.bind_external(UserId(1), ident(ISS_A, "x")).unwrap(); + assert_eq!( + s.bind_external(UserId(1), ident(ISS_A, "x")), + Err(StoreConflict::DuplicateExternal) + ); + assert_eq!( + s.bind_external(UserId(2), ident(ISS_A, "x")), + Err(StoreConflict::ExternalHeldBy { holder: UserId(1) }) + ); + assert_eq!( + s.bind_external(UserId(99), ident(ISS_A, "y")), + Err(StoreConflict::UnknownUser(UserId(99))) + ); + // never overwritten + assert_eq!( + s.resolve_external(&ident(ISS_A, "x")).map(|u| u.id), + Some(UserId(1)) + ); +} + +#[test] +fn duplicate_subject_or_userid_is_rejected() { + let mut s = LocalUserStore::new(); + s.insert(user(1, "alice", vec![])); + assert_eq!( + s.try_insert(user(1, "other", vec![])), + Err(StoreConflict::DuplicateUserId(UserId(1))) + ); + assert_eq!( + s.try_insert(user(2, "alice", vec![])), + Err(StoreConflict::DuplicateSubject("alice".to_string())) + ); + let b = AuthBinding::new(ProviderId::LOCAL, "dup"); + assert_eq!( + s.try_insert(user(3, "carol", vec![b.clone(), b])), + Err(StoreConflict::DuplicateBinding) + ); +} + +#[test] +#[should_panic(expected = "conflicting user")] +fn insert_panics_on_conflict() { + let mut s = LocalUserStore::new(); + s.insert(user(1, "alice", vec![])); + s.insert(user(1, "alice", vec![])); +} + +#[test] +fn distinct_bindings_insert_and_resolve_uniquely() { + let mut s = LocalUserStore::new(); + s.try_insert(user( + 1, + "alice", + vec![AuthBinding::new(ProviderId::LOCAL, "alice")], + )) + .unwrap(); + s.try_insert(user( + 2, + "bob", + vec![AuthBinding::new(ProviderId::LOCAL, "bob")], + )) + .unwrap(); + assert_eq!( + s.resolve(&AuthBinding::new(ProviderId::LOCAL, "alice")) + .map(|u| u.id), + Some(UserId(1)) + ); + assert_eq!( + s.resolve(&AuthBinding::new(ProviderId::LOCAL, "bob")) + .map(|u| u.id), + Some(UserId(2)) + ); +} + +// ── F3: no email linking ──────────────────────────────────────────────────── + +#[test] +fn matching_email_with_other_iss_sub_resolves_to_nothing() { + let mut s = LocalUserStore::new(); + s.insert(user( + 1, + "house", + vec![AuthBinding::new(ProviderId::LOCAL, "house@ex.org")], + )); + s.bind_external(UserId(1), ident(ISS_A, "x")).unwrap(); + + // Different (iss, sub), but the email equals the local username. + let v = StubVerifier::ok(ident(ISS_B, "y"), Some("house@ex.org")); + let r = establish(&v, &trusted(), "tok", &s); + assert!(matches!(r, Err(LoginError::Unbound)), "got {r:?}"); +} + +#[test] +fn bound_identity_with_any_email_resolves() { + let mut s = LocalUserStore::new(); + s.insert(user(1, "house", vec![])); + s.bind_external(UserId(1), ident(ISS_A, "x")).unwrap(); + for email in [None, Some("unrelated@ex.org"), Some("")] { + let v = StubVerifier::ok(ident(ISS_A, "x"), email); + let login = establish(&v, &trusted(), "tok", &s).expect("bound identity resolves"); + assert_eq!(login.authenticated_user().user.id, UserId(1)); + } +} + +// ── F4: trust + proof ─────────────────────────────────────────────────────── + +#[test] +fn untrusted_issuer_is_refused() { + let mut s = LocalUserStore::new(); + s.insert(user(1, "alice", vec![])); + let rogue = "https://rogue.example"; + s.bind_external(UserId(1), ident(rogue, "x")).unwrap(); + let v = StubVerifier::ok(ident(rogue, "x"), None); + let r = establish(&v, &trusted(), "tok", &s); + assert!(matches!(r, Err(LoginError::UntrustedIssuer)), "got {r:?}"); +} + +#[test] +fn verifier_failure_is_surfaced() { + let s = LocalUserStore::new(); + let v = StubVerifier { + claims: Err("bad signature"), + }; + let r = establish(&v, &trusted(), "tok", &s); + assert!( + matches!(r, Err(LoginError::Verify("bad signature"))), + "got {r:?}" + ); +} + +#[test] +fn registered_verifier_and_binding_yield_verified_login() { + let mut s = LocalUserStore::new(); + s.insert(user(1, "alice", vec![])); + s.bind_external(UserId(1), ident(ISS_A, "x")).unwrap(); + let v = StubVerifier::ok(ident(ISS_A, "x"), None); + let login = establish(&v, &trusted(), "tok", &s).expect("login"); + + assert_eq!(login.verifier_id(), "stub"); + assert_eq!(login.identity(), &ident(ISS_A, "x")); + let au = login.authenticated_user(); + assert_eq!(au.user.id, UserId(1)); + assert!(au.auth.authenticated); + assert_eq!(au.auth.provider, PROV_A, "provider comes from the registry"); + assert_eq!(au.auth.strength, AuthStrength::MultiFactor); + assert_eq!(login.actor_context(), au.actor_context()); + assert_eq!(login.into_authenticated_user().user.id, UserId(1)); +} + +#[test] +fn hand_built_context_gives_a_user_but_no_login() { + // A hand-built AuthenticatedUser is possible (public fields)... + let u = AuthenticatedUser { + user: user(1, "alice", vec![]), + auth: AuthContext::federated(PROV_A, AuthStrength::MultiFactor), + }; + assert!(u.auth.authenticated); + // ...but there is no way to turn it into a `VerifiedLogin`: no struct + // literal (private fields) and no `From`. The two `compile_fail` doctests + // in `ogar_auth::federation` pin that. +} diff --git a/crates/ogar-rbac/src/lib.rs b/crates/ogar-rbac/src/lib.rs index 2b9eaa26..78ee5e1d 100644 --- a/crates/ogar-rbac/src/lib.rs +++ b/crates/ogar-rbac/src/lib.rs @@ -850,4 +850,116 @@ mod tests { assert!(matches!(d.decision, AccessDecision::Deny { .. })); assert_eq!(d.scope, None); } + + /// Federated logins established through `ogar_auth::federation::establish`. + mod federated { + use super::*; + use ogar_auth::federation::{ + AssertionVerifier, LoginError, TrustedIssuers, VerifiedClaims, establish, + }; + use ogar_auth::identity::{ExternalIdentity, ExternalSubject, Issuer}; + + const ISS_1: &str = "https://idp-one.example"; + const ISS_2: &str = "https://idp-two.example"; + + fn ident(iss: &str, sub: &str) -> ExternalIdentity { + ExternalIdentity::new( + Issuer::new(iss).expect("issuer"), + ExternalSubject::new(sub).expect("subject"), + ) + } + + /// Returns the configured identity; stands in for a real adapter. + struct Verifier(ExternalIdentity); + impl AssertionVerifier for Verifier { + type Input = str; + type Error = (); + const ID: &'static str = "test-verifier"; + fn verify(&self, _t: &TrustedIssuers, _i: &str) -> Result { + Ok(VerifiedClaims { + identity: self.0.clone(), + strength: AuthStrength::MultiFactor, + email: None, + }) + } + } + + fn trusted() -> TrustedIssuers { + let mut t = TrustedIssuers::new(); + t.insert(Issuer::new(ISS_1).unwrap(), ZITADEL).unwrap(); + t.insert(Issuer::new(ISS_2).unwrap(), ENTRA).unwrap(); + t + } + + fn user(id: u64, subject: &str, role: &str) -> User { + User { + id: UserId(id), + subject: subject.to_string(), + tenant: 7, + roles: vec![role.to_string()], + memberships: vec![], + bindings: vec![], + key_refs: vec![], + } + } + + fn act() -> Operation<'static> { + Operation::Act { action: "approve" } + } + + /// F5 — two providers both explicitly bound to user 42 give the same + /// envelope and the same decision. + #[test] + fn provider_switch_keeps_decision_for_explicitly_bound_actor() { + let mut s = LocalUserStore::new(); + s.insert(user(42, "dr-house", "physician")); + s.bind_external(UserId(42), ident(ISS_1, "a1")).unwrap(); + s.bind_external(UserId(42), ident(ISS_2, "b2")).unwrap(); + + let l1 = establish(&Verifier(ident(ISS_1, "a1")), &trusted(), "t", &s) + .expect("login via issuer one"); + let l2 = establish(&Verifier(ident(ISS_2, "b2")), &trusted(), "t", &s) + .expect("login via issuer two"); + assert_ne!( + l1.authenticated_user().auth.provider, + l2.authenticated_user().auth.provider + ); + assert_eq!(l1.actor_context(), l2.actor_context()); + + let a = authority(); + let d1 = a.authorize_user(l1.authenticated_user(), patient_class(), act()); + let d2 = a.authorize_user(l2.authenticated_user(), patient_class(), act()); + assert_eq!(d1, d2, "the provider must not change the decision"); + assert_eq!(d1.decision, AccessDecision::Allow); + } + + /// Stay-silent: a second provider that was never bound yields no login. + #[test] + fn unbound_second_provider_yields_no_login() { + let mut s = LocalUserStore::new(); + s.insert(user(42, "dr-house", "physician")); + s.bind_external(UserId(42), ident(ISS_1, "a1")).unwrap(); + let r = establish(&Verifier(ident(ISS_2, "b2")), &trusted(), "t", &s); + assert!(matches!(r, Err(LoginError::Unbound))); + } + + /// Can-fire: binding the second provider to a DIFFERENT canonical user + /// changes the decision — the check is not vacuously "always Allow". + #[test] + fn different_canonical_user_changes_decision() { + let mut s = LocalUserStore::new(); + s.insert(user(42, "dr-house", "physician")); + s.insert(user(43, "betty", "cashier")); + s.bind_external(UserId(42), ident(ISS_1, "a1")).unwrap(); + s.bind_external(UserId(43), ident(ISS_2, "b2")).unwrap(); + + let l1 = establish(&Verifier(ident(ISS_1, "a1")), &trusted(), "t", &s).unwrap(); + let l2 = establish(&Verifier(ident(ISS_2, "b2")), &trusted(), "t", &s).unwrap(); + let a = authority(); + let d1 = a.authorize_user(l1.authenticated_user(), patient_class(), act()); + let d2 = a.authorize_user(l2.authenticated_user(), patient_class(), act()); + assert_eq!(d1.decision, AccessDecision::Allow); + assert!(matches!(d2.decision, AccessDecision::Deny { .. })); + } + } } diff --git a/docs/AUTH-EXTERNAL-IDENTITY.md b/docs/AUTH-EXTERNAL-IDENTITY.md new file mode 100644 index 00000000..5ccd5277 --- /dev/null +++ b/docs/AUTH-EXTERNAL-IDENTITY.md @@ -0,0 +1,75 @@ +# External identity and the verified-login boundary (`ogar-auth`) + +> Scope: `ogar-auth::{identity, federation}` and the additions to +> `ogar-auth::user`. Provider-agnostic. No token validation lives here. + +## 1. The issuer rule + +An OIDC `sub` is unique only **within its issuer**. A federated login is +therefore identified by the pair `(iss, sub)` — `identity::ExternalIdentity` — +never by `sub` alone, never by a provider label plus `sub`. + +- `Issuer` is compared **byte for byte**: no trailing-slash trim, no case fold. + `https://idp.example` and `https://idp.example/` are different issuers. +- `Issuer::new` rejects, without rewriting: empty, whitespace/control + characters, anything not starting with `https://`, nothing after the prefix, + any `?` or `#`. +- `ExternalSubject::new` rejects only the empty string. + +## 2. Uniqueness invariant + +`LocalUserStore` guarantees that resolution is never ambiguous: + +- `try_insert` refuses a duplicate `UserId`, a duplicate subject, an + `AuthBinding` already held by another user, and a duplicate binding on the + same user (`StoreConflict`). +- `bind_external(user, identity)` refuses an unknown user and an identity + already bound (to another user, or to this one). It never overwrites. +- Each `(iss, sub)` maps to at most one user; `resolve_external` is a map lookup + on the full pair. The `UserStore` default for `resolve_external` is `None` + (fail closed). + +## 3. No email linking + +Email is a display hint (`VerifiedClaims::email`). `establish` never uses it, +and never falls back to an `AuthBinding` or a provider label: an identity with +no `bind_external` entry is `LoginError::Unbound`. Linking a new IdP identity to +an existing user is an explicit `bind_external` call by the deployment. + +## 4. Verified-login boundary + +`federation::establish(verifier, trusted, input, store)` runs, in order: +verifier → `TrustedIssuers::provider_of(issuer)` (else `UntrustedIssuer`) → +`store.resolve_external(identity)` (else `Unbound`) → `VerifiedLogin`. +The `ProviderId` on the resulting `AuthContext` comes from the registry, not the +claims. + +`VerifiedLogin` has private fields and no public constructor/`From`/`Default`; +two `compile_fail` doctests pin that. `AuthContext::federated` and +`AuthenticatedUser` stay **descriptive**: public fields, constructing one proves +nothing. + +### What visibility cannot guarantee + +- An `AssertionVerifier` implementation in any crate can lie; `establish` trusts + what it returns. +- Code can hand-build an `AuthenticatedUser` and pass it straight to RBAC. + +Both are a **deployment trust boundary**: which verifiers are linked in, and +which code may call RBAC with a hand-built user, is not enforceable by types. + +## 5. Compatibility + +- New types/methods are additive; existing signatures are unchanged. +- **Behaviour change:** `LocalUserStore::insert` now panics on conflicting input + (duplicate id/subject/binding). Use `try_insert` for untrusted input. +- Existing provider-scoped federated `AuthBinding`s (`provider` label + + unscoped subject) **cannot be auto-migrated**: the issuer is not recorded in + them. Re-bind explicitly with `bind_external`. + +## 6. PR-01 handoff + +The next piece is a reusable OIDC adapter implementing `AssertionVerifier` +(signature, audience, expiry, JWKS) that returns `VerifiedClaims`. It must stay +provider-agnostic: provider differences are data in `TrustedIssuers`, not match +arms. diff --git a/docs/UPSTREAM-DEPS.md b/docs/UPSTREAM-DEPS.md index e90c6e2c..a261acd4 100644 --- a/docs/UPSTREAM-DEPS.md +++ b/docs/UPSTREAM-DEPS.md @@ -21,6 +21,7 @@ | 5 (`lance-graph-contract` SoA integration) | `NiblePath` segment encoding (27-bit identity per segment), `LanceDataset::append`, `versions()` watcher | LOCKED — breaking changes coordinated | | 6 (`lance-graph-ontology` cache) | manifest-watch event API for cache invalidation | LOCKED | | 7 (`lance-graph-callcenter`) | dataset transactional commit boundary; tag-based version retention | LOCKED | +| `ogar-auth` (identity / federation) | `lance_graph_contract::auth::ActorContext` (canonical identity envelope) + `sla::TenantId`; git dep, branch `main`, unpinned. Measured: OGAR base `5b82ae0`; resolved lance-graph-contract at `f859d484c46dda9725ace2dc870c73e95203edde` | CURRENT-CONTRACT (unpinned `main`; re-measure the resolved rev when it matters) | **Coordination notes**: - OGAR's `Identity::class_identity_versioned(prefix, class, n)` MUST produce a string that `NiblePath` can split losslessly.