role_system/
resource.rs

1//! Resource definitions for access control.
2
3use crate::error::Error;
4use std::collections::HashMap;
5
6/// A resource represents something that can be accessed or acted upon.
7#[derive(Debug, Clone, PartialEq, Eq)]
8#[cfg_attr(feature = "persistence", derive(serde::Serialize, serde::Deserialize))]
9pub struct Resource {
10    /// Unique identifier for the resource.
11    id: String,
12    /// Type of resource (e.g., "document", "user", "project").
13    resource_type: String,
14    /// Optional name for the resource.
15    name: Option<String>,
16    /// Additional attributes for the resource.
17    attributes: HashMap<String, String>,
18    /// Hierarchical path for the resource (e.g., "/projects/web-app/documents/readme.md").
19    path: Option<String>,
20}
21
22impl Resource {
23    /// Create a new resource with validation.
24    ///
25    /// # Errors
26    ///
27    /// Returns a `ValidationError` if the ID or resource type contains path traversal
28    /// sequences or null characters.
29    pub fn new_checked(
30        id: impl Into<String>,
31        resource_type: impl Into<String>,
32    ) -> Result<Self, Error> {
33        let id = id.into();
34        let resource_type = resource_type.into();
35
36        // Validate resource ID for path traversal attempts
37        if id.contains("..") || id.contains('\0') {
38            return Err(Error::ValidationError {
39                field: "id".to_string(),
40                reason: "Resource ID cannot contain path traversal sequences or null characters"
41                    .to_string(),
42                invalid_value: Some(id),
43            });
44        }
45
46        // Validate resource type for path traversal attempts
47        if resource_type.contains("..") || resource_type.contains('\0') {
48            return Err(Error::ValidationError {
49                field: "resource_type".to_string(),
50                reason: "Resource type cannot contain path traversal sequences or null characters"
51                    .to_string(),
52                invalid_value: Some(resource_type),
53            });
54        }
55
56        Ok(Self {
57            id,
58            resource_type,
59            name: None,
60            attributes: HashMap::new(),
61            path: None,
62        })
63    }
64
65    /// Create a new resource.
66    ///
67    /// # Panics
68    ///
69    /// This method panics if the ID or resource type contains path traversal
70    /// sequences or null characters. For non-panicking validation, use `new_checked`.
71    pub fn new(id: impl Into<String>, resource_type: impl Into<String>) -> Self {
72        match Self::new_checked(id, resource_type) {
73            Ok(resource) => resource,
74            Err(e) => panic!("Resource validation failed: {}", e),
75        }
76    }
77
78    /// Get the resource's unique identifier.
79    pub fn id(&self) -> &str {
80        &self.id
81    }
82
83    /// Get the resource type.
84    pub fn resource_type(&self) -> &str {
85        &self.resource_type
86    }
87
88    /// Set the resource name.
89    pub fn with_name(mut self, name: impl Into<String>) -> Self {
90        self.name = Some(name.into());
91        self
92    }
93
94    /// Get the resource name.
95    pub fn name(&self) -> Option<&str> {
96        self.name.as_deref()
97    }
98
99    /// Set the resource name.
100    pub fn set_name(&mut self, name: impl Into<String>) {
101        self.name = Some(name.into());
102    }
103
104    /// Set the resource path.
105    pub fn with_path(mut self, path: impl Into<String>) -> Self {
106        self.path = Some(path.into());
107        self
108    }
109
110    /// Get the resource path.
111    pub fn path(&self) -> Option<&str> {
112        self.path.as_deref()
113    }
114
115    /// Set the resource path.
116    pub fn set_path(&mut self, path: impl Into<String>) {
117        self.path = Some(path.into());
118    }
119
120    /// Add an attribute to the resource.
121    pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
122        self.attributes.insert(key.into(), value.into());
123        self
124    }
125
126    /// Set an attribute on the resource.
127    pub fn set_attribute(&mut self, key: impl Into<String>, value: impl Into<String>) {
128        self.attributes.insert(key.into(), value.into());
129    }
130
131    /// Get an attribute value.
132    pub fn attribute(&self, key: &str) -> Option<&str> {
133        self.attributes.get(key).map(|s| s.as_str())
134    }
135
136    /// Get all attributes.
137    pub fn attributes(&self) -> &HashMap<String, String> {
138        &self.attributes
139    }
140
141    /// Remove an attribute.
142    pub fn remove_attribute(&mut self, key: &str) -> Option<String> {
143        self.attributes.remove(key)
144    }
145
146    /// Check if the resource has a specific attribute.
147    pub fn has_attribute(&self, key: &str) -> bool {
148        self.attributes.contains_key(key)
149    }
150
151    /// Get the effective name for display purposes.
152    pub fn effective_name(&self) -> &str {
153        self.name.as_deref().unwrap_or(&self.id)
154    }
155
156    /// Check if this resource matches a pattern.
157    /// Patterns can include wildcards (*) and hierarchical matching.
158    pub fn matches_pattern(&self, pattern: &str) -> bool {
159        if pattern == "*" {
160            return true;
161        }
162
163        // Exact match
164        if pattern == self.id || pattern == self.resource_type {
165            return true;
166        }
167
168        // Type wildcard (e.g., "documents/*")
169        if let Some(type_prefix) = pattern.strip_suffix("/*")
170            && self.resource_type == type_prefix
171        {
172            return true;
173        }
174
175        // Path matching
176        if let Some(resource_path) = &self.path
177            && self.matches_path_pattern(resource_path, pattern)
178        {
179            return true;
180        }
181
182        false
183    }
184
185    /// Check if the resource is within a specific parent path.
186    pub fn is_under_path(&self, parent_path: &str) -> bool {
187        if let Some(resource_path) = &self.path {
188            resource_path.starts_with(parent_path)
189        } else {
190            false
191        }
192    }
193
194    /// Get the parent path of this resource.
195    pub fn parent_path(&self) -> Option<String> {
196        self.path
197            .as_ref()
198            .and_then(|p| p.rfind('/').map(|i| p[..i].to_string()))
199    }
200
201    fn matches_path_pattern(&self, path: &str, pattern: &str) -> bool {
202        // Simple glob-style matching
203        if pattern.contains('*') {
204            let parts: Vec<&str> = pattern.split('*').collect();
205            if parts.len() == 2 {
206                let prefix = parts[0];
207                let suffix = parts[1];
208                return path.starts_with(prefix) && path.ends_with(suffix);
209            }
210        }
211
212        path == pattern
213    }
214}
215
216impl std::fmt::Display for Resource {
217    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        match (&self.name, &self.path) {
219            (Some(name), Some(path)) => write!(
220                f,
221                "{} ({}:{} at {})",
222                name, self.resource_type, self.id, path
223            ),
224            (Some(name), None) => write!(f, "{} ({}:{})", name, self.resource_type, self.id),
225            (None, Some(path)) => write!(f, "{}:{} at {}", self.resource_type, self.id, path),
226            (None, None) => write!(f, "{}:{}", self.resource_type, self.id),
227        }
228    }
229}
230
231/// Builder for creating resources with a fluent API.
232#[derive(Debug, Default)]
233pub struct ResourceBuilder {
234    id: Option<String>,
235    resource_type: Option<String>,
236    name: Option<String>,
237    path: Option<String>,
238    attributes: HashMap<String, String>,
239}
240
241impl ResourceBuilder {
242    /// Create a new resource builder.
243    pub fn new() -> Self {
244        Self::default()
245    }
246
247    /// Set the resource ID.
248    pub fn id(mut self, id: impl Into<String>) -> Self {
249        self.id = Some(id.into());
250        self
251    }
252
253    /// Set the resource type.
254    pub fn resource_type(mut self, resource_type: impl Into<String>) -> Self {
255        self.resource_type = Some(resource_type.into());
256        self
257    }
258
259    /// Set the resource name.
260    pub fn name(mut self, name: impl Into<String>) -> Self {
261        self.name = Some(name.into());
262        self
263    }
264
265    /// Set the resource path.
266    pub fn path(mut self, path: impl Into<String>) -> Self {
267        self.path = Some(path.into());
268        self
269    }
270
271    /// Add an attribute.
272    pub fn attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
273        self.attributes.insert(key.into(), value.into());
274        self
275    }
276
277    /// Build the resource.
278    pub fn build(self) -> Result<Resource, String> {
279        let id = self.id.ok_or("Resource ID is required")?;
280        let resource_type = self.resource_type.ok_or("Resource type is required")?;
281
282        let mut resource = Resource::new(id, resource_type);
283
284        if let Some(name) = self.name {
285            resource = resource.with_name(name);
286        }
287
288        if let Some(path) = self.path {
289            resource = resource.with_path(path);
290        }
291
292        for (key, value) in self.attributes {
293            resource = resource.with_attribute(key, value);
294        }
295
296        Ok(resource)
297    }
298}
299
300/// Common resource types for convenience.
301pub mod types {
302    use super::Resource;
303
304    /// Create a document resource.
305    pub fn document(id: impl Into<String>) -> Resource {
306        Resource::new(id, "document")
307    }
308
309    /// Create a user resource.
310    pub fn user(id: impl Into<String>) -> Resource {
311        Resource::new(id, "user")
312    }
313
314    /// Create a project resource.
315    pub fn project(id: impl Into<String>) -> Resource {
316        Resource::new(id, "project")
317    }
318
319    /// Create a file resource.
320    pub fn file(id: impl Into<String>) -> Resource {
321        Resource::new(id, "file")
322    }
323
324    /// Create a database resource.
325    pub fn database(id: impl Into<String>) -> Resource {
326        Resource::new(id, "database")
327    }
328
329    /// Create an API endpoint resource.
330    pub fn api_endpoint(id: impl Into<String>) -> Resource {
331        Resource::new(id, "api_endpoint")
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::types::*;
338    use super::*;
339
340    #[test]
341    fn test_resource_creation() {
342        let resource = Resource::new("doc123", "document")
343            .with_name("My Document")
344            .with_path("/projects/web-app/docs/readme.md")
345            .with_attribute("owner", "john@example.com")
346            .with_attribute("created", "2024-01-01");
347
348        assert_eq!(resource.id(), "doc123");
349        assert_eq!(resource.resource_type(), "document");
350        assert_eq!(resource.name(), Some("My Document"));
351        assert_eq!(resource.path(), Some("/projects/web-app/docs/readme.md"));
352        assert_eq!(resource.attribute("owner"), Some("john@example.com"));
353        assert_eq!(resource.effective_name(), "My Document");
354    }
355
356    #[test]
357    fn test_resource_pattern_matching() {
358        let resource =
359            Resource::new("doc1", "document").with_path("/projects/web-app/docs/readme.md");
360
361        assert!(resource.matches_pattern("*"));
362        assert!(resource.matches_pattern("doc1"));
363        assert!(resource.matches_pattern("document"));
364        assert!(resource.matches_pattern("document/*"));
365        assert!(!resource.matches_pattern("user"));
366        assert!(!resource.matches_pattern("users/*"));
367    }
368
369    #[test]
370    fn test_resource_path_operations() {
371        let resource =
372            Resource::new("doc1", "document").with_path("/projects/web-app/docs/readme.md");
373
374        assert!(resource.is_under_path("/projects"));
375        assert!(resource.is_under_path("/projects/web-app"));
376        assert!(!resource.is_under_path("/other"));
377
378        assert_eq!(
379            resource.parent_path(),
380            Some("/projects/web-app/docs".to_string())
381        );
382    }
383
384    #[test]
385    fn test_resource_builder() {
386        let resource = ResourceBuilder::new()
387            .id("test-id")
388            .resource_type("test-type")
389            .name("Test Resource")
390            .path("/test/path")
391            .attribute("key", "value")
392            .build()
393            .unwrap();
394
395        assert_eq!(resource.id(), "test-id");
396        assert_eq!(resource.resource_type(), "test-type");
397        assert_eq!(resource.name(), Some("Test Resource"));
398        assert_eq!(resource.path(), Some("/test/path"));
399        assert_eq!(resource.attribute("key"), Some("value"));
400    }
401
402    #[test]
403    fn test_common_resource_types() {
404        let doc = document("doc1");
405        let user_res = user("user1");
406        let proj = project("proj1");
407
408        assert_eq!(doc.resource_type(), "document");
409        assert_eq!(user_res.resource_type(), "user");
410        assert_eq!(proj.resource_type(), "project");
411    }
412
413    #[test]
414    fn test_resource_effective_name() {
415        let named_resource = Resource::new("r1", "type").with_name("Named Resource");
416        let unnamed_resource = Resource::new("r2", "type");
417
418        assert_eq!(named_resource.effective_name(), "Named Resource");
419        assert_eq!(unnamed_resource.effective_name(), "r2");
420    }
421}