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