oxify_authz/
recommendations.rs

1//! Permission Recommendations
2//!
3//! This module analyzes existing permission tuples and access patterns to suggest
4//! optimizations, identify redundant permissions, and detect over-permissive grants.
5//!
6//! # Features
7//! - Detect redundant permissions (can be simplified via hierarchy)
8//! - Identify unused or rarely-used permissions
9//! - Suggest tuple consolidations
10//! - Recommend role-based patterns
11//! - Find over-permissive grants
12//!
13//! # Example
14//! ```rust,ignore
15//! use oxify_authz::recommendations::{RecommendationEngine, RecommendationConfig};
16//!
17//! let config = RecommendationConfig::default();
18//! let mut engine = RecommendationEngine::new(config);
19//!
20//! // Analyze tuples
21//! engine.add_tuple(&tuple);
22//! engine.record_access("user:alice", "doc:123", "read");
23//!
24//! // Get recommendations
25//! let recommendations = engine.generate_recommendations();
26//! for rec in recommendations {
27//!     println!("{}: {}", rec.priority, rec.description);
28//! }
29//! ```
30
31use crate::{RelationTuple, Subject};
32use serde::{Deserialize, Serialize};
33use std::collections::{HashMap, HashSet};
34use std::time::{Duration, SystemTime};
35
36/// Configuration for recommendation engine
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct RecommendationConfig {
39    /// Minimum usage threshold (0.0-1.0) below which permissions are flagged as unused
40    pub min_usage_threshold: f64,
41
42    /// Time window for analyzing access patterns
43    pub analysis_window: Duration,
44
45    /// Minimum number of similar permissions to suggest consolidation
46    pub min_consolidation_count: usize,
47
48    /// Enable hierarchical redundancy detection
49    pub enable_hierarchy_analysis: bool,
50
51    /// Enable role pattern suggestions
52    pub enable_role_suggestions: bool,
53}
54
55impl Default for RecommendationConfig {
56    fn default() -> Self {
57        Self {
58            min_usage_threshold: 0.1,                             // Flag if < 10% usage
59            analysis_window: Duration::from_secs(30 * 24 * 3600), // 30 days
60            min_consolidation_count: 3,
61            enable_hierarchy_analysis: true,
62            enable_role_suggestions: true,
63        }
64    }
65}
66
67/// Type of recommendation
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69pub enum RecommendationType {
70    /// Permission is granted but never or rarely used
71    UnusedPermission,
72
73    /// Multiple similar permissions can be consolidated
74    Consolidation,
75
76    /// Permission is redundant due to hierarchy
77    HierarchicalRedundancy,
78
79    /// Suggest creating a role for common permission pattern
80    RoleSuggestion,
81
82    /// Permission is overly broad
83    OverPermissive,
84
85    /// Conflicting permissions exist
86    Conflict,
87}
88
89/// Priority level for recommendations
90#[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/// A recommendation for permission optimization
110#[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/// Track usage of a permission tuple
121#[derive(Debug, Clone)]
122struct TupleUsage {
123    tuple: RelationTuple,
124    access_count: usize,
125    last_accessed: Option<SystemTime>,
126    granted_count: usize,
127}
128
129/// Main recommendation engine
130pub struct RecommendationEngine {
131    config: RecommendationConfig,
132    tuple_usage: HashMap<String, TupleUsage>,
133    access_patterns: HashMap<(String, String), usize>, // (subject_id, resource_id) -> count
134}
135
136impl RecommendationEngine {
137    /// Create a new recommendation engine
138    pub fn new(config: RecommendationConfig) -> Self {
139        Self {
140            config,
141            tuple_usage: HashMap::new(),
142            access_patterns: HashMap::new(),
143        }
144    }
145
146    /// Add a tuple to be analyzed
147    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    /// Record an access event (used for usage tracking)
158    pub fn record_access(&mut self, subject_id: &str, resource_id: &str, relation: &str) {
159        // Find matching tuples
160        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        // Track access patterns
170        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        // Check if the tuple could grant this access
181        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    /// Generate recommendations based on collected data
196    pub fn generate_recommendations(&self) -> Vec<Recommendation> {
197        let mut recommendations = Vec::new();
198
199        // 1. Detect unused permissions
200        recommendations.extend(self.detect_unused_permissions());
201
202        // 2. Detect hierarchical redundancies
203        if self.config.enable_hierarchy_analysis {
204            recommendations.extend(self.detect_hierarchical_redundancy());
205        }
206
207        // 3. Suggest consolidations
208        recommendations.extend(self.suggest_consolidations());
209
210        // 4. Suggest role patterns
211        if self.config.enable_role_suggestions {
212            recommendations.extend(self.suggest_roles());
213        }
214
215        // 5. Detect conflicts
216        recommendations.extend(self.detect_conflicts());
217
218        // Sort by priority (highest first)
219        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            // Calculate usage rate
229            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            // Check if permission is unused or rarely used
237            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        // Group tuples by subject
282        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        // Check for redundant permissions due to hierarchy
289        for (subject, tuples) in subject_tuples {
290            if tuples.len() < 2 {
291                continue;
292            }
293
294            // Look for parent-child relationships
295            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        // Check if tuple1 is redundant because tuple2 provides the same or broader access
323        // For now, we do simple namespace-based checking since the current RelationTuple
324        // doesn't have explicit parent references
325        if tuple1.relation != tuple2.relation {
326            return false;
327        }
328
329        // Check if they're in the same namespace
330        if tuple1.namespace != tuple2.namespace {
331            return false;
332        }
333
334        // Simple heuristic: if object IDs suggest parent-child relationship
335        // e.g., "folder/subfolder" and "folder"
336        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        // Group tuples by relation
345        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        // Look for consolidation opportunities
354        for (relation, tuples) in relation_tuples {
355            if tuples.len() < self.config.min_consolidation_count {
356                continue;
357            }
358
359            // Group by resource namespace
360            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        // Analyze access patterns to find common permission sets
399        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        // Find common permission sets (potential roles)
411        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        // Suggest roles for common patterns
422        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        // Group tuples by (subject, resource)
452        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        // Check for potentially conflicting permissions
464        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                // Check for conflicting relations (e.g., read + admin might indicate over-permission)
470                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        // Estimate how many times this permission could have been used
491        // based on subject's total activity
492        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    /// Get usage statistics for a specific tuple
508    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    /// Clear old data
520    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/// Usage statistics for a tuple
527#[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        // Add unused tuple
596        let unused_tuple = create_simple_tuple("user:bob", "write", "doc", "456");
597        engine.add_tuple(&unused_tuple);
598
599        // Add used tuple
600        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        // Should recommend removing unused permission
609        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        // Add many similar permissions
626        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        // Should suggest consolidation
635        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        // Add identical permission sets for multiple users
652        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        // Should suggest role creation
662        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        // Cleanup with future cutoff removes all data
682        let future = SystemTime::now() + Duration::from_secs(3600);
683        engine.cleanup(future);
684
685        assert_eq!(engine.tuple_usage.len(), 0);
686    }
687}