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    /// A pull request was opened on a repository, or a closed, unmerged one
168    /// was reopened (`git-ns/bridge/event` 0.4 `pullRequestOpened`). Only
169    /// who and where: never its title, body, branches or contents.
170    PullRequestOpened {
171        /// The repository it targets.
172        repo: Resource,
173        /// Its forge id.
174        forge_id: u64,
175        /// The pull request's number in `repo`.
176        number: u64,
177        /// `true` for a reopen, `false` for an opening.
178        reopened: bool,
179        /// Who opened the pull request.
180        author: ForgeAccount,
181        /// Who performed this action, as the forge records it: the author
182        /// for an opening, whoever reopened it for a reopen.
183        actor: ForgeAccount,
184        /// Whether it is a draft, when the forge says.
185        draft: Option<bool>,
186        /// Whether its head branch lives in another repository, when the
187        /// forge says.
188        from_fork: Option<bool>,
189    },
190    /// The automation installation on a namespace changed.
191    InstallationChanged {
192        /// The namespace.
193        namespace: Resource,
194        /// The installation id.
195        installation_id: u64,
196        /// How.
197        change: InstallationChange,
198    },
199}
200
201/// One way the protection falls short of §5.3.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase", tag = "type")]
204#[non_exhaustive]
205pub enum ProtectionGap {
206    /// The managed rule is gone.
207    Missing,
208    /// It exists but is not enforced.
209    NotEnforced,
210    /// It does not cover the default branch.
211    DefaultBranchNotCovered,
212    /// Pull requests are not required.
213    PullRequestNotRequired,
214    /// The check is not among the required ones.
215    CheckNotRequired {
216        /// The check that should be required.
217        check: String,
218    },
219    /// Force-pushes are allowed.
220    ForcePushAllowed,
221    /// Deletion is allowed.
222    DeletionAllowed,
223    /// Someone can bypass it.
224    BypassActors {
225        /// Who, as the forge describes them.
226        actors: Vec<String>,
227    },
228    /// The check's own workflow is within reach of the change under test:
229    /// the namespace required workflow is missing, weakened or re-pinned, or
230    /// the owner review that guards the repository's workflow is off.
231    CheckSourceUnprotected {
232        /// What is wrong, in the forge's terms.
233        detail: String,
234    },
235    /// A pull request could change these paths — the workflow or the exempt
236    /// keyring — and so rewrite the check it is judged by.
237    UnprotectedPaths {
238        /// The paths (forge glob syntax) that should be protected and are not.
239        paths: Vec<String>,
240    },
241    /// A merge method is allowed that lands commits the check never saw
242    /// (a forge-made merge, rebase or squash commit).
243    MergeMethodAllowed {
244        /// The method.
245        method: MergeMethod,
246    },
247    /// CI is disabled on the repository: the required check can never report.
248    CiDisabled,
249}
250
251/// A difference between forge state and the VTC projection (§5.6 table).
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase", tag = "type")]
254#[non_exhaustive]
255pub enum Drift {
256    /// Someone holds a direct role the VTC did not grant (added in the forge
257    /// UI). Default response: report; adopt or revert.
258    UnexpectedRole {
259        /// Who.
260        account: ForgeAccount,
261        /// Their forge role.
262        observed: ForgeRole,
263    },
264    /// Someone the VTC granted has no role. Default response: re-apply.
265    MissingRole {
266        /// Who.
267        account: ForgeAccount,
268        /// The role they should have.
269        expected: ForgeRole,
270    },
271    /// Someone's role differs from the projection.
272    RoleMismatch {
273        /// Who.
274        account: ForgeAccount,
275        /// The role they should have.
276        expected: ForgeRole,
277        /// The role they have.
278        observed: ForgeRole,
279    },
280    /// The required-check protection is weaker than it must be. Default
281    /// response: re-apply and alert (§5.6).
282    ProtectionWeakened {
283        /// Every shortfall found.
284        gaps: Vec<ProtectionGap>,
285    },
286    /// The repository now lives at another resource (same forge id).
287    /// Registry tuples must be rewritten (§9, rename attacks).
288    Renamed {
289        /// Where the VTC thinks it is.
290        expected: Resource,
291        /// Where it is.
292        observed: Resource,
293    },
294    /// The forge id differs: this is a different repository under the same
295    /// name — never inherit grants onto it.
296    Replaced {
297        /// The id the VTC recorded.
298        expected: u64,
299        /// The id there now.
300        observed: u64,
301    },
302    /// Archived state differs.
303    ArchiveMismatch {
304        /// Desired.
305        expected: bool,
306        /// Observed.
307        observed: bool,
308    },
309    /// Visibility differs.
310    VisibilityMismatch {
311        /// Desired.
312        expected: Visibility,
313        /// Observed.
314        observed: Visibility,
315    },
316    /// The repository was bootstrapped for a different shape than it has
317    /// now (its owner count crossed one ↔ two, say): run its bootstrap plan
318    /// again. Not critical by itself; a weakening is reported separately.
319    ReplanNeeded {
320        /// Why.
321        reason: String,
322    },
323}
324
325impl Drift {
326    /// Whether this drift removes the commit-trust guarantee or points
327    /// rights at the wrong repository. These are enforced by default rather
328    /// than reported (§5.6): a weakened ruleset lets unverified commits
329    /// merge, and a replaced repo must not inherit grants.
330    pub fn is_critical(&self) -> bool {
331        matches!(
332            self,
333            Drift::ProtectionWeakened { .. } | Drift::Replaced { .. } | Drift::Renamed { .. }
334        )
335    }
336}
337
338/// Protection shortfalls of `observed` against a required `check`.
339pub fn protection_gaps(observed: &ProtectionState, check: &str) -> Vec<ProtectionGap> {
340    if !observed.present {
341        let mut gaps = vec![ProtectionGap::Missing];
342        gaps.extend(observed.other_gaps.iter().cloned());
343        return gaps;
344    }
345    let mut gaps = Vec::new();
346    if !observed.enforced {
347        gaps.push(ProtectionGap::NotEnforced);
348    }
349    if !observed.covers_default_branch {
350        gaps.push(ProtectionGap::DefaultBranchNotCovered);
351    }
352    if !observed.requires_pull_request {
353        gaps.push(ProtectionGap::PullRequestNotRequired);
354    }
355    if !observed.required_checks.iter().any(|c| c == check) {
356        gaps.push(ProtectionGap::CheckNotRequired {
357            check: check.to_string(),
358        });
359    }
360    if !observed.blocks_force_push {
361        gaps.push(ProtectionGap::ForcePushAllowed);
362    }
363    if !observed.blocks_deletion {
364        gaps.push(ProtectionGap::DeletionAllowed);
365    }
366    if !observed.bypass_actors.is_empty() {
367        gaps.push(ProtectionGap::BypassActors {
368            actors: observed.bypass_actors.clone(),
369        });
370    }
371    gaps.extend(observed.other_gaps.iter().cloned());
372    gaps
373}
374
375/// The default [`crate::Forge::diff`]: compare observed state with the
376/// projection field by field. Roles are matched on the numeric account id,
377/// never the login; a pending invitation counts as present.
378pub fn default_diff(observed: &RepoState, desired: &Projection) -> Vec<Drift> {
379    let mut drift = Vec::new();
380
381    if let Some(expected) = desired.forge_id
382        && expected != observed.forge_id
383    {
384        // A different repository: nothing else about it is comparable, and
385        // reporting role drift on it would invite "fixing" a stranger's repo.
386        return vec![Drift::Replaced {
387            expected,
388            observed: observed.forge_id,
389        }];
390    }
391    if observed.resource != desired.resource {
392        drift.push(Drift::Renamed {
393            expected: desired.resource.clone(),
394            observed: observed.resource.clone(),
395        });
396    }
397    if observed.archived != desired.archived {
398        drift.push(Drift::ArchiveMismatch {
399            expected: desired.archived,
400            observed: observed.archived,
401        });
402    }
403    if let Some(expected) = desired.visibility
404        && expected != observed.visibility
405    {
406        drift.push(Drift::VisibilityMismatch {
407            expected,
408            observed: observed.visibility,
409        });
410    }
411    if let Some(check) = &desired.required_check {
412        let gaps = protection_gaps(&observed.protection, check);
413        if !gaps.is_empty() {
414            drift.push(Drift::ProtectionWeakened { gaps });
415        }
416    }
417
418    drift.extend(role_drift(observed, &desired.roles));
419    drift
420}
421
422fn role_drift(observed: &RepoState, desired: &[RoleAssignment]) -> Vec<Drift> {
423    let mut drift = Vec::new();
424    for want in desired {
425        let have = observed
426            .collaborators
427            .iter()
428            .find(|c| c.account.id == want.account.id);
429        match have {
430            None if want.role != ForgeRole::None => drift.push(Drift::MissingRole {
431                account: want.account.clone(),
432                expected: want.role,
433            }),
434            Some(c) if want.role == ForgeRole::None => drift.push(Drift::UnexpectedRole {
435                account: c.account.clone(),
436                observed: c.role,
437            }),
438            Some(c) if c.role != want.role => drift.push(Drift::RoleMismatch {
439                account: c.account.clone(),
440                expected: want.role,
441                observed: c.role,
442            }),
443            _ => {}
444        }
445    }
446    for c in &observed.collaborators {
447        if c.role != ForgeRole::None && !desired.iter().any(|d| d.account.id == c.account.id) {
448            drift.push(Drift::UnexpectedRole {
449                account: c.account.clone(),
450                observed: c.role,
451            });
452        }
453    }
454    drift
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use crate::model::Collaborator;
461
462    fn res(s: &str) -> Resource {
463        Resource::parse(s).unwrap()
464    }
465
466    fn protected(check: &str) -> ProtectionState {
467        ProtectionState {
468            present: true,
469            enforced: true,
470            covers_default_branch: true,
471            requires_pull_request: true,
472            required_checks: vec![check.into()],
473            blocks_force_push: true,
474            blocks_deletion: true,
475            bypass_actors: vec![],
476            ..ProtectionState::default()
477        }
478    }
479
480    fn alice() -> ForgeAccount {
481        ForgeAccount::new(1, "alice")
482    }
483    fn bob() -> ForgeAccount {
484        ForgeAccount::new(2, "bob")
485    }
486
487    #[test]
488    fn a_matching_repo_has_no_drift() {
489        let mut state = RepoState::new(res("github.com/acme/w"), 9);
490        state.protection = protected("Verify commit trust");
491        state.collaborators = vec![
492            Collaborator::new(ForgeAccount::new(1, "alice-renamed"), ForgeRole::Admin),
493            Collaborator::invited(bob(), ForgeRole::Maintain),
494        ];
495        let mut want = Projection::new(res("github.com/acme/w"));
496        want.forge_id = Some(9);
497        want.required_check = Some("Verify commit trust".into());
498        want.roles = vec![
499            RoleAssignment::new(alice(), ForgeRole::Admin),
500            RoleAssignment::new(bob(), ForgeRole::Maintain),
501        ];
502        assert_eq!(default_diff(&state, &want), vec![]);
503    }
504
505    #[test]
506    fn role_drift_is_matched_by_id() {
507        let mut state = RepoState::new(res("github.com/acme/w"), 9);
508        state.collaborators = vec![
509            Collaborator::new(alice(), ForgeRole::Write),
510            Collaborator::new(ForgeAccount::new(3, "mallory"), ForgeRole::Admin),
511        ];
512        let mut want = Projection::new(res("github.com/acme/w"));
513        want.roles = vec![
514            RoleAssignment::new(alice(), ForgeRole::Admin),
515            RoleAssignment::new(bob(), ForgeRole::Maintain),
516        ];
517        let drift = default_diff(&state, &want);
518        assert_eq!(
519            drift,
520            vec![
521                Drift::RoleMismatch {
522                    account: alice(),
523                    expected: ForgeRole::Admin,
524                    observed: ForgeRole::Write
525                },
526                Drift::MissingRole {
527                    account: bob(),
528                    expected: ForgeRole::Maintain
529                },
530                Drift::UnexpectedRole {
531                    account: ForgeAccount::new(3, "mallory"),
532                    observed: ForgeRole::Admin
533                },
534            ]
535        );
536        assert!(!drift.iter().any(Drift::is_critical));
537    }
538
539    #[test]
540    fn weakened_protection_lists_every_gap() {
541        let mut state = RepoState::new(res("github.com/acme/w"), 9);
542        let mut p = protected("something else");
543        p.enforced = false;
544        p.blocks_force_push = false;
545        p.bypass_actors = vec!["OrganizationAdmin".into()];
546        state.protection = p;
547        let mut want = Projection::new(res("github.com/acme/w"));
548        want.required_check = Some("Verify commit trust".into());
549        let drift = default_diff(&state, &want);
550        assert_eq!(
551            drift,
552            vec![Drift::ProtectionWeakened {
553                gaps: vec![
554                    ProtectionGap::NotEnforced,
555                    ProtectionGap::CheckNotRequired {
556                        check: "Verify commit trust".into()
557                    },
558                    ProtectionGap::ForcePushAllowed,
559                    ProtectionGap::BypassActors {
560                        actors: vec!["OrganizationAdmin".into()]
561                    },
562                ]
563            }]
564        );
565        assert!(drift[0].is_critical());
566
567        state.protection = ProtectionState::default();
568        assert_eq!(
569            default_diff(&state, &want),
570            vec![Drift::ProtectionWeakened {
571                gaps: vec![ProtectionGap::Missing]
572            }]
573        );
574    }
575
576    #[test]
577    fn gaps_in_what_guards_the_workflow_are_reported_too() {
578        let mut state = RepoState::new(res("github.com/acme/w"), 9);
579        let unprotected = ProtectionGap::CheckSourceUnprotected {
580            detail: "the org ruleset is missing".into(),
581        };
582        state.protection = protected("Verify commit trust");
583        state.protection.other_gaps = vec![unprotected.clone()];
584        let mut want = Projection::new(res("github.com/acme/w"));
585        want.required_check = Some("Verify commit trust".into());
586        let drift = default_diff(&state, &want);
587        assert_eq!(
588            drift,
589            vec![Drift::ProtectionWeakened {
590                gaps: vec![unprotected.clone()]
591            }]
592        );
593        assert!(drift[0].is_critical());
594
595        // Alongside a missing repo rule, not instead of it.
596        state.protection = ProtectionState {
597            other_gaps: vec![unprotected.clone()],
598            ..ProtectionState::default()
599        };
600        assert_eq!(
601            protection_gaps(&state.protection, "Verify commit trust"),
602            vec![ProtectionGap::Missing, unprotected]
603        );
604    }
605
606    #[test]
607    fn rename_and_replacement_are_told_apart_by_forge_id() {
608        let state = RepoState::new(res("github.com/acme/new-name"), 9);
609        let mut want = Projection::new(res("github.com/acme/w"));
610        want.forge_id = Some(9);
611        assert_eq!(
612            default_diff(&state, &want),
613            vec![Drift::Renamed {
614                expected: res("github.com/acme/w"),
615                observed: res("github.com/acme/new-name")
616            }]
617        );
618
619        let imposter = RepoState::new(res("github.com/acme/w"), 10);
620        assert_eq!(
621            default_diff(&imposter, &want),
622            vec![Drift::Replaced {
623                expected: 9,
624                observed: 10
625            }]
626        );
627    }
628}