Skip to main content

ferro_audit/
actor.rs

1//! `AuditActor` — typed actor enum stringly-keyed to keep `ferro-audit`
2//! domain-agnostic (D-05).
3//!
4//! The DB representation is `(actor_kind: String, actor_id: Option<String>)`.
5//! `actor_kind` is the snake_case variant name returned by [`AuditActor::kind`];
6//! `actor_id` is the contained string returned by [`AuditActor::id`], or `NULL`
7//! for `System` and `Anonymous` (no specific identity).
8
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub enum AuditActor {
11    /// Concrete end-user. `String` is consumer-chosen: `i64.to_string()`,
12    /// `Uuid` rendered, slug — anything stable for the consumer.
13    User(String),
14
15    /// Background process with no specific user identity (cron, queue worker,
16    /// system-driven mutation). Persists `actor_id = NULL`.
17    System,
18
19    /// Queued job — the contained string is the job name
20    /// (e.g. `"stripe.webhook.subscription_updated"`).
21    Job(String),
22
23    /// API client — the contained string is the API key id / OAuth client id.
24    ApiClient(String),
25
26    /// Unauthenticated public action (rare but valid). Persists `actor_id = NULL`.
27    Anonymous,
28}
29
30impl AuditActor {
31    /// Returns the snake_case actor kind. Persisted in the `actor_kind` column.
32    pub fn kind(&self) -> &'static str {
33        match self {
34            Self::User(_) => "user",
35            Self::System => "system",
36            Self::Job(_) => "job",
37            Self::ApiClient(_) => "api_client",
38            Self::Anonymous => "anonymous",
39        }
40    }
41
42    /// Returns the actor id, if the variant carries one. `System` and
43    /// `Anonymous` return `None` and persist `actor_id = NULL`.
44    pub fn id(&self) -> Option<&str> {
45        match self {
46            Self::User(id) | Self::Job(id) | Self::ApiClient(id) => Some(id.as_str()),
47            Self::System | Self::Anonymous => None,
48        }
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    #[test]
57    fn user_kind_and_id() {
58        let a = AuditActor::User("u_42".into());
59        assert_eq!(a.kind(), "user");
60        assert_eq!(a.id(), Some("u_42"));
61    }
62
63    #[test]
64    fn system_kind_and_id() {
65        let a = AuditActor::System;
66        assert_eq!(a.kind(), "system");
67        assert_eq!(a.id(), None);
68    }
69
70    #[test]
71    fn job_kind_and_id() {
72        let a = AuditActor::Job("stripe.webhook.subscription_updated".into());
73        assert_eq!(a.kind(), "job");
74        assert_eq!(a.id(), Some("stripe.webhook.subscription_updated"));
75    }
76
77    #[test]
78    fn api_client_kind_and_id() {
79        let a = AuditActor::ApiClient("oauth_client_xyz".into());
80        assert_eq!(a.kind(), "api_client");
81        assert_eq!(a.id(), Some("oauth_client_xyz"));
82    }
83
84    #[test]
85    fn anonymous_kind_and_id() {
86        let a = AuditActor::Anonymous;
87        assert_eq!(a.kind(), "anonymous");
88        assert_eq!(a.id(), None);
89    }
90}