1use 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
18pub struct Principal {
19 pub id: PrincipalId,
21 pub kind: PrincipalKind,
23 pub tenant_id: DataTenantId,
25 pub roles: Vec<RoleRef>,
27 pub effective_permissions: PermissionSet,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub credential_id: Option<Uuid>,
38}
39
40#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case", tag = "kind", deny_unknown_fields)]
49pub enum PrincipalKind {
50 TenantAdmin,
56 User,
58 Service {
65 card_ref: Option<CardRef>,
67 card_ref_scope: CardRefScope,
70 },
71 Agent {
73 card_ref: CardRef,
75 card_ref_scope: CardRefScope,
77 },
78 System {
86 card_ref_scope: CardRefScope,
88 },
89}
90
91impl PrincipalKind {
92 #[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#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
107#[serde(transparent)]
108pub struct RoleRef(String);
109
110impl RoleRef {
111 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 #[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#[derive(Debug, thiserror::Error)]
150#[error("role name must match ^[a-z0-9_]{{1,64}}$")]
151pub struct InvalidRoleName;
152
153impl Principal {
154 #[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 #[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 #[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 #[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 #[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 #[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 #[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
268pub enum CardAttribution<'a> {
269 Scoped(&'a CardRefScope),
271 AnyRegistered,
273}
274
275#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(deny_unknown_fields)]
278pub struct PrincipalRef {
279 pub id: PrincipalId,
281 pub kind: PrincipalKind,
283}
284
285impl PrincipalRef {
286 #[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 #[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 #[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 #[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 #[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 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 #[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
604pub struct PlatformPrincipal {
605 pub id: PrincipalId,
607 pub kind: PrincipalKindTag,
614 pub effective_permissions: PermissionSet,
619 #[serde(default, skip_serializing_if = "Option::is_none")]
622 pub credential_id: Option<Uuid>,
623}
624
625impl PlatformPrincipal {
626 #[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 #[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
661#[serde(rename_all = "snake_case", tag = "scope")]
662pub enum AuthContext {
663 Platform(PlatformPrincipal),
665 Tenant(Box<Principal>),
667}
668
669impl AuthContext {
670 #[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 #[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 #[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 #[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 #[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 #[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 #[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 fn from(principal: Principal) -> Self {
758 Self::Tenant(Box::new(principal))
759 }
760}
761
762impl From<PlatformPrincipal> for AuthContext {
763 fn from(principal: PlatformPrincipal) -> Self {
768 Self::Platform(principal)
769 }
770}
771
772#[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 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 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 #[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 #[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 #[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 #[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}