Skip to main content

systemprompt_identifiers/
actor.rs

1//! Principal + surface attribution for audit and event rows.
2//!
3//! Every actor-bearing row persists `(user_id, kind, kind.actor_id())` as a
4//! unit; the three values cannot be separated at the call site because they
5//! live inside [`Actor`]. The `user_id` is always a real `users` row — the
6//! kind disambiguates which surface ran on that user's behalf.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use std::fmt;
12
13use serde::{Deserialize, Serialize};
14
15use crate::UserId;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct Actor {
19    pub user_id: UserId,
20    pub kind: ActorKind,
21}
22
23impl Actor {
24    #[must_use]
25    pub const fn user(user_id: UserId) -> Self {
26        Self {
27            user_id,
28            kind: ActorKind::User,
29        }
30    }
31
32    /// Unauthenticated traffic that has already been bound to a real
33    /// (typically ephemeral) `anonymous_*` user row. The `user_id` is the
34    /// provisioned row's id, not a sentinel.
35    #[must_use]
36    pub const fn anonymous(user_id: UserId) -> Self {
37        Self {
38            user_id,
39            kind: ActorKind::Anonymous,
40        }
41    }
42
43    /// Platform-originated work (bootstrap jobs, scheduler tick, internal
44    /// fallbacks). The caller passes the resolved system-admin user id;
45    /// no sentinel is fabricated inside the constructor.
46    #[must_use]
47    pub const fn system(user_id: UserId) -> Self {
48        Self {
49            user_id,
50            kind: ActorKind::System,
51        }
52    }
53
54    #[must_use]
55    pub fn job(user_id: UserId, job_name: impl Into<String>) -> Self {
56        Self {
57            user_id,
58            kind: ActorKind::Job {
59                job_name: job_name.into(),
60            },
61        }
62    }
63
64    #[must_use]
65    pub fn mcp(user_id: UserId, server_name: impl Into<String>) -> Self {
66        Self {
67            user_id,
68            kind: ActorKind::Mcp {
69                server_name: server_name.into(),
70            },
71        }
72    }
73
74    /// A configured agent (Claude Code session, autonomous agent, etc.)
75    /// acting on the user's behalf. The agent is the surface; the user is
76    /// the accountable principal.
77    #[must_use]
78    pub fn agent(user_id: UserId, agent_id: impl Into<String>) -> Self {
79        Self {
80            user_id,
81            kind: ActorKind::Agent {
82                agent_id: agent_id.into(),
83            },
84        }
85    }
86
87    #[must_use]
88    pub fn audit_columns(&self) -> (&str, &str) {
89        (self.kind.as_str(), self.kind.actor_id(&self.user_id))
90    }
91
92    #[must_use]
93    pub fn from_tool_name(user_id: UserId, agent_id: Option<&str>, tool_name: &str) -> Self {
94        if let Some(rest) = tool_name.strip_prefix("mcp__")
95            && let Some(server) = rest.split("__").next()
96            && !server.is_empty()
97        {
98            return Self::mcp(user_id, server);
99        }
100        match agent_id {
101            Some(id) if !id.is_empty() => Self::agent(user_id, id),
102            _ => Self::user(user_id),
103        }
104    }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(tag = "kind", rename_all = "snake_case")]
109pub enum ActorKind {
110    User,
111    Anonymous,
112    System,
113    Job { job_name: String },
114    Mcp { server_name: String },
115    Agent { agent_id: String },
116}
117
118impl ActorKind {
119    #[must_use]
120    pub const fn as_str(&self) -> &'static str {
121        match self {
122            Self::User => "user",
123            Self::Anonymous => "anonymous",
124            Self::System => "system",
125            Self::Job { .. } => "job",
126            Self::Mcp { .. } => "mcp",
127            Self::Agent { .. } => "agent",
128        }
129    }
130
131    #[must_use]
132    pub fn actor_id<'a>(&'a self, user_id: &'a UserId) -> &'a str {
133        match self {
134            Self::User | Self::Anonymous | Self::System => user_id.as_str(),
135            Self::Job { job_name } => job_name.as_str(),
136            Self::Mcp { server_name } => server_name.as_str(),
137            Self::Agent { agent_id } => agent_id.as_str(),
138        }
139    }
140}
141
142impl ActorKind {
143    #[must_use]
144    pub const fn tag(&self) -> ActorKindTag {
145        match self {
146            Self::User => ActorKindTag::User,
147            Self::Anonymous => ActorKindTag::Anonymous,
148            Self::System => ActorKindTag::System,
149            Self::Job { .. } => ActorKindTag::Job,
150            Self::Mcp { .. } => ActorKindTag::Mcp,
151            Self::Agent { .. } => ActorKindTag::Agent,
152        }
153    }
154}
155
156impl fmt::Display for ActorKind {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        f.write_str(self.as_str())
159    }
160}
161
162/// Discriminant-only view of [`ActorKind`], bound to the `actor_kind` column
163/// in `governance_decisions`.
164///
165/// Binding a typed value couples the SQL CHECK allow-list to the enum at
166/// compile time; adding a variant without extending the constraint fails the
167/// build instead of silently rejecting rows at runtime.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
169#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
170#[cfg_attr(feature = "sqlx", sqlx(type_name = "TEXT", rename_all = "snake_case"))]
171#[serde(rename_all = "snake_case")]
172pub enum ActorKindTag {
173    User,
174    Anonymous,
175    System,
176    Job,
177    Mcp,
178    Agent,
179}
180
181impl ActorKindTag {
182    #[must_use]
183    pub const fn as_str(self) -> &'static str {
184        match self {
185            Self::User => "user",
186            Self::Anonymous => "anonymous",
187            Self::System => "system",
188            Self::Job => "job",
189            Self::Mcp => "mcp",
190            Self::Agent => "agent",
191        }
192    }
193}
194
195impl fmt::Display for ActorKindTag {
196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197        f.write_str(self.as_str())
198    }
199}