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::{CardAccess, PrincipalKindTag, WorkloadFamily};
10use wyrd_spec::ids::CardUid;
11
12use crate::permission::PermissionSet;
13
14pub use wyrd_spec::auth::PrincipalId;
15
16/// The authenticated identity behind a request.
17///
18/// Account identity (`id`, `kind`, `tenant_id`) is stable across releases.
19/// Which exact Service version the request runs as is execution
20/// context carried beside it in `workload_uid`, and which Cards it may
21/// attribute evidence to is the signed `card_access` limit; neither changes
22/// the account.
23#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Principal {
25    /// Stable principal id.
26    pub id: PrincipalId,
27    /// Principal kind.
28    pub kind: PrincipalKind,
29    /// Tenant isolation key.
30    pub tenant_id: DataTenantId,
31    /// Role names carried by the verified token.
32    pub roles: Vec<RoleRef>,
33    /// The token's permission snapshot: resolved from the principal's roles
34    /// once at issuance and carried unchanged until the token expires.
35    pub effective_permissions: PermissionSet,
36    /// Non-secret id of the credential the request authenticated with, when one
37    /// was presented.
38    ///
39    /// Audit records it so a decision names which of a principal's several live
40    /// credentials made it. `None` for a federated human session and for an
41    /// identity the server minted for itself.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub credential_id: Option<Uuid>,
44    /// Effective limit on which Cards this request may attribute evidence to.
45    ///
46    /// Independent of `effective_permissions`: Card-attributed work needs both.
47    pub card_access: CardAccess,
48    /// The subject's exact Service root, when one was selected.
49    ///
50    /// Present exactly when the subject's own context is a validated root.
51    /// For delegated work the owning root is the current actor's instead; see
52    /// [`crate::request_context::owning_root`].
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub workload_uid: Option<CardUid>,
55}
56
57/// Kind of authenticated account.
58///
59/// Every variant here is **tenant-scoped**: [`Principal`] carries a required
60/// tenant, so this enum is the tenant-plane projection of a principal.
61/// Platform-scope principals are a separate projection and are never
62/// representable as a [`Principal`], which is what keeps the two control planes
63/// from collapsing into one another by accident. The kind names the account
64/// only; execution context lives on [`Principal::workload_uid`].
65#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum PrincipalKind {
68    /// Tenant administrative identity created during tenant provisioning.
69    TenantAdmin,
70    /// Human user identity.
71    User,
72    /// Stable machine Service account, independent of every Card version.
73    Service,
74    /// Internal credentialless verification-result writer.
75    ///
76    /// Exactly one exists per tenant. It holds no roles or credential and has
77    /// no public lifecycle; the server mints it a short-lived token scoped to
78    /// the single Verifier whose results it writes.
79    System,
80}
81
82impl PrincipalKind {
83    /// Projects to the wire discriminator.
84    #[must_use]
85    pub const fn tag(self) -> PrincipalKindTag {
86        match self {
87            Self::TenantAdmin => PrincipalKindTag::TenantAdmin,
88            Self::User => PrincipalKindTag::User,
89            Self::Service => PrincipalKindTag::Service,
90            Self::System => PrincipalKindTag::System,
91        }
92    }
93}
94
95/// Reference to a role by name.
96#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
97#[serde(transparent)]
98pub struct RoleRef(String);
99
100impl RoleRef {
101    /// Build a validated role reference.
102    ///
103    /// # Errors
104    /// Returns an error when the name does not match `^[a-z0-9_]{1,64}$`.
105    pub fn new(value: &str) -> Result<Self, InvalidRoleName> {
106        if value.is_empty()
107            || value.len() > 64
108            || !value
109                .bytes()
110                .all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_'))
111        {
112            return Err(InvalidRoleName);
113        }
114        Ok(Self(value.to_owned()))
115    }
116
117    /// Borrow as a string.
118    #[must_use]
119    pub fn as_str(&self) -> &str {
120        &self.0
121    }
122}
123
124impl fmt::Display for RoleRef {
125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126        f.write_str(&self.0)
127    }
128}
129
130impl FromStr for RoleRef {
131    type Err = InvalidRoleName;
132
133    fn from_str(value: &str) -> Result<Self, Self::Err> {
134        Self::new(value)
135    }
136}
137
138/// Invalid role name.
139#[derive(Debug, thiserror::Error)]
140#[error("role name must match ^[a-z0-9_]{{1,64}}$")]
141pub struct InvalidRoleName;
142
143impl Principal {
144    /// Construct a rootless principal.
145    ///
146    /// The Card limit defaults to `any_registered` (the rootless User,
147    /// administrator, and unrestricted Service state), except the SYSTEM
148    /// writer, which starts with an empty scope. Context and credential are
149    /// attached separately with [`Principal::with_card_access`] and
150    /// [`Principal::with_credential_id`].
151    #[must_use]
152    pub fn new(
153        id: PrincipalId,
154        kind: PrincipalKind,
155        tenant_id: DataTenantId,
156        roles: Vec<RoleRef>,
157        effective_permissions: PermissionSet,
158    ) -> Self {
159        let card_access = match kind {
160            PrincipalKind::System => CardAccess::Scoped { cards: Vec::new() },
161            PrincipalKind::TenantAdmin | PrincipalKind::User | PrincipalKind::Service => {
162                CardAccess::AnyRegistered
163            }
164        };
165        Self {
166            id,
167            kind,
168            tenant_id,
169            roles,
170            effective_permissions,
171            credential_id: None,
172            card_access,
173            workload_uid: None,
174        }
175    }
176
177    /// Name the credential this principal authenticated with.
178    #[must_use]
179    pub fn with_credential_id(mut self, credential_id: Option<Uuid>) -> Self {
180        self.credential_id = credential_id;
181        self
182    }
183
184    /// Attach the signed Card limit and the subject's selected root.
185    #[must_use]
186    pub fn with_card_access(
187        mut self,
188        card_access: CardAccess,
189        workload_uid: Option<CardUid>,
190    ) -> Self {
191        self.card_access = card_access;
192        self.workload_uid = workload_uid;
193        self
194    }
195
196    /// The exact signed Card UID scope, when access is `scoped`.
197    #[must_use]
198    pub fn card_scope(&self) -> Option<&[CardUid]> {
199        match &self.card_access {
200            CardAccess::Scoped { cards } => Some(cards),
201            CardAccess::AnyRegistered | CardAccess::RootRequired { .. } => None,
202        }
203    }
204
205    /// The Cards this principal may attribute observations and verifications to.
206    #[must_use]
207    pub fn card_attribution(&self) -> CardAttribution<'_> {
208        match &self.card_access {
209            CardAccess::Scoped { cards } => CardAttribution::Scoped(cards),
210            CardAccess::AnyRegistered => CardAttribution::AnyRegistered,
211            CardAccess::RootRequired { restriction } => {
212                CardAttribution::RootRequired(restriction.family())
213            }
214        }
215    }
216
217    /// True when the signed scope names `card`.
218    #[must_use]
219    pub fn authorizes_card_uid(&self, card: &CardUid) -> bool {
220        self.card_scope().is_some_and(|cards| cards.contains(card))
221    }
222}
223
224/// Which Cards a principal may attribute observations and verifications to.
225///
226/// Returned by [`Principal::card_attribution`]. The distinction is explicit so
227/// an unbound principal is never mistaken for one with an empty bounded scope,
228/// and an unresolved family-restricted account is never mistaken for either.
229#[derive(Clone, Copy, Debug, PartialEq, Eq)]
230pub enum CardAttribution<'a> {
231    /// Only the members of this signed Card UID scope.
232    Scoped(&'a [CardUid]),
233    /// Any registered observation-target Card in the principal's tenant.
234    AnyRegistered,
235    /// A family-restricted account with no validated root: no Card-attributed
236    /// authority until a root of this family is selected.
237    RootRequired(&'a WorkloadFamily),
238}
239
240/// Projection of one delegation participant.
241///
242/// Account identity plus the participant's own root and non-secret credential
243/// provenance. Historical participants confer no authority.
244#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(deny_unknown_fields)]
246pub struct PrincipalRef {
247    /// Stable principal id.
248    pub id: PrincipalId,
249    /// Principal kind.
250    pub kind: PrincipalKind,
251    /// The participant's exact root.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub workload_uid: Option<CardUid>,
254    /// Non-secret id of the credential the participant authenticated with.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub credential_id: Option<Uuid>,
257}
258
259impl PrincipalRef {
260    /// Project a full principal into a delegation-chain reference.
261    #[must_use]
262    pub fn from_principal(principal: &Principal) -> Self {
263        Self {
264            id: principal.id,
265            kind: principal.kind,
266            workload_uid: principal.workload_uid.clone(),
267            credential_id: principal.credential_id,
268        }
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::{CardAttribution, Principal, PrincipalId, PrincipalKind, PrincipalRef, RoleRef};
275    use crate::permission::PermissionSet;
276    use wyrd_spec::auth::{CardAccess, FamilyRestriction, WorkloadFamily, WorkloadKind};
277    use wyrd_spec::ids::{CardName, CardUid};
278
279    fn uid() -> CardUid {
280        CardUid::from_uuid(uuid::Uuid::now_v7()).expect("v7 Card UID")
281    }
282
283    fn principal(kind: PrincipalKind) -> Principal {
284        Principal::new(
285            PrincipalId::new(uuid::Uuid::now_v7()),
286            kind,
287            wyrd_spec::DataTenantId::new_v7(),
288            Vec::new(),
289            PermissionSet::new(),
290        )
291    }
292
293    #[test]
294    fn role_ref_accepts_locked_name_shape() {
295        let role = RoleRef::new("credential_admin").expect("role name is valid");
296
297        assert_eq!(role.as_str(), "credential_admin");
298    }
299
300    #[test]
301    fn role_ref_rejects_punctuation_and_uppercase() {
302        assert!(RoleRef::new("RuntimeAdmin").is_err());
303        assert!(RoleRef::new("runtime-admin").is_err());
304    }
305
306    #[test]
307    fn principal_id_roundtrips_as_uuid() {
308        let uuid = uuid::Uuid::now_v7();
309        let id = PrincipalId::new(uuid);
310
311        assert_eq!(id.to_string(), uuid.to_string());
312        assert_eq!(id.as_uuid(), uuid);
313    }
314
315    /// Rootless accounts attribute to any registered Card, SYSTEM starts
316    /// empty, a scope authorizes exactly its UIDs, and an unresolved family
317    /// account is neither.
318    ///
319    /// # Panics
320    /// Panics when a state projects the wrong attribution.
321    #[test]
322    fn card_attribution_follows_the_signed_access_state() {
323        for kind in [
324            PrincipalKind::TenantAdmin,
325            PrincipalKind::User,
326            PrincipalKind::Service,
327        ] {
328            assert_eq!(
329                principal(kind).card_attribution(),
330                CardAttribution::AnyRegistered
331            );
332        }
333        assert_eq!(
334            principal(PrincipalKind::System).card_attribution(),
335            CardAttribution::Scoped(&[])
336        );
337        let (root, member, outside) = (uid(), uid(), uid());
338        let scoped = principal(PrincipalKind::Service).with_card_access(
339            CardAccess::Scoped {
340                cards: vec![root.clone(), member.clone()],
341            },
342            Some(root.clone()),
343        );
344        assert!(scoped.authorizes_card_uid(&member));
345        assert!(!scoped.authorizes_card_uid(&outside));
346        let projected = PrincipalRef::from_principal(&scoped);
347        assert_eq!(projected.workload_uid, Some(root));
348        let family = WorkloadFamily {
349            kind: WorkloadKind::Service,
350            name: CardName::new("checkout").expect("static name"),
351        };
352        let unresolved = principal(PrincipalKind::Service).with_card_access(
353            CardAccess::RootRequired {
354                restriction: FamilyRestriction::Family(family.clone()),
355            },
356            None,
357        );
358        assert_eq!(
359            unresolved.card_attribution(),
360            CardAttribution::RootRequired(&family)
361        );
362        assert!(!unresolved.authorizes_card_uid(&member));
363    }
364}
365
366/// Platform-scope authenticated identity.
367///
368/// Deliberately carries **no tenant**. A platform principal operates the
369/// tenant directory — creating, inspecting, suspending, and recovering
370/// administration for tenants — and is never implicitly authorized over the
371/// resources inside one. Because the type has no tenant to offer, a handler
372/// holding one cannot open a tenant-scoped connection at all; the control-plane
373/// boundary is enforced by what is representable rather than by a check a
374/// caller could forget.
375#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
376pub struct PlatformPrincipal {
377    /// Stable principal id.
378    pub id: PrincipalId,
379    /// The kind the platform directory stores for this principal.
380    ///
381    /// `GlobalAdmin` for a machine root, `User` for a human registered for
382    /// federated sign-in. Audit reads it, so a human's decision is never
383    /// recorded as a machine's. It is read from the store at session
384    /// verification, never inferred from what the principal may do.
385    pub kind: PrincipalKindTag,
386    /// Permissions resolved from the principal's platform grant.
387    ///
388    /// Absence of a grant is denial: a platform principal with an empty set
389    /// authenticates but authorizes nothing.
390    pub effective_permissions: PermissionSet,
391    /// Non-secret id of the credential the session was minted from, when one
392    /// was presented. `None` for a federated login.
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub credential_id: Option<Uuid>,
395}
396
397impl PlatformPrincipal {
398    /// Construct a platform-scope principal.
399    #[must_use]
400    pub const fn new(
401        id: PrincipalId,
402        kind: PrincipalKindTag,
403        effective_permissions: PermissionSet,
404    ) -> Self {
405        Self {
406            id,
407            kind,
408            effective_permissions,
409            credential_id: None,
410        }
411    }
412
413    /// Name the credential this session was minted from.
414    #[must_use]
415    pub const fn with_credential_id(mut self, credential_id: Option<Uuid>) -> Self {
416        self.credential_id = credential_id;
417        self
418    }
419}
420
421/// The authenticated identity behind a request, in exactly one control plane.
422///
423/// One authentication pipeline produces this, whichever entry path the caller
424/// used — a machine credential exchanged for a token, or a human federated
425/// login. Everything downstream authorizes against it and never branches on how
426/// authentication happened.
427///
428/// The two variants are closed and carry different payloads, so a platform
429/// identity is not representable where a tenant identity is required and the
430/// reverse. That mirrors the database tier's `OperatorPool` / `TenantConn`
431/// split at the identity tier.
432#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
433#[serde(rename_all = "snake_case", tag = "scope")]
434pub enum AuthContext {
435    /// Platform control plane: tenant lifecycle and administrative recovery.
436    Platform(PlatformPrincipal),
437    /// Tenant control plane: the resources of exactly one tenant.
438    Tenant(Box<Principal>),
439}
440
441impl AuthContext {
442    /// Borrow the platform principal, when this context is platform-scoped.
443    #[must_use]
444    pub fn platform(&self) -> Option<&PlatformPrincipal> {
445        match self {
446            Self::Platform(principal) => Some(principal),
447            Self::Tenant(_) => None,
448        }
449    }
450
451    /// Borrow the tenant principal, when this context is tenant-scoped.
452    #[must_use]
453    pub fn tenant(&self) -> Option<&Principal> {
454        match self {
455            Self::Tenant(principal) => Some(principal),
456            Self::Platform(_) => None,
457        }
458    }
459
460    /// The authenticated principal id, whichever plane this context names.
461    ///
462    /// Audit uses this so every privileged operation is attributable to a
463    /// principal rather than to a credential.
464    #[must_use]
465    pub fn principal_id(&self) -> PrincipalId {
466        match self {
467            Self::Platform(principal) => principal.id,
468            Self::Tenant(principal) => principal.id,
469        }
470    }
471
472    /// The kind of principal behind the request, whichever plane it names.
473    ///
474    /// Audit records this, so it has to be what the store holds rather than a
475    /// per-plane constant: a platform plane carrying both machine roots and
476    /// registered humans would otherwise attribute every human decision to a
477    /// machine.
478    #[must_use]
479    pub fn principal_kind(&self) -> PrincipalKindTag {
480        match self {
481            Self::Platform(principal) => principal.kind,
482            Self::Tenant(principal) => principal.kind.tag(),
483        }
484    }
485
486    /// The credential the request authenticated with, on either plane.
487    ///
488    /// Audit records it beside the principal, because a principal may hold
489    /// several live credentials at once and the recorded decision has to say
490    /// which one was used.
491    #[must_use]
492    pub const fn credential_id(&self) -> Option<Uuid> {
493        match self {
494            Self::Platform(principal) => principal.credential_id,
495            Self::Tenant(principal) => principal.credential_id,
496        }
497    }
498
499    /// The permissions authorization is decided against.
500    ///
501    /// One vocabulary and one checker serve both planes; only the set differs.
502    #[must_use]
503    pub fn effective_permissions(&self) -> &PermissionSet {
504        match self {
505            Self::Platform(principal) => &principal.effective_permissions,
506            Self::Tenant(principal) => &principal.effective_permissions,
507        }
508    }
509
510    /// The tenant this context is scoped to, when it is tenant-scoped.
511    ///
512    /// Returns `None` for a platform context because a platform principal has
513    /// no tenant to return — not because one was withheld.
514    #[must_use]
515    pub fn tenant_id(&self) -> Option<DataTenantId> {
516        match self {
517            Self::Tenant(principal) => Some(principal.tenant_id),
518            Self::Platform(_) => None,
519        }
520    }
521}
522
523impl From<Principal> for AuthContext {
524    /// Lift a verified tenant principal into the shared context.
525    ///
526    /// Boxed because a tenant principal carries its roles and resolved
527    /// permissions, and the platform variant carries far less; keeping the
528    /// enum small matters where it is passed by value on every request.
529    fn from(principal: Principal) -> Self {
530        Self::Tenant(Box::new(principal))
531    }
532}
533
534impl From<PlatformPrincipal> for AuthContext {
535    /// Lift a verified platform principal into the shared context.
536    ///
537    /// The platform arm is the one that carries no tenant, which is what
538    /// downstream tenancy checks read the context for.
539    fn from(principal: PlatformPrincipal) -> Self {
540        Self::Platform(principal)
541    }
542}
543
544/// The plane an [`AuthContext`] reports, and the tenancy that follows from it.
545#[cfg(test)]
546mod auth_context_tests {
547    use wyrd_spec::DataTenantId;
548
549    use wyrd_spec::auth::PrincipalKindTag;
550
551    use super::{AuthContext, PlatformPrincipal, Principal, PrincipalId, PrincipalKind};
552    use crate::permission::{Permission, PermissionSet};
553
554    /// A platform principal holding tenant-lifecycle authority.
555    fn platform() -> PlatformPrincipal {
556        PlatformPrincipal::new(
557            PrincipalId::new(uuid::Uuid::now_v7()),
558            PrincipalKindTag::GlobalAdmin,
559            {
560                let mut set = PermissionSet::new();
561                set.insert(Permission::tenant_create());
562                set
563            },
564        )
565    }
566
567    /// A tenant administrative principal in some tenant.
568    fn tenant() -> Principal {
569        Principal::new(
570            PrincipalId::new(uuid::Uuid::now_v7()),
571            PrincipalKind::TenantAdmin,
572            DataTenantId::new_v7(),
573            Vec::new(),
574            {
575                let mut set = PermissionSet::new();
576                set.insert(Permission::card_read());
577                set
578            },
579        )
580    }
581
582    /// A platform context has no tenant to offer, so no handler holding one can
583    /// reach tenant-scoped data even by mistake.
584    #[test]
585    fn platform_context_carries_no_tenant() {
586        let context = AuthContext::from(platform());
587
588        assert_eq!(context.tenant_id(), None);
589        assert!(context.tenant().is_none());
590        assert!(context.platform().is_some());
591    }
592
593    /// A tenant context names exactly one tenant and never satisfies a
594    /// platform-scoped accessor.
595    #[test]
596    fn tenant_context_carries_exactly_its_tenant() {
597        let principal = tenant();
598        let expected = principal.tenant_id;
599        let context = AuthContext::from(principal);
600
601        assert_eq!(context.tenant_id(), Some(expected));
602        assert!(context.platform().is_none());
603        assert!(context.tenant().is_some());
604    }
605
606    /// Authorization reads one permission set whichever plane produced the
607    /// context, so the checker needs no plane-specific branch.
608    #[test]
609    fn permissions_resolve_from_whichever_plane_authenticated() {
610        let platform_context = AuthContext::from(platform());
611        let tenant_context = AuthContext::from(tenant());
612
613        assert!(
614            platform_context
615                .effective_permissions()
616                .contains(&Permission::tenant_create())
617        );
618        assert!(
619            !tenant_context
620                .effective_permissions()
621                .contains(&Permission::tenant_create()),
622            "a tenant principal never holds platform authority"
623        );
624        assert!(
625            tenant_context
626                .effective_permissions()
627                .contains(&Permission::card_read())
628        );
629    }
630
631    /// Every privileged operation is attributable to a principal, on either
632    /// plane.
633    #[test]
634    fn principal_id_is_available_on_both_planes() {
635        let principal = platform();
636        let expected = principal.id;
637        assert_eq!(AuthContext::from(principal).principal_id(), expected);
638
639        let principal = tenant();
640        let expected = principal.id;
641        assert_eq!(AuthContext::from(principal).principal_id(), expected);
642    }
643}