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(String),
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
172/// Conversion from serde_json::Error to our Serialization error variant.
173#[cfg(feature = "persistence")]
174impl From<serde_json::Error> for Error {
175    fn from(err: serde_json::Error) -> Self {
176        Self::Serialization(err.to_string())
177    }
178}
179
180impl Error {
181    /// Validates an identifier (role name, subject ID, etc.) for security.
182    pub fn validate_identifier(value: &str, field_name: &str) -> Result<()> {
183        if value.is_empty() {
184            return Err(Error::ValidationError {
185                field: field_name.to_string(),
186                reason: "cannot be empty".to_string(),
187                invalid_value: Some(value.to_string()),
188            });
189        }
190
191        if value.len() > 256 {
192            return Err(Error::ValidationError {
193                field: field_name.to_string(),
194                reason: "too long (maximum 256 characters)".to_string(),
195                invalid_value: Some(value.to_string()),
196            });
197        }
198
199        // Check for dangerous characters that could indicate injection attacks
200        let dangerous_chars = [';', '\'', '"', '\\', '\0', '\n', '\r'];
201        let dangerous_sequences = ["--", "/*", "*/", "<", ">", "{", "}", "[", "]"];
202
203        for &ch in &dangerous_chars {
204            if value.contains(ch) {
205                return Err(Error::ValidationError {
206                    field: field_name.to_string(),
207                    reason: "contains invalid characters".to_string(),
208                    invalid_value: Some(value.to_string()),
209                });
210            }
211        }
212
213        for &seq in &dangerous_sequences {
214            if value.contains(seq) {
215                return Err(Error::ValidationError {
216                    field: field_name.to_string(),
217                    reason: "contains invalid characters".to_string(),
218                    invalid_value: Some(value.to_string()),
219                });
220            }
221        }
222
223        // Check for potential path traversal
224        if value.contains("..") {
225            return Err(Error::ValidationError {
226                field: field_name.to_string(),
227                reason: "potential path traversal detected".to_string(),
228                invalid_value: Some(value.to_string()),
229            });
230        }
231
232        Ok(())
233    }
234
235    /// Validates a resource path for security.
236    pub fn validate_resource_path(path: &str) -> Result<()> {
237        // Empty path is allowed (means "any resource")
238        if path.is_empty() {
239            return Ok(());
240        }
241
242        // Must start with /
243        if !path.starts_with('/') {
244            return Err(Error::ValidationError {
245                field: "resource_path".to_string(),
246                reason: "must start with '/' or be empty".to_string(),
247                invalid_value: Some(path.to_string()),
248            });
249        }
250
251        // Check for path traversal attempts
252        if path.contains("../") || path.contains("..\\") {
253            return Err(Error::ValidationError {
254                field: "resource_path".to_string(),
255                reason: "path traversal detected".to_string(),
256                invalid_value: Some(path.to_string()),
257            });
258        }
259
260        // Check for null bytes
261        if path.contains('\0') {
262            return Err(Error::ValidationError {
263                field: "resource_path".to_string(),
264                reason: "null byte detected".to_string(),
265                invalid_value: Some(path.to_string()),
266            });
267        }
268
269        Ok(())
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn test_recovery_suggestion_creation() {
279        let suggestion = RecoverySuggestion::new("Permission denied")
280            .with_action("Assign the 'admin' role to the user")
281            .with_action("Check if the resource exists")
282            .with_documentation("https://docs.example.com/permissions");
283
284        assert_eq!(suggestion.message, "Permission denied");
285        assert_eq!(suggestion.suggested_actions.len(), 2);
286        assert_eq!(
287            suggestion.suggested_actions[0],
288            "Assign the 'admin' role to the user"
289        );
290        assert_eq!(
291            suggestion.suggested_actions[1],
292            "Check if the resource exists"
293        );
294        assert_eq!(
295            suggestion.documentation_link,
296            Some("https://docs.example.com/permissions".to_string())
297        );
298    }
299
300    #[test]
301    fn test_permission_denied_details_display() {
302        let details = PermissionDeniedDetails {
303            action: "delete".to_string(),
304            resource: "document.txt".to_string(),
305            subject: "alice".to_string(),
306            required_permissions: vec!["delete:documents".to_string()],
307            suggested_roles: vec!["admin".to_string(), "editor".to_string()],
308            recovery: Some(RecoverySuggestion::new("Assign appropriate role")),
309        };
310
311        let display = format!("{}", details);
312        assert!(display.contains("Permission denied"));
313        assert!(display.contains("delete"));
314        assert!(display.contains("document.txt"));
315        assert!(display.contains("alice"));
316    }
317
318    #[test]
319    fn test_permission_denied_error_creation() {
320        let details = PermissionDeniedDetails {
321            action: "read".to_string(),
322            resource: "secret.txt".to_string(),
323            subject: "bob".to_string(),
324            required_permissions: vec!["read:secrets".to_string()],
325            suggested_roles: vec!["security_admin".to_string()],
326            recovery: Some(
327                RecoverySuggestion::new("User needs security clearance")
328                    .with_action("Contact security administrator")
329                    .with_documentation("https://docs.example.com/security"),
330            ),
331        };
332
333        let error = Error::PermissionDenied(Box::new(details));
334
335        match error {
336            Error::PermissionDenied(d) => {
337                assert_eq!(d.action, "read");
338                assert_eq!(d.resource, "secret.txt");
339                assert_eq!(d.subject, "bob");
340                assert!(d.recovery.is_some());
341                assert_eq!(
342                    d.recovery.unwrap().suggested_actions[0],
343                    "Contact security administrator"
344                );
345            }
346            _ => panic!("Expected PermissionDenied error"),
347        }
348    }
349
350    #[test]
351    fn test_validation_error_formatting() {
352        let error = Error::ValidationError {
353            field: "username".to_string(),
354            reason: "contains invalid characters".to_string(),
355            invalid_value: Some("user@name!".to_string()),
356        };
357
358        let error_string = format!("{}", error);
359        assert!(error_string.contains("Validation failed"));
360        assert!(error_string.contains("username"));
361        assert!(error_string.contains("invalid characters"));
362    }
363
364    #[test]
365    fn test_security_validation_basic() {
366        // Valid inputs should pass
367        assert!(Error::validate_identifier("valid_user", "username").is_ok());
368        assert!(Error::validate_identifier("role123", "role").is_ok());
369        assert!(Error::validate_identifier("resource_name", "resource").is_ok());
370    }
371
372    #[test]
373    fn test_security_validation_empty_input() {
374        let result = Error::validate_identifier("", "field");
375        assert!(result.is_err());
376        match result.unwrap_err() {
377            Error::ValidationError { field, reason, .. } => {
378                assert_eq!(field, "field");
379                assert!(reason.contains("cannot be empty"));
380            }
381            _ => panic!("Expected ValidationError"),
382        }
383    }
384
385    #[test]
386    fn test_security_validation_invalid_characters() {
387        let test_cases = vec![
388            "user;name",    // semicolon
389            "user'name",    // single quote
390            "user\"name",   // double quote
391            "user--name",   // double dash
392            "user/*name",   // comment sequence
393            "user<script>", // HTML/script tag
394            "user{name}",   // braces
395            "user[name]",   // brackets
396            "user\\name",   // backslash
397        ];
398
399        for test_case in test_cases {
400            let result = Error::validate_identifier(test_case, "field");
401            assert!(result.is_err(), "Should reject: {}", test_case);
402            match result.unwrap_err() {
403                Error::ValidationError { reason, .. } => {
404                    assert!(reason.contains("invalid characters"));
405                }
406                _ => panic!("Expected ValidationError for: {}", test_case),
407            }
408        }
409    }
410
411    #[test]
412    fn test_resource_path_validation_valid() {
413        assert!(Error::validate_resource_path("").is_ok()); // Empty is allowed
414        assert!(Error::validate_resource_path("/documents").is_ok());
415        assert!(Error::validate_resource_path("/documents/file.txt").is_ok());
416        assert!(Error::validate_resource_path("/api/v1/users").is_ok());
417    }
418
419    #[test]
420    fn test_comprehensive_error_scenarios() {
421        // Test all error variants can be created and formatted
422        let errors = vec![
423            Error::RoleNotFound("admin".to_string()),
424            Error::RoleAlreadyExists("user".to_string()),
425            Error::SubjectNotFound("alice".to_string()),
426            Error::CircularDependency("role cycle detected".to_string()),
427            Error::ValidationError {
428                field: "username".to_string(),
429                reason: "invalid format".to_string(),
430                invalid_value: Some("test@user".to_string()),
431            },
432            Error::Storage("connection failed".to_string()),
433            Error::InvalidConfiguration("missing config".to_string()),
434            Error::RateLimitExceeded {
435                subject: "user123".to_string(),
436                limit: 100,
437                window: "1 minute".to_string(),
438            },
439            Error::ConcurrencyConflict {
440                operation: "role_assignment".to_string(),
441                resource_id: "role_123".to_string(),
442            },
443        ];
444
445        for error in errors {
446            // Ensure all errors can be formatted
447            let error_string = format!("{}", error);
448            assert!(!error_string.is_empty());
449
450            // Ensure all errors can be debugged
451            let debug_string = format!("{:?}", error);
452            assert!(!debug_string.is_empty());
453        }
454    }
455
456    #[test]
457    fn test_enhanced_error_context_integration() {
458        // Test creating a complex permission denied error with full context
459        let recovery = RecoverySuggestion::new("User needs additional permissions")
460            .with_action("Assign the 'documents_admin' role")
461            .with_action("Verify the document exists")
462            .with_action("Check if the user's access has expired")
463            .with_documentation("https://docs.company.com/rbac/troubleshooting");
464
465        let details = PermissionDeniedDetails {
466            action: "delete".to_string(),
467            resource: "/documents/confidential/report.pdf".to_string(),
468            subject: "employee_123".to_string(),
469            required_permissions: vec![
470                "delete:documents".to_string(),
471                "access:confidential".to_string(),
472            ],
473            suggested_roles: vec![
474                "documents_admin".to_string(),
475                "confidential_access".to_string(),
476            ],
477            recovery: Some(recovery),
478        };
479
480        let error = Error::PermissionDenied(Box::new(details));
481
482        // Verify all components are present
483        match &error {
484            Error::PermissionDenied(d) => {
485                assert_eq!(d.action, "delete");
486                assert_eq!(d.resource, "/documents/confidential/report.pdf");
487                assert_eq!(d.subject, "employee_123");
488                assert_eq!(d.required_permissions.len(), 2);
489                assert_eq!(d.suggested_roles.len(), 2);
490                assert!(d.recovery.is_some());
491
492                let recovery = d.recovery.as_ref().unwrap();
493                assert_eq!(recovery.suggested_actions.len(), 3);
494                assert!(recovery.documentation_link.is_some());
495            }
496            _ => panic!("Expected PermissionDenied"),
497        }
498
499        // Verify error message is comprehensive
500        let error_message = format!("{}", error);
501        assert!(error_message.contains("Permission denied"));
502        assert!(error_message.contains("delete"));
503        assert!(error_message.contains("confidential"));
504    }
505
506    #[test]
507    fn test_role_operation_failed_error() {
508        let error = Error::RoleOperationFailed {
509            operation: "assign".to_string(),
510            role: "admin".to_string(),
511            reason: "circular dependency detected".to_string(),
512        };
513
514        let error_string = format!("{}", error);
515        assert!(error_string.contains("Role operation failed"));
516        assert!(error_string.contains("assign"));
517        assert!(error_string.contains("admin"));
518        assert!(error_string.contains("circular dependency"));
519    }
520
521    #[test]
522    fn test_permission_operation_failed_error() {
523        let mut context = HashMap::new();
524        context.insert("user_group".to_string(), "employees".to_string());
525        context.insert("resource_owner".to_string(), "security_team".to_string());
526
527        let error = Error::PermissionOperationFailed {
528            operation: "check".to_string(),
529            subject: "alice".to_string(),
530            resource: "classified_document".to_string(),
531            reason: "insufficient clearance level".to_string(),
532            context: Box::new(context),
533        };
534
535        let error_string = format!("{}", error);
536        assert!(error_string.contains("Permission operation failed"));
537        assert!(error_string.contains("check"));
538        assert!(error_string.contains("alice"));
539        assert!(error_string.contains("classified_document"));
540        assert!(error_string.contains("insufficient clearance"));
541    }
542
543    #[test]
544    fn test_rate_limit_exceeded_error() {
545        let error = Error::RateLimitExceeded {
546            subject: "user123".to_string(),
547            limit: 100,
548            window: "1 minute".to_string(),
549        };
550
551        let error_string = format!("{}", error);
552        assert!(error_string.contains("Rate limit exceeded"));
553        assert!(error_string.contains("user123"));
554        assert!(error_string.contains("100"));
555        assert!(error_string.contains("1 minute"));
556    }
557
558    #[test]
559    fn test_concurrency_conflict_error() {
560        let error = Error::ConcurrencyConflict {
561            operation: "role_assignment".to_string(),
562            resource_id: "role_123".to_string(),
563        };
564
565        let error_string = format!("{}", error);
566        assert!(error_string.contains("Concurrency conflict"));
567        assert!(error_string.contains("role_assignment"));
568        assert!(error_string.contains("concurrent modification"));
569    }
570
571    #[test]
572    fn test_authentication_failed_error() {
573        let error = Error::AuthenticationFailed {
574            reason: "invalid credentials".to_string(),
575            subject_id: Some("user123".to_string()),
576        };
577
578        let error_string = format!("{}", error);
579        assert!(error_string.contains("Authentication failed"));
580        assert!(error_string.contains("invalid credentials"));
581    }
582
583    #[test]
584    fn test_authorization_failed_error() {
585        let error = Error::AuthorizationFailed {
586            subject: "alice".to_string(),
587            permission: "delete:documents".to_string(),
588            resource: "confidential.txt".to_string(),
589            required_roles: vec!["admin".to_string(), "editor".to_string()],
590        };
591
592        let error_string = format!("{}", error);
593        assert!(error_string.contains("Authorization failed"));
594        assert!(error_string.contains("alice"));
595        assert!(error_string.contains("delete:documents"));
596        assert!(error_string.contains("confidential.txt"));
597    }
598
599    #[cfg(feature = "persistence")]
600    #[test]
601    fn test_serialization_error_conversion() {
602        // Test that we can convert serde_json::Error to our Error type
603        let json_error = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
604        let our_error: Error = json_error.into();
605
606        match &our_error {
607            Error::Serialization(msg) => {
608                assert!(msg.contains("expected value"));
609            }
610            _ => panic!("Expected Serialization error variant"),
611        }
612
613        // Verify error can be cloned (which was the original issue)
614        let _cloned = our_error.clone();
615
616        let error_string = format!("{}", our_error);
617        assert!(error_string.contains("Serialization error"));
618    }
619}