libverify_core/controls/
repository_permissions_audit.rs1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::EvidenceBundle;
3
4pub struct RepositoryPermissionsAuditControl;
14
15const 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}