Skip to main content

wyrd_runtime/
principal.rs

1//! Runtime principal identity model.
2
3use std::fmt;
4use std::str::FromStr;
5
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8use wyrd_spec::DataTenantId;
9use wyrd_spec::auth::PrincipalKindTag;
10use wyrd_spec::reference::{CardRef, CardRefScope};
11
12use crate::permission::PermissionSet;
13
14pub use wyrd_spec::auth::PrincipalId;
15
16/// The authenticated identity behind a request.
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
18pub struct Principal {
19    /// Stable principal id.
20    pub id: PrincipalId,
21    /// Principal kind.
22    pub kind: PrincipalKind,
23    /// Tenant isolation key.
24    pub tenant_id: DataTenantId,
25    /// Role names carried by the verified token.
26    pub roles: Vec<RoleRef>,
27    /// The token's permission snapshot: resolved from the principal's roles
28    /// once at issuance and carried unchanged until the token expires.
29    pub effective_permissions: PermissionSet,
30    /// Non-secret id of the credential the request authenticated with, when one
31    /// was presented.
32    ///
33    /// Audit records it so a decision names which of a principal's several live
34    /// credentials made it. `None` for a federated human session and for an
35    /// identity the server minted for itself.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub credential_id: Option<Uuid>,
38}
39
40/// Kind of authenticated identity.
41///
42/// Every variant here is **tenant-scoped**: [`Principal`] carries a required
43/// tenant, so this enum is the tenant-plane projection of a principal.
44/// Platform-scope principals are a separate projection and are never
45/// representable as a [`Principal`], which is what keeps the two control planes
46/// from collapsing into one another by accident.
47#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case", tag = "kind", deny_unknown_fields)]
49pub enum PrincipalKind {
50    /// Tenant administrative identity created during tenant provisioning.
51    ///
52    /// The tenant's headless root of trust. It binds no Card — an
53    /// administrative identity is not a registered AI-system component — and it
54    /// coexists with any human administrator the tenant later federates.
55    TenantAdmin,
56    /// Human user identity.
57    User,
58    /// Machine service identity, optionally bound to a Service card.
59    ///
60    /// A deployed workload carries its Service card and the emit scope derived
61    /// from it. Tenant automation created by a tenant administrator carries no
62    /// Card and therefore no emit scope, because Card binding is a property of
63    /// a machine principal rather than a precondition for holding a credential.
64    Service {
65        /// Bound Service card, absent for a Card-free automation identity.
66        card_ref: Option<CardRef>,
67        /// Transitive card authorization set; contains `card_ref` when one is
68        /// bound and is empty otherwise.
69        card_ref_scope: CardRefScope,
70    },
71    /// Card-bound agent identity.
72    Agent {
73        /// Bound Agent card.
74        card_ref: CardRef,
75        /// Transitive card authorization set; always contains `card_ref`.
76        card_ref_scope: CardRefScope,
77    },
78    /// Internal credentialless verification-result writer.
79    ///
80    /// Exactly one exists per tenant. It binds no root Card, holds no roles or
81    /// credential, and has no public lifecycle; the server mints it a
82    /// short-lived token scoped to the single Verifier whose results it
83    /// writes, so ingest authorizes and stamps each result row against that
84    /// signed Verifier reference.
85    System {
86        /// Signed authorization set naming exactly one UID-bearing Verifier.
87        card_ref_scope: CardRefScope,
88    },
89}
90
91impl PrincipalKind {
92    /// Projects to the card-free wire discriminator.
93    #[must_use]
94    pub fn tag(&self) -> PrincipalKindTag {
95        match self {
96            Self::TenantAdmin => PrincipalKindTag::TenantAdmin,
97            Self::User => PrincipalKindTag::User,
98            Self::Service { .. } => PrincipalKindTag::Service,
99            Self::Agent { .. } => PrincipalKindTag::Agent,
100            Self::System { .. } => PrincipalKindTag::System,
101        }
102    }
103}
104
105/// Reference to a role by name.
106#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
107#[serde(transparent)]
108pub struct RoleRef(String);
109
110impl RoleRef {
111    /// Build a validated role reference.
112    ///
113    /// # Errors
114    /// Returns an error when the name does not match `^[a-z0-9_]{1,64}$`.
115    pub fn new(value: &str) -> Result<Self, InvalidRoleName> {
116        if value.is_empty()
117            || value.len() > 64
118            || !value
119                .bytes()
120                .all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_'))
121        {
122            return Err(InvalidRoleName);
123        }
124        Ok(Self(value.to_owned()))
125    }
126
127    /// Borrow as a string.
128    #[must_use]
129    pub fn as_str(&self) -> &str {
130        &self.0
131    }
132}
133
134impl fmt::Display for RoleRef {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        f.write_str(&self.0)
137    }
138}
139
140impl FromStr for RoleRef {
141    type Err = InvalidRoleName;
142
143    fn from_str(value: &str) -> Result<Self, Self::Err> {
144        Self::new(value)
145    }
146}
147
148/// Invalid role name.
149#[derive(Debug, thiserror::Error)]
150#[error("role name must match ^[a-z0-9_]{{1,64}}$")]
151pub struct InvalidRoleName;
152
153impl Principal {
154    /// Construct a principal.
155    ///
156    /// The authenticating credential is attached separately with
157    /// [`Principal::with_credential_id`], because most callers — internal
158    /// service identities, federated humans, test fixtures — have no credential
159    /// to name and would otherwise all pass `None`.
160    #[must_use]
161    pub fn new(
162        id: PrincipalId,
163        kind: PrincipalKind,
164        tenant_id: DataTenantId,
165        roles: Vec<RoleRef>,
166        effective_permissions: PermissionSet,
167    ) -> Self {
168        Self {
169            id,
170            kind,
171            tenant_id,
172            roles,
173            effective_permissions,
174            credential_id: None,
175        }
176    }
177
178    /// Name the credential this principal authenticated with.
179    #[must_use]
180    pub fn with_credential_id(mut self, credential_id: Option<Uuid>) -> Self {
181        self.credential_id = credential_id;
182        self
183    }
184
185    /// Returns the bound card ref, when this principal has one.
186    ///
187    /// An agent always binds a Card. A service binds one only when it is a
188    /// deployed workload; Card-free tenant automation, tenant administrators,
189    /// humans, and the tenant SYSTEM principal return `None`.
190    #[must_use]
191    pub fn card_ref(&self) -> Option<&CardRef> {
192        match &self.kind {
193            PrincipalKind::Service { card_ref, .. } => card_ref.as_ref(),
194            PrincipalKind::Agent { card_ref, .. } => Some(card_ref),
195            PrincipalKind::TenantAdmin | PrincipalKind::User | PrincipalKind::System { .. } => None,
196        }
197    }
198
199    /// The UID of the owner Service Card this principal is deployed as, when
200    /// one is available without IO.
201    ///
202    /// Only a Card-bound Service principal whose bound Card is a resolved
203    /// `Service` names an owner. An Agent's Card is never widened to a parent
204    /// Service, and Card-free, human, administrator and SYSTEM principals have
205    /// none, so their verifications record a null owner.
206    #[must_use]
207    pub fn owner_service_uid(&self) -> Option<&wyrd_spec::ids::CardUid> {
208        match &self.kind {
209            PrincipalKind::Service {
210                card_ref: Some(card_ref),
211                ..
212            } if card_ref.kind == wyrd_spec::envelope::CardKind::Service => card_ref.uid.as_ref(),
213            _ => None,
214        }
215    }
216
217    /// Returns the card scope for card-carrying machine principals.
218    ///
219    /// A Card-free service still reports its (empty) scope, so callers can
220    /// distinguish "no emit authority" from "not a machine principal". The
221    /// tenant SYSTEM principal reports its in-process scope: exactly the
222    /// Verifier a result frame is attributed to, or empty for an input read.
223    #[must_use]
224    pub fn card_ref_scope(&self) -> Option<&CardRefScope> {
225        match &self.kind {
226            PrincipalKind::Service { card_ref_scope, .. }
227            | PrincipalKind::Agent { card_ref_scope, .. }
228            | PrincipalKind::System { card_ref_scope } => Some(card_ref_scope),
229            PrincipalKind::TenantAdmin | PrincipalKind::User => None,
230        }
231    }
232
233    /// The Cards this principal may attribute observations and verifications to.
234    ///
235    /// A Card-bound Service or Agent and the tenant SYSTEM writer are limited
236    /// to their signed scope. A principal bound to no Card — a tenant
237    /// administrator, a human user, or Card-free tenant automation — may
238    /// attribute evidence to any registered observation-target Card in its own
239    /// tenant, which the write path resolves against the tenant registry.
240    #[must_use]
241    pub fn card_attribution(&self) -> CardAttribution<'_> {
242        match &self.kind {
243            PrincipalKind::Service {
244                card_ref: Some(_),
245                card_ref_scope,
246            }
247            | PrincipalKind::Agent { card_ref_scope, .. }
248            | PrincipalKind::System { card_ref_scope } => CardAttribution::Scoped(card_ref_scope),
249            PrincipalKind::Service { card_ref: None, .. }
250            | PrincipalKind::TenantAdmin
251            | PrincipalKind::User => CardAttribution::AnyRegistered,
252        }
253    }
254
255    /// True when this principal is authorized to emit for `card`.
256    #[must_use]
257    pub fn authorizes_card(&self, card: &CardRef) -> bool {
258        self.card_ref_scope()
259            .is_some_and(|scope| scope.authorizes(card))
260    }
261}
262
263/// Which Cards a principal may attribute observations and verifications to.
264///
265/// Returned by [`Principal::card_attribution`]. The distinction is explicit so
266/// an unbound principal is never mistaken for one with an empty bounded scope.
267#[derive(Clone, Copy, Debug, PartialEq, Eq)]
268pub enum CardAttribution<'a> {
269    /// Only the members of this signed Card scope.
270    Scoped(&'a CardRefScope),
271    /// Any registered observation-target Card in the principal's tenant.
272    AnyRegistered,
273}
274
275/// Self-describing projection of a principal for request delegation chains.
276#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(deny_unknown_fields)]
278pub struct PrincipalRef {
279    /// Stable principal id.
280    pub id: PrincipalId,
281    /// Principal kind.
282    pub kind: PrincipalKind,
283}
284
285impl PrincipalRef {
286    /// Project a full principal into a delegation-chain reference.
287    #[must_use]
288    pub fn from_principal(principal: &Principal) -> Self {
289        Self {
290            id: principal.id,
291            kind: principal.kind.clone(),
292        }
293    }
294
295    /// Returns the bound card ref, when this principal has one.
296    ///
297    /// Mirrors [`Principal::card_ref`] for the delegation-chain projection.
298    #[must_use]
299    pub fn card_ref(&self) -> Option<&CardRef> {
300        match &self.kind {
301            PrincipalKind::Service { card_ref, .. } => card_ref.as_ref(),
302            PrincipalKind::Agent { card_ref, .. } => Some(card_ref),
303            PrincipalKind::TenantAdmin | PrincipalKind::User | PrincipalKind::System { .. } => None,
304        }
305    }
306
307    /// Returns the card scope for card-carrying machine principals.
308    ///
309    /// Mirrors [`Principal::card_ref_scope`] for the delegation-chain
310    /// projection.
311    #[must_use]
312    pub fn card_ref_scope(&self) -> Option<&CardRefScope> {
313        match &self.kind {
314            PrincipalKind::Service { card_ref_scope, .. }
315            | PrincipalKind::Agent { card_ref_scope, .. }
316            | PrincipalKind::System { card_ref_scope } => Some(card_ref_scope),
317            PrincipalKind::TenantAdmin | PrincipalKind::User => None,
318        }
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::{CardAttribution, Principal, PrincipalId, PrincipalKind, PrincipalRef, RoleRef};
325    use crate::permission::{Permission, PermissionSet};
326    use wyrd_semver::VersionBlock;
327    use wyrd_spec::envelope::CardKind;
328    use wyrd_spec::ids::{CardName, SpaceName};
329    use wyrd_spec::reference::{CardRef, CardRefScope};
330
331    fn service_card_ref() -> CardRef {
332        CardRef {
333            kind: CardKind::Service,
334            name: CardName::new("billing").expect("static card name is valid"),
335            version: VersionBlock::parse("1.0.0").expect("static version is valid"),
336            space: Some(SpaceName::new("prod").expect("static space is valid")),
337            uid: None,
338        }
339    }
340
341    #[test]
342    fn role_ref_accepts_locked_name_shape() {
343        let role = RoleRef::new("credential_admin").expect("role name is valid");
344
345        assert_eq!(role.as_str(), "credential_admin");
346    }
347
348    #[test]
349    fn role_ref_rejects_punctuation_and_uppercase() {
350        assert!(RoleRef::new("RuntimeAdmin").is_err());
351        assert!(RoleRef::new("runtime-admin").is_err());
352    }
353
354    #[test]
355    fn principal_id_roundtrips_as_uuid() {
356        let uuid = uuid::Uuid::now_v7();
357        let id = PrincipalId::new(uuid);
358
359        assert_eq!(id.to_string(), uuid.to_string());
360        assert_eq!(id.as_uuid(), uuid);
361    }
362
363    #[test]
364    fn service_kind_carries_card_ref() {
365        let card_ref = service_card_ref();
366        let principal = Principal {
367            id: PrincipalId::new(uuid::Uuid::now_v7()),
368            kind: PrincipalKind::Service {
369                card_ref: Some(card_ref.clone()),
370                card_ref_scope: CardRefScope::own(&card_ref),
371            },
372            tenant_id: wyrd_spec::DataTenantId::new_v7(),
373            roles: vec![RoleRef::new("agent").expect("static role is valid")],
374            effective_permissions: PermissionSet::from_iter([Permission::card_read()]),
375            credential_id: None,
376        };
377
378        assert_eq!(principal.card_ref(), Some(&card_ref));
379    }
380
381    /// Only a Card-bound Service principal with a resolved Service UID names
382    /// an owner; an unresolved Service ref and an Agent record none.
383    ///
384    /// # Panics
385    /// Panics when an owner is reported for the wrong principal kind.
386    #[test]
387    fn owner_service_uid_copies_only_a_resolved_service_card() {
388        let principal = |kind| Principal {
389            id: PrincipalId::new(uuid::Uuid::now_v7()),
390            kind,
391            tenant_id: wyrd_spec::DataTenantId::new_v7(),
392            roles: Vec::new(),
393            effective_permissions: PermissionSet::new(),
394            credential_id: None,
395        };
396        let uid = wyrd_spec::ids::CardUid::from_uuid(uuid::Uuid::now_v7()).expect("v7 Card UID");
397        let resolved = CardRef {
398            uid: Some(uid.clone()),
399            ..service_card_ref()
400        };
401        let service = |card_ref: CardRef| PrincipalKind::Service {
402            card_ref_scope: CardRefScope::own(&card_ref),
403            card_ref: Some(card_ref),
404        };
405        assert_eq!(
406            principal(service(resolved.clone())).owner_service_uid(),
407            Some(&uid)
408        );
409        assert_eq!(
410            principal(service(service_card_ref())).owner_service_uid(),
411            None
412        );
413        let agent = CardRef {
414            kind: CardKind::Agent,
415            ..resolved
416        };
417        assert_eq!(
418            principal(PrincipalKind::Agent {
419                card_ref_scope: CardRefScope::own(&agent),
420                card_ref: agent,
421            })
422            .owner_service_uid(),
423            None
424        );
425    }
426
427    #[test]
428    fn principal_ref_keeps_card_ref_projection() {
429        let card_ref = service_card_ref();
430        let principal = Principal {
431            id: PrincipalId::new(uuid::Uuid::now_v7()),
432            kind: PrincipalKind::Service {
433                card_ref: Some(card_ref.clone()),
434                card_ref_scope: CardRefScope::own(&card_ref),
435            },
436            tenant_id: wyrd_spec::DataTenantId::new_v7(),
437            roles: vec![RoleRef::new("agent").expect("static role is valid")],
438            effective_permissions: PermissionSet::new(),
439            credential_id: None,
440        };
441
442        let principal_ref = PrincipalRef::from_principal(&principal);
443
444        assert_eq!(principal_ref.id, principal.id);
445        assert_eq!(principal_ref.card_ref(), Some(&card_ref));
446        assert_eq!(
447            principal_ref.card_ref_scope(),
448            Some(&CardRefScope::own(&card_ref))
449        );
450        assert_eq!(principal_ref.kind, principal.kind);
451    }
452
453    #[test]
454    fn principal_authorizes_cards_from_scope() {
455        let own = service_card_ref();
456        let mut composed = service_card_ref();
457        composed.name = CardName::new("composed").expect("static card name is valid");
458        let principal = Principal {
459            id: PrincipalId::new(uuid::Uuid::now_v7()),
460            kind: PrincipalKind::Service {
461                card_ref: Some(own.clone()),
462                card_ref_scope: CardRefScope::from_root_and_members(&own, [composed.clone()]),
463            },
464            tenant_id: wyrd_spec::DataTenantId::new_v7(),
465            roles: Vec::new(),
466            effective_permissions: PermissionSet::new(),
467            credential_id: None,
468        };
469
470        assert!(principal.authorizes_card(&own));
471        assert!(principal.authorizes_card(&composed));
472
473        let mut unrelated = service_card_ref();
474        unrelated.name = CardName::new("unrelated").expect("static card name is valid");
475        assert!(!principal.authorizes_card(&unrelated));
476    }
477
478    /// A Card-bound principal attributes only within its signed scope, while a
479    /// tenant administrator, a user, and Card-free automation may attribute to
480    /// any registered Card rather than to an empty bounded scope.
481    ///
482    /// # Panics
483    /// Panics when a principal kind projects the wrong attribution.
484    #[test]
485    fn only_unbound_principals_attribute_to_any_registered_card() {
486        let own = service_card_ref();
487        let scope = CardRefScope::own(&own);
488        let principal = |kind| {
489            Principal::new(
490                PrincipalId::new(uuid::Uuid::now_v7()),
491                kind,
492                wyrd_spec::DataTenantId::new_v7(),
493                Vec::new(),
494                PermissionSet::new(),
495            )
496        };
497
498        let bound = principal(PrincipalKind::Service {
499            card_ref: Some(own),
500            card_ref_scope: scope.clone(),
501        });
502        assert_eq!(bound.card_attribution(), CardAttribution::Scoped(&scope));
503        for unbound in [
504            PrincipalKind::TenantAdmin,
505            PrincipalKind::User,
506            PrincipalKind::Service {
507                card_ref: None,
508                card_ref_scope: CardRefScope::default(),
509            },
510        ] {
511            assert_eq!(
512                principal(unbound).card_attribution(),
513                CardAttribution::AnyRegistered
514            );
515        }
516    }
517
518    /// Build the UID-bearing Verifier reference a SYSTEM result frame is
519    /// scoped to.
520    ///
521    /// # Panics
522    /// Panics when a static identity component is invalid.
523    fn verifier_card_ref() -> CardRef {
524        CardRef {
525            kind: CardKind::Verifier,
526            name: CardName::new("drift").expect("static card name is valid"),
527            version: VersionBlock::parse("1.0.0").expect("static version is valid"),
528            space: Some(SpaceName::new("prod").expect("static space is valid")),
529            uid: Some(
530                wyrd_spec::ids::CardUid::from_uuid(uuid::Uuid::now_v7())
531                    .expect("UUIDv7 is a valid card UID"),
532            ),
533        }
534    }
535
536    /// The tenant SYSTEM principal binds no root Card, yet exposes the one
537    /// Verifier scope of a result frame so Scribe can stamp result rows.
538    ///
539    /// # Panics
540    /// Panics when the kind projects a root Card, loses its scope, authorizes
541    /// a Card outside that scope, or does not round-trip as `system`.
542    #[test]
543    fn system_kind_exposes_its_verifier_scope_without_a_root_card() {
544        let verifier = verifier_card_ref();
545        let principal = Principal::new(
546            PrincipalId::new(uuid::Uuid::now_v7()),
547            PrincipalKind::System {
548                card_ref_scope: CardRefScope::own(&verifier),
549            },
550            wyrd_spec::DataTenantId::new_v7(),
551            Vec::new(),
552            PermissionSet::from_iter([Permission::bifrost_record_write()]),
553        );
554
555        assert_eq!(
556            principal.kind.tag(),
557            wyrd_spec::auth::PrincipalKindTag::System
558        );
559        assert_eq!(principal.card_ref(), None);
560        assert_eq!(
561            principal.card_ref_scope(),
562            Some(&CardRefScope::own(&verifier))
563        );
564        assert!(principal.authorizes_card(&verifier));
565        assert!(!principal.authorizes_card(&service_card_ref()));
566
567        let principal_ref = PrincipalRef::from_principal(&principal);
568        assert_eq!(principal_ref.card_ref(), None);
569        assert_eq!(principal_ref.card_ref_scope(), principal.card_ref_scope());
570
571        let wire = serde_json::to_value(&principal.kind).expect("kind serializes");
572        assert_eq!(wire["kind"], "system");
573        assert_eq!(
574            serde_json::from_value::<PrincipalKind>(wire).expect("kind deserializes"),
575            principal.kind
576        );
577    }
578
579    #[test]
580    fn user_principal_authorizes_no_cards() {
581        let principal = Principal {
582            id: PrincipalId::new(uuid::Uuid::now_v7()),
583            kind: PrincipalKind::User,
584            tenant_id: wyrd_spec::DataTenantId::new_v7(),
585            roles: Vec::new(),
586            effective_permissions: PermissionSet::new(),
587            credential_id: None,
588        };
589
590        assert!(!principal.authorizes_card(&service_card_ref()));
591    }
592}
593
594/// Platform-scope authenticated identity.
595///
596/// Deliberately carries **no tenant**. A platform principal operates the
597/// tenant directory — creating, inspecting, suspending, and recovering
598/// administration for tenants — and is never implicitly authorized over the
599/// resources inside one. Because the type has no tenant to offer, a handler
600/// holding one cannot open a tenant-scoped connection at all; the control-plane
601/// boundary is enforced by what is representable rather than by a check a
602/// caller could forget.
603#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
604pub struct PlatformPrincipal {
605    /// Stable principal id.
606    pub id: PrincipalId,
607    /// The kind the platform directory stores for this principal.
608    ///
609    /// `GlobalAdmin` for a machine root, `User` for a human registered for
610    /// federated sign-in. Audit reads it, so a human's decision is never
611    /// recorded as a machine's. It is read from the store at session
612    /// verification, never inferred from what the principal may do.
613    pub kind: PrincipalKindTag,
614    /// Permissions resolved from the principal's platform grant.
615    ///
616    /// Absence of a grant is denial: a platform principal with an empty set
617    /// authenticates but authorizes nothing.
618    pub effective_permissions: PermissionSet,
619    /// Non-secret id of the credential the session was minted from, when one
620    /// was presented. `None` for a federated login.
621    #[serde(default, skip_serializing_if = "Option::is_none")]
622    pub credential_id: Option<Uuid>,
623}
624
625impl PlatformPrincipal {
626    /// Construct a platform-scope principal.
627    #[must_use]
628    pub const fn new(
629        id: PrincipalId,
630        kind: PrincipalKindTag,
631        effective_permissions: PermissionSet,
632    ) -> Self {
633        Self {
634            id,
635            kind,
636            effective_permissions,
637            credential_id: None,
638        }
639    }
640
641    /// Name the credential this session was minted from.
642    #[must_use]
643    pub const fn with_credential_id(mut self, credential_id: Option<Uuid>) -> Self {
644        self.credential_id = credential_id;
645        self
646    }
647}
648
649/// The authenticated identity behind a request, in exactly one control plane.
650///
651/// One authentication pipeline produces this, whichever entry path the caller
652/// used — a machine credential exchanged for a token, or a human federated
653/// login. Everything downstream authorizes against it and never branches on how
654/// authentication happened.
655///
656/// The two variants are closed and carry different payloads, so a platform
657/// identity is not representable where a tenant identity is required and the
658/// reverse. That mirrors the database tier's `OperatorPool` / `TenantConn`
659/// split at the identity tier.
660#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
661#[serde(rename_all = "snake_case", tag = "scope")]
662pub enum AuthContext {
663    /// Platform control plane: tenant lifecycle and administrative recovery.
664    Platform(PlatformPrincipal),
665    /// Tenant control plane: the resources of exactly one tenant.
666    Tenant(Box<Principal>),
667}
668
669impl AuthContext {
670    /// Borrow the platform principal, when this context is platform-scoped.
671    #[must_use]
672    pub fn platform(&self) -> Option<&PlatformPrincipal> {
673        match self {
674            Self::Platform(principal) => Some(principal),
675            Self::Tenant(_) => None,
676        }
677    }
678
679    /// Borrow the tenant principal, when this context is tenant-scoped.
680    #[must_use]
681    pub fn tenant(&self) -> Option<&Principal> {
682        match self {
683            Self::Tenant(principal) => Some(principal),
684            Self::Platform(_) => None,
685        }
686    }
687
688    /// The authenticated principal id, whichever plane this context names.
689    ///
690    /// Audit uses this so every privileged operation is attributable to a
691    /// principal rather than to a credential.
692    #[must_use]
693    pub fn principal_id(&self) -> PrincipalId {
694        match self {
695            Self::Platform(principal) => principal.id,
696            Self::Tenant(principal) => principal.id,
697        }
698    }
699
700    /// The kind of principal behind the request, whichever plane it names.
701    ///
702    /// Audit records this, so it has to be what the store holds rather than a
703    /// per-plane constant: a platform plane carrying both machine roots and
704    /// registered humans would otherwise attribute every human decision to a
705    /// machine.
706    #[must_use]
707    pub fn principal_kind(&self) -> PrincipalKindTag {
708        match self {
709            Self::Platform(principal) => principal.kind,
710            Self::Tenant(principal) => principal.kind.tag(),
711        }
712    }
713
714    /// The credential the request authenticated with, on either plane.
715    ///
716    /// Audit records it beside the principal, because a principal may hold
717    /// several live credentials at once and the recorded decision has to say
718    /// which one was used.
719    #[must_use]
720    pub const fn credential_id(&self) -> Option<Uuid> {
721        match self {
722            Self::Platform(principal) => principal.credential_id,
723            Self::Tenant(principal) => principal.credential_id,
724        }
725    }
726
727    /// The permissions authorization is decided against.
728    ///
729    /// One vocabulary and one checker serve both planes; only the set differs.
730    #[must_use]
731    pub fn effective_permissions(&self) -> &PermissionSet {
732        match self {
733            Self::Platform(principal) => &principal.effective_permissions,
734            Self::Tenant(principal) => &principal.effective_permissions,
735        }
736    }
737
738    /// The tenant this context is scoped to, when it is tenant-scoped.
739    ///
740    /// Returns `None` for a platform context because a platform principal has
741    /// no tenant to return — not because one was withheld.
742    #[must_use]
743    pub fn tenant_id(&self) -> Option<DataTenantId> {
744        match self {
745            Self::Tenant(principal) => Some(principal.tenant_id),
746            Self::Platform(_) => None,
747        }
748    }
749}
750
751impl From<Principal> for AuthContext {
752    /// Lift a verified tenant principal into the shared context.
753    ///
754    /// Boxed because a tenant principal carries its roles and resolved
755    /// permissions, and the platform variant carries far less; keeping the
756    /// enum small matters where it is passed by value on every request.
757    fn from(principal: Principal) -> Self {
758        Self::Tenant(Box::new(principal))
759    }
760}
761
762impl From<PlatformPrincipal> for AuthContext {
763    /// Lift a verified platform principal into the shared context.
764    ///
765    /// The platform arm is the one that carries no tenant, which is what
766    /// downstream tenancy checks read the context for.
767    fn from(principal: PlatformPrincipal) -> Self {
768        Self::Platform(principal)
769    }
770}
771
772/// The plane an [`AuthContext`] reports, and the tenancy that follows from it.
773#[cfg(test)]
774mod auth_context_tests {
775    use wyrd_spec::DataTenantId;
776
777    use wyrd_spec::auth::PrincipalKindTag;
778
779    use super::{AuthContext, PlatformPrincipal, Principal, PrincipalId, PrincipalKind};
780    use crate::permission::{Permission, PermissionSet};
781
782    /// A platform principal holding tenant-lifecycle authority.
783    fn platform() -> PlatformPrincipal {
784        PlatformPrincipal::new(
785            PrincipalId::new(uuid::Uuid::now_v7()),
786            PrincipalKindTag::GlobalAdmin,
787            {
788                let mut set = PermissionSet::new();
789                set.insert(Permission::tenant_create());
790                set
791            },
792        )
793    }
794
795    /// A tenant administrative principal in some tenant.
796    fn tenant() -> Principal {
797        Principal::new(
798            PrincipalId::new(uuid::Uuid::now_v7()),
799            PrincipalKind::TenantAdmin,
800            DataTenantId::new_v7(),
801            Vec::new(),
802            {
803                let mut set = PermissionSet::new();
804                set.insert(Permission::card_read());
805                set
806            },
807        )
808    }
809
810    /// A platform context has no tenant to offer, so no handler holding one can
811    /// reach tenant-scoped data even by mistake.
812    #[test]
813    fn platform_context_carries_no_tenant() {
814        let context = AuthContext::from(platform());
815
816        assert_eq!(context.tenant_id(), None);
817        assert!(context.tenant().is_none());
818        assert!(context.platform().is_some());
819    }
820
821    /// A tenant context names exactly one tenant and never satisfies a
822    /// platform-scoped accessor.
823    #[test]
824    fn tenant_context_carries_exactly_its_tenant() {
825        let principal = tenant();
826        let expected = principal.tenant_id;
827        let context = AuthContext::from(principal);
828
829        assert_eq!(context.tenant_id(), Some(expected));
830        assert!(context.platform().is_none());
831        assert!(context.tenant().is_some());
832    }
833
834    /// Authorization reads one permission set whichever plane produced the
835    /// context, so the checker needs no plane-specific branch.
836    #[test]
837    fn permissions_resolve_from_whichever_plane_authenticated() {
838        let platform_context = AuthContext::from(platform());
839        let tenant_context = AuthContext::from(tenant());
840
841        assert!(
842            platform_context
843                .effective_permissions()
844                .contains(&Permission::tenant_create())
845        );
846        assert!(
847            !tenant_context
848                .effective_permissions()
849                .contains(&Permission::tenant_create()),
850            "a tenant principal never holds platform authority"
851        );
852        assert!(
853            tenant_context
854                .effective_permissions()
855                .contains(&Permission::card_read())
856        );
857    }
858
859    /// Every privileged operation is attributable to a principal, on either
860    /// plane.
861    #[test]
862    fn principal_id_is_available_on_both_planes() {
863        let principal = platform();
864        let expected = principal.id;
865        assert_eq!(AuthContext::from(principal).principal_id(), expected);
866
867        let principal = tenant();
868        let expected = principal.id;
869        assert_eq!(AuthContext::from(principal).principal_id(), expected);
870    }
871}