Skip to main content

api_bones/
audit.rs

1//! Audit metadata for API resources.
2//!
3//! Provides [`AuditInfo`], an embeddable struct that tracks when a resource
4//! was created and last updated, and by whom, plus [`Principal`] — the
5//! canonical actor-identity newtype threaded through audit events across
6//! services.
7//!
8//! # Standards
9//! - Timestamps: [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339)
10
11use crate::common::Timestamp;
12#[cfg(feature = "uuid")]
13use crate::org_id::OrgId;
14#[cfg(all(not(feature = "std"), feature = "alloc"))]
15use alloc::borrow::Cow;
16#[cfg(all(not(feature = "std"), feature = "alloc", feature = "uuid"))]
17use alloc::borrow::ToOwned;
18#[cfg(all(not(feature = "std"), feature = "alloc"))]
19use alloc::string::String;
20#[cfg(all(not(feature = "std"), feature = "alloc", feature = "uuid"))]
21use alloc::string::ToString;
22#[cfg(all(not(feature = "std"), feature = "alloc", feature = "uuid"))]
23use alloc::vec::Vec;
24
25#[cfg(feature = "std")]
26use std::borrow::Cow;
27
28#[cfg(feature = "uuid")]
29use uuid::Uuid;
30
31#[cfg(feature = "serde")]
32use serde::{Deserialize, Serialize};
33
34// ---------------------------------------------------------------------------
35// PrincipalId
36// ---------------------------------------------------------------------------
37
38/// Opaque principal identifier. Flexible string storage:
39/// UUID strings for User principals, static service names for System/Service.
40#[derive(Clone, PartialEq, Eq, Hash)]
41#[cfg_attr(feature = "serde", derive(Serialize, Deserialize), serde(transparent))]
42#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
43#[cfg_attr(feature = "utoipa", schema(value_type = String))]
44#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
45#[cfg_attr(feature = "schemars", schemars(transparent))]
46pub struct PrincipalId(Cow<'static, str>);
47
48impl PrincipalId {
49    /// Wrap a `&'static str` — for compile-time system/service names.
50    #[must_use]
51    pub const fn static_str(s: &'static str) -> Self {
52        Self(Cow::Borrowed(s))
53    }
54
55    /// Wrap an owned String — for DB round-trips and dynamic construction.
56    #[must_use]
57    pub fn from_owned(s: String) -> Self {
58        Self(Cow::Owned(s))
59    }
60
61    /// Borrow the id as `&str`.
62    #[must_use]
63    pub fn as_str(&self) -> &str {
64        &self.0
65    }
66
67    /// Construct from a UUID (stores as hyphenated string).
68    #[cfg(feature = "uuid")]
69    #[must_use]
70    pub fn from_uuid(uuid: Uuid) -> Self {
71        Self(Cow::Owned(uuid.to_string()))
72    }
73}
74
75impl core::fmt::Debug for PrincipalId {
76    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
77        f.debug_tuple("PrincipalId").field(&self.as_str()).finish()
78    }
79}
80
81impl core::fmt::Display for PrincipalId {
82    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
83        f.write_str(self.as_str())
84    }
85}
86
87/// Discriminator for the kind of actor a Principal represents.
88#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
89#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
90#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
91#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
92#[non_exhaustive]
93pub enum PrincipalKind {
94    /// Human or end-user identity (id is a UUID string).
95    User,
96    /// Autonomous service-to-service identity (id is a service name).
97    Service,
98    /// Platform-level system actor (id is a static name, may be outside any org).
99    System,
100}
101
102// ---------------------------------------------------------------------------
103// PrincipalParseError
104// ---------------------------------------------------------------------------
105
106/// Error returned by [`Principal::try_parse`] when the input is not a valid
107/// UUID string.
108///
109/// Wraps the offending input so callers can surface it in diagnostics.
110/// The value is included in both `Display` and `Debug` output; callers must
111/// not log this in contexts where the input might contain PII.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct PrincipalParseError {
114    /// The string that failed UUID parsing.
115    pub input: String,
116}
117
118impl core::fmt::Display for PrincipalParseError {
119    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
120        write!(
121            f,
122            "invalid Principal: expected a UUID string, got {:?}",
123            self.input
124        )
125    }
126}
127
128#[cfg(feature = "std")]
129impl std::error::Error for PrincipalParseError {}
130
131// ---------------------------------------------------------------------------
132// Principal
133// ---------------------------------------------------------------------------
134
135/// Canonical actor identity. Carries id, kind, and org-tree position.
136///
137/// Thread the *same* `Principal` through every downstream audit-emitting
138/// service instead of forking local newtypes.
139///
140/// `org_path` is root-to-self inclusive. Platform-internal actors outside
141/// any org tree use `org_path: vec![]`.
142///
143/// # Construction
144///
145/// - [`Principal::human`] — for human / end-user identities. Accepts a
146///   [`uuid::Uuid`] to prevent PII (emails, display names) from entering
147///   audit logs. Requires the `uuid` feature.
148/// - [`Principal::try_parse`] — parse a UUID string into a `Principal`.
149///   Returns [`PrincipalParseError`] for non-UUID input. Requires `uuid`.
150/// - [`Principal::system`] — for autonomous or system actors. Infallible but
151///   no longer `const` due to `Vec` in `org_path`.
152///
153/// # Semantics
154///
155/// Identity-only. `Principal` carries **no authorization semantics**: it
156/// names an actor, nothing more. JWT/OIDC parsing, scope checks, and
157/// permission resolution all belong in caller layers.
158///
159/// Principals are **not secrets** — `Debug` is *not* redacted, to preserve
160/// visibility in audit logs and tracing output.
161///
162/// # Examples
163///
164/// ```rust
165/// # #[cfg(feature = "uuid")] {
166/// use api_bones::Principal;
167/// use uuid::Uuid;
168///
169/// // Human principal — UUID only, no emails or display names
170/// let id = Uuid::new_v4();
171/// let alice = Principal::human(id);
172/// assert_eq!(alice.as_str(), id.to_string().as_str());
173///
174/// // System principal
175/// let rotation = Principal::system("billing.rotation-engine");
176/// assert_eq!(rotation.as_str(), "billing.rotation-engine");
177/// # }
178/// ```
179#[derive(Clone, PartialEq, Eq, Hash, Debug)]
180#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
181#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
182#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
183pub struct Principal {
184    /// The opaque principal identifier.
185    pub id: PrincipalId,
186    /// The kind of actor this principal represents.
187    pub kind: PrincipalKind,
188    /// Org path from root to the acting org (inclusive). Empty = platform scope.
189    /// Only present when the `uuid` feature is enabled.
190    #[cfg(feature = "uuid")]
191    #[cfg_attr(feature = "serde", serde(default))]
192    pub org_path: Vec<OrgId>,
193}
194
195impl Principal {
196    /// Construct a principal for a human actor from a [`uuid::Uuid`].
197    ///
198    /// This is the correct constructor for end-user / operator identities.
199    /// By requiring a `Uuid` the API prevents callers from accidentally
200    /// passing emails, display names, or other PII that would propagate into
201    /// audit logs and OTEL spans (see issue #204).
202    ///
203    /// # Examples
204    ///
205    /// ```rust
206    /// # #[cfg(feature = "uuid")] {
207    /// use api_bones::Principal;
208    /// use uuid::Uuid;
209    ///
210    /// let id = Uuid::new_v4();
211    /// let p = Principal::human(id);
212    /// assert_eq!(p.as_str(), id.to_string().as_str());
213    /// # }
214    /// ```
215    #[cfg(feature = "uuid")]
216    #[must_use]
217    pub fn human(uuid: Uuid) -> Self {
218        Self {
219            id: PrincipalId::from_uuid(uuid),
220            kind: PrincipalKind::User,
221            #[cfg(feature = "uuid")]
222            org_path: Vec::new(),
223        }
224    }
225
226    /// Parse a UUID string into a `Principal`.
227    ///
228    /// Accepts any UUID text form that [`uuid::Uuid::parse_str`] recognises
229    /// (hyphenated, simple, URN, braced). Returns [`PrincipalParseError`] for
230    /// anything else, including emails and empty strings.
231    ///
232    /// # Errors
233    ///
234    /// Returns [`PrincipalParseError`] when `s` is not a valid UUID string.
235    ///
236    /// # Examples
237    ///
238    /// ```rust
239    /// # #[cfg(feature = "uuid")] {
240    /// use api_bones::Principal;
241    ///
242    /// let p = Principal::try_parse("550e8400-e29b-41d4-a716-446655440000").unwrap();
243    /// assert_eq!(p.as_str(), "550e8400-e29b-41d4-a716-446655440000");
244    ///
245    /// assert!(Principal::try_parse("alice@example.com").is_err());
246    /// # }
247    /// ```
248    #[cfg(feature = "uuid")]
249    pub fn try_parse(s: &str) -> Result<Self, PrincipalParseError> {
250        Uuid::parse_str(s)
251            .map(Self::human)
252            .map_err(|_| PrincipalParseError {
253                input: s.to_owned(),
254            })
255    }
256
257    /// Construct a system principal from a `&'static` string.
258    ///
259    /// Infallible but no longer `const` since `org_path` is a `Vec`.
260    ///
261    /// # Examples
262    ///
263    /// ```rust
264    /// use api_bones::Principal;
265    ///
266    /// let bootstrap = Principal::system("orders.bootstrap");
267    /// assert_eq!(bootstrap.as_str(), "orders.bootstrap");
268    /// ```
269    #[must_use]
270    pub fn system(id: &'static str) -> Self {
271        Self {
272            id: PrincipalId::static_str(id),
273            kind: PrincipalKind::System,
274            #[cfg(feature = "uuid")]
275            org_path: Vec::new(),
276        }
277    }
278
279    /// Borrow the principal as a `&str`.
280    ///
281    /// # Examples
282    ///
283    /// ```rust
284    /// use api_bones::Principal;
285    ///
286    /// assert_eq!(Principal::system("bob").as_str(), "bob");
287    /// ```
288    #[must_use]
289    pub fn as_str(&self) -> &str {
290        self.id.as_str()
291    }
292
293    /// Set the org path on this principal (builder-style).
294    ///
295    /// # Examples
296    ///
297    /// ```rust
298    /// # #[cfg(feature = "uuid")] {
299    /// use api_bones::{Principal, OrgId};
300    /// use uuid::Uuid;
301    ///
302    /// let p = Principal::human(Uuid::nil())
303    ///     .with_org_path(vec![OrgId::generate()]);
304    /// assert!(!p.org_path.is_empty());
305    /// # }
306    /// ```
307    #[cfg(feature = "uuid")]
308    #[must_use]
309    pub fn with_org_path(mut self, org_path: Vec<OrgId>) -> Self {
310        self.org_path = org_path;
311        self
312    }
313
314    /// Returns the org ancestry path as a comma-separated UUID string.
315    ///
316    /// Produces `""` for platform-internal actors with no org affiliation,
317    /// and `"<uuid1>,<uuid2>,..."` (root-to-self) for org-scoped actors.
318    /// Intended for use as an OTEL span attribute value (`enduser.org_path`).
319    ///
320    /// ```
321    /// # #[cfg(feature = "uuid")] {
322    /// use api_bones::Principal;
323    ///
324    /// let p = Principal::system("svc");
325    /// assert_eq!(p.org_path_display(), "");
326    /// # }
327    /// ```
328    #[cfg(feature = "uuid")]
329    #[must_use]
330    pub fn org_path_display(&self) -> String {
331        self.org_path
332            .iter()
333            .map(ToString::to_string)
334            .collect::<Vec<_>>()
335            .join(",")
336    }
337}
338
339impl core::fmt::Display for Principal {
340    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
341        f.write_str(self.as_str())
342    }
343}
344
345/// When `uuid` is available, generate UUID-backed principals so the fuzzer
346/// never produces PII-shaped values (emails, display names, etc.).
347#[cfg(all(feature = "arbitrary", feature = "uuid"))]
348impl<'a> arbitrary::Arbitrary<'a> for Principal {
349    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
350        let bytes = <[u8; 16] as arbitrary::Arbitrary>::arbitrary(u)?;
351        Ok(Self::human(Uuid::from_bytes(bytes)))
352    }
353}
354
355/// Fallback when `uuid` feature is disabled: generate an arbitrary system principal.
356/// This path should rarely be reached in practice since `uuid` is in the
357/// default feature set.
358#[cfg(all(feature = "arbitrary", not(feature = "uuid")))]
359impl<'a> arbitrary::Arbitrary<'a> for Principal {
360    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
361        let s = <String as arbitrary::Arbitrary>::arbitrary(u)?;
362        Ok(Self {
363            id: PrincipalId::from_owned(s),
364            kind: PrincipalKind::System,
365            #[cfg(feature = "uuid")]
366            org_path: Vec::new(),
367        })
368    }
369}
370
371/// arbitrary impl for `PrincipalId`
372#[cfg(all(feature = "arbitrary", feature = "uuid"))]
373impl<'a> arbitrary::Arbitrary<'a> for PrincipalId {
374    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
375        let bytes = <[u8; 16] as arbitrary::Arbitrary>::arbitrary(u)?;
376        Ok(Self::from_uuid(Uuid::from_bytes(bytes)))
377    }
378}
379
380#[cfg(all(feature = "arbitrary", not(feature = "uuid")))]
381impl<'a> arbitrary::Arbitrary<'a> for PrincipalId {
382    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
383        let s = <String as arbitrary::Arbitrary>::arbitrary(u)?;
384        Ok(Self::from_owned(s))
385    }
386}
387
388/// arbitrary impl for `PrincipalKind`
389#[cfg(feature = "arbitrary")]
390impl<'a> arbitrary::Arbitrary<'a> for PrincipalKind {
391    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
392        match <u8 as arbitrary::Arbitrary>::arbitrary(u)? % 3 {
393            0 => Ok(Self::User),
394            1 => Ok(Self::Service),
395            _ => Ok(Self::System),
396        }
397    }
398}
399
400/// When `uuid` is available, generate UUID-backed principals so proptest
401/// never generates PII-shaped values (emails, display names, etc.).
402#[cfg(all(feature = "proptest", feature = "uuid"))]
403impl proptest::arbitrary::Arbitrary for Principal {
404    type Parameters = ();
405    type Strategy = proptest::strategy::BoxedStrategy<Self>;
406
407    fn arbitrary_with((): ()) -> Self::Strategy {
408        use proptest::prelude::*;
409        any::<[u8; 16]>()
410            .prop_map(|b| Self::human(Uuid::from_bytes(b)))
411            .boxed()
412    }
413}
414
415/// Fallback when `uuid` feature is disabled.
416#[cfg(all(feature = "proptest", not(feature = "uuid")))]
417impl proptest::arbitrary::Arbitrary for Principal {
418    type Parameters = ();
419    type Strategy = proptest::strategy::BoxedStrategy<Self>;
420
421    fn arbitrary_with((): ()) -> Self::Strategy {
422        use proptest::prelude::*;
423        any::<String>()
424            .prop_map(|s| Self {
425                id: PrincipalId::from_owned(s),
426                kind: PrincipalKind::System,
427                #[cfg(feature = "uuid")]
428                org_path: Vec::new(),
429            })
430            .boxed()
431    }
432}
433
434/// proptest impl for `PrincipalId`
435#[cfg(all(feature = "proptest", feature = "uuid"))]
436impl proptest::arbitrary::Arbitrary for PrincipalId {
437    type Parameters = ();
438    type Strategy = proptest::strategy::BoxedStrategy<Self>;
439
440    fn arbitrary_with((): ()) -> Self::Strategy {
441        use proptest::prelude::*;
442        any::<[u8; 16]>()
443            .prop_map(|b| Self::from_uuid(Uuid::from_bytes(b)))
444            .boxed()
445    }
446}
447
448#[cfg(all(feature = "proptest", not(feature = "uuid")))]
449impl proptest::arbitrary::Arbitrary for PrincipalId {
450    type Parameters = ();
451    type Strategy = proptest::strategy::BoxedStrategy<Self>;
452
453    fn arbitrary_with((): ()) -> Self::Strategy {
454        use proptest::prelude::*;
455        any::<String>().prop_map(|s| Self::from_owned(s)).boxed()
456    }
457}
458
459/// proptest impl for `PrincipalKind`
460#[cfg(feature = "proptest")]
461impl proptest::arbitrary::Arbitrary for PrincipalKind {
462    type Parameters = ();
463    type Strategy = proptest::strategy::BoxedStrategy<Self>;
464
465    fn arbitrary_with((): ()) -> Self::Strategy {
466        use proptest::prelude::*;
467        prop_oneof![Just(Self::User), Just(Self::Service), Just(Self::System),].boxed()
468    }
469}
470
471// ---------------------------------------------------------------------------
472// AuditInfo
473// ---------------------------------------------------------------------------
474
475/// Audit metadata embedded in API resource structs.
476///
477/// Tracks creation and last-update times (RFC 3339) and the [`Principal`]
478/// that performed each action. Both actor fields are **non-optional** —
479/// system processes are still actors and must declare themselves via
480/// [`Principal::system`].
481///
482/// # Example
483///
484/// ```rust
485/// # #[cfg(feature = "chrono")] {
486/// use api_bones::{AuditInfo, Principal};
487///
488/// # #[cfg(feature = "uuid")] {
489/// use uuid::Uuid;
490/// let info = AuditInfo::now(Principal::human(Uuid::nil()));
491/// # }
492/// # }
493/// ```
494#[derive(Debug, Clone, PartialEq, Eq)]
495#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
496#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
497#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
498// When chrono is disabled, Timestamp = String which implements Arbitrary/proptest.
499#[cfg_attr(
500    all(feature = "arbitrary", not(feature = "chrono")),
501    derive(arbitrary::Arbitrary)
502)]
503#[cfg_attr(
504    all(feature = "proptest", not(feature = "chrono")),
505    derive(proptest_derive::Arbitrary)
506)]
507pub struct AuditInfo {
508    /// When the resource was created (RFC 3339).
509    #[cfg_attr(
510        feature = "utoipa",
511        schema(value_type = String, format = DateTime)
512    )]
513    pub created_at: Timestamp,
514    /// When the resource was last updated (RFC 3339).
515    #[cfg_attr(
516        feature = "utoipa",
517        schema(value_type = String, format = DateTime)
518    )]
519    pub updated_at: Timestamp,
520    /// Identity of the actor who created the resource.
521    pub created_by: Principal,
522    /// Identity of the actor who last updated the resource.
523    pub updated_by: Principal,
524}
525
526impl AuditInfo {
527    /// Construct an `AuditInfo` with explicit timestamps and principals.
528    ///
529    /// # Examples
530    ///
531    /// ```rust
532    /// # #[cfg(all(feature = "chrono", feature = "uuid"))] {
533    /// use api_bones::{AuditInfo, Principal};
534    /// use chrono::Utc;
535    /// use uuid::Uuid;
536    ///
537    /// let now = Utc::now();
538    /// let actor = Principal::human(Uuid::nil());
539    /// let info = AuditInfo::new(now, now, actor.clone(), actor);
540    /// # }
541    /// ```
542    #[must_use]
543    pub fn new(
544        created_at: Timestamp,
545        updated_at: Timestamp,
546        created_by: Principal,
547        updated_by: Principal,
548    ) -> Self {
549        Self {
550            created_at,
551            updated_at,
552            created_by,
553            updated_by,
554        }
555    }
556
557    /// Construct an `AuditInfo` with `created_at` and `updated_at` set to
558    /// the current UTC time. `updated_by` is initialized to a clone of
559    /// `created_by`.
560    ///
561    /// Requires the `chrono` feature.
562    ///
563    /// # Examples
564    ///
565    /// ```rust
566    /// # #[cfg(feature = "chrono")] {
567    /// use api_bones::{AuditInfo, Principal};
568    ///
569    /// # use uuid::Uuid;
570    /// let actor = Principal::human(Uuid::nil());
571    /// let info = AuditInfo::now(actor.clone());
572    /// assert_eq!(info.created_by, actor);
573    /// assert_eq!(info.updated_by, actor);
574    /// # }
575    /// ```
576    #[cfg(feature = "chrono")]
577    #[must_use]
578    pub fn now(created_by: Principal) -> Self {
579        let now = chrono::Utc::now();
580        let updated_by = created_by.clone();
581        Self {
582            created_at: now,
583            updated_at: now,
584            created_by,
585            updated_by,
586        }
587    }
588
589    /// Update `updated_at` to the current UTC time and set `updated_by`.
590    ///
591    /// Requires the `chrono` feature.
592    ///
593    /// # Examples
594    ///
595    /// ```rust
596    /// # #[cfg(feature = "chrono")] {
597    /// use api_bones::{AuditInfo, Principal};
598    ///
599    /// # use uuid::Uuid;
600    /// let mut info = AuditInfo::now(Principal::human(Uuid::nil()));
601    /// info.touch(Principal::system("billing.rotation-engine"));
602    /// assert_eq!(info.updated_by.as_str(), "billing.rotation-engine");
603    /// # }
604    /// ```
605    #[cfg(feature = "chrono")]
606    pub fn touch(&mut self, updated_by: Principal) {
607        self.updated_at = chrono::Utc::now();
608        self.updated_by = updated_by;
609    }
610}
611
612// ---------------------------------------------------------------------------
613// ResolvedPrincipal — read-path display helper
614// ---------------------------------------------------------------------------
615
616/// A [`Principal`] paired with an optional human-readable display name.
617///
618/// `Principal` stores only an opaque UUID — never PII. When a presentation
619/// layer (API response, audit log UI) needs to show a user-friendly name, an
620/// identity service resolves the UUID at read time and wraps it here.
621/// The display name is **never persisted**; only the opaque `id` is stored.
622///
623/// # Examples
624///
625/// ```rust
626/// use api_bones::{Principal, ResolvedPrincipal};
627/// # #[cfg(feature = "uuid")] {
628/// use uuid::Uuid;
629///
630/// let id = Principal::human(Uuid::nil());
631/// let r = ResolvedPrincipal::new(id, Some("Alice Martin".to_owned()));
632/// assert_eq!(r.display(), "Alice Martin");
633///
634/// let anonymous = ResolvedPrincipal::new(Principal::human(Uuid::nil()), None);
635/// assert_eq!(anonymous.display(), anonymous.id.as_str());
636/// # }
637/// ```
638#[derive(Debug, Clone, PartialEq, Eq)]
639#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
640pub struct ResolvedPrincipal {
641    /// The opaque, stored identity.
642    pub id: Principal,
643    /// Human-readable display name resolved from the identity service.
644    /// `None` when the resolution has not been performed or the actor is
645    /// a system principal with no display name.
646    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
647    pub display_name: Option<String>,
648}
649
650impl ResolvedPrincipal {
651    /// Wrap a [`Principal`] with an optional display name.
652    #[must_use]
653    pub fn new(id: Principal, display_name: Option<String>) -> Self {
654        Self { id, display_name }
655    }
656
657    /// Return the display name when available, otherwise fall back to the
658    /// opaque principal string (UUID or system name).
659    #[must_use]
660    pub fn display(&self) -> &str {
661        self.display_name
662            .as_deref()
663            .unwrap_or_else(|| self.id.as_str())
664    }
665}
666
667impl From<Principal> for ResolvedPrincipal {
668    fn from(id: Principal) -> Self {
669        Self {
670            id,
671            display_name: None,
672        }
673    }
674}
675
676// ---------------------------------------------------------------------------
677// arbitrary / proptest impls — chrono Timestamp requires manual impl
678// ---------------------------------------------------------------------------
679
680/// When `chrono` is enabled, `Timestamp = chrono::DateTime<Utc>` which does
681/// not implement `arbitrary::Arbitrary`, so we provide a hand-rolled impl.
682#[cfg(all(feature = "arbitrary", feature = "chrono"))]
683impl<'a> arbitrary::Arbitrary<'a> for AuditInfo {
684    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
685        // Generate timestamps as i64 seconds in a sane range (year 2000–3000).
686        let created_secs = <i64 as arbitrary::Arbitrary>::arbitrary(u)? % 32_503_680_000i64;
687        let updated_secs = <i64 as arbitrary::Arbitrary>::arbitrary(u)? % 32_503_680_000i64;
688        let created_at = chrono::DateTime::from_timestamp(created_secs.abs(), 0)
689            .unwrap_or_else(chrono::Utc::now);
690        let updated_at = chrono::DateTime::from_timestamp(updated_secs.abs(), 0)
691            .unwrap_or_else(chrono::Utc::now);
692        Ok(Self {
693            created_at,
694            updated_at,
695            created_by: Principal::arbitrary(u)?,
696            updated_by: Principal::arbitrary(u)?,
697        })
698    }
699}
700
701#[cfg(all(feature = "proptest", feature = "chrono"))]
702impl proptest::arbitrary::Arbitrary for AuditInfo {
703    type Parameters = ();
704    type Strategy = proptest::strategy::BoxedStrategy<Self>;
705
706    fn arbitrary_with((): ()) -> Self::Strategy {
707        use proptest::prelude::*;
708        (
709            0i64..=32_503_680_000i64,
710            0i64..=32_503_680_000i64,
711            any::<Principal>(),
712            any::<Principal>(),
713        )
714            .prop_map(|(cs, us, cb, ub)| Self {
715                created_at: chrono::DateTime::from_timestamp(cs, 0)
716                    .unwrap_or_else(chrono::Utc::now),
717                updated_at: chrono::DateTime::from_timestamp(us, 0)
718                    .unwrap_or_else(chrono::Utc::now),
719                created_by: cb,
720                updated_by: ub,
721            })
722            .boxed()
723    }
724}
725
726// ---------------------------------------------------------------------------
727// Tests
728// ---------------------------------------------------------------------------
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733    #[cfg(feature = "uuid")]
734    use uuid::Uuid;
735
736    // -- PrincipalId --------------------------------------------------------
737
738    #[test]
739    fn principal_id_static_str() {
740        let id = PrincipalId::static_str("foo");
741        assert_eq!(id.as_str(), "foo");
742    }
743
744    #[test]
745    fn principal_id_from_owned() {
746        let id = PrincipalId::from_owned("bar".to_owned());
747        assert_eq!(id.as_str(), "bar");
748    }
749
750    #[cfg(feature = "uuid")]
751    #[test]
752    fn principal_id_from_uuid() {
753        let id = PrincipalId::from_uuid(Uuid::nil());
754        assert_eq!(id.as_str(), "00000000-0000-0000-0000-000000000000");
755    }
756
757    #[test]
758    fn principal_id_display() {
759        let id = PrincipalId::static_str("test");
760        assert_eq!(format!("{id}"), "test");
761    }
762
763    #[cfg(feature = "serde")]
764    #[test]
765    fn principal_id_serde_transparent() {
766        let id = PrincipalId::static_str("myid");
767        let json = serde_json::to_value(&id).unwrap();
768        assert_eq!(json, serde_json::json!("myid"));
769        let back: PrincipalId = serde_json::from_value(json).unwrap();
770        assert_eq!(back, id);
771    }
772
773    // -- PrincipalKind -------------------------------------------------------
774
775    #[test]
776    fn principal_kind_copy_and_eq() {
777        let k1 = PrincipalKind::User;
778        let k2 = k1;
779        assert_eq!(k1, k2);
780    }
781
782    #[test]
783    fn principal_kind_all_variants() {
784        let _ = PrincipalKind::User;
785        let _ = PrincipalKind::Service;
786        let _ = PrincipalKind::System;
787    }
788
789    // -- Principal --------------------------------------------------------
790
791    #[cfg(feature = "uuid")]
792    #[test]
793    fn principal_human_has_user_kind() {
794        let p = Principal::human(Uuid::nil());
795        assert_eq!(p.kind, PrincipalKind::User);
796    }
797
798    #[cfg(feature = "uuid")]
799    #[test]
800    fn principal_human_has_empty_org_path() {
801        let p = Principal::human(Uuid::nil());
802        assert!(p.org_path.is_empty());
803    }
804
805    #[test]
806    fn principal_system_has_system_kind() {
807        let p = Principal::system("s");
808        assert_eq!(p.kind, PrincipalKind::System);
809    }
810
811    #[cfg(feature = "uuid")]
812    #[test]
813    fn principal_system_has_empty_org_path() {
814        let p = Principal::system("s");
815        assert!(p.org_path.is_empty());
816    }
817
818    #[cfg(feature = "uuid")]
819    #[test]
820    fn principal_with_org_path_builder() {
821        let org_id = crate::org_id::OrgId::generate();
822        let p = Principal::system("test").with_org_path(vec![org_id]);
823        assert_eq!(p.org_path.len(), 1);
824        assert_eq!(p.org_path[0], org_id);
825    }
826
827    #[cfg(feature = "uuid")]
828    #[test]
829    fn org_path_display_empty_for_system_principal() {
830        let p = Principal::system("svc");
831        assert_eq!(p.org_path_display(), "");
832    }
833
834    #[cfg(feature = "uuid")]
835    #[test]
836    fn org_path_display_single_org() {
837        let org_id = crate::org_id::OrgId::generate();
838        let p = Principal::system("svc").with_org_path(vec![org_id]);
839        assert_eq!(p.org_path_display(), org_id.to_string());
840    }
841
842    #[cfg(feature = "uuid")]
843    #[test]
844    fn org_path_display_multiple_orgs_comma_separated() {
845        let root = crate::org_id::OrgId::generate();
846        let child = crate::org_id::OrgId::generate();
847        let p = Principal::system("svc").with_org_path(vec![root, child]);
848        assert_eq!(p.org_path_display(), format!("{root},{child}"));
849    }
850
851    #[cfg(feature = "uuid")]
852    #[test]
853    fn principal_try_parse_accepts_valid_uuid() {
854        let s = "550e8400-e29b-41d4-a716-446655440000";
855        let p = Principal::try_parse(s).expect("valid UUID should parse");
856        assert_eq!(p.as_str(), s);
857    }
858
859    #[cfg(feature = "uuid")]
860    #[test]
861    fn principal_try_parse_sets_user_kind() {
862        let p = Principal::try_parse("550e8400-e29b-41d4-a716-446655440000").unwrap();
863        assert_eq!(p.kind, PrincipalKind::User);
864    }
865
866    #[cfg(feature = "uuid")]
867    #[test]
868    fn principal_try_parse_rejects_email_string() {
869        let err = Principal::try_parse("alice@example.com").expect_err("email must be rejected");
870        assert_eq!(err.input, "alice@example.com");
871        assert!(err.to_string().contains("alice@example.com"));
872    }
873
874    #[cfg(feature = "uuid")]
875    #[test]
876    fn principal_try_parse_rejects_empty_string() {
877        let err = Principal::try_parse("").expect_err("empty string must be rejected");
878        assert_eq!(err.input, "");
879    }
880
881    #[test]
882    fn principal_as_str_returns_id_str() {
883        let p = Principal::system("x");
884        assert_eq!(p.as_str(), "x");
885    }
886
887    #[cfg(feature = "uuid")]
888    #[test]
889    fn principal_display_forwards_to_as_str() {
890        let p = Principal::human(Uuid::nil());
891        let s = format!("{p}");
892        assert_eq!(s, Uuid::nil().to_string());
893    }
894
895    #[cfg(feature = "uuid")]
896    #[test]
897    fn principal_debug_is_not_redacted() {
898        let p = Principal::human(Uuid::nil());
899        let s = format!("{p:?}");
900        assert!(
901            s.contains(&Uuid::nil().to_string()),
902            "debug must not redact: {s}"
903        );
904        assert!(s.contains("Principal"), "debug must name the type: {s}");
905    }
906
907    #[test]
908    fn principal_equality_and_hash_across_owned_and_borrowed() {
909        use std::collections::hash_map::DefaultHasher;
910        use std::hash::{Hash, Hasher};
911
912        let p1 = Principal::system("orders.bootstrap");
913        let p2 = Principal::system("orders.bootstrap");
914        assert_eq!(p1, p2);
915
916        let mut h1 = DefaultHasher::new();
917        p1.hash(&mut h1);
918        let mut h2 = DefaultHasher::new();
919        p2.hash(&mut h2);
920        assert_eq!(h1.finish(), h2.finish());
921    }
922
923    #[cfg(feature = "uuid")]
924    #[test]
925    fn principal_clone_roundtrip() {
926        let p = Principal::human(Uuid::nil());
927        let q = p.clone();
928        assert_eq!(p, q);
929    }
930
931    #[cfg(all(feature = "serde", feature = "uuid"))]
932    #[test]
933    fn principal_serde_struct_roundtrip_human() {
934        let p = Principal::human(Uuid::nil());
935        let json = serde_json::to_value(&p).unwrap();
936        let back: Principal = serde_json::from_value(json).unwrap();
937        assert_eq!(back, p);
938    }
939
940    #[cfg(feature = "serde")]
941    #[test]
942    fn principal_serde_struct_roundtrip_system() {
943        let p = Principal::system("billing.rotation-engine");
944        let json = serde_json::to_value(&p).unwrap();
945        let back: Principal = serde_json::from_value(json).unwrap();
946        assert_eq!(back, p);
947    }
948
949    #[cfg(all(feature = "serde", feature = "uuid"))]
950    #[test]
951    fn principal_serde_includes_org_path() {
952        let p = Principal::system("test");
953        let json = serde_json::to_value(&p).unwrap();
954        assert!(json.get("org_path").is_some());
955    }
956
957    // -- AuditInfo --------------------------------------------------------
958
959    #[cfg(all(feature = "chrono", feature = "uuid"))]
960    #[test]
961    fn now_sets_created_at_and_updated_at() {
962        let actor = Principal::human(Uuid::nil());
963        let before = chrono::Utc::now();
964        let info = AuditInfo::now(actor.clone());
965        let after = chrono::Utc::now();
966
967        assert!(info.created_at >= before && info.created_at <= after);
968        assert!(info.updated_at >= before && info.updated_at <= after);
969        assert_eq!(info.created_by, actor);
970        assert_eq!(info.updated_by, actor);
971    }
972
973    #[cfg(all(feature = "chrono", feature = "serde"))]
974    #[test]
975    fn now_with_system_principal() {
976        let info = AuditInfo::now(Principal::system("billing.rotation-engine"));
977        let json = serde_json::to_value(&info).unwrap();
978        let back: AuditInfo = serde_json::from_value(json).unwrap();
979        assert_eq!(back, info);
980    }
981
982    #[cfg(all(feature = "chrono", feature = "uuid"))]
983    #[test]
984    fn touch_updates_updated_at_and_updated_by() {
985        let mut info = AuditInfo::now(Principal::human(Uuid::nil()));
986        let engine = Principal::system("billing.rotation-engine");
987        let before_touch = chrono::Utc::now();
988        info.touch(engine.clone());
989        let after_touch = chrono::Utc::now();
990
991        assert!(info.updated_at >= before_touch && info.updated_at <= after_touch);
992        assert_eq!(info.updated_by, engine);
993    }
994
995    #[cfg(all(feature = "chrono", feature = "uuid"))]
996    #[test]
997    fn new_constructor() {
998        let now = chrono::Utc::now();
999        let actor = Principal::human(Uuid::nil());
1000        let engine = Principal::system("billing.rotation-engine");
1001        let info = AuditInfo::new(now, now, actor.clone(), engine.clone());
1002        assert_eq!(info.created_at, now);
1003        assert_eq!(info.updated_at, now);
1004        assert_eq!(info.created_by, actor);
1005        assert_eq!(info.updated_by, engine);
1006    }
1007
1008    #[cfg(all(feature = "chrono", feature = "serde"))]
1009    #[test]
1010    fn serde_round_trip_with_system_actor() {
1011        let info = AuditInfo::now(Principal::system("orders.bootstrap"));
1012        let json = serde_json::to_value(&info).unwrap();
1013        let back: AuditInfo = serde_json::from_value(json).unwrap();
1014        assert_eq!(back, info);
1015    }
1016
1017    #[cfg(all(feature = "chrono", feature = "serde"))]
1018    #[test]
1019    fn serde_actor_fields_are_always_present() {
1020        let info = AuditInfo::now(Principal::system("orders.bootstrap"));
1021        let json = serde_json::to_value(&info).unwrap();
1022        assert!(
1023            json.get("created_by").is_some(),
1024            "created_by must always serialize"
1025        );
1026        assert!(
1027            json.get("updated_by").is_some(),
1028            "updated_by must always serialize"
1029        );
1030        // Principal is now a struct with id, kind, org_path fields
1031        assert_eq!(
1032            json["created_by"]["id"],
1033            serde_json::json!("orders.bootstrap")
1034        );
1035    }
1036
1037    // -- PrincipalParseError ----------------------------------------------
1038
1039    #[test]
1040    fn principal_parse_error_display_contains_input() {
1041        let err = PrincipalParseError {
1042            input: "bad-value".to_owned(),
1043        };
1044        assert!(err.to_string().contains("bad-value"));
1045    }
1046
1047    #[cfg(feature = "std")]
1048    #[test]
1049    fn principal_parse_error_is_std_error() {
1050        let err = PrincipalParseError {
1051            input: "x".to_owned(),
1052        };
1053        let _: &dyn std::error::Error = &err;
1054    }
1055
1056    // -- ResolvedPrincipal ------------------------------------------------
1057
1058    #[cfg(feature = "uuid")]
1059    #[test]
1060    fn resolved_principal_new_and_display_with_name() {
1061        let p = Principal::human(Uuid::nil());
1062        let r = ResolvedPrincipal::new(p, Some("Alice Martin".to_owned()));
1063        assert_eq!(r.display(), "Alice Martin");
1064    }
1065
1066    #[cfg(feature = "uuid")]
1067    #[test]
1068    fn resolved_principal_display_falls_back_to_uuid() {
1069        let p = Principal::human(Uuid::nil());
1070        let r = ResolvedPrincipal::new(p.clone(), None);
1071        assert_eq!(r.display(), p.as_str());
1072    }
1073
1074    #[cfg(feature = "uuid")]
1075    #[test]
1076    fn resolved_principal_from_principal() {
1077        let p = Principal::human(Uuid::nil());
1078        let r = ResolvedPrincipal::from(p.clone());
1079        assert_eq!(r.id, p);
1080        assert!(r.display_name.is_none());
1081    }
1082
1083    #[cfg(all(feature = "uuid", feature = "serde"))]
1084    #[test]
1085    fn resolved_principal_serde_omits_none_display_name() {
1086        let p = Principal::human(Uuid::nil());
1087        let r = ResolvedPrincipal::from(p);
1088        let json = serde_json::to_value(&r).unwrap();
1089        assert!(
1090            json.get("display_name").is_none(),
1091            "display_name must be absent when None"
1092        );
1093    }
1094
1095    #[cfg(all(feature = "uuid", feature = "serde"))]
1096    #[test]
1097    fn resolved_principal_serde_includes_display_name_when_set() {
1098        let p = Principal::human(Uuid::nil());
1099        let r = ResolvedPrincipal::new(p, Some("Bob".to_owned()));
1100        let json = serde_json::to_value(&r).unwrap();
1101        assert_eq!(json["display_name"], serde_json::json!("Bob"));
1102    }
1103}