Skip to main content

modkit_security/
context.rs

1use secrecy::SecretString;
2use uuid::Uuid;
3
4/// Error returned when `SecurityContextBuilder::build()` is called without
5/// required fields.
6#[derive(Debug, thiserror::Error)]
7pub enum SecurityContextBuildError {
8    #[error(
9        "subject_id is required - use SecurityContext::anonymous() for unauthenticated contexts"
10    )]
11    MissingSubjectId,
12    #[error(
13        "subject_tenant_id is required - use SecurityContext::anonymous() for unauthenticated contexts"
14    )]
15    MissingSubjectTenantId,
16}
17
18/// `SecurityContext` encapsulates the security-related information for a request or operation.
19///
20/// Built by the `AuthN` Resolver during authentication and passed through the request lifecycle.
21/// Modules use this context together with the `AuthZ` Resolver to obtain access scopes.
22#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23pub struct SecurityContext {
24    /// Subject ID — the authenticated user, service, or system making the request.
25    subject_id: Uuid,
26    /// Subject type classification (e.g., "user", "service").
27    subject_type: Option<String>,
28    /// Subject's home tenant (from `AuthN`). Required — every authenticated
29    /// subject belongs to a tenant.
30    subject_tenant_id: Uuid,
31    /// Token capability restrictions. `["*"]` means first-party / unrestricted.
32    /// Empty means no scopes were asserted (treat as unrestricted for backward compatibility).
33    #[serde(default)]
34    token_scopes: Vec<String>,
35    /// Original bearer token for PDP forwarding. Never serialized/persisted.
36    /// Wrapped in `SecretString` so `Debug` redacts the value automatically.
37    #[serde(skip)]
38    bearer_token: Option<SecretString>,
39}
40
41impl SecurityContext {
42    /// Create a new `SecurityContext` builder
43    #[must_use]
44    pub fn builder() -> SecurityContextBuilder {
45        SecurityContextBuilder::default()
46    }
47
48    /// Create an anonymous `SecurityContext` with no tenant, subject, or permissions.
49    ///
50    /// Use this for unauthenticated / dev / auth-disabled contexts where no
51    /// authenticated subject exists.
52    #[must_use]
53    pub fn anonymous() -> Self {
54        Self {
55            subject_id: Uuid::default(),
56            subject_type: None,
57            subject_tenant_id: Uuid::default(),
58            token_scopes: Vec::new(),
59            bearer_token: None,
60        }
61    }
62
63    /// Get the subject ID (user, service, or system) associated with the security context
64    #[must_use]
65    pub fn subject_id(&self) -> Uuid {
66        self.subject_id
67    }
68
69    /// Get the subject type classification (e.g., "user", "service").
70    #[must_use]
71    pub fn subject_type(&self) -> Option<&str> {
72        self.subject_type.as_deref()
73    }
74
75    /// Get the subject's home tenant ID (from `AuthN` token).
76    #[must_use]
77    pub fn subject_tenant_id(&self) -> Uuid {
78        self.subject_tenant_id
79    }
80
81    /// Get the token scopes. `["*"]` means first-party / unrestricted.
82    #[must_use]
83    pub fn token_scopes(&self) -> &[String] {
84        &self.token_scopes
85    }
86
87    /// Get the original bearer token (for PDP forwarding).
88    #[must_use]
89    pub fn bearer_token(&self) -> Option<&SecretString> {
90        self.bearer_token.as_ref()
91    }
92}
93
94#[derive(Default)]
95pub struct SecurityContextBuilder {
96    subject_id: Option<Uuid>,
97    subject_type: Option<String>,
98    subject_tenant_id: Option<Uuid>,
99    token_scopes: Vec<String>,
100    bearer_token: Option<SecretString>,
101}
102
103impl SecurityContextBuilder {
104    #[must_use]
105    pub fn subject_id(mut self, subject_id: Uuid) -> Self {
106        self.subject_id = Some(subject_id);
107        self
108    }
109
110    #[must_use]
111    pub fn subject_type(mut self, subject_type: &str) -> Self {
112        self.subject_type = Some(subject_type.to_owned());
113        self
114    }
115
116    #[must_use]
117    pub fn subject_tenant_id(mut self, subject_tenant_id: Uuid) -> Self {
118        self.subject_tenant_id = Some(subject_tenant_id);
119        self
120    }
121
122    #[must_use]
123    pub fn token_scopes(mut self, scopes: Vec<String>) -> Self {
124        self.token_scopes = scopes;
125        self
126    }
127
128    #[must_use]
129    pub fn bearer_token(mut self, token: impl Into<SecretString>) -> Self {
130        self.bearer_token = Some(token.into());
131        self
132    }
133
134    /// Build the `SecurityContext`.
135    ///
136    /// # Errors
137    ///
138    /// Returns `SecurityContextBuildError` if `subject_id` or
139    /// `subject_tenant_id` was not set. Use `SecurityContext::anonymous()`
140    /// for contexts that intentionally have no authenticated subject.
141    pub fn build(self) -> Result<SecurityContext, SecurityContextBuildError> {
142        let subject_id = self
143            .subject_id
144            .ok_or(SecurityContextBuildError::MissingSubjectId)?;
145        let subject_tenant_id = self
146            .subject_tenant_id
147            .ok_or(SecurityContextBuildError::MissingSubjectTenantId)?;
148        Ok(SecurityContext {
149            subject_id,
150            subject_type: self.subject_type,
151            subject_tenant_id,
152            token_scopes: self.token_scopes,
153            bearer_token: self.bearer_token,
154        })
155    }
156}
157
158#[cfg(test)]
159#[cfg_attr(coverage_nightly, coverage(off))]
160mod tests {
161    use secrecy::ExposeSecret;
162
163    use super::*;
164
165    #[test]
166    fn test_security_context_builder_full() {
167        let subject_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
168        let subject_tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap();
169
170        let ctx = SecurityContext::builder()
171            .subject_id(subject_id)
172            .subject_type("user")
173            .subject_tenant_id(subject_tenant_id)
174            .token_scopes(vec!["read:events".to_owned(), "write:events".to_owned()])
175            .bearer_token("test-token-123".to_owned())
176            .build()
177            .unwrap();
178
179        assert_eq!(ctx.subject_id(), subject_id);
180        assert_eq!(ctx.subject_tenant_id(), subject_tenant_id);
181        assert_eq!(ctx.token_scopes(), &["read:events", "write:events"]);
182        assert_eq!(
183            ctx.bearer_token().map(ExposeSecret::expose_secret),
184            Some("test-token-123"),
185        );
186    }
187
188    #[test]
189    fn test_security_context_builder_missing_subject_id() {
190        let err = SecurityContext::builder()
191            .subject_tenant_id(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap())
192            .build();
193
194        assert!(matches!(
195            err,
196            Err(SecurityContextBuildError::MissingSubjectId)
197        ));
198    }
199
200    #[test]
201    fn test_security_context_builder_missing_tenant_id() {
202        let err = SecurityContext::builder()
203            .subject_id(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap())
204            .build();
205
206        assert!(matches!(
207            err,
208            Err(SecurityContextBuildError::MissingSubjectTenantId)
209        ));
210    }
211
212    #[test]
213    fn test_security_context_builder_missing_both() {
214        let err = SecurityContext::builder().build();
215
216        assert!(matches!(
217            err,
218            Err(SecurityContextBuildError::MissingSubjectId)
219        ));
220    }
221
222    #[test]
223    fn test_security_context_anonymous() {
224        let ctx = SecurityContext::anonymous();
225
226        assert_eq!(ctx.subject_id(), Uuid::default());
227        assert_eq!(ctx.subject_tenant_id(), Uuid::default());
228        assert!(ctx.token_scopes().is_empty());
229        assert!(ctx.bearer_token().is_none());
230    }
231
232    #[test]
233    fn test_security_context_builder_chaining() {
234        let subject_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
235        let subject_tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap();
236
237        let ctx = SecurityContext::builder()
238            .subject_id(subject_id)
239            .subject_type("user")
240            .subject_tenant_id(subject_tenant_id)
241            .build()
242            .unwrap();
243
244        assert_eq!(ctx.subject_id(), subject_id);
245    }
246
247    #[test]
248    fn test_security_context_clone() {
249        let subject_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
250        let subject_tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap();
251
252        let ctx1 = SecurityContext::builder()
253            .subject_id(subject_id)
254            .subject_tenant_id(subject_tenant_id)
255            .token_scopes(vec!["*".to_owned()])
256            .bearer_token("secret".to_owned())
257            .build()
258            .unwrap();
259
260        let ctx2 = ctx1.clone();
261
262        assert_eq!(ctx2.subject_id(), ctx1.subject_id());
263        assert_eq!(ctx2.subject_tenant_id(), ctx1.subject_tenant_id());
264        assert_eq!(ctx2.token_scopes(), ctx1.token_scopes());
265        assert_eq!(
266            ctx2.bearer_token().map(ExposeSecret::expose_secret),
267            ctx1.bearer_token().map(ExposeSecret::expose_secret),
268        );
269    }
270
271    #[test]
272    fn test_security_context_serialize_deserialize() {
273        let subject_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
274        let subject_tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440002").unwrap();
275
276        let original = SecurityContext::builder()
277            .subject_id(subject_id)
278            .subject_type("user")
279            .subject_tenant_id(subject_tenant_id)
280            .token_scopes(vec!["admin".to_owned()])
281            .bearer_token("secret-token".to_owned())
282            .build()
283            .unwrap();
284
285        let serialized = serde_json::to_string(&original).unwrap();
286        let deserialized: SecurityContext = serde_json::from_str(&serialized).unwrap();
287
288        assert_eq!(deserialized.subject_id(), original.subject_id());
289        assert_eq!(
290            deserialized.subject_tenant_id(),
291            original.subject_tenant_id()
292        );
293        assert_eq!(deserialized.token_scopes(), original.token_scopes());
294        // bearer_token is skipped during serialization
295        assert!(deserialized.bearer_token().is_none());
296    }
297
298    #[test]
299    fn test_security_context_bearer_token_not_serialized() {
300        let ctx = SecurityContext::anonymous();
301
302        let serialized = serde_json::to_string(&ctx).unwrap();
303        assert!(!serialized.contains("bearer_token"));
304    }
305
306    #[test]
307    fn test_security_context_empty_scopes() {
308        let ctx = SecurityContext::anonymous();
309
310        assert!(ctx.token_scopes().is_empty());
311    }
312}