Skip to main content

libverify_core/controls/
repository_permissions_audit.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::EvidenceBundle;
3
4/// Validates that repository access follows least-privilege principles.
5///
6/// Maps to SOC2 CC6.1 (Logical Access), NIST 800-53 CM-5 / AC-6,
7/// ISMAP Ch.9.2.3 (Privileged Access Management).
8///
9/// Checks:
10/// - Admin count is reasonable (threshold: <= 3)
11/// - Direct (non-team) collaborators with write/admin access are minimized
12///   (threshold: 0 — all access should be team-based)
13pub struct RepositoryPermissionsAuditControl;
14
15/// Maximum admins before the control is violated.
16const MAX_ADMINS: u32 = 3;
17
18impl Control for RepositoryPermissionsAuditControl {
19    fn id(&self) -> ControlId {
20        builtin::id(builtin::REPOSITORY_PERMISSIONS_AUDIT)
21    }
22
23    fn description(&self) -> &'static str {
24        "Repository access must follow least-privilege: limited admins, team-based access"
25    }
26
27    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
28        let posture = match ControlFinding::extract_posture(self.id(), evidence) {
29            Ok(p) => p,
30            Err(findings) => return findings,
31        };
32
33        let mut issues = Vec::new();
34
35        if posture.admin_count > MAX_ADMINS {
36            issues.push(format!(
37                "{} admins detected (maximum {})",
38                posture.admin_count, MAX_ADMINS
39            ));
40        }
41
42        if posture.direct_collaborator_count > 0 {
43            issues.push(format!(
44                "{} direct collaborators with write/admin access (should use team-based access)",
45                posture.direct_collaborator_count
46            ));
47        }
48
49        if issues.is_empty() {
50            vec![ControlFinding::satisfied(
51                self.id(),
52                format!(
53                    "Repository access follows least-privilege: {} admin(s), no direct collaborators",
54                    posture.admin_count
55                ),
56                vec!["repository:permissions".into()],
57            )]
58        } else {
59            vec![ControlFinding::violated(
60                self.id(),
61                issues.join("; "),
62                vec!["repository:permissions".into()],
63            )]
64        }
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use crate::control::ControlStatus;
72    use crate::evidence::{EvidenceState, RepositoryPosture};
73
74    fn bundle_with(admin_count: u32, direct_collaborator_count: u32) -> EvidenceBundle {
75        EvidenceBundle {
76            repository_posture: EvidenceState::complete(RepositoryPosture {
77                admin_count,
78                direct_collaborator_count,
79                ..Default::default()
80            }),
81            ..Default::default()
82        }
83    }
84
85    #[test]
86    fn satisfied_when_few_admins_no_direct() {
87        let findings = RepositoryPermissionsAuditControl.evaluate(&bundle_with(2, 0));
88        assert_eq!(findings[0].status, ControlStatus::Satisfied);
89    }
90
91    #[test]
92    fn violated_when_too_many_admins() {
93        let findings = RepositoryPermissionsAuditControl.evaluate(&bundle_with(5, 0));
94        assert_eq!(findings[0].status, ControlStatus::Violated);
95        assert!(findings[0].rationale.contains("5 admins"));
96    }
97
98    #[test]
99    fn violated_when_direct_collaborators_exist() {
100        let findings = RepositoryPermissionsAuditControl.evaluate(&bundle_with(1, 3));
101        assert_eq!(findings[0].status, ControlStatus::Violated);
102        assert!(findings[0].rationale.contains("3 direct collaborators"));
103    }
104
105    #[test]
106    fn violated_when_both_issues() {
107        let findings = RepositoryPermissionsAuditControl.evaluate(&bundle_with(10, 5));
108        assert_eq!(findings[0].status, ControlStatus::Violated);
109        assert!(findings[0].rationale.contains("10 admins"));
110        assert!(findings[0].rationale.contains("5 direct collaborators"));
111    }
112
113    #[test]
114    fn indeterminate_when_posture_missing() {
115        let findings = RepositoryPermissionsAuditControl.evaluate(&EvidenceBundle {
116            repository_posture: EvidenceState::missing(vec![]),
117            ..Default::default()
118        });
119        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
120    }
121
122    #[test]
123    fn not_applicable_when_posture_not_applicable() {
124        let findings = RepositoryPermissionsAuditControl.evaluate(&EvidenceBundle {
125            repository_posture: EvidenceState::not_applicable(),
126            ..Default::default()
127        });
128        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
129    }
130}