Skip to main content

riffdb_types/
actor.rs

1//! Foundational actor and bounded provenance-component values.
2
3use std::error::Error;
4use std::fmt;
5
6use crate::limits::{
7    MAX_APPROVAL_ID_BYTES, MAX_APPROVAL_REFERENCE_BYTES, MAX_AUDIENCE_BYTES,
8    MAX_PROVENANCE_REASON_BYTES, MAX_SOURCE_COMMIT_BYTES, MAX_SOURCE_REPOSITORY_BYTES,
9};
10use crate::{ActorId, AgentSessionId, TenantScope};
11
12/// A safe validation failure for a bounded text component.
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum BoundedTextError {
15    /// The supplied value is empty.
16    Empty,
17    /// The UTF-8 representation exceeds the component's byte limit.
18    TooLong {
19        /// The applicable maximum byte length.
20        maximum: usize,
21        /// The supplied byte length.
22        actual: usize,
23    },
24    /// The component contains a byte outside visible ASCII.
25    InvalidCharacter {
26        /// The zero-based byte index of the invalid character.
27        index: usize,
28    },
29}
30
31impl fmt::Display for BoundedTextError {
32    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            Self::Empty => formatter.write_str("value must not be empty"),
35            Self::TooLong { maximum, actual } => write!(
36                formatter,
37                "value is {actual} bytes but the maximum is {maximum} bytes"
38            ),
39            Self::InvalidCharacter { index } => {
40                write!(formatter, "value has an invalid character at byte {index}")
41            }
42        }
43    }
44}
45
46impl Error for BoundedTextError {}
47
48fn validate_bounded_utf8(value: &str, maximum: usize) -> Result<(), BoundedTextError> {
49    if value.is_empty() {
50        return Err(BoundedTextError::Empty);
51    }
52    if value.len() > maximum {
53        return Err(BoundedTextError::TooLong {
54            maximum,
55            actual: value.len(),
56        });
57    }
58    Ok(())
59}
60
61fn validate_bounded_visible_ascii(value: &str, maximum: usize) -> Result<(), BoundedTextError> {
62    validate_bounded_utf8(value, maximum)?;
63    if let Some(index) = value
64        .bytes()
65        .position(|byte| !(0x21..=0x7e).contains(&byte))
66    {
67        return Err(BoundedTextError::InvalidCharacter { index });
68    }
69    Ok(())
70}
71
72macro_rules! bounded_text_type {
73    ($(#[$meta:meta])* $name:ident, $maximum:ident, $validator:ident) => {
74        $(#[$meta])*
75        #[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
76        pub struct $name(String);
77
78        crate::inspect_owned_capacity_tuple_v1!($name, String);
79
80        impl $name {
81            /// Validates and constructs the component without normalizing it.
82            pub fn new(value: impl Into<String>) -> Result<Self, BoundedTextError> {
83                let value = value.into();
84                $validator(&value, $maximum)?;
85                Ok(Self(value))
86            }
87
88            /// Borrows the exact validated text.
89            #[must_use]
90            pub fn as_str(&self) -> &str {
91                &self.0
92            }
93
94            /// Borrows the exact validated bytes.
95            #[must_use]
96            pub fn as_bytes(&self) -> &[u8] {
97                self.0.as_bytes()
98            }
99        }
100
101        impl fmt::Debug for $name {
102            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
103                formatter.write_str(concat!(stringify!($name), "([REDACTED])"))
104            }
105        }
106    };
107}
108
109bounded_text_type!(
110    /// An exact configured authentication audience.
111    Audience,
112    MAX_AUDIENCE_BYTES,
113    validate_bounded_visible_ascii
114);
115
116bounded_text_type!(
117    /// An untrusted, bounded source-repository claim.
118    SourceRepository,
119    MAX_SOURCE_REPOSITORY_BYTES,
120    validate_bounded_utf8
121);
122
123bounded_text_type!(
124    /// An untrusted, bounded source-commit claim.
125    SourceCommit,
126    MAX_SOURCE_COMMIT_BYTES,
127    validate_bounded_visible_ascii
128);
129
130bounded_text_type!(
131    /// An untrusted, bounded reason claim for provenance review.
132    ProvenanceReason,
133    MAX_PROVENANCE_REASON_BYTES,
134    validate_bounded_utf8
135);
136
137bounded_text_type!(
138    /// An untrusted, bounded approval reference.
139    ApprovalReference,
140    MAX_APPROVAL_REFERENCE_BYTES,
141    validate_bounded_visible_ascii
142);
143
144bounded_text_type!(
145    /// A policy-validated approval identity.
146    ApprovalId,
147    MAX_APPROVAL_ID_BYTES,
148    validate_bounded_visible_ascii
149);
150
151/// The trusted class of a stable actor principal.
152#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
153pub enum ActorKind {
154    /// A human principal.
155    Human,
156    /// An autonomous or assisted agent principal.
157    Agent,
158    /// A service principal.
159    Service,
160}
161
162impl ActorKind {
163    /// Every accepted v1 actor kind, in tag order.
164    pub const ALL: [Self; 3] = [Self::Human, Self::Agent, Self::Service];
165
166    /// Returns the stable v1 semantic tag.
167    #[must_use]
168    pub const fn tag(self) -> u8 {
169        match self {
170            Self::Human => 0x01,
171            Self::Agent => 0x02,
172            Self::Service => 0x03,
173        }
174    }
175
176    /// Decodes a stable v1 semantic tag, rejecting zero and unknown values.
177    #[must_use]
178    pub const fn from_tag(tag: u8) -> Option<Self> {
179        match tag {
180            0x01 => Some(Self::Human),
181            0x02 => Some(Self::Agent),
182            0x03 => Some(Self::Service),
183            _ => None,
184        }
185    }
186}
187
188/// The immutable actor context admitted for deterministic command execution.
189#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
190pub struct AdmittedActorContext {
191    principal_id: ActorId,
192    actor_kind: ActorKind,
193    tenant_scope: TenantScope,
194    agent_session_id: Option<AgentSessionId>,
195}
196
197impl AdmittedActorContext {
198    /// Constructs an admitted context from already checked and authorized values.
199    #[must_use]
200    pub const fn new(
201        principal_id: ActorId,
202        actor_kind: ActorKind,
203        tenant_scope: TenantScope,
204        agent_session_id: Option<AgentSessionId>,
205    ) -> Self {
206        Self {
207            principal_id,
208            actor_kind,
209            tenant_scope,
210            agent_session_id,
211        }
212    }
213
214    /// Borrows the stable admitted principal identity.
215    #[must_use]
216    pub const fn principal_id(&self) -> &ActorId {
217        &self.principal_id
218    }
219
220    /// Returns the trusted admitted actor kind.
221    #[must_use]
222    pub const fn actor_kind(&self) -> ActorKind {
223        self.actor_kind
224    }
225
226    /// Borrows the authorization-resolved tenant scope.
227    #[must_use]
228    pub const fn tenant_scope(&self) -> &TenantScope {
229        &self.tenant_scope
230    }
231
232    /// Returns the optional policy-admitted agent-session identity.
233    #[must_use]
234    pub const fn agent_session_id(&self) -> Option<AgentSessionId> {
235        self.agent_session_id
236    }
237}
238
239impl fmt::Debug for AdmittedActorContext {
240    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
241        formatter.write_str("AdmittedActorContext([REDACTED])")
242    }
243}
244
245// Exhaustive non-durable ownership inventories for exact-provider admission.
246crate::inspect_owned_capacity_copy_v1!(ActorKind);
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::TenantId;
252
253    fn test_agent_session_id() -> AgentSessionId {
254        let mut bytes = [0x22; 16];
255        bytes[6] = 0x72;
256        bytes[8] = 0xa2;
257        AgentSessionId::from_bytes(bytes).expect("test UUID is version 7")
258    }
259
260    #[test]
261    fn actor_kind_tags_are_closed_and_stable() {
262        assert_eq!(ActorKind::ALL.map(ActorKind::tag), [0x01, 0x02, 0x03]);
263        for actor_kind in ActorKind::ALL {
264            assert_eq!(ActorKind::from_tag(actor_kind.tag()), Some(actor_kind));
265        }
266        assert_eq!(ActorKind::from_tag(0), None);
267        assert_eq!(ActorKind::from_tag(4), None);
268        assert_eq!(ActorKind::from_tag(u8::MAX), None);
269    }
270
271    #[test]
272    fn visible_ascii_components_enforce_exact_alphabet_and_bounds() {
273        let audience = Audience::new(format!("!{}~", "a".repeat(510)))
274            .expect("visible ASCII at the limit is valid");
275        assert_eq!(audience.as_bytes().len(), MAX_AUDIENCE_BYTES);
276        assert_eq!(Audience::new(""), Err(BoundedTextError::Empty));
277        assert_eq!(
278            Audience::new("a".repeat(MAX_AUDIENCE_BYTES + 1)),
279            Err(BoundedTextError::TooLong {
280                maximum: MAX_AUDIENCE_BYTES,
281                actual: MAX_AUDIENCE_BYTES + 1,
282            })
283        );
284        assert_eq!(
285            Audience::new("valid except space"),
286            Err(BoundedTextError::InvalidCharacter { index: 5 })
287        );
288        assert_eq!(
289            SourceCommit::new("abc\n123"),
290            Err(BoundedTextError::InvalidCharacter { index: 3 })
291        );
292        assert_eq!(
293            ApprovalReference::new("\u{7f}"),
294            Err(BoundedTextError::InvalidCharacter { index: 0 })
295        );
296        assert_eq!(
297            ApprovalId::new("caf\u{e9}"),
298            Err(BoundedTextError::InvalidCharacter { index: 3 })
299        );
300    }
301
302    #[test]
303    fn utf8_components_use_encoded_byte_lengths_without_normalization() {
304        let exact = "\u{e9}".repeat(MAX_SOURCE_REPOSITORY_BYTES / 2);
305        let repository =
306            SourceRepository::new(exact.clone()).expect("exact UTF-8 byte limit is valid");
307        assert_eq!(repository.as_str(), exact);
308        assert_eq!(repository.as_bytes().len(), MAX_SOURCE_REPOSITORY_BYTES);
309
310        let over = format!("{exact}x");
311        assert_eq!(
312            SourceRepository::new(over),
313            Err(BoundedTextError::TooLong {
314                maximum: MAX_SOURCE_REPOSITORY_BYTES,
315                actual: MAX_SOURCE_REPOSITORY_BYTES + 1,
316            })
317        );
318
319        let reason = ProvenanceReason::new("Case and \u{212b} stay exact")
320            .expect("bounded UTF-8 reason is valid");
321        assert_eq!(reason.as_str(), "Case and \u{212b} stay exact");
322    }
323
324    #[test]
325    fn every_text_value_rejects_empty_input() {
326        assert_eq!(Audience::new(""), Err(BoundedTextError::Empty));
327        assert_eq!(SourceRepository::new(""), Err(BoundedTextError::Empty));
328        assert_eq!(SourceCommit::new(""), Err(BoundedTextError::Empty));
329        assert_eq!(ProvenanceReason::new(""), Err(BoundedTextError::Empty));
330        assert_eq!(ApprovalReference::new(""), Err(BoundedTextError::Empty));
331        assert_eq!(ApprovalId::new(""), Err(BoundedTextError::Empty));
332    }
333
334    #[test]
335    fn bounded_values_and_admitted_context_redact_debug_output() {
336        let audience = Audience::new("https://database.invalid").expect("valid audience");
337        let repository = SourceRepository::new("secret/repository").expect("valid repository");
338        let commit = SourceCommit::new("0123456789abcdef").expect("valid commit");
339        let reason = ProvenanceReason::new("sensitive reason").expect("valid reason");
340        let reference = ApprovalReference::new("approval-reference").expect("valid reference");
341        let approval_id = ApprovalId::new("approval-id").expect("valid approval ID");
342
343        for (debug, secret) in [
344            (format!("{audience:?}"), "database.invalid"),
345            (format!("{repository:?}"), "secret/repository"),
346            (format!("{commit:?}"), "0123456789abcdef"),
347            (format!("{reason:?}"), "sensitive reason"),
348            (format!("{reference:?}"), "approval-reference"),
349            (format!("{approval_id:?}"), "approval-id"),
350        ] {
351            assert!(debug.contains("[REDACTED]"));
352            assert!(!debug.contains(secret));
353        }
354
355        let principal_id = ActorId::new("principal-secret").expect("valid principal");
356        let tenant_id = TenantId::new("tenant-secret").expect("valid tenant");
357        let context = AdmittedActorContext::new(
358            principal_id,
359            ActorKind::Agent,
360            TenantScope::Tenant(tenant_id),
361            Some(test_agent_session_id()),
362        );
363        let debug = format!("{context:?}");
364        assert_eq!(debug, "AdmittedActorContext([REDACTED])");
365        assert!(!debug.contains("principal-secret"));
366        assert!(!debug.contains("tenant-secret"));
367    }
368
369    #[test]
370    fn admitted_actor_context_preserves_exact_authorized_components() {
371        let principal_id = ActorId::new("actor-1").expect("valid actor");
372        let tenant_id = TenantId::new("tenant-1").expect("valid tenant");
373        let session_id = test_agent_session_id();
374        let context = AdmittedActorContext::new(
375            principal_id.clone(),
376            ActorKind::Agent,
377            TenantScope::Tenant(tenant_id.clone()),
378            Some(session_id),
379        );
380
381        assert_eq!(context.principal_id(), &principal_id);
382        assert_eq!(context.actor_kind(), ActorKind::Agent);
383        assert_eq!(context.tenant_scope(), &TenantScope::Tenant(tenant_id));
384        assert_eq!(context.agent_session_id(), Some(session_id));
385    }
386}