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    #[must_use]
33    pub const fn anonymous(user_id: UserId) -> Self {
34        Self {
35            user_id,
36            kind: ActorKind::Anonymous,
37        }
38    }
39
40    #[must_use]
41    pub const fn system(user_id: UserId) -> Self {
42        Self {
43            user_id,
44            kind: ActorKind::System,
45        }
46    }
47
48    #[must_use]
49    pub fn job(user_id: UserId, job_name: impl Into<String>) -> Self {
50        Self {
51            user_id,
52            kind: ActorKind::Job {
53                job_name: job_name.into(),
54            },
55        }
56    }
57
58    #[must_use]
59    pub fn mcp(user_id: UserId, server_name: impl Into<String>) -> Self {
60        Self {
61            user_id,
62            kind: ActorKind::Mcp {
63                server_name: server_name.into(),
64            },
65        }
66    }
67
68    #[must_use]
69    pub fn agent(user_id: UserId, agent_id: impl Into<String>) -> Self {
70        Self {
71            user_id,
72            kind: ActorKind::Agent {
73                agent_id: agent_id.into(),
74            },
75        }
76    }
77
78    #[must_use]
79    pub fn audit_columns(&self) -> (&str, &str) {
80        (self.kind.as_str(), self.kind.actor_id(&self.user_id))
81    }
82
83    #[must_use]
84    pub fn from_tool_name(user_id: UserId, agent_id: Option<&str>, tool_name: &str) -> Self {
85        if let Some(rest) = tool_name.strip_prefix("mcp__")
86            && let Some(server) = rest.split("__").next()
87            && !server.is_empty()
88        {
89            return Self::mcp(user_id, server);
90        }
91        match agent_id {
92            Some(id) if !id.is_empty() => Self::agent(user_id, id),
93            _ => Self::user(user_id),
94        }
95    }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(tag = "kind", rename_all = "snake_case")]
100pub enum ActorKind {
101    User,
102    Anonymous,
103    System,
104    Job { job_name: String },
105    Mcp { server_name: String },
106    Agent { agent_id: String },
107}
108
109impl ActorKind {
110    #[must_use]
111    pub const fn as_str(&self) -> &'static str {
112        match self {
113            Self::User => "user",
114            Self::Anonymous => "anonymous",
115            Self::System => "system",
116            Self::Job { .. } => "job",
117            Self::Mcp { .. } => "mcp",
118            Self::Agent { .. } => "agent",
119        }
120    }
121
122    #[must_use]
123    pub fn actor_id<'a>(&'a self, user_id: &'a UserId) -> &'a str {
124        match self {
125            Self::User | Self::Anonymous | Self::System => user_id.as_str(),
126            Self::Job { job_name } => job_name.as_str(),
127            Self::Mcp { server_name } => server_name.as_str(),
128            Self::Agent { agent_id } => agent_id.as_str(),
129        }
130    }
131}
132
133impl ActorKind {
134    #[must_use]
135    pub const fn tag(&self) -> ActorKindTag {
136        match self {
137            Self::User => ActorKindTag::User,
138            Self::Anonymous => ActorKindTag::Anonymous,
139            Self::System => ActorKindTag::System,
140            Self::Job { .. } => ActorKindTag::Job,
141            Self::Mcp { .. } => ActorKindTag::Mcp,
142            Self::Agent { .. } => ActorKindTag::Agent,
143        }
144    }
145}
146
147impl fmt::Display for ActorKind {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        f.write_str(self.as_str())
150    }
151}
152
153/// Discriminant-only view of [`ActorKind`], bound to the `actor_kind` column
154/// in `governance_decisions`.
155///
156/// Binding a typed value couples the SQL CHECK allow-list to the enum at
157/// compile time; adding a variant without extending the constraint fails the
158/// build instead of silently rejecting rows at runtime.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
160#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
161#[cfg_attr(feature = "sqlx", sqlx(type_name = "TEXT", rename_all = "snake_case"))]
162#[serde(rename_all = "snake_case")]
163pub enum ActorKindTag {
164    User,
165    Anonymous,
166    System,
167    Job,
168    Mcp,
169    Agent,
170}
171
172impl ActorKindTag {
173    #[must_use]
174    pub const fn as_str(self) -> &'static str {
175        match self {
176            Self::User => "user",
177            Self::Anonymous => "anonymous",
178            Self::System => "system",
179            Self::Job => "job",
180            Self::Mcp => "mcp",
181            Self::Agent => "agent",
182        }
183    }
184}
185
186impl fmt::Display for ActorKindTag {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        f.write_str(self.as_str())
189    }
190}