role_system/
error.rs

1//! Error types for the role system.
2
3use std::collections::HashMap;
4use thiserror::Error;
5
6/// Recovery suggestion for handling errors.
7#[derive(Debug, Clone)]
8pub struct RecoverySuggestion {
9    pub message: String,
10    pub suggested_actions: Vec<String>,
11    pub documentation_link: Option<String>,
12}
13
14impl RecoverySuggestion {
15    pub fn new(message: impl Into<String>) -> Self {
16        Self {
17            message: message.into(),
18            suggested_actions: Vec::new(),
19            documentation_link: None,
20        }
21    }
22
23    pub fn with_action(mut self, action: impl Into<String>) -> Self {
24        self.suggested_actions.push(action.into());
25        self
26    }
27
28    pub fn with_documentation(mut self, link: impl Into<String>) -> Self {
29        self.documentation_link = Some(link.into());
30        self
31    }
32}
33
34/// Details for permission denied errors.
35#[derive(Debug, Clone)]
36pub struct PermissionDeniedDetails {
37    pub action: String,
38    pub resource: String,
39    pub subject: String,
40    pub required_permissions: Vec<String>,
41    pub suggested_roles: Vec<String>,
42    pub recovery: Option<RecoverySuggestion>,
43}
44
45impl std::fmt::Display for PermissionDeniedDetails {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        write!(
48            f,
49            "Permission denied: {} on {} for {}",
50            self.action, self.resource, self.subject
51        )
52    }
53}
54
55/// The main error type for role system operations.
56#[derive(Error, Debug, Clone)]
57pub enum Error {
58    /// Role with the given name already exists.
59    #[error("Role '{0}' already exists")]
60    RoleAlreadyExists(String),
61
62    /// Role with the given name was not found.
63    #[error("Role '{0}' not found")]
64    RoleNotFound(String),
65
66    /// Subject with the given ID was not found.
67    #[error("Subject '{0}' not found")]
68    SubjectNotFound(String),
69
70    /// Permission was denied for the requested operation.
71    #[error("{0}")]
72    PermissionDenied(Box<PermissionDeniedDetails>),
73
74    /// Circular dependency detected in role hierarchy.
75    #[error("Circular dependency detected in role hierarchy involving '{0}'")]
76    CircularDependency(String),
77
78    /// Invalid permission format.
79    #[error("Invalid permission format: {0}")]
80    InvalidPermission(String),
81
82    /// Invalid resource format.
83    #[error("Invalid resource format: {0}")]
84    InvalidResource(String),
85
86    /// Role elevation has expired.
87    #[error("Role elevation for subject '{0}' has expired")]
88    ElevationExpired(String),
89
90    /// Maximum role hierarchy depth exceeded.
91    #[error("Maximum role hierarchy depth exceeded (max: {0})")]
92    MaxDepthExceeded(usize),
93
94    /// Serialization error.
95    #[cfg(feature = "persistence")]
96    #[error("Serialization error: {0}")]
97    Serialization(#[from] serde_json::Error),
98
99    /// Storage operation failed.
100    #[error("Storage operation failed: {0}")]
101    Storage(String),
102
103    /// Invalid configuration.
104    #[error("Invalid configuration: {0}")]
105    InvalidConfiguration(String),
106
107    /// Enhanced role operation error with context.
108    #[error("Role operation failed: {operation} on role '{role}' - {reason}")]
109    RoleOperationFailed {
110        operation: String,
111        role: String,
112        reason: String,
113    },
114
115    /// Permission operation error with detailed context.
116    #[error(
117        "Permission operation failed: {operation} for subject '{subject}' on resource '{resource}' - {reason}"
118    )]
119    PermissionOperationFailed {
120        operation: String,
121        subject: String,
122        resource: String,
123        reason: String,
124        context: Box<HashMap<String, String>>,
125    },
126
127    /// Validation error with field-specific information.
128    #[error("Validation failed for field '{field}': {reason}")]
129    ValidationError {
130        field: String,
131        reason: String,
132        invalid_value: Option<String>,
133    },
134
135    /// Rate limiting error.
136    #[error("Rate limit exceeded for subject '{subject}': {limit} operations per {window}")]
137    RateLimitExceeded {
138        subject: String,
139        limit: u64,
140        window: String,
141    },
142
143    /// Concurrency conflict error.
144    #[error("Concurrency conflict: {operation} failed due to concurrent modification")]
145    ConcurrencyConflict {
146        operation: String,
147        resource_id: String,
148    },
149
150    /// Authentication error.
151    #[error("Authentication failed: {reason}")]
152    AuthenticationFailed {
153        reason: String,
154        subject_id: Option<String>,
155    },
156
157    /// Authorization error with detailed context.
158    #[error(
159        "Authorization failed: subject '{subject}' lacks permission '{permission}' for resource '{resource}'"
160    )]
161    AuthorizationFailed {
162        subject: String,
163        permission: String,
164        resource: String,
165        required_roles: Vec<String>,
166    },
167}
168
169/// Result type alias for role system operations.
170pub type Result<T> = std::result::Result<T, Error>;
171
172impl Error {
173    /// Validates an identifier (role name, subject ID, etc.) for security.
174    pub fn validate_identifier(value: &str, field_name: &str) -> Result<()> {
175        if value.is_empty() {
176            return Err(Error::ValidationError {
177                field: field_name.to_string(),
178                reason: "cannot be empty".to_string(),
179                invalid_value: Some(value.to_string()),
180            });
181        }
182
183        if value.len() > 256 {
184            return Err(Error::ValidationError {
185                field: field_name.to_string(),
186                reason: "too long (maximum 256 characters)".to_string(),
187                invalid_value: Some(value.to_string()),
188            });
189        }
190
191        // Check for dangerous characters that could indicate injection attacks
192        let dangerous_chars = [';', '\'', '"', '\\', '\0', '\n', '\r'];
193        let dangerous_sequences = ["--", "/*", "*/", "<", ">", "{", "}", "[", "]"];
194
195        for &ch in &dangerous_chars {
196            if value.contains(ch) {
197                return Err(Error::ValidationError {
198                    field: field_name.to_string(),
199                    reason: "contains invalid characters".to_string(),
200                    invalid_value: Some(value.to_string()),
201                });
202            }
203        }
204
205        for &seq in &dangerous_sequences {
206            if value.contains(seq) {
207                return Err(Error::ValidationError {
208                    field: field_name.to_string(),
209                    reason: "contains invalid characters".to_string(),
210                    invalid_value: Some(value.to_string()),
211                });
212            }
213        }
214
215        // Check for potential path traversal
216        if value.contains("..") {
217            return Err(Error::ValidationError {
218                field: field_name.to_string(),
219                reason: "potential path traversal detected".to_string(),
220                invalid_value: Some(value.to_string()),
221            });
222        }
223
224        Ok(())
225    }
226
227    /// Validates a resource path for security.
228    pub fn validate_resource_path(path: &str) -> Result<()> {
229        // Empty path is allowed (means "any resource")
230        if path.is_empty() {
231            return Ok(());
232        }
233
234        // Must start with /
235        if !path.starts_with('/') {
236            return Err(Error::ValidationError {
237                field: "resource_path".to_string(),
238                reason: "must start with '/' or be empty".to_string(),
239                invalid_value: Some(path.to_string()),
240            });
241        }
242
243        // Check for path traversal attempts
244        if path.contains("../") || path.contains("..\\") {
245            return Err(Error::ValidationError {
246                field: "resource_path".to_string(),
247                reason: "path traversal detected".to_string(),
248                invalid_value: Some(path.to_string()),
249            });
250        }
251
252        // Check for null bytes
253        if path.contains('\0') {
254            return Err(Error::ValidationError {
255                field: "resource_path".to_string(),
256                reason: "null byte detected".to_string(),
257                invalid_value: Some(path.to_string()),
258            });
259        }
260
261        Ok(())
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_recovery_suggestion_creation() {
271        let suggestion = RecoverySuggestion::new("Permission denied")
272            .with_action("Assign the 'admin' role to the user")
273            .with_action("Check if the resource exists")
274            .with_documentation("https://docs.example.com/permissions");
275
276        assert_eq!(suggestion.message, "Permission denied");
277        assert_eq!(suggestion.suggested_actions.len(), 2);
278        assert_eq!(
279            suggestion.suggested_actions[0],
280            "Assign the 'admin' role to the user"
281        );
282        assert_eq!(
283            suggestion.suggested_actions[1],
284            "Check if the resource exists"
285        );
286        assert_eq!(
287            suggestion.documentation_link,
288            Some("https://docs.example.com/permissions".to_string())
289        );
290    }
291
292    #[test]
293    fn test_permission_denied_details_display() {
294        let details = PermissionDeniedDetails {
295            action: "delete".to_string(),
296            resource: "document.txt".to_string(),
297            subject: "alice".to_string(),
298            required_permissions: vec!["delete:documents".to_string()],
299            suggested_roles: vec!["admin".to_string(), "editor".to_string()],
300            recovery: Some(RecoverySuggestion::new("Assign appropriate role")),
301        };
302
303        let display = format!("{}", details);
304        assert!(display.contains("Permission denied"));
305        assert!(display.contains("delete"));
306        assert!(display.contains("document.txt"));
307        assert!(display.contains("alice"));
308    }
309
310    #[test]
311    fn test_permission_denied_error_creation() {
312        let details = PermissionDeniedDetails {
313            action: "read".to_string(),
314            resource: "secret.txt".to_string(),
315            subject: "bob".to_string(),
316            required_permissions: vec!["read:secrets".to_string()],
317            suggested_roles: vec!["security_admin".to_string()],
318            recovery: Some(
319                RecoverySuggestion::new("User needs security clearance")
320                    .with_action("Contact security administrator")
321                    .with_documentation("https://docs.example.com/security"),
322            ),
323        };
324
325        let error = Error::PermissionDenied(Box::new(details));
326
327        match error {
328            Error::PermissionDenied(d) => {
329                assert_eq!(d.action, "read");
330                assert_eq!(d.resource, "secret.txt");
331                assert_eq!(d.subject, "bob");
332                assert!(d.recovery.is_some());
333                assert_eq!(
334                    d.recovery.unwrap().suggested_actions[0],
335                    "Contact security administrator"
336                );
337            }
338            _ => panic!("Expected PermissionDenied error"),
339        }
340    }
341
342    #[test]
343    fn test_validation_error_formatting() {
344        let error = Error::ValidationError {
345            field: "username".to_string(),
346            reason: "contains invalid characters".to_string(),
347            invalid_value: Some("user@name!".to_string()),
348        };
349
350        let error_string = format!("{}", error);
351        assert!(error_string.contains("Validation failed"));
352        assert!(error_string.contains("username"));
353        assert!(error_string.contains("invalid characters"));
354    }
355
356    #[test]
357    fn test_security_validation_basic() {
358        // Valid inputs should pass
359        assert!(Error::validate_identifier("valid_user", "username").is_ok());
360        assert!(Error::validate_identifier("role123", "role").is_ok());
361        assert!(Error::validate_identifier("resource_name", "resource").is_ok());
362    }
363
364    #[test]
365    fn test_security_validation_empty_input() {
366        let result = Error::validate_identifier("", "field");
367        assert!(result.is_err());
368        match result.unwrap_err() {
369            Error::ValidationError { field, reason, .. } => {
370                assert_eq!(field, "field");
371                assert!(reason.contains("cannot be empty"));
372            }
373            _ => panic!("Expected ValidationError"),
374        }
375    }
376
377    #[test]
378    fn test_security_validation_invalid_characters() {
379        let test_cases = vec![
380            "user;name",    // semicolon
381            "user'name",    // single quote
382            "user\"name",   // double quote
383            "user--name",   // double dash
384            "user/*name",   // comment sequence
385            "user<script>", // HTML/script tag
386            "user{name}",   // braces
387            "user[name]",   // brackets
388            "user\\name",   // backslash
389        ];
390
391        for test_case in test_cases {
392            let result = Error::validate_identifier(test_case, "field");
393            assert!(result.is_err(), "Should reject: {}", test_case);
394            match result.unwrap_err() {
395                Error::ValidationError { reason, .. } => {
396                    assert!(reason.contains("invalid characters"));
397                }
398                _ => panic!("Expected ValidationError for: {}", test_case),
399            }
400        }
401    }
402
403    #[test]
404    fn test_resource_path_validation_valid() {
405        assert!(Error::validate_resource_path("").is_ok()); // Empty is allowed
406        assert!(Error::validate_resource_path("/documents").is_ok());
407        assert!(Error::validate_resource_path("/documents/file.txt").is_ok());
408        assert!(Error::validate_resource_path("/api/v1/users").is_ok());
409    }
410
411    #[test]
412    fn test_comprehensive_error_scenarios() {
413        // Test all error variants can be created and formatted
414        let errors = vec![
415            Error::RoleNotFound("admin".to_string()),
416            Error::RoleAlreadyExists("user".to_string()),
417            Error::SubjectNotFound("alice".to_string()),
418            Error::CircularDependency("role cycle detected".to_string()),
419            Error::ValidationError {
420                field: "username".to_string(),
421                reason: "invalid format".to_string(),
422                invalid_value: Some("test@user".to_string()),
423            },
424            Error::Storage("connection failed".to_string()),
425            Error::InvalidConfiguration("missing config".to_string()),
426            Error::RateLimitExceeded {
427                subject: "user123".to_string(),
428                limit: 100,
429                window: "1 minute".to_string(),
430            },
431            Error::ConcurrencyConflict {
432                operation: "role_assignment".to_string(),
433                resource_id: "role_123".to_string(),
434            },
435        ];
436
437        for error in errors {
438            // Ensure all errors can be formatted
439            let error_string = format!("{}", error);
440            assert!(!error_string.is_empty());
441
442            // Ensure all errors can be debugged
443            let debug_string = format!("{:?}", error);
444            assert!(!debug_string.is_empty());
445        }
446    }
447
448    #[test]
449    fn test_enhanced_error_context_integration() {
450        // Test creating a complex permission denied error with full context
451        let recovery = RecoverySuggestion::new("User needs additional permissions")
452            .with_action("Assign the 'documents_admin' role")
453            .with_action("Verify the document exists")
454            .with_action("Check if the user's access has expired")
455            .with_documentation("https://docs.company.com/rbac/troubleshooting");
456
457        let details = PermissionDeniedDetails {
458            action: "delete".to_string(),
459            resource: "/documents/confidential/report.pdf".to_string(),
460            subject: "employee_123".to_string(),
461            required_permissions: vec![
462                "delete:documents".to_string(),
463                "access:confidential".to_string(),
464            ],
465            suggested_roles: vec![
466                "documents_admin".to_string(),
467                "confidential_access".to_string(),
468            ],
469            recovery: Some(recovery),
470        };
471
472        let error = Error::PermissionDenied(Box::new(details));
473
474        // Verify all components are present
475        match &error {
476            Error::PermissionDenied(d) => {
477                assert_eq!(d.action, "delete");
478                assert_eq!(d.resource, "/documents/confidential/report.pdf");
479                assert_eq!(d.subject, "employee_123");
480                assert_eq!(d.required_permissions.len(), 2);
481                assert_eq!(d.suggested_roles.len(), 2);
482                assert!(d.recovery.is_some());
483
484                let recovery = d.recovery.as_ref().unwrap();
485                assert_eq!(recovery.suggested_actions.len(), 3);
486                assert!(recovery.documentation_link.is_some());
487            }
488            _ => panic!("Expected PermissionDenied"),
489        }
490
491        // Verify error message is comprehensive
492        let error_message = format!("{}", error);
493        assert!(error_message.contains("Permission denied"));
494        assert!(error_message.contains("delete"));
495        assert!(error_message.contains("confidential"));
496    }
497
498    #[test]
499    fn test_role_operation_failed_error() {
500        let error = Error::RoleOperationFailed {
501            operation: "assign".to_string(),
502            role: "admin".to_string(),
503            reason: "circular dependency detected".to_string(),
504        };
505
506        let error_string = format!("{}", error);
507        assert!(error_string.contains("Role operation failed"));
508        assert!(error_string.contains("assign"));
509        assert!(error_string.contains("admin"));
510        assert!(error_string.contains("circular dependency"));
511    }
512
513    #[test]
514    fn test_permission_operation_failed_error() {
515        let mut context = HashMap::new();
516        context.insert("user_group".to_string(), "employees".to_string());
517        context.insert("resource_owner".to_string(), "security_team".to_string());
518
519        let error = Error::PermissionOperationFailed {
520            operation: "check".to_string(),
521            subject: "alice".to_string(),
522            resource: "classified_document".to_string(),
523            reason: "insufficient clearance level".to_string(),
524            context: Box::new(context),
525        };
526
527        let error_string = format!("{}", error);
528        assert!(error_string.contains("Permission operation failed"));
529        assert!(error_string.contains("check"));
530        assert!(error_string.contains("alice"));
531        assert!(error_string.contains("classified_document"));
532        assert!(error_string.contains("insufficient clearance"));
533    }
534
535    #[test]
536    fn test_rate_limit_exceeded_error() {
537        let error = Error::RateLimitExceeded {
538            subject: "user123".to_string(),
539            limit: 100,
540            window: "1 minute".to_string(),
541        };
542
543        let error_string = format!("{}", error);
544        assert!(error_string.contains("Rate limit exceeded"));
545        assert!(error_string.contains("user123"));
546        assert!(error_string.contains("100"));
547        assert!(error_string.contains("1 minute"));
548    }
549
550    #[test]
551    fn test_concurrency_conflict_error() {
552        let error = Error::ConcurrencyConflict {
553            operation: "role_assignment".to_string(),
554            resource_id: "role_123".to_string(),
555        };
556
557        let error_string = format!("{}", error);
558        assert!(error_string.contains("Concurrency conflict"));
559        assert!(error_string.contains("role_assignment"));
560        assert!(error_string.contains("concurrent modification"));
561    }
562
563    #[test]
564    fn test_authentication_failed_error() {
565        let error = Error::AuthenticationFailed {
566            reason: "invalid credentials".to_string(),
567            subject_id: Some("user123".to_string()),
568        };
569
570        let error_string = format!("{}", error);
571        assert!(error_string.contains("Authentication failed"));
572        assert!(error_string.contains("invalid credentials"));
573    }
574
575    #[test]
576    fn test_authorization_failed_error() {
577        let error = Error::AuthorizationFailed {
578            subject: "alice".to_string(),
579            permission: "delete:documents".to_string(),
580            resource: "confidential.txt".to_string(),
581            required_roles: vec!["admin".to_string(), "editor".to_string()],
582        };
583
584        let error_string = format!("{}", error);
585        assert!(error_string.contains("Authorization failed"));
586        assert!(error_string.contains("alice"));
587        assert!(error_string.contains("delete:documents"));
588        assert!(error_string.contains("confidential.txt"));
589    }
590}