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    /// The rule asks for fewer approving reviews than the community requires,
250    /// so a pull request can merge with less review than it should.
251    ApprovalsBelow {
252        /// What the community requires.
253        required: u8,
254        /// What the rule asks for.
255        observed: u8,
256    },
257}
258
259/// A difference between forge state and the VTC projection (§5.6 table).
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(rename_all = "camelCase", tag = "type")]
262#[non_exhaustive]
263pub enum Drift {
264    /// Someone holds a direct role the VTC did not grant (added in the forge
265    /// UI). Default response: report; adopt or revert.
266    UnexpectedRole {
267        /// Who.
268        account: ForgeAccount,
269        /// Their forge role.
270        observed: ForgeRole,
271    },
272    /// Someone the VTC granted has no role. Default response: re-apply.
273    MissingRole {
274        /// Who.
275        account: ForgeAccount,
276        /// The role they should have.
277        expected: ForgeRole,
278    },
279    /// Someone's role differs from the projection.
280    RoleMismatch {
281        /// Who.
282        account: ForgeAccount,
283        /// The role they should have.
284        expected: ForgeRole,
285        /// The role they have.
286        observed: ForgeRole,
287    },
288    /// The required-check protection is weaker than it must be. Default
289    /// response: re-apply and alert (§5.6).
290    ProtectionWeakened {
291        /// Every shortfall found.
292        gaps: Vec<ProtectionGap>,
293    },
294    /// The repository now lives at another resource (same forge id).
295    /// Registry tuples must be rewritten (§9, rename attacks).
296    Renamed {
297        /// Where the VTC thinks it is.
298        expected: Resource,
299        /// Where it is.
300        observed: Resource,
301    },
302    /// The forge id differs: this is a different repository under the same
303    /// name — never inherit grants onto it.
304    Replaced {
305        /// The id the VTC recorded.
306        expected: u64,
307        /// The id there now.
308        observed: u64,
309    },
310    /// Archived state differs.
311    ArchiveMismatch {
312        /// Desired.
313        expected: bool,
314        /// Observed.
315        observed: bool,
316    },
317    /// Visibility differs.
318    VisibilityMismatch {
319        /// Desired.
320        expected: Visibility,
321        /// Observed.
322        observed: Visibility,
323    },
324    /// The repository was bootstrapped for a different shape than it has
325    /// now (its owner count crossed one ↔ two, say): run its bootstrap plan
326    /// again. Not critical by itself; a weakening is reported separately.
327    ReplanNeeded {
328        /// Why.
329        reason: String,
330    },
331}
332
333impl Drift {
334    /// Whether this drift removes the commit-trust guarantee or points
335    /// rights at the wrong repository. These are enforced by default rather
336    /// than reported (§5.6): a weakened ruleset lets unverified commits
337    /// merge, and a replaced repo must not inherit grants.
338    pub fn is_critical(&self) -> bool {
339        matches!(
340            self,
341            Drift::ProtectionWeakened { .. } | Drift::Replaced { .. } | Drift::Renamed { .. }
342        )
343    }
344}
345
346/// Protection shortfalls of `observed` against a required `check`.
347pub fn protection_gaps(observed: &ProtectionState, check: &str) -> Vec<ProtectionGap> {
348    if !observed.present {
349        let mut gaps = vec![ProtectionGap::Missing];
350        gaps.extend(observed.other_gaps.iter().cloned());
351        return gaps;
352    }
353    let mut gaps = Vec::new();
354    if !observed.enforced {
355        gaps.push(ProtectionGap::NotEnforced);
356    }
357    if !observed.covers_default_branch {
358        gaps.push(ProtectionGap::DefaultBranchNotCovered);
359    }
360    if !observed.requires_pull_request {
361        gaps.push(ProtectionGap::PullRequestNotRequired);
362    }
363    if !observed.required_checks.iter().any(|c| c == check) {
364        gaps.push(ProtectionGap::CheckNotRequired {
365            check: check.to_string(),
366        });
367    }
368    if !observed.blocks_force_push {
369        gaps.push(ProtectionGap::ForcePushAllowed);
370    }
371    if !observed.blocks_deletion {
372        gaps.push(ProtectionGap::DeletionAllowed);
373    }
374    if !observed.bypass_actors.is_empty() {
375        gaps.push(ProtectionGap::BypassActors {
376            actors: observed.bypass_actors.clone(),
377        });
378    }
379    gaps.extend(observed.other_gaps.iter().cloned());
380    gaps
381}
382
383/// The default [`crate::Forge::diff`]: compare observed state with the
384/// projection field by field. Roles are matched on the numeric account id,
385/// never the login; a pending invitation counts as present.
386pub fn default_diff(observed: &RepoState, desired: &Projection) -> Vec<Drift> {
387    let mut drift = Vec::new();
388
389    if let Some(expected) = desired.forge_id
390        && expected != observed.forge_id
391    {
392        // A different repository: nothing else about it is comparable, and
393        // reporting role drift on it would invite "fixing" a stranger's repo.
394        return vec![Drift::Replaced {
395            expected,
396            observed: observed.forge_id,
397        }];
398    }
399    if observed.resource != desired.resource {
400        drift.push(Drift::Renamed {
401            expected: desired.resource.clone(),
402            observed: observed.resource.clone(),
403        });
404    }
405    if observed.archived != desired.archived {
406        drift.push(Drift::ArchiveMismatch {
407            expected: desired.archived,
408            observed: observed.archived,
409        });
410    }
411    if let Some(expected) = desired.visibility
412        && expected != observed.visibility
413    {
414        drift.push(Drift::VisibilityMismatch {
415            expected,
416            observed: observed.visibility,
417        });
418    }
419    if let Some(check) = &desired.required_check {
420        let mut gaps = protection_gaps(&observed.protection, check);
421        // Only a shortfall: a rule asking for more review is not weaker. A
422        // missing rule is already a gap of its own.
423        if observed.protection.present
424            && observed.protection.required_approvals < desired.required_approvals
425        {
426            gaps.push(ProtectionGap::ApprovalsBelow {
427                required: desired.required_approvals,
428                observed: observed.protection.required_approvals,
429            });
430        }
431        if !gaps.is_empty() {
432            drift.push(Drift::ProtectionWeakened { gaps });
433        }
434    }
435
436    drift.extend(role_drift(observed, &desired.roles));
437    drift
438}
439
440fn role_drift(observed: &RepoState, desired: &[RoleAssignment]) -> Vec<Drift> {
441    let mut drift = Vec::new();
442    for want in desired {
443        let have = observed
444            .collaborators
445            .iter()
446            .find(|c| c.account.id == want.account.id);
447        match have {
448            None if want.role != ForgeRole::None => drift.push(Drift::MissingRole {
449                account: want.account.clone(),
450                expected: want.role,
451            }),
452            Some(c) if want.role == ForgeRole::None => drift.push(Drift::UnexpectedRole {
453                account: c.account.clone(),
454                observed: c.role,
455            }),
456            Some(c) if c.role != want.role => drift.push(Drift::RoleMismatch {
457                account: c.account.clone(),
458                expected: want.role,
459                observed: c.role,
460            }),
461            _ => {}
462        }
463    }
464    for c in &observed.collaborators {
465        if c.role != ForgeRole::None && !desired.iter().any(|d| d.account.id == c.account.id) {
466            drift.push(Drift::UnexpectedRole {
467                account: c.account.clone(),
468                observed: c.role,
469            });
470        }
471    }
472    drift
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::model::Collaborator;
479
480    fn res(s: &str) -> Resource {
481        Resource::parse(s).unwrap()
482    }
483
484    fn protected(check: &str) -> ProtectionState {
485        ProtectionState {
486            present: true,
487            enforced: true,
488            covers_default_branch: true,
489            requires_pull_request: true,
490            required_checks: vec![check.into()],
491            blocks_force_push: true,
492            blocks_deletion: true,
493            bypass_actors: vec![],
494            ..ProtectionState::default()
495        }
496    }
497
498    fn alice() -> ForgeAccount {
499        ForgeAccount::new(1, "alice")
500    }
501    fn bob() -> ForgeAccount {
502        ForgeAccount::new(2, "bob")
503    }
504
505    /// Fewer required approvals than the community asks for is drift; more is
506    /// not (a stricter rule is not weaker), and none is asked of a repository
507    /// whose projection requires none.
508    #[test]
509    fn fewer_approvals_than_required_is_drift_and_more_is_not() {
510        let mut state = RepoState::new(res("github.com/acme/w"), 9);
511        state.protection = protected("Verify commit trust");
512        let mut want = Projection::new(res("github.com/acme/w"));
513        want.forge_id = Some(9);
514        want.required_check = Some("Verify commit trust".into());
515        assert_eq!(default_diff(&state, &want), vec![]);
516
517        want.required_approvals = 2;
518        state.protection.required_approvals = 1;
519        assert_eq!(
520            default_diff(&state, &want),
521            vec![Drift::ProtectionWeakened {
522                gaps: vec![ProtectionGap::ApprovalsBelow {
523                    required: 2,
524                    observed: 1
525                }]
526            }]
527        );
528
529        state.protection.required_approvals = 3;
530        assert_eq!(default_diff(&state, &want), vec![]);
531    }
532
533    #[test]
534    fn a_matching_repo_has_no_drift() {
535        let mut state = RepoState::new(res("github.com/acme/w"), 9);
536        state.protection = protected("Verify commit trust");
537        state.collaborators = vec![
538            Collaborator::new(ForgeAccount::new(1, "alice-renamed"), ForgeRole::Admin),
539            Collaborator::invited(bob(), ForgeRole::Maintain),
540        ];
541        let mut want = Projection::new(res("github.com/acme/w"));
542        want.forge_id = Some(9);
543        want.required_check = Some("Verify commit trust".into());
544        want.roles = vec![
545            RoleAssignment::new(alice(), ForgeRole::Admin),
546            RoleAssignment::new(bob(), ForgeRole::Maintain),
547        ];
548        assert_eq!(default_diff(&state, &want), vec![]);
549    }
550
551    #[test]
552    fn role_drift_is_matched_by_id() {
553        let mut state = RepoState::new(res("github.com/acme/w"), 9);
554        state.collaborators = vec![
555            Collaborator::new(alice(), ForgeRole::Write),
556            Collaborator::new(ForgeAccount::new(3, "mallory"), ForgeRole::Admin),
557        ];
558        let mut want = Projection::new(res("github.com/acme/w"));
559        want.roles = vec![
560            RoleAssignment::new(alice(), ForgeRole::Admin),
561            RoleAssignment::new(bob(), ForgeRole::Maintain),
562        ];
563        let drift = default_diff(&state, &want);
564        assert_eq!(
565            drift,
566            vec![
567                Drift::RoleMismatch {
568                    account: alice(),
569                    expected: ForgeRole::Admin,
570                    observed: ForgeRole::Write
571                },
572                Drift::MissingRole {
573                    account: bob(),
574                    expected: ForgeRole::Maintain
575                },
576                Drift::UnexpectedRole {
577                    account: ForgeAccount::new(3, "mallory"),
578                    observed: ForgeRole::Admin
579                },
580            ]
581        );
582        assert!(!drift.iter().any(Drift::is_critical));
583    }
584
585    #[test]
586    fn weakened_protection_lists_every_gap() {
587        let mut state = RepoState::new(res("github.com/acme/w"), 9);
588        let mut p = protected("something else");
589        p.enforced = false;
590        p.blocks_force_push = false;
591        p.bypass_actors = vec!["OrganizationAdmin".into()];
592        state.protection = p;
593        let mut want = Projection::new(res("github.com/acme/w"));
594        want.required_check = Some("Verify commit trust".into());
595        let drift = default_diff(&state, &want);
596        assert_eq!(
597            drift,
598            vec![Drift::ProtectionWeakened {
599                gaps: vec![
600                    ProtectionGap::NotEnforced,
601                    ProtectionGap::CheckNotRequired {
602                        check: "Verify commit trust".into()
603                    },
604                    ProtectionGap::ForcePushAllowed,
605                    ProtectionGap::BypassActors {
606                        actors: vec!["OrganizationAdmin".into()]
607                    },
608                ]
609            }]
610        );
611        assert!(drift[0].is_critical());
612
613        state.protection = ProtectionState::default();
614        assert_eq!(
615            default_diff(&state, &want),
616            vec![Drift::ProtectionWeakened {
617                gaps: vec![ProtectionGap::Missing]
618            }]
619        );
620    }
621
622    #[test]
623    fn gaps_in_what_guards_the_workflow_are_reported_too() {
624        let mut state = RepoState::new(res("github.com/acme/w"), 9);
625        let unprotected = ProtectionGap::CheckSourceUnprotected {
626            detail: "the org ruleset is missing".into(),
627        };
628        state.protection = protected("Verify commit trust");
629        state.protection.other_gaps = vec![unprotected.clone()];
630        let mut want = Projection::new(res("github.com/acme/w"));
631        want.required_check = Some("Verify commit trust".into());
632        let drift = default_diff(&state, &want);
633        assert_eq!(
634            drift,
635            vec![Drift::ProtectionWeakened {
636                gaps: vec![unprotected.clone()]
637            }]
638        );
639        assert!(drift[0].is_critical());
640
641        // Alongside a missing repo rule, not instead of it.
642        state.protection = ProtectionState {
643            other_gaps: vec![unprotected.clone()],
644            ..ProtectionState::default()
645        };
646        assert_eq!(
647            protection_gaps(&state.protection, "Verify commit trust"),
648            vec![ProtectionGap::Missing, unprotected]
649        );
650    }
651
652    #[test]
653    fn rename_and_replacement_are_told_apart_by_forge_id() {
654        let state = RepoState::new(res("github.com/acme/new-name"), 9);
655        let mut want = Projection::new(res("github.com/acme/w"));
656        want.forge_id = Some(9);
657        assert_eq!(
658            default_diff(&state, &want),
659            vec![Drift::Renamed {
660                expected: res("github.com/acme/w"),
661                observed: res("github.com/acme/new-name")
662            }]
663        );
664
665        let imposter = RepoState::new(res("github.com/acme/w"), 10);
666        assert_eq!(
667            default_diff(&imposter, &want),
668            vec![Drift::Replaced {
669                expected: 9,
670                observed: 10
671            }]
672        );
673    }
674}