Skip to main content

vgi_forge/
event.rs

1//! Forge events and drift (§5.6).
2//!
3//! A [`ForgeEvent`] is a verified webhook translated into forge-neutral
4//! terms: the core decides what to do about it. A [`Drift`] is one way the
5//! forge's state differs from the VTC's projection, found by comparing an
6//! [`crate::RepoState`] to a [`crate::Projection`] — whether an event
7//! prompted the comparison or a scheduled sweep did.
8
9use serde::{Deserialize, Serialize};
10
11use crate::bootstrap::MergeMethod;
12use crate::model::{
13    ForgeAccount, Projection, ProtectionState, RepoState, RoleAssignment, Visibility,
14};
15use crate::resource::Resource;
16use crate::rights::ForgeRole;
17
18/// A verified, translated webhook delivery.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21#[non_exhaustive]
22pub struct ForgeEvent {
23    /// The forge's delivery id, for de-duplication. Webhook signatures carry
24    /// no timestamp, so a replayed delivery verifies; dropping repeats by id
25    /// is the core's job.
26    pub delivery_id: Option<String>,
27    /// What happened.
28    pub kind: ForgeEventKind,
29}
30
31impl ForgeEvent {
32    /// An event.
33    pub fn new(delivery_id: Option<String>, kind: ForgeEventKind) -> Self {
34        ForgeEvent { delivery_id, kind }
35    }
36}
37
38/// How a membership or collaborator changed.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase")]
41#[non_exhaustive]
42pub enum MemberChange {
43    /// Added.
44    Added,
45    /// Removed.
46    Removed,
47    /// Role or permission edited.
48    Edited,
49}
50
51/// How an automation installation changed.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54#[non_exhaustive]
55pub enum InstallationChange {
56    /// Installed.
57    Created,
58    /// Uninstalled: the namespace can no longer be managed.
59    Deleted,
60    /// Suspended by the owner.
61    Suspended,
62    /// Unsuspended.
63    Unsuspended,
64    /// The owner accepted a permission change.
65    PermissionsAccepted,
66}
67
68/// What a [`ForgeEvent`] reports.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase", tag = "type")]
71#[non_exhaustive]
72pub enum ForgeEventKind {
73    /// A repository appeared — through the bridge or not (§5.6 *unmanaged*).
74    RepoCreated {
75        /// The repository.
76        repo: Resource,
77        /// Its forge id.
78        forge_id: u64,
79    },
80    /// A repository was deleted.
81    RepoDeleted {
82        /// The repository.
83        repo: Resource,
84        /// Its forge id.
85        forge_id: u64,
86    },
87    /// A repository was renamed within its owner.
88    RepoRenamed {
89        /// Its forge id — what the VTC keys rights on.
90        forge_id: u64,
91        /// The old resource.
92        from: Resource,
93        /// The new resource.
94        to: Resource,
95    },
96    /// A repository moved to another owner.
97    RepoTransferred {
98        /// Its forge id.
99        forge_id: u64,
100        /// The previous owner's namespace, when the forge says.
101        from_namespace: Option<Resource>,
102        /// The new resource.
103        to: Resource,
104    },
105    /// Archived or unarchived.
106    RepoArchived {
107        /// The repository.
108        repo: Resource,
109        /// Its forge id.
110        forge_id: u64,
111        /// The new state.
112        archived: bool,
113    },
114    /// Visibility changed.
115    RepoVisibilityChanged {
116        /// The repository.
117        repo: Resource,
118        /// Its forge id.
119        forge_id: u64,
120        /// The new visibility.
121        visibility: Visibility,
122    },
123    /// A direct collaborator was added, removed or changed.
124    CollaboratorChanged {
125        /// The repository.
126        repo: Resource,
127        /// Its forge id.
128        forge_id: u64,
129        /// Who.
130        account: ForgeAccount,
131        /// How.
132        change: MemberChange,
133    },
134    /// Someone joined or left the organisation.
135    OrgMembershipChanged {
136        /// The namespace.
137        namespace: Resource,
138        /// Who.
139        account: ForgeAccount,
140        /// How.
141        change: MemberChange,
142    },
143    /// Someone joined or left a team in the organisation. Distinct from
144    /// [`ForgeEventKind::OrgMembershipChanged`]: leaving a team does not mean
145    /// leaving the organisation.
146    TeamMembershipChanged {
147        /// The namespace.
148        namespace: Resource,
149        /// The team's slug.
150        team: String,
151        /// Who.
152        account: ForgeAccount,
153        /// How.
154        change: MemberChange,
155    },
156    /// Branch protection or a ruleset changed. Always worth an `inspect`:
157    /// a weakened rule is the drift that silently removes the guarantee.
158    ProtectionChanged {
159        /// The repository, or `None` for an owner-level rule.
160        repo: Option<Resource>,
161        /// The namespace.
162        namespace: Resource,
163        /// The forge's action word (`created`, `edited`, `deleted`), for the
164        /// audit log.
165        action: String,
166    },
167    /// The automation installation on a namespace changed.
168    InstallationChanged {
169        /// The namespace.
170        namespace: Resource,
171        /// The installation id.
172        installation_id: u64,
173        /// How.
174        change: InstallationChange,
175    },
176}
177
178/// One way the protection falls short of §5.3.
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "camelCase", tag = "type")]
181#[non_exhaustive]
182pub enum ProtectionGap {
183    /// The managed rule is gone.
184    Missing,
185    /// It exists but is not enforced.
186    NotEnforced,
187    /// It does not cover the default branch.
188    DefaultBranchNotCovered,
189    /// Pull requests are not required.
190    PullRequestNotRequired,
191    /// The check is not among the required ones.
192    CheckNotRequired {
193        /// The check that should be required.
194        check: String,
195    },
196    /// Force-pushes are allowed.
197    ForcePushAllowed,
198    /// Deletion is allowed.
199    DeletionAllowed,
200    /// Someone can bypass it.
201    BypassActors {
202        /// Who, as the forge describes them.
203        actors: Vec<String>,
204    },
205    /// The check's own workflow is within reach of the change under test:
206    /// the namespace required workflow is missing, weakened or re-pinned, or
207    /// the owner review that guards the repository's workflow is off.
208    CheckSourceUnprotected {
209        /// What is wrong, in the forge's terms.
210        detail: String,
211    },
212    /// A pull request could change these paths — the workflow or the exempt
213    /// keyring — and so rewrite the check it is judged by.
214    UnprotectedPaths {
215        /// The paths (forge glob syntax) that should be protected and are not.
216        paths: Vec<String>,
217    },
218    /// A merge method is allowed that lands commits the check never saw
219    /// (a forge-made merge, rebase or squash commit).
220    MergeMethodAllowed {
221        /// The method.
222        method: MergeMethod,
223    },
224    /// CI is disabled on the repository: the required check can never report.
225    CiDisabled,
226}
227
228/// A difference between forge state and the VTC projection (§5.6 table).
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(rename_all = "camelCase", tag = "type")]
231#[non_exhaustive]
232pub enum Drift {
233    /// Someone holds a direct role the VTC did not grant (added in the forge
234    /// UI). Default response: report; adopt or revert.
235    UnexpectedRole {
236        /// Who.
237        account: ForgeAccount,
238        /// Their forge role.
239        observed: ForgeRole,
240    },
241    /// Someone the VTC granted has no role. Default response: re-apply.
242    MissingRole {
243        /// Who.
244        account: ForgeAccount,
245        /// The role they should have.
246        expected: ForgeRole,
247    },
248    /// Someone's role differs from the projection.
249    RoleMismatch {
250        /// Who.
251        account: ForgeAccount,
252        /// The role they should have.
253        expected: ForgeRole,
254        /// The role they have.
255        observed: ForgeRole,
256    },
257    /// The required-check protection is weaker than it must be. Default
258    /// response: re-apply and alert (§5.6).
259    ProtectionWeakened {
260        /// Every shortfall found.
261        gaps: Vec<ProtectionGap>,
262    },
263    /// The repository now lives at another resource (same forge id).
264    /// Registry tuples must be rewritten (§9, rename attacks).
265    Renamed {
266        /// Where the VTC thinks it is.
267        expected: Resource,
268        /// Where it is.
269        observed: Resource,
270    },
271    /// The forge id differs: this is a different repository under the same
272    /// name — never inherit grants onto it.
273    Replaced {
274        /// The id the VTC recorded.
275        expected: u64,
276        /// The id there now.
277        observed: u64,
278    },
279    /// Archived state differs.
280    ArchiveMismatch {
281        /// Desired.
282        expected: bool,
283        /// Observed.
284        observed: bool,
285    },
286    /// Visibility differs.
287    VisibilityMismatch {
288        /// Desired.
289        expected: Visibility,
290        /// Observed.
291        observed: Visibility,
292    },
293    /// The repository was bootstrapped for a different shape than it has
294    /// now (its owner count crossed one ↔ two, say): run its bootstrap plan
295    /// again. Not critical by itself; a weakening is reported separately.
296    ReplanNeeded {
297        /// Why.
298        reason: String,
299    },
300}
301
302impl Drift {
303    /// Whether this drift removes the commit-trust guarantee or points
304    /// rights at the wrong repository. These are enforced by default rather
305    /// than reported (§5.6): a weakened ruleset lets unverified commits
306    /// merge, and a replaced repo must not inherit grants.
307    pub fn is_critical(&self) -> bool {
308        matches!(
309            self,
310            Drift::ProtectionWeakened { .. } | Drift::Replaced { .. } | Drift::Renamed { .. }
311        )
312    }
313}
314
315/// Protection shortfalls of `observed` against a required `check`.
316pub fn protection_gaps(observed: &ProtectionState, check: &str) -> Vec<ProtectionGap> {
317    if !observed.present {
318        let mut gaps = vec![ProtectionGap::Missing];
319        gaps.extend(observed.other_gaps.iter().cloned());
320        return gaps;
321    }
322    let mut gaps = Vec::new();
323    if !observed.enforced {
324        gaps.push(ProtectionGap::NotEnforced);
325    }
326    if !observed.covers_default_branch {
327        gaps.push(ProtectionGap::DefaultBranchNotCovered);
328    }
329    if !observed.requires_pull_request {
330        gaps.push(ProtectionGap::PullRequestNotRequired);
331    }
332    if !observed.required_checks.iter().any(|c| c == check) {
333        gaps.push(ProtectionGap::CheckNotRequired {
334            check: check.to_string(),
335        });
336    }
337    if !observed.blocks_force_push {
338        gaps.push(ProtectionGap::ForcePushAllowed);
339    }
340    if !observed.blocks_deletion {
341        gaps.push(ProtectionGap::DeletionAllowed);
342    }
343    if !observed.bypass_actors.is_empty() {
344        gaps.push(ProtectionGap::BypassActors {
345            actors: observed.bypass_actors.clone(),
346        });
347    }
348    gaps.extend(observed.other_gaps.iter().cloned());
349    gaps
350}
351
352/// The default [`crate::Forge::diff`]: compare observed state with the
353/// projection field by field. Roles are matched on the numeric account id,
354/// never the login; a pending invitation counts as present.
355pub fn default_diff(observed: &RepoState, desired: &Projection) -> Vec<Drift> {
356    let mut drift = Vec::new();
357
358    if let Some(expected) = desired.forge_id
359        && expected != observed.forge_id
360    {
361        // A different repository: nothing else about it is comparable, and
362        // reporting role drift on it would invite "fixing" a stranger's repo.
363        return vec![Drift::Replaced {
364            expected,
365            observed: observed.forge_id,
366        }];
367    }
368    if observed.resource != desired.resource {
369        drift.push(Drift::Renamed {
370            expected: desired.resource.clone(),
371            observed: observed.resource.clone(),
372        });
373    }
374    if observed.archived != desired.archived {
375        drift.push(Drift::ArchiveMismatch {
376            expected: desired.archived,
377            observed: observed.archived,
378        });
379    }
380    if let Some(expected) = desired.visibility
381        && expected != observed.visibility
382    {
383        drift.push(Drift::VisibilityMismatch {
384            expected,
385            observed: observed.visibility,
386        });
387    }
388    if let Some(check) = &desired.required_check {
389        let gaps = protection_gaps(&observed.protection, check);
390        if !gaps.is_empty() {
391            drift.push(Drift::ProtectionWeakened { gaps });
392        }
393    }
394
395    drift.extend(role_drift(observed, &desired.roles));
396    drift
397}
398
399fn role_drift(observed: &RepoState, desired: &[RoleAssignment]) -> Vec<Drift> {
400    let mut drift = Vec::new();
401    for want in desired {
402        let have = observed
403            .collaborators
404            .iter()
405            .find(|c| c.account.id == want.account.id);
406        match have {
407            None if want.role != ForgeRole::None => drift.push(Drift::MissingRole {
408                account: want.account.clone(),
409                expected: want.role,
410            }),
411            Some(c) if want.role == ForgeRole::None => drift.push(Drift::UnexpectedRole {
412                account: c.account.clone(),
413                observed: c.role,
414            }),
415            Some(c) if c.role != want.role => drift.push(Drift::RoleMismatch {
416                account: c.account.clone(),
417                expected: want.role,
418                observed: c.role,
419            }),
420            _ => {}
421        }
422    }
423    for c in &observed.collaborators {
424        if c.role != ForgeRole::None && !desired.iter().any(|d| d.account.id == c.account.id) {
425            drift.push(Drift::UnexpectedRole {
426                account: c.account.clone(),
427                observed: c.role,
428            });
429        }
430    }
431    drift
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::model::Collaborator;
438
439    fn res(s: &str) -> Resource {
440        Resource::parse(s).unwrap()
441    }
442
443    fn protected(check: &str) -> ProtectionState {
444        ProtectionState {
445            present: true,
446            enforced: true,
447            covers_default_branch: true,
448            requires_pull_request: true,
449            required_checks: vec![check.into()],
450            blocks_force_push: true,
451            blocks_deletion: true,
452            bypass_actors: vec![],
453            ..ProtectionState::default()
454        }
455    }
456
457    fn alice() -> ForgeAccount {
458        ForgeAccount::new(1, "alice")
459    }
460    fn bob() -> ForgeAccount {
461        ForgeAccount::new(2, "bob")
462    }
463
464    #[test]
465    fn a_matching_repo_has_no_drift() {
466        let mut state = RepoState::new(res("github.com/acme/w"), 9);
467        state.protection = protected("Verify commit trust");
468        state.collaborators = vec![
469            Collaborator::new(ForgeAccount::new(1, "alice-renamed"), ForgeRole::Admin),
470            Collaborator::invited(bob(), ForgeRole::Maintain),
471        ];
472        let mut want = Projection::new(res("github.com/acme/w"));
473        want.forge_id = Some(9);
474        want.required_check = Some("Verify commit trust".into());
475        want.roles = vec![
476            RoleAssignment::new(alice(), ForgeRole::Admin),
477            RoleAssignment::new(bob(), ForgeRole::Maintain),
478        ];
479        assert_eq!(default_diff(&state, &want), vec![]);
480    }
481
482    #[test]
483    fn role_drift_is_matched_by_id() {
484        let mut state = RepoState::new(res("github.com/acme/w"), 9);
485        state.collaborators = vec![
486            Collaborator::new(alice(), ForgeRole::Write),
487            Collaborator::new(ForgeAccount::new(3, "mallory"), ForgeRole::Admin),
488        ];
489        let mut want = Projection::new(res("github.com/acme/w"));
490        want.roles = vec![
491            RoleAssignment::new(alice(), ForgeRole::Admin),
492            RoleAssignment::new(bob(), ForgeRole::Maintain),
493        ];
494        let drift = default_diff(&state, &want);
495        assert_eq!(
496            drift,
497            vec![
498                Drift::RoleMismatch {
499                    account: alice(),
500                    expected: ForgeRole::Admin,
501                    observed: ForgeRole::Write
502                },
503                Drift::MissingRole {
504                    account: bob(),
505                    expected: ForgeRole::Maintain
506                },
507                Drift::UnexpectedRole {
508                    account: ForgeAccount::new(3, "mallory"),
509                    observed: ForgeRole::Admin
510                },
511            ]
512        );
513        assert!(!drift.iter().any(Drift::is_critical));
514    }
515
516    #[test]
517    fn weakened_protection_lists_every_gap() {
518        let mut state = RepoState::new(res("github.com/acme/w"), 9);
519        let mut p = protected("something else");
520        p.enforced = false;
521        p.blocks_force_push = false;
522        p.bypass_actors = vec!["OrganizationAdmin".into()];
523        state.protection = p;
524        let mut want = Projection::new(res("github.com/acme/w"));
525        want.required_check = Some("Verify commit trust".into());
526        let drift = default_diff(&state, &want);
527        assert_eq!(
528            drift,
529            vec![Drift::ProtectionWeakened {
530                gaps: vec![
531                    ProtectionGap::NotEnforced,
532                    ProtectionGap::CheckNotRequired {
533                        check: "Verify commit trust".into()
534                    },
535                    ProtectionGap::ForcePushAllowed,
536                    ProtectionGap::BypassActors {
537                        actors: vec!["OrganizationAdmin".into()]
538                    },
539                ]
540            }]
541        );
542        assert!(drift[0].is_critical());
543
544        state.protection = ProtectionState::default();
545        assert_eq!(
546            default_diff(&state, &want),
547            vec![Drift::ProtectionWeakened {
548                gaps: vec![ProtectionGap::Missing]
549            }]
550        );
551    }
552
553    #[test]
554    fn gaps_in_what_guards_the_workflow_are_reported_too() {
555        let mut state = RepoState::new(res("github.com/acme/w"), 9);
556        let unprotected = ProtectionGap::CheckSourceUnprotected {
557            detail: "the org ruleset is missing".into(),
558        };
559        state.protection = protected("Verify commit trust");
560        state.protection.other_gaps = vec![unprotected.clone()];
561        let mut want = Projection::new(res("github.com/acme/w"));
562        want.required_check = Some("Verify commit trust".into());
563        let drift = default_diff(&state, &want);
564        assert_eq!(
565            drift,
566            vec![Drift::ProtectionWeakened {
567                gaps: vec![unprotected.clone()]
568            }]
569        );
570        assert!(drift[0].is_critical());
571
572        // Alongside a missing repo rule, not instead of it.
573        state.protection = ProtectionState {
574            other_gaps: vec![unprotected.clone()],
575            ..ProtectionState::default()
576        };
577        assert_eq!(
578            protection_gaps(&state.protection, "Verify commit trust"),
579            vec![ProtectionGap::Missing, unprotected]
580        );
581    }
582
583    #[test]
584    fn rename_and_replacement_are_told_apart_by_forge_id() {
585        let state = RepoState::new(res("github.com/acme/new-name"), 9);
586        let mut want = Projection::new(res("github.com/acme/w"));
587        want.forge_id = Some(9);
588        assert_eq!(
589            default_diff(&state, &want),
590            vec![Drift::Renamed {
591                expected: res("github.com/acme/w"),
592                observed: res("github.com/acme/new-name")
593            }]
594        );
595
596        let imposter = RepoState::new(res("github.com/acme/w"), 10);
597        assert_eq!(
598            default_diff(&imposter, &want),
599            vec![Drift::Replaced {
600                expected: 9,
601                observed: 10
602            }]
603        );
604    }
605}