Skip to main content

cts_common/auth/claims/
actor_id.rs

1use arrayvec::ArrayString;
2use serde::{Deserialize, Serialize};
3use std::fmt::Display;
4use std::str::FromStr;
5use vitaminc::random::{Generatable, SafeRand};
6
7const ACTOR_ID_BYTE_LEN: usize = 10;
8const ACTOR_ID_ENCODED_LEN: usize = 16;
9const ALPHABET: base32::Alphabet = base32::Alphabet::Rfc4648 { padding: false };
10
11type ActorIdString = ArrayString<ACTOR_ID_ENCODED_LEN>;
12
13/// The kind of actor — determines the prefix in the serialized `ActorIdentifier`.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, utoipa::ToSchema)]
15#[serde(rename_all = "lowercase")]
16pub enum ActorKind {
17    App,
18    Agent,
19}
20
21impl ActorKind {
22    pub fn as_str(&self) -> &'static str {
23        match self {
24            Self::App => "app",
25            Self::Agent => "agent",
26        }
27    }
28
29    pub fn parse(s: &str) -> Option<Self> {
30        match s {
31            "app" => Some(Self::App),
32            "agent" => Some(Self::Agent),
33            _ => None,
34        }
35    }
36}
37
38/// A compact, stack-allocated unique identifier for an actor.
39///
40/// Contains only the random base32-encoded portion (16 chars / 10 bytes),
41/// matching the format of [`WorkspaceId`](crate::WorkspaceId).
42/// Used as the primary key in the `actors` table.
43///
44/// For the full identifier including the actor kind prefix (e.g. `"app-JBSWY3DPEHPK3PXP"`),
45/// see [`ActorIdentifier`].
46#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
47#[serde(transparent)]
48#[cfg_attr(
49    feature = "server",
50    derive(diesel::expression::AsExpression, diesel::deserialize::FromSqlRow)
51)]
52#[cfg_attr(feature = "server", diesel(sql_type = diesel::sql_types::Text))]
53pub struct ActorId(ActorIdString);
54
55impl ActorId {
56    /// Generate a new random `ActorId`.
57    pub fn generate() -> Result<Self, vitaminc::random::RandomError> {
58        let mut rng = SafeRand::from_entropy()?;
59        let buf: [u8; ACTOR_ID_BYTE_LEN] = Generatable::random(&mut rng)?;
60        let encoded = base32::encode(ALPHABET, &buf);
61        let mut id = ActorIdString::new();
62        id.push_str(&encoded);
63        Ok(Self(id))
64    }
65
66    pub fn as_str(&self) -> &str {
67        self.0.as_str()
68    }
69}
70
71impl Display for ActorId {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        write!(f, "{}", self.0)
74    }
75}
76
77impl TryFrom<&str> for ActorId {
78    type Error = InvalidActorId;
79
80    fn try_from(value: &str) -> Result<Self, Self::Error> {
81        if is_valid_actor_id(value) {
82            let mut id = ActorIdString::new();
83            id.push_str(value);
84            Ok(Self(id))
85        } else {
86            Err(InvalidActorId(value.to_string()))
87        }
88    }
89}
90
91impl TryFrom<String> for ActorId {
92    type Error = InvalidActorId;
93
94    fn try_from(value: String) -> Result<Self, Self::Error> {
95        Self::try_from(value.as_str())
96    }
97}
98
99impl FromStr for ActorId {
100    type Err = InvalidActorId;
101
102    fn from_str(s: &str) -> Result<Self, Self::Err> {
103        Self::try_from(s)
104    }
105}
106
107impl PartialEq<&str> for ActorId {
108    fn eq(&self, other: &&str) -> bool {
109        self.0.as_str() == *other
110    }
111}
112
113/// A full actor identifier combining the actor kind and unique ID.
114///
115/// Serializes as `"app-JBSWY3DPEHPK3PXP"` or `"agent-JBSWY3DPEHPK3PXP"`.
116/// Used in JWT claims and external APIs.
117///
118/// For just the unique ID portion (used as the database primary key),
119/// see [`ActorId`].
120#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
121pub struct ActorIdentifier {
122    kind: ActorKind,
123    id: ActorId,
124}
125
126impl ActorIdentifier {
127    /// Create an `ActorIdentifier` from a kind and ID.
128    pub fn new(kind: ActorKind, id: ActorId) -> Self {
129        Self { kind, id }
130    }
131
132    /// Generate a new random `ActorIdentifier` of the given kind.
133    pub fn generate(kind: ActorKind) -> Result<Self, vitaminc::random::RandomError> {
134        let id = ActorId::generate()?;
135        Ok(Self { kind, id })
136    }
137
138    /// The kind of actor (App or Agent).
139    pub fn kind(&self) -> ActorKind {
140        self.kind
141    }
142
143    /// The unique actor ID.
144    pub fn id(&self) -> ActorId {
145        self.id
146    }
147}
148
149impl Display for ActorIdentifier {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        write!(f, "{}-{}", self.kind.as_str(), self.id)
152    }
153}
154
155impl TryFrom<&str> for ActorIdentifier {
156    type Error = InvalidActorId;
157
158    fn try_from(value: &str) -> Result<Self, Self::Error> {
159        let (prefix, id_str) = value
160            .split_once('-')
161            .ok_or_else(|| InvalidActorId(value.to_string()))?;
162
163        let kind = ActorKind::parse(prefix).ok_or_else(|| InvalidActorId(value.to_string()))?;
164        let id = ActorId::try_from(id_str)?;
165
166        Ok(Self { kind, id })
167    }
168}
169
170impl FromStr for ActorIdentifier {
171    type Err = InvalidActorId;
172
173    fn from_str(s: &str) -> Result<Self, Self::Err> {
174        Self::try_from(s)
175    }
176}
177
178impl Serialize for ActorIdentifier {
179    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
180    where
181        S: serde::Serializer,
182    {
183        serializer.serialize_str(&self.to_string())
184    }
185}
186
187impl<'de> Deserialize<'de> for ActorIdentifier {
188    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
189    where
190        D: serde::Deserializer<'de>,
191    {
192        let s = String::deserialize(deserializer)?;
193        Self::try_from(s.as_str()).map_err(serde::de::Error::custom)
194    }
195}
196
197fn is_valid_actor_id(id: &str) -> bool {
198    base32::decode(ALPHABET, id)
199        .map(|bytes| bytes.len() == ACTOR_ID_BYTE_LEN)
200        .unwrap_or(false)
201}
202
203#[derive(Debug, thiserror::Error)]
204#[error("Invalid actor ID: {0}")]
205pub struct InvalidActorId(String);
206
207#[cfg(feature = "server")]
208mod sql_types {
209    use super::ActorId;
210    use diesel::{
211        backend::Backend,
212        deserialize::{self, FromSql},
213        serialize::{self, Output, ToSql},
214        sql_types::Text,
215    };
216
217    impl<DB> ToSql<Text, DB> for ActorId
218    where
219        DB: Backend,
220        str: ToSql<Text, DB>,
221    {
222        fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, DB>) -> serialize::Result {
223            self.0.to_sql(out)
224        }
225    }
226
227    impl<DB> FromSql<Text, DB> for ActorId
228    where
229        DB: Backend,
230        String: FromSql<Text, DB>,
231    {
232        fn from_sql(bytes: DB::RawValue<'_>) -> deserialize::Result<Self> {
233            let raw = String::from_sql(bytes)?;
234            let actor_id = ActorId::try_from(raw)?;
235            Ok(actor_id)
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use serde_json::json;
244
245    mod actor_id {
246        use super::*;
247
248        #[test]
249        fn generate_produces_valid_id() {
250            let id = ActorId::generate().unwrap();
251            assert_eq!(id.as_str().len(), 16, "base32 ID should be 16 chars");
252        }
253
254        #[test]
255        fn round_trips_through_serde() {
256            let id = ActorId::generate().unwrap();
257            let json = serde_json::to_value(id).unwrap();
258            let parsed: ActorId = serde_json::from_value(json).unwrap();
259            assert_eq!(parsed, id);
260        }
261
262        #[test]
263        fn from_str_round_trips() {
264            let id = ActorId::generate().unwrap();
265            let s = id.to_string();
266            let parsed: ActorId = s.parse().unwrap();
267            assert_eq!(parsed, id);
268        }
269
270        #[test]
271        fn rejects_invalid_base32() {
272            assert!(ActorId::try_from("!!!INVALID!!!").is_err());
273        }
274
275        #[test]
276        fn rejects_wrong_length() {
277            assert!(ActorId::try_from("AAAA").is_err());
278        }
279    }
280
281    mod identifier_app {
282        use super::*;
283
284        #[test]
285        fn generate_produces_valid_identifier() {
286            let ident = ActorIdentifier::generate(ActorKind::App).unwrap();
287            assert_eq!(ident.kind(), ActorKind::App, "kind should be App");
288            assert_eq!(
289                ident.id().as_str().len(),
290                16,
291                "base32 ID should be 16 chars"
292            );
293        }
294
295        #[test]
296        fn serializes_with_prefix() {
297            let ident = ActorIdentifier::generate(ActorKind::App).unwrap();
298            let serialized = serde_json::to_value(ident).unwrap();
299            let s = serialized.as_str().unwrap();
300            assert!(s.starts_with("app-"), "should start with 'app-', got: {s}");
301            assert_eq!(s.len(), 20, "app-<16 chars> = 20 chars, got: {s}");
302        }
303
304        #[test]
305        fn round_trips_through_serde() {
306            let ident = ActorIdentifier::generate(ActorKind::App).unwrap();
307            let json = serde_json::to_value(ident).unwrap();
308            let parsed: ActorIdentifier = serde_json::from_value(json).unwrap();
309            assert_eq!(parsed, ident, "should round-trip through serde");
310        }
311    }
312
313    mod identifier_agent {
314        use super::*;
315
316        #[test]
317        fn generate_produces_valid_identifier() {
318            let ident = ActorIdentifier::generate(ActorKind::Agent).unwrap();
319            assert_eq!(ident.kind(), ActorKind::Agent, "kind should be Agent");
320            assert_eq!(
321                ident.id().as_str().len(),
322                16,
323                "base32 ID should be 16 chars"
324            );
325        }
326
327        #[test]
328        fn serializes_with_prefix() {
329            let ident = ActorIdentifier::generate(ActorKind::Agent).unwrap();
330            let serialized = serde_json::to_value(ident).unwrap();
331            let s = serialized.as_str().unwrap();
332            assert!(
333                s.starts_with("agent-"),
334                "should start with 'agent-', got: {s}"
335            );
336            assert_eq!(s.len(), 22, "agent-<16 chars> = 22 chars, got: {s}");
337        }
338
339        #[test]
340        fn round_trips_through_serde() {
341            let ident = ActorIdentifier::generate(ActorKind::Agent).unwrap();
342            let json = serde_json::to_value(ident).unwrap();
343            let parsed: ActorIdentifier = serde_json::from_value(json).unwrap();
344            assert_eq!(parsed, ident, "should round-trip through serde");
345        }
346    }
347
348    mod identifier_invalid {
349        use super::*;
350
351        #[test]
352        fn rejects_unknown_prefix() {
353            let json = json!("unknown-JBSWY3DPEHPK3PXP");
354            let result: Result<ActorIdentifier, _> = serde_json::from_value(json);
355            assert!(result.is_err(), "should reject unknown actor kind");
356        }
357
358        #[test]
359        fn rejects_missing_delimiter() {
360            let json = json!("appJBSWY3DPEHPK3PXP");
361            let result: Result<ActorIdentifier, _> = serde_json::from_value(json);
362            assert!(result.is_err(), "should reject missing delimiter");
363        }
364
365        #[test]
366        fn rejects_invalid_base32() {
367            let json = json!("app-!!!INVALID!!!");
368            let result: Result<ActorIdentifier, _> = serde_json::from_value(json);
369            assert!(result.is_err(), "should reject invalid base32");
370        }
371
372        #[test]
373        fn rejects_wrong_length() {
374            let json = json!("app-AAAA");
375            let result: Result<ActorIdentifier, _> = serde_json::from_value(json);
376            assert!(result.is_err(), "should reject wrong-length ID");
377        }
378
379        #[test]
380        fn rejects_empty_string() {
381            let json = json!("");
382            let result: Result<ActorIdentifier, _> = serde_json::from_value(json);
383            assert!(result.is_err(), "should reject empty string");
384        }
385    }
386
387    #[test]
388    fn from_str_round_trips() {
389        let ident = ActorIdentifier::generate(ActorKind::App).unwrap();
390        let s = ident.to_string();
391        let parsed: ActorIdentifier = s.parse().unwrap();
392        assert_eq!(parsed, ident);
393    }
394
395    #[test]
396    fn from_str_rejects_invalid() {
397        assert!("not-valid".parse::<ActorIdentifier>().is_err());
398    }
399
400    #[test]
401    fn display_matches_serialize() {
402        let ident = ActorIdentifier::generate(ActorKind::App).unwrap();
403        let display = ident.to_string();
404        let serialized = serde_json::to_value(ident).unwrap();
405        assert_eq!(
406            display,
407            serialized.as_str().unwrap(),
408            "Display and Serialize should produce the same string"
409        );
410    }
411
412    #[test]
413    fn new_constructs_from_parts() {
414        let id = ActorId::generate().unwrap();
415        let ident = ActorIdentifier::new(ActorKind::App, id);
416        assert_eq!(ident.kind(), ActorKind::App);
417        assert_eq!(ident.id(), id);
418    }
419}