1use std::collections::HashMap;
4use thiserror::Error;
5
6#[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#[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#[derive(Error, Debug, Clone)]
57pub enum Error {
58 #[error("Role '{0}' already exists")]
60 RoleAlreadyExists(String),
61
62 #[error("Role '{0}' not found")]
64 RoleNotFound(String),
65
66 #[error("Subject '{0}' not found")]
68 SubjectNotFound(String),
69
70 #[error("{0}")]
72 PermissionDenied(Box<PermissionDeniedDetails>),
73
74 #[error("Circular dependency detected in role hierarchy involving '{0}'")]
76 CircularDependency(String),
77
78 #[error("Invalid permission format: {0}")]
80 InvalidPermission(String),
81
82 #[error("Invalid resource format: {0}")]
84 InvalidResource(String),
85
86 #[error("Role elevation for subject '{0}' has expired")]
88 ElevationExpired(String),
89
90 #[error("Maximum role hierarchy depth exceeded (max: {0})")]
92 MaxDepthExceeded(usize),
93
94 #[cfg(feature = "persistence")]
96 #[error("Serialization error: {0}")]
97 Serialization(#[from] serde_json::Error),
98
99 #[error("Storage operation failed: {0}")]
101 Storage(String),
102
103 #[error("Invalid configuration: {0}")]
105 InvalidConfiguration(String),
106
107 #[error("Role operation failed: {operation} on role '{role}' - {reason}")]
109 RoleOperationFailed {
110 operation: String,
111 role: String,
112 reason: String,
113 },
114
115 #[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 #[error("Validation failed for field '{field}': {reason}")]
129 ValidationError {
130 field: String,
131 reason: String,
132 invalid_value: Option<String>,
133 },
134
135 #[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 #[error("Concurrency conflict: {operation} failed due to concurrent modification")]
145 ConcurrencyConflict {
146 operation: String,
147 resource_id: String,
148 },
149
150 #[error("Authentication failed: {reason}")]
152 AuthenticationFailed {
153 reason: String,
154 subject_id: Option<String>,
155 },
156
157 #[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
169pub type Result<T> = std::result::Result<T, Error>;
171
172impl Error {
173 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 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 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 pub fn validate_resource_path(path: &str) -> Result<()> {
229 if path.is_empty() {
231 return Ok(());
232 }
233
234 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 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 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 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", "user'name", "user\"name", "user--name", "user/*name", "user<script>", "user{name}", "user[name]", "user\\name", ];
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()); 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 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 let error_string = format!("{}", error);
440 assert!(!error_string.is_empty());
441
442 let debug_string = format!("{:?}", error);
444 assert!(!debug_string.is_empty());
445 }
446 }
447
448 #[test]
449 fn test_enhanced_error_context_integration() {
450 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 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 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}