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