Skip to main content

modkit_security/
permission.rs

1use uuid::Uuid;
2
3/// Represents a permission consisting of a resource and an action
4/// Serializes to format: `"{tenant_id}:{resource_pattern}:{resource_id}:{action}"`
5/// where `tenant_id` and `resource_id` are "*" if None
6/// Examples:
7///  - "550e8400-e29b-41d4-a716-446655440000:gts.x.core.events.topic.v1~vendor.*:*:publish"
8///  - "`*:file_parser:*:edit`"
9///  - "550e8400-e29b-41d4-a716-446655440001:gts.x.core.events.type.v1~:660e8400-e29b-41d4-a716-446655440002:edit"
10#[derive(Debug, Clone)]
11pub struct Permission {
12    /// Optional tenant ID the permission applies to
13    /// e.g., a specific tenant UUID
14    tenant_id: Option<Uuid>,
15
16    /// A pattern that can include wildcards to match multiple resources
17    /// examples:
18    ///   - "gts.x.events.topic.v1~vendor.*"
19    ///   - "`gts.x.module.v1~x.file_parser.v1`"
20    resource_pattern: String,
21
22    /// Optional specific resource ID the permission applies to
23    /// e.g., a specific topic or file UUID
24    resource_id: Option<Uuid>,
25
26    /// The action that can be performed on the resource
27    /// e.g., "publish", "subscribe", "edit"
28    action: String,
29}
30
31impl serde::Serialize for Permission {
32    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
33    where
34        S: serde::Serializer,
35    {
36        let tenant_id_str = self
37            .tenant_id
38            .map_or_else(|| "*".to_owned(), |id| id.to_string());
39        let resource_id_str = self
40            .resource_id
41            .map_or_else(|| "*".to_owned(), |id| id.to_string());
42        let s = format!(
43            "{}:{}:{}:{}",
44            tenant_id_str, self.resource_pattern, resource_id_str, self.action
45        );
46        serializer.serialize_str(&s)
47    }
48}
49
50impl<'de> serde::Deserialize<'de> for Permission {
51    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
52    where
53        D: serde::Deserializer<'de>,
54    {
55        let s = String::deserialize(deserializer)?;
56        let parts: Vec<&str> = s.splitn(4, ':').collect();
57
58        if parts.len() != 4 {
59            return Err(serde::de::Error::custom(format!(
60                "Expected format 'tenant_id:resource_pattern:resource_id:action', got: {s}"
61            )));
62        }
63
64        let tenant_id = if parts[0] == "*" {
65            None
66        } else {
67            Some(Uuid::parse_str(parts[0]).map_err(serde::de::Error::custom)?)
68        };
69
70        let resource_id = if parts[2] == "*" {
71            None
72        } else {
73            Some(Uuid::parse_str(parts[2]).map_err(serde::de::Error::custom)?)
74        };
75
76        let action = parts[3];
77        if !action
78            .chars()
79            .all(|c| c.is_ascii_alphanumeric() || c == '_')
80        {
81            return Err(serde::de::Error::custom(format!(
82                "Action must contain only alphanumeric characters and underscores, got: {action}"
83            )));
84        }
85
86        Ok(Permission {
87            tenant_id,
88            resource_pattern: parts[1].to_owned(),
89            resource_id,
90            action: action.to_owned(),
91        })
92    }
93}
94
95impl Permission {
96    #[must_use]
97    pub fn builder() -> PermissionBuilder {
98        PermissionBuilder::default()
99    }
100
101    #[must_use]
102    pub fn tenant_id(&self) -> Option<Uuid> {
103        self.tenant_id
104    }
105
106    #[must_use]
107    pub fn resource_pattern(&self) -> &str {
108        &self.resource_pattern
109    }
110
111    #[must_use]
112    pub fn resource_id(&self) -> Option<Uuid> {
113        self.resource_id
114    }
115
116    #[must_use]
117    pub fn action(&self) -> &str {
118        &self.action
119    }
120}
121
122#[derive(Default)]
123pub struct PermissionBuilder {
124    tenant_id: Option<Uuid>,
125    resource_pattern: Option<String>,
126    resource_id: Option<Uuid>,
127    action: Option<String>,
128}
129
130impl PermissionBuilder {
131    #[must_use]
132    pub fn tenant_id(mut self, tenant_id: Uuid) -> Self {
133        self.tenant_id = Some(tenant_id);
134        self
135    }
136
137    #[must_use]
138    pub fn resource_pattern(mut self, resource_pattern: &str) -> Self {
139        self.resource_pattern = Some(resource_pattern.to_owned());
140        self
141    }
142
143    #[must_use]
144    pub fn resource_id(mut self, resource_id: Uuid) -> Self {
145        self.resource_id = Some(resource_id);
146        self
147    }
148
149    #[must_use]
150    pub fn action(mut self, action: &str) -> Self {
151        self.action = Some(action.to_owned());
152        self
153    }
154
155    /// Build the permission
156    ///
157    /// # Errors
158    ///
159    /// Returns an error if:
160    /// - `resource_pattern` is not set
161    /// - `action` is not set
162    /// - `action` contains characters other than alphanumeric or underscore
163    pub fn build(self) -> anyhow::Result<Permission> {
164        let resource_pattern = self
165            .resource_pattern
166            .ok_or_else(|| anyhow::anyhow!("resource_pattern is required"))?;
167
168        let action = self
169            .action
170            .ok_or_else(|| anyhow::anyhow!("action is required"))?;
171
172        // Validate action contains only alphanumeric characters and underscores
173        if !action
174            .chars()
175            .all(|c| c.is_ascii_alphanumeric() || c == '_')
176        {
177            return Err(anyhow::anyhow!(
178                "Action must contain only alphanumeric characters and underscores, got: {action}"
179            ));
180        }
181
182        Ok(Permission {
183            tenant_id: self.tenant_id,
184            resource_pattern,
185            resource_id: self.resource_id,
186            action,
187        })
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use uuid::Uuid;
195
196    #[test]
197    fn test_permission_builder_with_tenant_id() {
198        let tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
199        let permission = Permission::builder()
200            .tenant_id(tenant_id)
201            .resource_pattern("gts.x.core.events.topic.v1~vendor.*")
202            .action("publish")
203            .build()
204            .unwrap();
205
206        assert_eq!(permission.tenant_id(), Some(tenant_id));
207        assert_eq!(
208            permission.resource_pattern(),
209            "gts.x.core.events.topic.v1~vendor.*"
210        );
211        assert_eq!(permission.action(), "publish");
212    }
213
214    #[test]
215    fn test_permission_builder_without_tenant_id() {
216        let permission = Permission::builder()
217            .resource_pattern("file_parser")
218            .action("edit")
219            .build()
220            .unwrap();
221
222        assert_eq!(permission.tenant_id(), None);
223        assert_eq!(permission.resource_pattern(), "file_parser");
224        assert_eq!(permission.action(), "edit");
225    }
226
227    #[test]
228    fn test_permission_builder_missing_resource_pattern() {
229        let result = Permission::builder().action("edit").build();
230        assert!(result.is_err());
231        assert!(
232            result
233                .unwrap_err()
234                .to_string()
235                .contains("resource_pattern is required")
236        );
237    }
238
239    #[test]
240    fn test_permission_builder_missing_action() {
241        let result = Permission::builder()
242            .resource_pattern("file_parser")
243            .build();
244        assert!(result.is_err());
245        assert!(
246            result
247                .unwrap_err()
248                .to_string()
249                .contains("action is required")
250        );
251    }
252
253    #[test]
254    fn test_serialize_permission_with_tenant_id() {
255        let tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
256        let permission = Permission::builder()
257            .tenant_id(tenant_id)
258            .resource_pattern("gts.x.core.events.topic.v1~vendor.*")
259            .action("publish")
260            .build()
261            .unwrap();
262
263        let serialized = serde_json::to_string(&permission).unwrap();
264        assert_eq!(
265            serialized,
266            r#""550e8400-e29b-41d4-a716-446655440000:gts.x.core.events.topic.v1~vendor.*:*:publish""#
267        );
268    }
269
270    #[test]
271    fn test_serialize_permission_without_tenant_id() {
272        let permission = Permission::builder()
273            .resource_pattern("file_parser")
274            .action("edit")
275            .build()
276            .unwrap();
277
278        let serialized = serde_json::to_string(&permission).unwrap();
279        assert_eq!(serialized, r#""*:file_parser:*:edit""#);
280    }
281
282    #[test]
283    fn test_deserialize_permission_with_tenant_id() {
284        let json = r#""550e8400-e29b-41d4-a716-446655440000:gts.x.core.events.topic.v1~vendor.*:*:publish""#;
285        let permission: Permission = serde_json::from_str(json).unwrap();
286
287        let expected_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
288        assert_eq!(permission.tenant_id(), Some(expected_id));
289        assert_eq!(
290            permission.resource_pattern(),
291            "gts.x.core.events.topic.v1~vendor.*"
292        );
293        assert_eq!(permission.resource_id(), None);
294        assert_eq!(permission.action(), "publish");
295    }
296
297    #[test]
298    fn test_deserialize_permission_without_tenant_id() {
299        let json = r#""*:file_parser:*:edit""#;
300        let permission: Permission = serde_json::from_str(json).unwrap();
301
302        assert_eq!(permission.tenant_id(), None);
303        assert_eq!(permission.resource_pattern(), "file_parser");
304        assert_eq!(permission.resource_id(), None);
305        assert_eq!(permission.action(), "edit");
306    }
307
308    #[test]
309    fn test_deserialize_permission_invalid_action_with_colons() {
310        let json = r#""*:file_parser:*:action:with:colons""#;
311        let result: Result<Permission, _> = serde_json::from_str(json);
312
313        assert!(result.is_err());
314        let err = result.unwrap_err();
315        assert!(
316            err.to_string()
317                .contains("Action must contain only alphanumeric characters and underscores")
318        );
319    }
320
321    #[test]
322    fn test_deserialize_permission_invalid_action_with_special_chars() {
323        let json = r#""*:file_parser:*:edit-action""#;
324        let result: Result<Permission, _> = serde_json::from_str(json);
325
326        assert!(result.is_err());
327        let err = result.unwrap_err();
328        assert!(
329            err.to_string()
330                .contains("Action must contain only alphanumeric characters and underscores")
331        );
332    }
333
334    #[test]
335    fn test_permission_builder_invalid_action() {
336        let result = Permission::builder()
337            .resource_pattern("file_parser")
338            .action("invalid:action")
339            .build();
340
341        assert!(result.is_err());
342        let err = result.unwrap_err();
343        assert!(
344            err.to_string()
345                .contains("Action must contain only alphanumeric characters and underscores")
346        );
347    }
348
349    #[test]
350    fn test_deserialize_permission_invalid_format_missing_parts() {
351        let json = r#""invalid:format""#;
352        let result: Result<Permission, _> = serde_json::from_str(json);
353
354        assert!(result.is_err());
355        let err = result.unwrap_err();
356        assert!(
357            err.to_string()
358                .contains("Expected format 'tenant_id:resource_pattern:resource_id:action'")
359        );
360    }
361
362    #[test]
363    fn test_deserialize_permission_invalid_uuid() {
364        let json = r#""not-a-uuid:file_parser:edit""#;
365        let result: Result<Permission, _> = serde_json::from_str(json);
366
367        assert!(result.is_err());
368    }
369
370    #[test]
371    fn test_serialize_deserialize_roundtrip_with_tenant_id() {
372        let tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
373        let original = Permission::builder()
374            .tenant_id(tenant_id)
375            .resource_pattern("gts.x.core.events.type.v1~")
376            .action("edit")
377            .build()
378            .unwrap();
379
380        let serialized = serde_json::to_string(&original).unwrap();
381        let deserialized: Permission = serde_json::from_str(&serialized).unwrap();
382
383        assert_eq!(deserialized.tenant_id(), original.tenant_id());
384        assert_eq!(deserialized.resource_pattern(), original.resource_pattern());
385        assert_eq!(deserialized.action(), original.action());
386    }
387
388    #[test]
389    fn test_serialize_deserialize_roundtrip_without_tenant_id() {
390        let original = Permission::builder()
391            .resource_pattern("gts.x.core.events.topic.v1~*")
392            .action("subscribe")
393            .build()
394            .unwrap();
395
396        let serialized = serde_json::to_string(&original).unwrap();
397        let deserialized: Permission = serde_json::from_str(&serialized).unwrap();
398
399        assert_eq!(deserialized.tenant_id(), original.tenant_id());
400        assert_eq!(deserialized.resource_pattern(), original.resource_pattern());
401        assert_eq!(deserialized.action(), original.action());
402    }
403
404    #[test]
405    fn test_serialize_list_of_permissions() {
406        let tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
407        let permissions = vec![
408            Permission::builder()
409                .tenant_id(tenant_id)
410                .resource_pattern("gts.x.core.events.topic.v1~vendor.*")
411                .action("publish")
412                .build()
413                .unwrap(),
414            Permission::builder()
415                .resource_pattern("file_parser")
416                .action("edit")
417                .build()
418                .unwrap(),
419        ];
420
421        let serialized = serde_json::to_string(&permissions).unwrap();
422        let deserialized: Vec<Permission> = serde_json::from_str(&serialized).unwrap();
423
424        assert_eq!(deserialized.len(), 2);
425        assert_eq!(deserialized[0].tenant_id(), Some(tenant_id));
426        assert_eq!(
427            deserialized[0].resource_pattern(),
428            "gts.x.core.events.topic.v1~vendor.*"
429        );
430        assert_eq!(deserialized[0].action(), "publish");
431        assert_eq!(deserialized[1].tenant_id(), None);
432        assert_eq!(deserialized[1].resource_pattern(), "file_parser");
433        assert_eq!(deserialized[1].action(), "edit");
434    }
435
436    #[test]
437    fn test_permission_builder_with_resource_id() {
438        let tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
439        let resource_id = Uuid::parse_str("660e8400-e29b-41d4-a716-446655440002").unwrap();
440
441        let permission = Permission::builder()
442            .tenant_id(tenant_id)
443            .resource_pattern("gts.x.core.events.type.v1~")
444            .resource_id(resource_id)
445            .action("edit")
446            .build()
447            .unwrap();
448
449        assert_eq!(permission.tenant_id(), Some(tenant_id));
450        assert_eq!(permission.resource_pattern(), "gts.x.core.events.type.v1~");
451        assert_eq!(permission.resource_id(), Some(resource_id));
452        assert_eq!(permission.action(), "edit");
453    }
454
455    #[test]
456    fn test_serialize_permission_with_resource_id() {
457        let tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
458        let resource_id = Uuid::parse_str("660e8400-e29b-41d4-a716-446655440002").unwrap();
459
460        let permission = Permission::builder()
461            .tenant_id(tenant_id)
462            .resource_pattern("gts.x.core.events.type.v1~")
463            .resource_id(resource_id)
464            .action("edit")
465            .build()
466            .unwrap();
467
468        let serialized = serde_json::to_string(&permission).unwrap();
469        assert_eq!(
470            serialized,
471            r#""550e8400-e29b-41d4-a716-446655440000:gts.x.core.events.type.v1~:660e8400-e29b-41d4-a716-446655440002:edit""#
472        );
473    }
474
475    #[test]
476    fn test_deserialize_permission_with_resource_id() {
477        let json = r#""550e8400-e29b-41d4-a716-446655440000:gts.x.core.events.type.v1~:660e8400-e29b-41d4-a716-446655440002:edit""#;
478        let permission: Permission = serde_json::from_str(json).unwrap();
479
480        let expected_tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
481        let expected_resource_id = Uuid::parse_str("660e8400-e29b-41d4-a716-446655440002").unwrap();
482
483        assert_eq!(permission.tenant_id(), Some(expected_tenant_id));
484        assert_eq!(permission.resource_pattern(), "gts.x.core.events.type.v1~");
485        assert_eq!(permission.resource_id(), Some(expected_resource_id));
486        assert_eq!(permission.action(), "edit");
487    }
488
489    #[test]
490    fn test_serialize_deserialize_roundtrip_with_resource_id() {
491        let tenant_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
492        let resource_id = Uuid::parse_str("660e8400-e29b-41d4-a716-446655440002").unwrap();
493
494        let original = Permission::builder()
495            .tenant_id(tenant_id)
496            .resource_pattern("gts.x.core.events.type.v1~")
497            .resource_id(resource_id)
498            .action("edit")
499            .build()
500            .unwrap();
501
502        let serialized = serde_json::to_string(&original).unwrap();
503        let deserialized: Permission = serde_json::from_str(&serialized).unwrap();
504
505        assert_eq!(deserialized.tenant_id(), original.tenant_id());
506        assert_eq!(deserialized.resource_pattern(), original.resource_pattern());
507        assert_eq!(deserialized.resource_id(), original.resource_id());
508        assert_eq!(deserialized.action(), original.action());
509    }
510
511    #[test]
512    fn test_permission_with_wildcard_tenant_and_specific_resource() {
513        let resource_id = Uuid::parse_str("660e8400-e29b-41d4-a716-446655440002").unwrap();
514
515        let permission = Permission::builder()
516            .resource_pattern("gts.x.core.events.topic.v1~vendor.*")
517            .resource_id(resource_id)
518            .action("publish")
519            .build()
520            .unwrap();
521
522        assert_eq!(permission.tenant_id(), None);
523        assert_eq!(permission.resource_id(), Some(resource_id));
524
525        let serialized = serde_json::to_string(&permission).unwrap();
526        assert_eq!(
527            serialized,
528            r#""*:gts.x.core.events.topic.v1~vendor.*:660e8400-e29b-41d4-a716-446655440002:publish""#
529        );
530    }
531}