1use crate::{RelationTuple, Subject};
32use serde::{Deserialize, Serialize};
33use std::collections::{HashMap, HashSet};
34use std::time::{Duration, SystemTime};
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct RecommendationConfig {
39 pub min_usage_threshold: f64,
41
42 pub analysis_window: Duration,
44
45 pub min_consolidation_count: usize,
47
48 pub enable_hierarchy_analysis: bool,
50
51 pub enable_role_suggestions: bool,
53}
54
55impl Default for RecommendationConfig {
56 fn default() -> Self {
57 Self {
58 min_usage_threshold: 0.1, analysis_window: Duration::from_secs(30 * 24 * 3600), min_consolidation_count: 3,
61 enable_hierarchy_analysis: true,
62 enable_role_suggestions: true,
63 }
64 }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69pub enum RecommendationType {
70 UnusedPermission,
72
73 Consolidation,
75
76 HierarchicalRedundancy,
78
79 RoleSuggestion,
81
82 OverPermissive,
84
85 Conflict,
87}
88
89#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
91pub enum Priority {
92 Low = 1,
93 Medium = 2,
94 High = 3,
95 Critical = 4,
96}
97
98impl std::fmt::Display for Priority {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 match self {
101 Priority::Low => write!(f, "LOW"),
102 Priority::Medium => write!(f, "MEDIUM"),
103 Priority::High => write!(f, "HIGH"),
104 Priority::Critical => write!(f, "CRITICAL"),
105 }
106 }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct Recommendation {
112 pub recommendation_type: RecommendationType,
113 pub priority: Priority,
114 pub description: String,
115 pub affected_tuples: Vec<RelationTuple>,
116 pub suggested_action: String,
117 pub estimated_impact: String,
118}
119
120#[derive(Debug, Clone)]
122struct TupleUsage {
123 tuple: RelationTuple,
124 access_count: usize,
125 last_accessed: Option<SystemTime>,
126 granted_count: usize,
127}
128
129pub struct RecommendationEngine {
131 config: RecommendationConfig,
132 tuple_usage: HashMap<String, TupleUsage>,
133 access_patterns: HashMap<(String, String), usize>, }
135
136impl RecommendationEngine {
137 pub fn new(config: RecommendationConfig) -> Self {
139 Self {
140 config,
141 tuple_usage: HashMap::new(),
142 access_patterns: HashMap::new(),
143 }
144 }
145
146 pub fn add_tuple(&mut self, tuple: &RelationTuple) {
148 let key = self.tuple_key(tuple);
149 self.tuple_usage.entry(key).or_insert_with(|| TupleUsage {
150 tuple: tuple.clone(),
151 access_count: 0,
152 last_accessed: None,
153 granted_count: 0,
154 });
155 }
156
157 pub fn record_access(&mut self, subject_id: &str, resource_id: &str, relation: &str) {
159 let now = SystemTime::now();
161 for usage in self.tuple_usage.values_mut() {
162 if Self::matches_access_static(&usage.tuple, subject_id, resource_id, relation) {
163 usage.access_count += 1;
164 usage.last_accessed = Some(now);
165 usage.granted_count += 1;
166 }
167 }
168
169 let key = (subject_id.to_string(), resource_id.to_string());
171 *self.access_patterns.entry(key).or_insert(0) += 1;
172 }
173
174 fn matches_access_static(
175 tuple: &RelationTuple,
176 subject_id: &str,
177 resource_id: &str,
178 relation: &str,
179 ) -> bool {
180 let resource_matches = format!("{}:{}", tuple.namespace, tuple.object_id) == resource_id;
182
183 let subject_matches = match &tuple.subject {
184 Subject::User(id) => id == subject_id,
185 Subject::UserSet {
186 namespace: _,
187 object_id: _,
188 relation: _,
189 } => false,
190 };
191
192 resource_matches && subject_matches && tuple.relation == relation
193 }
194
195 pub fn generate_recommendations(&self) -> Vec<Recommendation> {
197 let mut recommendations = Vec::new();
198
199 recommendations.extend(self.detect_unused_permissions());
201
202 if self.config.enable_hierarchy_analysis {
204 recommendations.extend(self.detect_hierarchical_redundancy());
205 }
206
207 recommendations.extend(self.suggest_consolidations());
209
210 if self.config.enable_role_suggestions {
212 recommendations.extend(self.suggest_roles());
213 }
214
215 recommendations.extend(self.detect_conflicts());
217
218 recommendations.sort_by(|a, b| b.priority.cmp(&a.priority));
220
221 recommendations
222 }
223
224 fn detect_unused_permissions(&self) -> Vec<Recommendation> {
225 let mut recommendations = Vec::new();
226
227 for usage in self.tuple_usage.values() {
228 let total_possible_accesses = self.estimate_total_accesses(&usage.tuple);
230 let usage_rate = if total_possible_accesses > 0 {
231 usage.access_count as f64 / total_possible_accesses as f64
232 } else {
233 0.0
234 };
235
236 if usage_rate < self.config.min_usage_threshold {
238 let priority = if usage.access_count == 0 {
239 Priority::High
240 } else {
241 Priority::Medium
242 };
243
244 let description = if usage.access_count == 0 {
245 format!(
246 "Permission never used: {:?} {} on {}:{}",
247 usage.tuple.subject,
248 usage.tuple.relation,
249 usage.tuple.namespace,
250 usage.tuple.object_id
251 )
252 } else {
253 format!(
254 "Permission rarely used ({:.1}% usage): {:?} {} on {}:{}",
255 usage_rate * 100.0,
256 usage.tuple.subject,
257 usage.tuple.relation,
258 usage.tuple.namespace,
259 usage.tuple.object_id
260 )
261 };
262
263 recommendations.push(Recommendation {
264 recommendation_type: RecommendationType::UnusedPermission,
265 priority,
266 description,
267 affected_tuples: vec![usage.tuple.clone()],
268 suggested_action: "Consider revoking this permission if no longer needed"
269 .to_string(),
270 estimated_impact: format!("Remove {} unused permission(s)", 1),
271 });
272 }
273 }
274
275 recommendations
276 }
277
278 fn detect_hierarchical_redundancy(&self) -> Vec<Recommendation> {
279 let mut recommendations = Vec::new();
280
281 let mut subject_tuples: HashMap<String, Vec<&TupleUsage>> = HashMap::new();
283 for usage in self.tuple_usage.values() {
284 let subject_key = format!("{:?}", usage.tuple.subject);
285 subject_tuples.entry(subject_key).or_default().push(usage);
286 }
287
288 for (subject, tuples) in subject_tuples {
290 if tuples.len() < 2 {
291 continue;
292 }
293
294 for i in 0..tuples.len() {
296 for j in 0..tuples.len() {
297 if i == j {
298 continue;
299 }
300
301 if self.is_hierarchically_redundant(&tuples[i].tuple, &tuples[j].tuple) {
302 recommendations.push(Recommendation {
303 recommendation_type: RecommendationType::HierarchicalRedundancy,
304 priority: Priority::Medium,
305 description: format!(
306 "Redundant permission for {}: parent resource already grants access",
307 subject
308 ),
309 affected_tuples: vec![tuples[i].tuple.clone(), tuples[j].tuple.clone()],
310 suggested_action: "Remove child permission, keep parent".to_string(),
311 estimated_impact: "Simplify permission model".to_string(),
312 });
313 }
314 }
315 }
316 }
317
318 recommendations
319 }
320
321 fn is_hierarchically_redundant(&self, tuple1: &RelationTuple, tuple2: &RelationTuple) -> bool {
322 if tuple1.relation != tuple2.relation {
326 return false;
327 }
328
329 if tuple1.namespace != tuple2.namespace {
331 return false;
332 }
333
334 tuple1
337 .object_id
338 .starts_with(&format!("{}/", tuple2.object_id))
339 }
340
341 fn suggest_consolidations(&self) -> Vec<Recommendation> {
342 let mut recommendations = Vec::new();
343
344 let mut relation_tuples: HashMap<String, Vec<&TupleUsage>> = HashMap::new();
346 for usage in self.tuple_usage.values() {
347 relation_tuples
348 .entry(usage.tuple.relation.clone())
349 .or_default()
350 .push(usage);
351 }
352
353 for (relation, tuples) in relation_tuples {
355 if tuples.len() < self.config.min_consolidation_count {
356 continue;
357 }
358
359 let mut namespace_groups: HashMap<String, Vec<&TupleUsage>> = HashMap::new();
361 for usage in tuples {
362 let namespace = &usage.tuple.namespace;
363 namespace_groups
364 .entry(namespace.clone())
365 .or_default()
366 .push(usage);
367 }
368
369 for (namespace, group) in namespace_groups {
370 if group.len() >= self.config.min_consolidation_count {
371 let affected: Vec<RelationTuple> =
372 group.iter().map(|u| u.tuple.clone()).collect();
373
374 recommendations.push(Recommendation {
375 recommendation_type: RecommendationType::Consolidation,
376 priority: Priority::Low,
377 description: format!(
378 "Found {} similar permissions for relation '{}' in namespace '{}'",
379 group.len(), relation, namespace
380 ),
381 affected_tuples: affected,
382 suggested_action: format!(
383 "Consider creating a role or group for users with '{}' access to '{}' resources",
384 relation, namespace
385 ),
386 estimated_impact: format!("Consolidate {} tuples into 1 role assignment", group.len()),
387 });
388 }
389 }
390 }
391
392 recommendations
393 }
394
395 fn suggest_roles(&self) -> Vec<Recommendation> {
396 let mut recommendations = Vec::new();
397
398 let mut subject_permissions: HashMap<String, HashSet<String>> = HashMap::new();
400
401 for usage in self.tuple_usage.values() {
402 let subject_key = format!("{:?}", usage.tuple.subject);
403 let permission_key = format!("{}#{}", usage.tuple.namespace, usage.tuple.relation);
404 subject_permissions
405 .entry(subject_key)
406 .or_default()
407 .insert(permission_key);
408 }
409
410 let mut permission_set_counts: HashMap<Vec<String>, Vec<String>> = HashMap::new();
412 for (subject, permissions) in subject_permissions {
413 let mut sorted_perms: Vec<String> = permissions.into_iter().collect();
414 sorted_perms.sort();
415 permission_set_counts
416 .entry(sorted_perms)
417 .or_default()
418 .push(subject);
419 }
420
421 for (permissions, subjects) in permission_set_counts {
423 if subjects.len() >= 3 && permissions.len() >= 2 {
424 recommendations.push(Recommendation {
425 recommendation_type: RecommendationType::RoleSuggestion,
426 priority: Priority::Medium,
427 description: format!(
428 "Found {} users with identical permission set ({} permissions)",
429 subjects.len(),
430 permissions.len()
431 ),
432 affected_tuples: vec![],
433 suggested_action: format!(
434 "Create a role with permissions: {:?}",
435 permissions.iter().take(5).collect::<Vec<_>>()
436 ),
437 estimated_impact: format!(
438 "Replace {} individual permission grants with 1 role assignment each",
439 subjects.len()
440 ),
441 });
442 }
443 }
444
445 recommendations
446 }
447
448 fn detect_conflicts(&self) -> Vec<Recommendation> {
449 let mut recommendations = Vec::new();
450
451 let mut subject_resource_map: HashMap<(String, String), Vec<&TupleUsage>> = HashMap::new();
453
454 for usage in self.tuple_usage.values() {
455 let subject_key = format!("{:?}", usage.tuple.subject);
456 let resource_key = format!("{}:{}", usage.tuple.namespace, usage.tuple.object_id);
457 subject_resource_map
458 .entry((subject_key, resource_key))
459 .or_default()
460 .push(usage);
461 }
462
463 for ((subject, resource), tuples) in subject_resource_map {
465 if tuples.len() > 1 {
466 let relations: Vec<&str> =
467 tuples.iter().map(|u| u.tuple.relation.as_str()).collect();
468
469 if relations.contains(&"admin") && relations.len() > 1 {
471 recommendations.push(Recommendation {
472 recommendation_type: RecommendationType::Conflict,
473 priority: Priority::Low,
474 description: format!(
475 "Multiple permission levels for {} on {}: {:?}",
476 subject, resource, relations
477 ),
478 affected_tuples: tuples.iter().map(|u| u.tuple.clone()).collect(),
479 suggested_action: "Review if all permissions are necessary (admin typically includes other permissions)".to_string(),
480 estimated_impact: "Potential cleanup of redundant permissions".to_string(),
481 });
482 }
483 }
484 }
485
486 recommendations
487 }
488
489 fn estimate_total_accesses(&self, tuple: &RelationTuple) -> usize {
490 let subject_key = format!("{:?}", tuple.subject);
493 self.access_patterns
494 .iter()
495 .filter(|((subj, _), _)| *subj == subject_key)
496 .map(|(_, count)| count)
497 .sum()
498 }
499
500 fn tuple_key(&self, tuple: &RelationTuple) -> String {
501 format!(
502 "{:?}#{}#{}:{}",
503 tuple.subject, tuple.relation, tuple.namespace, tuple.object_id
504 )
505 }
506
507 pub fn get_tuple_usage(&self, tuple: &RelationTuple) -> Option<UsageStats> {
509 let key = self.tuple_key(tuple);
510 let usage = self.tuple_usage.get(&key)?;
511
512 Some(UsageStats {
513 access_count: usage.access_count,
514 granted_count: usage.granted_count,
515 last_accessed: usage.last_accessed,
516 })
517 }
518
519 pub fn cleanup(&mut self, cutoff: SystemTime) {
521 self.tuple_usage
522 .retain(|_, usage| usage.last_accessed.is_some_and(|t| t >= cutoff));
523 }
524}
525
526#[derive(Debug, Clone, Serialize, Deserialize)]
528pub struct UsageStats {
529 pub access_count: usize,
530 pub granted_count: usize,
531 pub last_accessed: Option<SystemTime>,
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537
538 fn create_simple_tuple(
539 subject: &str,
540 relation: &str,
541 resource_ns: &str,
542 resource_id: &str,
543 ) -> RelationTuple {
544 RelationTuple {
545 namespace: resource_ns.to_string(),
546 object_id: resource_id.to_string(),
547 relation: relation.to_string(),
548 subject: Subject::User(subject.to_string()),
549 condition: None,
550 }
551 }
552
553 #[test]
554 fn test_recommendation_engine_creation() {
555 let config = RecommendationConfig::default();
556 let engine = RecommendationEngine::new(config);
557 assert_eq!(engine.tuple_usage.len(), 0);
558 }
559
560 #[test]
561 fn test_add_tuple() {
562 let config = RecommendationConfig::default();
563 let mut engine = RecommendationEngine::new(config);
564
565 let tuple = create_simple_tuple("user:alice", "read", "doc", "123");
566 engine.add_tuple(&tuple);
567
568 assert_eq!(engine.tuple_usage.len(), 1);
569 }
570
571 #[test]
572 fn test_record_access() {
573 let config = RecommendationConfig::default();
574 let mut engine = RecommendationEngine::new(config);
575
576 let tuple = create_simple_tuple("user:alice", "read", "doc", "123");
577 engine.add_tuple(&tuple);
578
579 engine.record_access("user:alice", "doc:123", "read");
580
581 let stats = engine.get_tuple_usage(&tuple).unwrap();
582 assert_eq!(stats.access_count, 1);
583 assert_eq!(stats.granted_count, 1);
584 assert!(stats.last_accessed.is_some());
585 }
586
587 #[test]
588 fn test_detect_unused_permissions() {
589 let config = RecommendationConfig {
590 min_usage_threshold: 0.5,
591 ..Default::default()
592 };
593 let mut engine = RecommendationEngine::new(config);
594
595 let unused_tuple = create_simple_tuple("user:bob", "write", "doc", "456");
597 engine.add_tuple(&unused_tuple);
598
599 let used_tuple = create_simple_tuple("user:alice", "read", "doc", "123");
601 engine.add_tuple(&used_tuple);
602 for _ in 0..10 {
603 engine.record_access("user:alice", "doc:123", "read");
604 }
605
606 let recommendations = engine.generate_recommendations();
607
608 let unused_recs: Vec<_> = recommendations
610 .iter()
611 .filter(|r| r.recommendation_type == RecommendationType::UnusedPermission)
612 .collect();
613
614 assert!(!unused_recs.is_empty());
615 }
616
617 #[test]
618 fn test_suggest_consolidations() {
619 let config = RecommendationConfig {
620 min_consolidation_count: 3,
621 ..Default::default()
622 };
623 let mut engine = RecommendationEngine::new(config);
624
625 for i in 0..5 {
627 let tuple =
628 create_simple_tuple(&format!("user:{}", i), "read", "doc", &format!("{}", i));
629 engine.add_tuple(&tuple);
630 }
631
632 let recommendations = engine.generate_recommendations();
633
634 let consolidation_recs: Vec<_> = recommendations
636 .iter()
637 .filter(|r| r.recommendation_type == RecommendationType::Consolidation)
638 .collect();
639
640 assert!(!consolidation_recs.is_empty());
641 }
642
643 #[test]
644 fn test_suggest_roles() {
645 let config = RecommendationConfig {
646 enable_role_suggestions: true,
647 ..Default::default()
648 };
649 let mut engine = RecommendationEngine::new(config);
650
651 for i in 0..4 {
653 let tuple1 = create_simple_tuple(&format!("user:{}", i), "read", "doc", "shared");
654 let tuple2 = create_simple_tuple(&format!("user:{}", i), "write", "doc", "shared");
655 engine.add_tuple(&tuple1);
656 engine.add_tuple(&tuple2);
657 }
658
659 let recommendations = engine.generate_recommendations();
660
661 let role_recs: Vec<_> = recommendations
663 .iter()
664 .filter(|r| r.recommendation_type == RecommendationType::RoleSuggestion)
665 .collect();
666
667 assert!(!role_recs.is_empty());
668 }
669
670 #[test]
671 fn test_cleanup() {
672 let config = RecommendationConfig::default();
673 let mut engine = RecommendationEngine::new(config);
674
675 let tuple = create_simple_tuple("user:alice", "read", "doc", "123");
676 engine.add_tuple(&tuple);
677 engine.record_access("user:alice", "doc:123", "read");
678
679 assert_eq!(engine.tuple_usage.len(), 1);
680
681 let future = SystemTime::now() + Duration::from_secs(3600);
683 engine.cleanup(future);
684
685 assert_eq!(engine.tuple_usage.len(), 0);
686 }
687}