Skip to main content

modkit_security/
context.rs

1use crate::permission::Permission;
2use crate::{AccessScope, PolicyEngineRef};
3use uuid::Uuid;
4
5/// `SecurityContext` encapsulates the security-related information for a request or operation
6#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
7pub struct SecurityContext {
8    tenant_id: Uuid,
9    subject_id: Uuid,
10    subject_type: Option<String>,
11    permissions: Vec<Permission>,
12    environment: Vec<(String, String)>,
13}
14
15impl SecurityContext {
16    /// Create a new `SecurityContext` builder
17    #[must_use]
18    pub fn builder() -> SecurityContextBuilder {
19        SecurityContextBuilder::default()
20    }
21
22    /// Create an anonymous `SecurityContext` with no tenant, subject, or permissions
23    #[must_use]
24    pub fn anonymous() -> Self {
25        SecurityContextBuilder::default().build()
26    }
27
28    /// Get the tenant ID associated with the security context
29    #[must_use]
30    pub fn tenant_id(&self) -> Uuid {
31        self.tenant_id
32    }
33
34    /// Get the subject ID (user, service, or system) associated with the security context
35    #[must_use]
36    pub fn subject_id(&self) -> Uuid {
37        self.subject_id
38    }
39
40    /// Get the permissions assigned to the security context
41    #[must_use]
42    pub fn permissions(&self) -> Vec<Permission> {
43        self.permissions.clone()
44    }
45
46    /// Get the environmental attributes associated with the security context
47    /// (e.g., IP address, device type, location, time, etc.)
48    #[must_use]
49    pub fn environment(&self) -> Vec<(String, String)> {
50        self.environment.clone()
51    }
52
53    pub fn scope(&self, policy_engine: PolicyEngineRef) -> AccessScopeResolver {
54        AccessScopeResolver {
55            _policy_engine: policy_engine,
56            context: self.clone(),
57            accessible_tenants: None,
58        }
59    }
60}
61
62pub struct AccessScopeResolver {
63    _policy_engine: PolicyEngineRef,
64    context: SecurityContext,
65    /// Accessible tenant IDs (set via `include_accessible_tenants`).
66    accessible_tenants: Option<Vec<Uuid>>,
67}
68
69impl AccessScopeResolver {
70    /// Include a list of accessible tenant IDs in the scope.
71    ///
72    /// Use this method when the caller has already resolved which tenants
73    /// the current security context can access (typically via `TenantResolverGatewayClient`).
74    ///
75    /// # Example
76    ///
77    /// ```ignore
78    /// // Discover accessible tenants via hierarchy
79    /// let response = resolver.get_descendants(&ctx, tenant_id, None, None, None).await?;
80    /// let accessible: Vec<Uuid> = std::iter::once(response.tenant.id)
81    ///     .chain(response.descendants.iter().map(|t| t.id))
82    ///     .collect();
83    ///
84    /// // Build scope with accessible tenants
85    /// let scope = ctx
86    ///     .scope(policy_engine)
87    ///     .include_accessible_tenants(accessible)
88    ///     .prepare()
89    ///     .await?;
90    /// ```
91    #[must_use]
92    pub fn include_accessible_tenants(mut self, tenants: Vec<Uuid>) -> Self {
93        self.accessible_tenants = Some(tenants);
94        self
95    }
96
97    #[must_use]
98    pub fn include_resource_ids(&self) -> &Self {
99        self
100    }
101
102    /// Prepare and build the final `AccessScope` based on the resolver configuration
103    ///
104    /// # Errors
105    /// This function may return an error if the scope preparation fails
106    pub async fn prepare(&self) -> Result<AccessScope, Box<dyn std::error::Error>> {
107        // Keep this async to allow future policy-engine / IO-backed resolution without
108        // changing the public API. This no-op await also satisfies clippy::unused_async.
109        std::future::ready(()).await;
110
111        // If accessible tenants were provided, use them
112        if let Some(ref tenants) = self.accessible_tenants {
113            return Ok(AccessScope::tenants_only(tenants.clone()));
114        }
115
116        // Fallback: single tenant from context
117        if self.context.tenant_id != Uuid::default() {
118            return Ok(AccessScope::tenants_only(vec![self.context.tenant_id]));
119        }
120
121        // Empty scope = deny all
122        Ok(AccessScope::default())
123    }
124}
125
126#[derive(Default)]
127pub struct SecurityContextBuilder {
128    tenant_id: Option<Uuid>,
129    subject_id: Option<Uuid>,
130    subject_type: Option<String>,
131    permissions: Vec<Permission>,
132    environment: Vec<(String, String)>,
133}
134
135impl SecurityContextBuilder {
136    #[must_use]
137    pub fn tenant_id(mut self, tenant_id: Uuid) -> Self {
138        self.tenant_id = Some(tenant_id);
139        self
140    }
141
142    #[must_use]
143    pub fn subject_id(mut self, subject_id: Uuid) -> Self {
144        self.subject_id = Some(subject_id);
145        self
146    }
147
148    #[must_use]
149    pub fn subject_type(mut self, subject_type: &str) -> Self {
150        self.subject_type = Some(subject_type.to_owned());
151        self
152    }
153
154    #[must_use]
155    pub fn add_permission(mut self, permission: Permission) -> Self {
156        self.permissions.push(permission);
157        self
158    }
159
160    #[must_use]
161    pub fn add_environment_attribute(mut self, key: &str, value: &str) -> Self {
162        self.environment.push((key.to_owned(), value.to_owned()));
163        self
164    }
165
166    #[must_use]
167    pub fn build(self) -> SecurityContext {
168        SecurityContext {
169            tenant_id: self.tenant_id.unwrap_or_default(),
170            subject_id: self.subject_id.unwrap_or_default(),
171            subject_type: self.subject_type,
172            permissions: self.permissions,
173            environment: self.environment,
174        }
175    }
176}
177
178#[cfg(test)]
179#[cfg_attr(coverage_nightly, coverage(off))]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_security_context_builder_full() {
185        let tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
186        let subject_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
187
188        let permission1 = Permission::builder()
189            .tenant_id(tenant_id)
190            .resource_pattern("gts.x.core.events.topic.v1~*")
191            .action("publish")
192            .build()
193            .unwrap();
194
195        let permission2 = Permission::builder()
196            .resource_pattern("file-parser")
197            .action("edit")
198            .build()
199            .unwrap();
200
201        let ctx = SecurityContext::builder()
202            .tenant_id(tenant_id)
203            .subject_id(subject_id)
204            .subject_type("user")
205            .add_permission(permission1)
206            .add_permission(permission2)
207            .add_environment_attribute("ip", "192.168.1.1")
208            .add_environment_attribute("device", "mobile")
209            .build();
210
211        assert_eq!(ctx.tenant_id(), tenant_id);
212        assert_eq!(ctx.subject_id(), subject_id);
213        assert_eq!(ctx.permissions().len(), 2);
214        assert_eq!(ctx.environment().len(), 2);
215        assert_eq!(
216            ctx.environment()[0],
217            ("ip".to_owned(), "192.168.1.1".to_owned())
218        );
219        assert_eq!(
220            ctx.environment()[1],
221            ("device".to_owned(), "mobile".to_owned())
222        );
223    }
224
225    #[test]
226    fn test_security_context_builder_minimal() {
227        let ctx = SecurityContext::builder().build();
228
229        assert_eq!(ctx.tenant_id(), Uuid::default());
230        assert_eq!(ctx.subject_id(), Uuid::default());
231        assert_eq!(ctx.permissions().len(), 0);
232        assert_eq!(ctx.environment().len(), 0);
233    }
234
235    #[test]
236    fn test_security_context_builder_partial() {
237        let tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
238
239        let ctx = SecurityContext::builder()
240            .tenant_id(tenant_id)
241            .subject_type("service")
242            .build();
243
244        assert_eq!(ctx.tenant_id(), tenant_id);
245        assert_eq!(ctx.subject_id(), Uuid::default());
246        assert_eq!(ctx.permissions().len(), 0);
247        assert_eq!(ctx.environment().len(), 0);
248    }
249
250    #[test]
251    fn test_security_context_anonymous() {
252        let ctx = SecurityContext::anonymous();
253
254        assert_eq!(ctx.tenant_id(), Uuid::default());
255        assert_eq!(ctx.subject_id(), Uuid::default());
256        assert_eq!(ctx.permissions().len(), 0);
257        assert_eq!(ctx.environment().len(), 0);
258    }
259
260    #[test]
261    fn test_security_context_with_multiple_permissions() {
262        let tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
263
264        let permission1 = Permission::builder()
265            .tenant_id(tenant_id)
266            .resource_pattern("gts.x.core.events.topic.v1~vendor.*")
267            .action("publish")
268            .build()
269            .unwrap();
270
271        let permission2 = Permission::builder()
272            .tenant_id(tenant_id)
273            .resource_pattern("gts.x.core.events.topic.v1~vendor.*")
274            .action("subscribe")
275            .build()
276            .unwrap();
277
278        let permission3 = Permission::builder()
279            .resource_pattern("file-parser")
280            .action("edit")
281            .build()
282            .unwrap();
283
284        let ctx = SecurityContext::builder()
285            .tenant_id(tenant_id)
286            .add_permission(permission1)
287            .add_permission(permission2)
288            .add_permission(permission3)
289            .build();
290
291        let perms = ctx.permissions();
292        assert_eq!(perms.len(), 3);
293        assert_eq!(perms[0].tenant_id(), Some(tenant_id));
294        assert_eq!(
295            perms[0].resource_pattern(),
296            "gts.x.core.events.topic.v1~vendor.*"
297        );
298        assert_eq!(perms[0].action(), "publish");
299        assert_eq!(perms[1].action(), "subscribe");
300        assert_eq!(perms[2].resource_pattern(), "file-parser");
301    }
302
303    #[test]
304    fn test_security_context_with_multiple_environment_attributes() {
305        let ctx = SecurityContext::builder()
306            .add_environment_attribute("ip", "192.168.1.1")
307            .add_environment_attribute("device", "mobile")
308            .add_environment_attribute("location", "US")
309            .add_environment_attribute("time_zone", "PST")
310            .build();
311
312        let env = ctx.environment();
313        assert_eq!(env.len(), 4);
314        assert_eq!(env[0], ("ip".to_owned(), "192.168.1.1".to_owned()));
315        assert_eq!(env[1], ("device".to_owned(), "mobile".to_owned()));
316        assert_eq!(env[2], ("location".to_owned(), "US".to_owned()));
317        assert_eq!(env[3], ("time_zone".to_owned(), "PST".to_owned()));
318    }
319
320    #[test]
321    fn test_security_context_builder_chaining() {
322        let tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
323        let subject_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
324
325        // Test that builder methods can be chained fluently
326        let ctx = SecurityContext::builder()
327            .tenant_id(tenant_id)
328            .subject_id(subject_id)
329            .subject_type("user")
330            .build();
331
332        assert_eq!(ctx.tenant_id(), tenant_id);
333        assert_eq!(ctx.subject_id(), subject_id);
334    }
335
336    #[test]
337    fn test_security_context_getters_dont_mutate() {
338        let tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
339
340        let permission = Permission::builder()
341            .resource_pattern("file-parser")
342            .action("edit")
343            .build()
344            .unwrap();
345
346        let ctx = SecurityContext::builder()
347            .tenant_id(tenant_id)
348            .add_permission(permission)
349            .add_environment_attribute("ip", "192.168.1.1")
350            .build();
351
352        // Call getters multiple times
353        let _perms1 = ctx.permissions();
354        let perms2 = ctx.permissions();
355        assert_eq!(perms2.len(), 1);
356
357        let _env1 = ctx.environment();
358        let env2 = ctx.environment();
359        assert_eq!(env2.len(), 1);
360
361        // Original context should be unchanged
362        assert_eq!(ctx.tenant_id(), tenant_id);
363    }
364
365    #[test]
366    fn test_security_context_clone() {
367        let tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
368        let subject_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
369
370        let permission = Permission::builder()
371            .resource_pattern("file-parser")
372            .action("edit")
373            .build()
374            .unwrap();
375
376        let ctx1 = SecurityContext::builder()
377            .tenant_id(tenant_id)
378            .subject_id(subject_id)
379            .add_permission(permission)
380            .add_environment_attribute("ip", "192.168.1.1")
381            .build();
382
383        let ctx2 = ctx1.clone();
384
385        assert_eq!(ctx2.tenant_id(), ctx1.tenant_id());
386        assert_eq!(ctx2.subject_id(), ctx1.subject_id());
387        assert_eq!(ctx2.permissions().len(), ctx1.permissions().len());
388        assert_eq!(ctx2.environment().len(), ctx1.environment().len());
389    }
390
391    #[test]
392    fn test_security_context_serialize_deserialize() {
393        let tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
394        let subject_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
395
396        let permission = Permission::builder()
397            .tenant_id(tenant_id)
398            .resource_pattern("gts.x.core.events.topic.v1~*")
399            .action("publish")
400            .build()
401            .unwrap();
402
403        let original = SecurityContext::builder()
404            .tenant_id(tenant_id)
405            .subject_id(subject_id)
406            .subject_type("user")
407            .add_permission(permission)
408            .add_environment_attribute("ip", "192.168.1.1")
409            .build();
410
411        let serialized = serde_json::to_string(&original).unwrap();
412        let deserialized: SecurityContext = serde_json::from_str(&serialized).unwrap();
413
414        assert_eq!(deserialized.tenant_id(), original.tenant_id());
415        assert_eq!(deserialized.subject_id(), original.subject_id());
416        assert_eq!(deserialized.permissions().len(), 1);
417        assert_eq!(deserialized.environment().len(), 1);
418    }
419
420    #[test]
421    fn test_security_context_with_no_subject_type() {
422        let tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
423
424        let ctx = SecurityContext::builder().tenant_id(tenant_id).build();
425
426        // subject_type is optional and should be None when not set
427        assert_eq!(ctx.tenant_id(), tenant_id);
428    }
429
430    #[test]
431    fn test_security_context_empty_permissions() {
432        let ctx = SecurityContext::builder().build();
433
434        assert!(ctx.permissions().is_empty());
435    }
436}