ppoppo-identity 0.48.0

Principal-identity vocabulary for the ppoppo ecosystem — the Ppnum/PpnumId pair, the EntityType, LifecycleState and OAuth Scope value-sets, and the admin predicate, shared by the token engine, both services and every SDK
Documentation
//! The scope-set contract: which [`Scope`] atoms a phantom marker type stands
//! for.
//!
//! Every marker family in the workspace implements [`ScopeSet`] — the OIDC
//! markers in `ppoppo-token` (`Openid`, `Email`, …), and each SDK's sealed
//! family (`ppoppo-pcs-session`'s tiers, `ppoppo-pcs-external`'s and
//! `ppoppo-pas-plims`'s grants). A crate that performs an authorization leg
//! can then read a marker it has never heard of, and a wire scope string is
//! always *derived* from typed atoms, never spelled by hand.
//!
//! # Why here
//!
//! This is the one node every marker family can reach. `ppoppo-sdk-core`
//! depends on `ppoppo-token`, so a trait declared in sdk-core could never be
//! implemented by token's OIDC markers (`ADR_202610021053`). The trait sits
//! beside the vocabulary its atoms come from; sdk-core keeps the machinery
//! that consumes it (`missing_atoms`, `ScopedTokenSource`) and re-exports it.
//!
//! # Open parent, sealed children
//!
//! These traits are deliberately **not** sealed: a sealed trait could only be
//! implemented here. Each family stays sealed exactly as before — sealing
//! restricts who may implement *that family's* trait, not which other traits
//! its members implement.
//!
//! # It provides coherence, not authorization
//!
//! Implementing [`ScopeSet`] grants nothing. The atoms are what a client *asks
//! for*; the server decides what to give. PAS refuses unknown or
//! un-allowlisted scopes loudly (`invalid_scope`) and never silently
//! intersects them down. The authorization boundary is, and stays,
//! server-side.

use crate::Scope;

/// The OAuth2 scope atoms a marker type stands for.
///
/// # Atoms, not a joined string
///
/// [`SCOPES`](ScopeSet::SCOPES) is a slice of typed atoms because two things
/// need set semantics, and neither can be had from a pre-joined string:
///
/// 1. **The granted-vs-requested covers-check.** RFC 6749 §5.1 lets the token
///    response echo the granted scope; §3.3 makes order and whitespace
///    insignificant. So the check is `granted ⊇ requested` over atom sets —
///    never string equality, which would reject a reordered echo.
/// 2. **Per-atom correspondence.** The PCS capability map asserts each
///    capability marker's implied scope is present in every family that
///    claims it. Set membership is the assertion; a joined string would have
///    to be re-split by every test.
///
/// **Do not add a parallel joined const.** Two declarations of the same fact
/// is precisely the drift this contract exists to remove — the joined form is
/// derived on demand by [`scope_line`](ScopeSet::scope_line).
///
/// # Membership is not this trait's business
///
/// Which atoms a marker contains is the owning crate's decision and changes on
/// that crate's release cadence alone. Server-first, always: an atom must be
/// live in the PAS catalog ([`Scope`]) and the client's allowlist before a
/// binary requests it, or authorize fails loud — and since an atom is a
/// [`Scope`], a marker cannot name one PAS does not mint.
pub trait ScopeSet: Send + Sync + 'static {
    /// The scope atoms this marker stands for, one per element.
    const SCOPES: &'static [Scope];

    /// The atoms encoded for the wire — space-separated, RFC 6749 §3.3.
    ///
    /// Not a `const`: joining allocates, and neither const trait fns nor const
    /// heap allocation are stable. It runs once per authorization request, so
    /// the allocation is not on any hot path.
    #[must_use]
    fn scope_line() -> String {
        Self::SCOPES
            .iter()
            .map(|scope| scope.as_str())
            .collect::<Vec<_>>()
            .join(" ")
    }
}

/// A [`ScopeSet`] a **user can consent to** — a rung on an app's consent
/// ladder, requestable through an authorization-code flow.
///
/// Empty on purpose: it adds no atoms and no behaviour, it says where the
/// atoms may be asked for. `NativeAuthFlow<S>` and `RelyingParty<S>` are
/// bounded on it (`RFC_202609171314` T-31, SDK-G12): before the split, a fixed
/// grant satisfied the same bound, so `NativeAuthFlow::<Agent>` compiled and
/// sent an authorize request PAS answers `invalid_scope`.
pub trait ConsentScopes: ScopeSet {}

/// A [`ScopeSet`] **no user consents to**: PAS mints it for a credential of a
/// kind — an app's or an AI agent's own `client_credentials` grant, a
/// dependent agent's token exchange. Putting one on an authorize wire is a
/// request the server refuses, so the bound refuses it first.
///
/// The counterpart to [`ConsentScopes`], and the reason neither is the base:
/// generic machinery that only needs the *atoms* (the covers-check, a scoped
/// token source) is bounded on [`ScopeSet`] and serves both.
pub trait FixedScopes: ScopeSet {}

#[cfg(test)]
mod tests {
    use super::*;

    struct Single;
    impl ScopeSet for Single {
        const SCOPES: &'static [Scope] = &[Scope::ChatRead];
    }

    struct Triple;
    impl ScopeSet for Triple {
        const SCOPES: &'static [Scope] = &[Scope::ChatRead, Scope::ContactRead, Scope::ChatAck];
    }

    struct Empty;
    impl ScopeSet for Empty {
        const SCOPES: &'static [Scope] = &[];
    }

    #[test]
    fn scope_line_is_single_space_separated() {
        // RFC 6749 §3.3 wire form: exactly one space, no trailing separator.
        assert_eq!(Triple::scope_line(), "chat.read contact.read chat.ack");
    }

    #[test]
    fn single_atom_carries_no_separator() {
        assert_eq!(Single::scope_line(), "chat.read");
    }

    #[test]
    fn empty_set_is_an_empty_line() {
        // A marker requesting nothing is degenerate but representable; it must
        // not produce a stray space that PAS would read as an empty atom.
        assert_eq!(Empty::scope_line(), "");
    }

    /// The point of the contract: a generic function reads atoms from a marker
    /// it knows nothing about. This is the shape the authorization leg uses.
    #[test]
    fn atoms_are_reachable_generically() {
        fn requested<S: ScopeSet>() -> &'static [Scope] {
            S::SCOPES
        }
        assert_eq!(requested::<Single>(), [Scope::ChatRead]);
        assert_eq!(requested::<Triple>().len(), 3);
    }

    /// The split T-31 exists for, asserted rather than described: the two
    /// halves are disjoint markers over one atom carrier, so a bound on one
    /// cannot be satisfied by a member of the other.
    #[test]
    fn a_consent_tier_and_a_fixed_grant_are_not_interchangeable() {
        struct Ladder;
        impl ScopeSet for Ladder {
            const SCOPES: &'static [Scope] = &[Scope::ChatRead];
        }
        impl ConsentScopes for Ladder {}

        struct Minted;
        impl ScopeSet for Minted {
            const SCOPES: &'static [Scope] = &[Scope::AgentRead];
        }
        impl FixedScopes for Minted {}

        fn on_the_authorize_wire<S: ConsentScopes>() -> String {
            S::scope_line()
        }
        fn minted_for_a_credential<S: FixedScopes>() -> String {
            S::scope_line()
        }
        // Both reach the atoms through `ScopeSet`; neither reaches the other's
        // door. `on_the_authorize_wire::<Minted>()` does not compile — see
        // `ppoppo-pas-external`'s `native_auth_flow_boundary.rs` for the
        // compile-fail witness on the real `NativeAuthFlow`.
        assert_eq!(on_the_authorize_wire::<Ladder>(), "chat.read");
        assert_eq!(minted_for_a_credential::<Minted>(), "agent.read");
    }
}