Skip to main content

libverify_core/controls/
two_party_review.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{
3    ApprovalDisposition, EvidenceBundle, EvidenceGap, EvidenceState, GovernedChange,
4};
5use crate::integrity::{is_approver_independent, two_party_review_severity};
6use crate::verdict::Severity;
7
8/// Source L4: Verifies that at least two independent reviewers approved each change.
9pub struct TwoPartyReviewControl;
10
11impl Control for TwoPartyReviewControl {
12    fn id(&self) -> ControlId {
13        builtin::id(builtin::TWO_PARTY_REVIEW)
14    }
15
16    fn description(&self) -> &'static str {
17        "At least two independent reviewers must approve changes"
18    }
19
20    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
21        if evidence.change_requests.is_empty() {
22            return vec![ControlFinding::not_applicable(
23                self.id(),
24                "No governed changes were supplied",
25            )];
26        }
27
28        evidence
29            .change_requests
30            .iter()
31            .map(evaluate_change)
32            .collect()
33    }
34}
35
36fn evaluate_change(change: &GovernedChange) -> ControlFinding {
37    let subject = change.id.to_string();
38
39    if change.is_bot_submitted() {
40        return ControlFinding::not_applicable(
41            builtin::id(builtin::TWO_PARTY_REVIEW),
42            format!("{subject}: bot-submitted change; review verified on constituent PRs"),
43        );
44    }
45
46    let mut gaps = collect_gaps(&change.approval_decisions);
47    gaps.extend(collect_gaps(&change.source_revisions));
48
49    let approvals = match change.approval_decisions.value() {
50        Some(approvals) => approvals,
51        None => {
52            return ControlFinding::indeterminate(
53                builtin::id(builtin::TWO_PARTY_REVIEW),
54                "Approval evidence is unavailable",
55                vec![subject],
56                gaps,
57            );
58        }
59    };
60
61    let revisions = match change.source_revisions.value() {
62        Some(revisions) => revisions,
63        None => {
64            return ControlFinding::indeterminate(
65                builtin::id(builtin::TWO_PARTY_REVIEW),
66                "Source revision evidence is unavailable",
67                vec![subject],
68                gaps,
69            );
70        }
71    };
72
73    let mut authors: Vec<&str> = revisions
74        .iter()
75        .filter_map(|revision| revision.authored_by.as_deref())
76        .collect();
77    authors.sort_unstable();
78    authors.dedup();
79
80    if change.submitted_by.is_none() {
81        gaps.push(EvidenceGap::MissingField {
82            source: "control-normalization".to_string(),
83            subject: subject.clone(),
84            field: "submitted_by".to_string(),
85        });
86    }
87
88    if authors.is_empty() {
89        gaps.push(EvidenceGap::MissingField {
90            source: "control-normalization".to_string(),
91            subject: subject.clone(),
92            field: "source_revisions.authored_by".to_string(),
93        });
94    }
95
96    if !gaps.is_empty() {
97        return ControlFinding::indeterminate(
98            builtin::id(builtin::TWO_PARTY_REVIEW),
99            "Two-party review cannot be proven from partial evidence",
100            vec![subject],
101            gaps,
102        );
103    }
104
105    let requester = change
106        .submitted_by
107        .as_deref()
108        .expect("submitted_by guaranteed Some: early return on missing field");
109
110    let independent_count = approvals
111        .iter()
112        .filter(|approval| {
113            if approval.disposition != ApprovalDisposition::Approved {
114                return false;
115            }
116            let is_commit_author = authors.contains(&approval.actor.as_str());
117            let is_pr_author = approval.actor == requester;
118            is_approver_independent(is_commit_author, is_pr_author)
119        })
120        .count();
121
122    match two_party_review_severity(independent_count) {
123        Severity::Pass => ControlFinding::satisfied(
124            builtin::id(builtin::TWO_PARTY_REVIEW),
125            format!("{independent_count} independent approver(s) found (>= 2 required)"),
126            vec![subject],
127        ),
128        _ => ControlFinding::violated(
129            builtin::id(builtin::TWO_PARTY_REVIEW),
130            format!("Only {independent_count} independent approver(s) found; at least 2 required"),
131            vec![subject],
132        ),
133    }
134}
135
136fn collect_gaps<T>(state: &EvidenceState<T>) -> Vec<EvidenceGap> {
137    state.gaps().to_vec()
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::control::ControlStatus;
144    use crate::evidence::{
145        ApprovalDecision, AuthenticityEvidence, ChangeRequestId, EvidenceBundle, SourceRevision,
146    };
147
148    fn make_change(approvers: Vec<&str>) -> GovernedChange {
149        let decisions = approvers
150            .into_iter()
151            .map(|actor| ApprovalDecision {
152                actor: actor.to_string(),
153                disposition: ApprovalDisposition::Approved,
154                submitted_at: Some("2026-03-15T00:00:00Z".to_string()),
155            })
156            .collect();
157
158        GovernedChange {
159            id: ChangeRequestId::new("test", "owner/repo#1"),
160            title: "feat: add new control".to_string(),
161            summary: None,
162            submitted_by: Some("author".to_string()),
163            changed_assets: EvidenceState::complete(vec![]),
164            approval_decisions: EvidenceState::complete(decisions),
165            source_revisions: EvidenceState::complete(vec![SourceRevision {
166                id: "abc123".to_string(),
167                authored_by: Some("author".to_string()),
168                committed_at: Some("2026-03-14T00:00:00Z".to_string()),
169                merge: false,
170                authenticity: EvidenceState::complete(AuthenticityEvidence::new(
171                    true,
172                    Some("gpg".to_string()),
173                )),
174            }]),
175            work_item_refs: EvidenceState::complete(vec![]),
176        }
177    }
178
179    fn bundle(change: GovernedChange) -> EvidenceBundle {
180        EvidenceBundle {
181            change_requests: vec![change],
182            ..Default::default()
183        }
184    }
185
186    #[test]
187    fn satisfied_with_two_independent_approvers() {
188        let change = make_change(vec!["reviewer-a", "reviewer-b"]);
189        let findings = TwoPartyReviewControl.evaluate(&bundle(change));
190        assert_eq!(findings.len(), 1);
191        assert_eq!(findings[0].status, ControlStatus::Satisfied);
192        assert!(findings[0].rationale.contains("2"));
193    }
194
195    #[test]
196    fn violated_with_only_one_independent_approver() {
197        let change = make_change(vec!["reviewer-a"]);
198        let findings = TwoPartyReviewControl.evaluate(&bundle(change));
199        assert_eq!(findings.len(), 1);
200        assert_eq!(findings[0].status, ControlStatus::Violated);
201        assert!(findings[0].rationale.contains("1"));
202    }
203
204    #[test]
205    fn violated_when_self_approval_reduces_count() {
206        // author + one independent = only 1 independent
207        let change = make_change(vec!["author", "reviewer-a"]);
208        let findings = TwoPartyReviewControl.evaluate(&bundle(change));
209        assert_eq!(findings.len(), 1);
210        assert_eq!(findings[0].status, ControlStatus::Violated);
211    }
212
213    #[test]
214    fn not_applicable_when_no_changes() {
215        let evidence = EvidenceBundle::default();
216        let findings = TwoPartyReviewControl.evaluate(&evidence);
217        assert_eq!(findings.len(), 1);
218        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
219    }
220
221    #[test]
222    fn indeterminate_when_approvals_missing() {
223        let mut change = make_change(vec![]);
224        change.approval_decisions = EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
225            source: "github".to_string(),
226            subject: "reviews".to_string(),
227            detail: "API error".to_string(),
228        }]);
229        let findings = TwoPartyReviewControl.evaluate(&bundle(change));
230        assert_eq!(findings.len(), 1);
231        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
232    }
233
234    #[test]
235    fn indeterminate_when_revisions_missing() {
236        let mut change = make_change(vec!["reviewer-a", "reviewer-b"]);
237        change.source_revisions = EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
238            source: "github".to_string(),
239            subject: "commits".to_string(),
240            detail: "API error".to_string(),
241        }]);
242        let findings = TwoPartyReviewControl.evaluate(&bundle(change));
243        assert_eq!(findings.len(), 1);
244        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
245    }
246
247    #[test]
248    fn satisfied_with_three_independent_approvers() {
249        let change = make_change(vec!["reviewer-a", "reviewer-b", "reviewer-c"]);
250        let findings = TwoPartyReviewControl.evaluate(&bundle(change));
251        assert_eq!(findings.len(), 1);
252        assert_eq!(findings[0].status, ControlStatus::Satisfied);
253        assert!(findings[0].rationale.contains("3"));
254    }
255
256    #[test]
257    fn violated_when_zero_approvals() {
258        let change = make_change(vec![]);
259        let findings = TwoPartyReviewControl.evaluate(&bundle(change));
260        assert_eq!(findings.len(), 1);
261        assert_eq!(findings[0].status, ControlStatus::Violated);
262        assert!(findings[0].rationale.contains("0"));
263    }
264
265    #[test]
266    fn indeterminate_when_submitted_by_missing() {
267        let mut change = make_change(vec!["reviewer-a", "reviewer-b"]);
268        change.submitted_by = None;
269        let findings = TwoPartyReviewControl.evaluate(&bundle(change));
270        assert_eq!(findings.len(), 1);
271        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
272    }
273
274    #[test]
275    fn correct_control_id() {
276        assert_eq!(
277            TwoPartyReviewControl.id(),
278            builtin::id(builtin::TWO_PARTY_REVIEW)
279        );
280    }
281}