Skip to main content

api_bones/
org_context.rs

1// SPDX-License-Identifier: MIT
2//! Cross-cutting platform context bundle.
3//!
4//! [`OrganizationContext`] carries the tenant, principal, request-id, roles,
5//! and an optional opaque attestation in a single, cheap-to-clone bundle.
6//! Downstream services consume this type instead of threading
7//! `(org_id, principal)` pairs through every function.
8
9#[cfg(all(not(feature = "std"), feature = "alloc"))]
10use alloc::string::String;
11#[cfg(all(not(feature = "std"), feature = "alloc"))]
12use alloc::sync::Arc;
13#[cfg(all(not(feature = "std"), feature = "alloc"))]
14use alloc::vec::Vec;
15use core::fmt;
16
17#[cfg(feature = "std")]
18use std::sync::Arc;
19
20#[cfg(feature = "serde")]
21use serde::{Deserialize, Serialize};
22
23use crate::audit::Principal;
24use crate::org_id::OrgId;
25use crate::request_id::RequestId;
26
27// ---------------------------------------------------------------------------
28// Role
29// ---------------------------------------------------------------------------
30
31/// Authorization role identifier.
32///
33/// A lightweight, cloneable wrapper around a role name string.
34/// Roles are typically used in [`OrganizationContext`] to authorize
35/// operations on behalf of a principal.
36#[derive(Clone, PartialEq, Eq, Hash, Debug)]
37#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
38#[cfg_attr(feature = "utoipa", schema(value_type = String))]
39#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
40#[cfg_attr(feature = "schemars", schemars(transparent))]
41pub struct Role(Arc<str>);
42
43impl Role {
44    /// Construct a `Role` from a string reference.
45    ///
46    /// # Examples
47    ///
48    /// ```rust
49    /// use api_bones::Role;
50    ///
51    /// let admin = Role::from("admin");
52    /// assert_eq!(admin.as_str(), "admin");
53    /// ```
54    #[must_use]
55    pub fn as_str(&self) -> &str {
56        &self.0
57    }
58}
59
60impl From<&str> for Role {
61    fn from(s: &str) -> Self {
62        Self(Arc::from(s))
63    }
64}
65
66impl From<String> for Role {
67    fn from(s: String) -> Self {
68        Self(Arc::from(s.as_str()))
69    }
70}
71
72impl fmt::Display for Role {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        f.write_str(&self.0)
75    }
76}
77
78#[cfg(feature = "serde")]
79impl Serialize for Role {
80    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
81    where
82        S: serde::Serializer,
83    {
84        self.0.serialize(serializer)
85    }
86}
87
88#[cfg(feature = "serde")]
89impl<'de> Deserialize<'de> for Role {
90    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
91    where
92        D: serde::Deserializer<'de>,
93    {
94        let s = String::deserialize(deserializer)?;
95        Ok(Self(Arc::from(s.as_str())))
96    }
97}
98
99// ---------------------------------------------------------------------------
100// RoleScope
101// ---------------------------------------------------------------------------
102
103/// Scope at which a role binding applies.
104#[derive(Clone, PartialEq, Eq, Hash, Debug)]
105#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
106#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
107#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
108#[non_exhaustive]
109pub enum RoleScope {
110    /// Applies to exactly this org node only.
111    Self_,
112    /// Applies to this org and all its descendants.
113    Subtree,
114    /// Applies to exactly the named org (cross-org delegation).
115    Specific(OrgId),
116}
117
118// ---------------------------------------------------------------------------
119// RoleBinding
120// ---------------------------------------------------------------------------
121
122/// An authorization role paired with the scope at which it is valid.
123#[derive(Clone, PartialEq, Eq, Hash, Debug)]
124#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
125#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
126#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
127pub struct RoleBinding {
128    /// The role being granted.
129    pub role: Role,
130    /// The org scope over which this binding applies.
131    pub scope: RoleScope,
132}
133
134// ---------------------------------------------------------------------------
135// AttestationKind
136// ---------------------------------------------------------------------------
137
138/// Kind of attestation token or credential.
139///
140/// Describes the format and origin of the raw bytes in [`Attestation::raw`].
141#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
142#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
143#[non_exhaustive]
144pub enum AttestationKind {
145    /// Biscuit capability token
146    Biscuit,
147    /// JWT token
148    Jwt,
149    /// API key
150    ApiKey,
151    /// mTLS certificate
152    Mtls,
153}
154
155// ---------------------------------------------------------------------------
156// Attestation
157// ---------------------------------------------------------------------------
158
159/// Opaque attestation / credential bundle.
160///
161/// Carries the raw bytes of a credential token (JWT, Biscuit, API key, etc.)
162/// along with metadata about its kind. This is a convenience wrapper to avoid
163/// threading `(kind, raw_bytes)` pairs separately through middleware.
164#[derive(Clone, PartialEq, Eq, Debug)]
165#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
166pub struct Attestation {
167    /// The kind of attestation
168    pub kind: AttestationKind,
169    /// The raw attestation bytes
170    pub raw: Vec<u8>,
171}
172
173// ---------------------------------------------------------------------------
174// OrganizationContext
175// ---------------------------------------------------------------------------
176
177/// Platform context bundle — org, principal, request-id, roles, org-path, attestation.
178///
179/// Carries the cross-cutting request context (tenant ID, actor identity,
180/// request tracing ID, authorization roles, org-path, and optional credential) in a
181/// single, cheap-to-clone value. Avoids threading `(org_id, principal)`
182/// pairs separately through every function and middleware layer.
183///
184/// # Examples
185///
186/// ```rust
187/// # #[cfg(feature = "uuid")] {
188/// use api_bones::{OrganizationContext, OrgId, Principal, RequestId, Role, RoleBinding, RoleScope, Attestation, AttestationKind};
189/// use uuid::Uuid;
190///
191/// let org_id = OrgId::generate();
192/// let principal = Principal::human(Uuid::new_v4());
193/// let request_id = RequestId::new();
194///
195/// let ctx = OrganizationContext::new(org_id, principal, request_id)
196///     .with_roles(vec![RoleBinding { role: Role::from("admin"), scope: RoleScope::Self_ }])
197///     .with_attestation(Attestation {
198///         kind: AttestationKind::Jwt,
199///         raw: vec![1, 2, 3],
200///     });
201///
202/// assert_eq!(ctx.roles.len(), 1);
203/// assert!(ctx.attestation.is_some());
204/// # }
205/// ```
206#[derive(Clone, PartialEq, Eq, Debug)]
207#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
208pub struct OrganizationContext {
209    /// Tenant ID
210    pub org_id: OrgId,
211    /// Actor identity
212    pub principal: Principal,
213    /// Request tracing ID
214    pub request_id: RequestId,
215    /// Authorization roles
216    pub roles: Vec<RoleBinding>,
217    /// Org path from root to the acting org (inclusive). Empty = platform scope.
218    #[cfg_attr(feature = "serde", serde(default))]
219    pub org_path: Vec<OrgId>,
220    /// Optional credential/attestation
221    pub attestation: Option<Attestation>,
222}
223
224impl OrganizationContext {
225    /// Construct a new context with org, principal, and request-id.
226    ///
227    /// Roles default to an empty vec, `org_path` to empty, attestation to `None`.
228    ///
229    /// # Examples
230    ///
231    /// ```rust
232    /// # #[cfg(feature = "uuid")] {
233    /// use api_bones::{OrganizationContext, OrgId, Principal, RequestId};
234    /// use uuid::Uuid;
235    ///
236    /// let ctx = OrganizationContext::new(
237    ///     OrgId::generate(),
238    ///     Principal::human(Uuid::new_v4()),
239    ///     RequestId::new(),
240    /// );
241    ///
242    /// assert!(ctx.roles.is_empty());
243    /// assert!(ctx.org_path.is_empty());
244    /// assert!(ctx.attestation.is_none());
245    /// # }
246    /// ```
247    #[must_use]
248    pub fn new(org_id: OrgId, principal: Principal, request_id: RequestId) -> Self {
249        Self {
250            org_id,
251            principal,
252            request_id,
253            roles: Vec::new(),
254            org_path: Vec::new(),
255            attestation: None,
256        }
257    }
258
259    /// Set the roles on this context (builder-style).
260    ///
261    /// # Examples
262    ///
263    /// ```rust
264    /// # #[cfg(feature = "uuid")] {
265    /// use api_bones::{OrganizationContext, OrgId, Principal, RequestId, Role, RoleBinding, RoleScope};
266    /// use uuid::Uuid;
267    ///
268    /// let ctx = OrganizationContext::new(
269    ///     OrgId::generate(),
270    ///     Principal::human(Uuid::new_v4()),
271    ///     RequestId::new(),
272    /// ).with_roles(vec![RoleBinding { role: Role::from("editor"), scope: RoleScope::Self_ }]);
273    ///
274    /// assert_eq!(ctx.roles.len(), 1);
275    /// # }
276    /// ```
277    #[must_use]
278    pub fn with_roles(mut self, roles: Vec<RoleBinding>) -> Self {
279        self.roles = roles;
280        self
281    }
282
283    /// Set the org path on this context (builder-style).
284    ///
285    /// # Examples
286    ///
287    /// ```rust
288    /// # #[cfg(feature = "uuid")] {
289    /// use api_bones::{OrganizationContext, OrgId, Principal, RequestId};
290    /// use uuid::Uuid;
291    ///
292    /// let org_id = OrgId::generate();
293    /// let ctx = OrganizationContext::new(
294    ///     org_id,
295    ///     Principal::human(Uuid::new_v4()),
296    ///     RequestId::new(),
297    /// ).with_org_path(vec![org_id]);
298    ///
299    /// assert!(!ctx.org_path.is_empty());
300    /// # }
301    /// ```
302    #[must_use]
303    pub fn with_org_path(mut self, org_path: Vec<OrgId>) -> Self {
304        self.org_path = org_path;
305        self
306    }
307
308    /// Set the attestation on this context (builder-style).
309    ///
310    /// # Examples
311    ///
312    /// ```rust
313    /// # #[cfg(feature = "uuid")] {
314    /// use api_bones::{OrganizationContext, OrgId, Principal, RequestId, Attestation, AttestationKind};
315    /// use uuid::Uuid;
316    ///
317    /// let ctx = OrganizationContext::new(
318    ///     OrgId::generate(),
319    ///     Principal::human(Uuid::new_v4()),
320    ///     RequestId::new(),
321    /// ).with_attestation(Attestation {
322    ///     kind: AttestationKind::ApiKey,
323    ///     raw: vec![42],
324    /// });
325    ///
326    /// assert!(ctx.attestation.is_some());
327    /// # }
328    /// ```
329    #[must_use]
330    pub fn with_attestation(mut self, attestation: Attestation) -> Self {
331        self.attestation = Some(attestation);
332        self
333    }
334}
335
336// ---------------------------------------------------------------------------
337// Tests
338// ---------------------------------------------------------------------------
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use core::hash::{Hash, Hasher};
344    use std::collections::hash_map::DefaultHasher;
345
346    // RoleScope tests
347    #[test]
348    fn role_scope_self_clone_eq() {
349        let s1 = RoleScope::Self_;
350        let s2 = s1.clone();
351        assert_eq!(s1, s2);
352    }
353
354    #[test]
355    fn role_scope_subtree_clone_eq() {
356        let s1 = RoleScope::Subtree;
357        let s2 = s1.clone();
358        assert_eq!(s1, s2);
359    }
360
361    #[test]
362    fn role_scope_specific_eq() {
363        let id = OrgId::generate();
364        let s1 = RoleScope::Specific(id);
365        let s2 = RoleScope::Specific(id);
366        assert_eq!(s1, s2);
367    }
368
369    #[cfg(feature = "serde")]
370    #[test]
371    fn role_scope_serde_roundtrip_self() {
372        let scope = RoleScope::Self_;
373        let json = serde_json::to_string(&scope).unwrap();
374        let back: RoleScope = serde_json::from_str(&json).unwrap();
375        assert_eq!(scope, back);
376    }
377
378    #[cfg(feature = "serde")]
379    #[test]
380    fn role_scope_serde_roundtrip_subtree() {
381        let scope = RoleScope::Subtree;
382        let json = serde_json::to_string(&scope).unwrap();
383        let back: RoleScope = serde_json::from_str(&json).unwrap();
384        assert_eq!(scope, back);
385    }
386
387    #[cfg(feature = "serde")]
388    #[test]
389    fn role_scope_serde_roundtrip_specific() {
390        let id = OrgId::generate();
391        let scope = RoleScope::Specific(id);
392        let json = serde_json::to_string(&scope).unwrap();
393        let back: RoleScope = serde_json::from_str(&json).unwrap();
394        assert_eq!(scope, back);
395    }
396
397    // RoleBinding tests
398    #[test]
399    fn role_binding_construction() {
400        let binding = RoleBinding {
401            role: Role::from("admin"),
402            scope: RoleScope::Self_,
403        };
404        assert_eq!(binding.role, Role::from("admin"));
405        assert_eq!(binding.scope, RoleScope::Self_);
406    }
407
408    #[cfg(feature = "serde")]
409    #[test]
410    fn role_binding_serde_roundtrip() {
411        let binding = RoleBinding {
412            role: Role::from("editor"),
413            scope: RoleScope::Subtree,
414        };
415        let json = serde_json::to_string(&binding).unwrap();
416        let back: RoleBinding = serde_json::from_str(&json).unwrap();
417        assert_eq!(binding, back);
418    }
419
420    // Role tests
421    #[test]
422    fn role_construction_from_str() {
423        let role = Role::from("admin");
424        assert_eq!(role.as_str(), "admin");
425    }
426
427    #[test]
428    fn role_construction_from_string() {
429        let role = Role::from("viewer".to_owned());
430        assert_eq!(role.as_str(), "viewer");
431    }
432
433    #[test]
434    fn role_clone_eq() {
435        let role1 = Role::from("editor");
436        let role2 = role1.clone();
437        assert_eq!(role1, role2);
438    }
439
440    #[test]
441    fn role_hash_eq() {
442        let role1 = Role::from("admin");
443        let role2 = Role::from("admin");
444
445        let mut hasher1 = DefaultHasher::new();
446        role1.hash(&mut hasher1);
447        let hash1 = hasher1.finish();
448
449        let mut hasher2 = DefaultHasher::new();
450        role2.hash(&mut hasher2);
451        let hash2 = hasher2.finish();
452
453        assert_eq!(hash1, hash2);
454    }
455
456    #[test]
457    fn role_display() {
458        let role = Role::from("admin");
459        assert_eq!(format!("{role}"), "admin");
460    }
461
462    #[test]
463    fn role_debug() {
464        let role = Role::from("admin");
465        let debug_str = format!("{role:?}");
466        assert!(debug_str.contains("admin"));
467    }
468
469    // AttestationKind tests
470    #[test]
471    fn attestation_kind_copy() {
472        let kind1 = AttestationKind::Jwt;
473        let kind2 = kind1;
474        assert_eq!(kind1, kind2);
475    }
476
477    #[test]
478    fn attestation_kind_all_variants() {
479        match AttestationKind::Biscuit {
480            AttestationKind::Biscuit => {}
481            _ => panic!("expected Biscuit"),
482        }
483        match AttestationKind::Jwt {
484            AttestationKind::Jwt => {}
485            _ => panic!("expected Jwt"),
486        }
487        match AttestationKind::ApiKey {
488            AttestationKind::ApiKey => {}
489            _ => panic!("expected ApiKey"),
490        }
491        match AttestationKind::Mtls {
492            AttestationKind::Mtls => {}
493            _ => panic!("expected Mtls"),
494        }
495    }
496
497    // Attestation tests
498    #[test]
499    fn attestation_construction() {
500        let att = Attestation {
501            kind: AttestationKind::Jwt,
502            raw: vec![1, 2, 3],
503        };
504        assert_eq!(att.kind, AttestationKind::Jwt);
505        assert_eq!(att.raw, vec![1, 2, 3]);
506    }
507
508    #[test]
509    fn attestation_clone_eq() {
510        let att1 = Attestation {
511            kind: AttestationKind::ApiKey,
512            raw: vec![42],
513        };
514        let att2 = att1.clone();
515        assert_eq!(att1, att2);
516    }
517
518    #[test]
519    fn attestation_debug() {
520        let att = Attestation {
521            kind: AttestationKind::Jwt,
522            raw: vec![],
523        };
524        let debug_str = format!("{att:?}");
525        assert!(debug_str.contains("Jwt"));
526    }
527
528    // OrganizationContext tests
529    #[test]
530    fn org_context_construction() {
531        let org_id = OrgId::new(uuid::Uuid::nil());
532        let principal = Principal::system("test");
533        let request_id = RequestId::new();
534
535        let ctx = OrganizationContext::new(org_id, principal.clone(), request_id);
536
537        assert_eq!(ctx.org_id, org_id);
538        assert_eq!(ctx.principal, principal);
539        assert_eq!(ctx.request_id, request_id);
540        assert!(ctx.roles.is_empty());
541        assert!(ctx.attestation.is_none());
542    }
543
544    #[test]
545    fn org_context_with_role_bindings() {
546        let org_id = OrgId::generate();
547        let principal = Principal::system("test");
548        let request_id = RequestId::new();
549        let roles = vec![
550            RoleBinding {
551                role: Role::from("admin"),
552                scope: RoleScope::Self_,
553            },
554            RoleBinding {
555                role: Role::from("editor"),
556                scope: RoleScope::Subtree,
557            },
558        ];
559
560        let ctx = OrganizationContext::new(org_id, principal, request_id).with_roles(roles);
561
562        assert_eq!(ctx.roles.len(), 2);
563        assert_eq!(ctx.roles[0].role, Role::from("admin"));
564        assert_eq!(ctx.roles[1].role, Role::from("editor"));
565    }
566
567    #[test]
568    fn org_context_default_empty_org_path() {
569        let org_id = OrgId::generate();
570        let principal = Principal::system("test");
571        let request_id = RequestId::new();
572
573        let ctx = OrganizationContext::new(org_id, principal, request_id);
574
575        assert!(ctx.org_path.is_empty());
576    }
577
578    #[test]
579    fn org_context_with_org_path() {
580        let org_id = OrgId::generate();
581        let principal = Principal::system("test");
582        let request_id = RequestId::new();
583
584        let ctx =
585            OrganizationContext::new(org_id, principal, request_id).with_org_path(vec![org_id]);
586
587        assert_eq!(ctx.org_path.len(), 1);
588        assert_eq!(ctx.org_path[0], org_id);
589    }
590
591    #[test]
592    fn org_context_with_attestation() {
593        let org_id = OrgId::generate();
594        let principal = Principal::system("test");
595        let request_id = RequestId::new();
596        let att = Attestation {
597            kind: AttestationKind::ApiKey,
598            raw: vec![42],
599        };
600
601        let ctx =
602            OrganizationContext::new(org_id, principal, request_id).with_attestation(att.clone());
603
604        assert!(ctx.attestation.is_some());
605        assert_eq!(ctx.attestation.unwrap(), att);
606    }
607
608    #[test]
609    fn org_context_clone_eq() {
610        let org_id = OrgId::generate();
611        let principal = Principal::system("test");
612        let request_id = RequestId::new();
613
614        let ctx1 =
615            OrganizationContext::new(org_id, principal, request_id).with_roles(vec![RoleBinding {
616                role: Role::from("viewer"),
617                scope: RoleScope::Self_,
618            }]);
619        let ctx2 = ctx1.clone();
620
621        assert_eq!(ctx1, ctx2);
622    }
623
624    #[test]
625    fn org_context_debug() {
626        let org_id = OrgId::generate();
627        let principal = Principal::system("test");
628        let request_id = RequestId::new();
629
630        let ctx = OrganizationContext::new(org_id, principal, request_id);
631        let debug_str = format!("{ctx:?}");
632        assert!(debug_str.contains("OrganizationContext"));
633    }
634
635    #[test]
636    fn org_context_no_attestation() {
637        let org_id = OrgId::generate();
638        let principal = Principal::system("test");
639        let request_id = RequestId::new();
640
641        let ctx = OrganizationContext::new(org_id, principal, request_id);
642
643        assert!(ctx.attestation.is_none());
644    }
645
646    // Serde tests
647    #[cfg(feature = "serde")]
648    #[test]
649    fn role_serde_roundtrip() {
650        let role = Role::from("admin");
651        let json = serde_json::to_string(&role).unwrap();
652        let back: Role = serde_json::from_str(&json).unwrap();
653        assert_eq!(role, back);
654    }
655
656    #[cfg(feature = "serde")]
657    #[test]
658    fn attestation_kind_serde_roundtrip_jwt() {
659        let kind = AttestationKind::Jwt;
660        let json = serde_json::to_string(&kind).unwrap();
661        let back: AttestationKind = serde_json::from_str(&json).unwrap();
662        assert_eq!(kind, back);
663    }
664
665    #[cfg(feature = "serde")]
666    #[test]
667    fn attestation_serde_roundtrip() {
668        let att = Attestation {
669            kind: AttestationKind::ApiKey,
670            raw: vec![1, 2, 3],
671        };
672        let json = serde_json::to_string(&att).unwrap();
673        let back: Attestation = serde_json::from_str(&json).unwrap();
674        assert_eq!(att, back);
675    }
676
677    #[cfg(feature = "serde")]
678    #[test]
679    fn org_context_serde_roundtrip() {
680        let org_id = OrgId::new(uuid::Uuid::nil());
681        let principal = Principal::system("test");
682        let request_id = RequestId::new();
683
684        let ctx = OrganizationContext::new(org_id, principal, request_id)
685            .with_roles(vec![RoleBinding {
686                role: Role::from("admin"),
687                scope: RoleScope::Self_,
688            }])
689            .with_org_path(vec![org_id])
690            .with_attestation(Attestation {
691                kind: AttestationKind::Jwt,
692                raw: vec![42],
693            });
694
695        let json = serde_json::to_string(&ctx).unwrap();
696        let back: OrganizationContext = serde_json::from_str(&json).unwrap();
697        assert_eq!(ctx, back);
698    }
699}