1use 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Principal {
25 pub id: PrincipalId,
27 pub kind: PrincipalKind,
29 pub tenant_id: DataTenantId,
31 pub roles: Vec<RoleRef>,
33 pub effective_permissions: PermissionSet,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub credential_id: Option<Uuid>,
44 pub card_access: CardAccess,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub workload_uid: Option<CardUid>,
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum PrincipalKind {
68 TenantAdmin,
70 User,
72 Service,
74 System,
80}
81
82impl PrincipalKind {
83 #[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#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
97#[serde(transparent)]
98pub struct RoleRef(String);
99
100impl RoleRef {
101 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 #[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#[derive(Debug, thiserror::Error)]
140#[error("role name must match ^[a-z0-9_]{{1,64}}$")]
141pub struct InvalidRoleName;
142
143impl Principal {
144 #[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 #[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 #[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 #[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 #[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 #[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
230pub enum CardAttribution<'a> {
231 Scoped(&'a [CardUid]),
233 AnyRegistered,
235 RootRequired(&'a WorkloadFamily),
238}
239
240#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(deny_unknown_fields)]
246pub struct PrincipalRef {
247 pub id: PrincipalId,
249 pub kind: PrincipalKind,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub workload_uid: Option<CardUid>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub credential_id: Option<Uuid>,
257}
258
259impl PrincipalRef {
260 #[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 #[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
376pub struct PlatformPrincipal {
377 pub id: PrincipalId,
379 pub kind: PrincipalKindTag,
386 pub effective_permissions: PermissionSet,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub credential_id: Option<Uuid>,
395}
396
397impl PlatformPrincipal {
398 #[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 #[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
433#[serde(rename_all = "snake_case", tag = "scope")]
434pub enum AuthContext {
435 Platform(PlatformPrincipal),
437 Tenant(Box<Principal>),
439}
440
441impl AuthContext {
442 #[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 #[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 #[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 #[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 #[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 #[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 #[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 fn from(principal: Principal) -> Self {
530 Self::Tenant(Box::new(principal))
531 }
532}
533
534impl From<PlatformPrincipal> for AuthContext {
535 fn from(principal: PlatformPrincipal) -> Self {
540 Self::Platform(principal)
541 }
542}
543
544#[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 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 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 #[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 #[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 #[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 #[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}