Skip to main content

vgi_forge/
rights.rs

1//! VTC rights (§4.2) and how they project onto forge roles.
2//!
3//! The VTC evaluates delegation and implication; an adapter only ever sees
4//! the result for one person on one repository, as [`EffectiveRights`], and
5//! turns it into one [`ForgeRole`]. The mapping never grants more than the
6//! community asked for: a forge whose role ladder lacks the requested level
7//! gets the next level *down*, never up (§5.8, "fewer `role_levels`").
8
9use std::fmt;
10
11use serde::{Deserialize, Serialize};
12
13/// One of the five git rights a VTC grants (§4.2), by registry action.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
15#[non_exhaustive]
16pub enum Right {
17    /// `git.ns.admin` on a namespace.
18    #[serde(rename = "git.ns.admin")]
19    NsAdmin,
20    /// `git.repo.create` on a namespace.
21    #[serde(rename = "git.repo.create")]
22    RepoCreate,
23    /// `git.repo.own` on a repository.
24    #[serde(rename = "git.repo.own")]
25    RepoOwn,
26    /// `git.repo.maintain` on a repository.
27    #[serde(rename = "git.repo.maintain")]
28    RepoMaintain,
29    /// `git.commit.sign` on a repository or namespace — the tuple
30    /// verify-trust checks.
31    #[serde(rename = "git.commit.sign")]
32    CommitSign,
33}
34
35impl Right {
36    /// Every right, broadest first.
37    pub const ALL: [Right; 5] = [
38        Right::NsAdmin,
39        Right::RepoCreate,
40        Right::RepoOwn,
41        Right::RepoMaintain,
42        Right::CommitSign,
43    ];
44
45    /// The registry action string.
46    pub fn action(self) -> &'static str {
47        match self {
48            Right::NsAdmin => "git.ns.admin",
49            Right::RepoCreate => "git.repo.create",
50            Right::RepoOwn => "git.repo.own",
51            Right::RepoMaintain => "git.repo.maintain",
52            Right::CommitSign => "git.commit.sign",
53        }
54    }
55
56    /// Parse a registry action string. Unknown actions are `None`, not an
57    /// error: the registry carries other capabilities' actions too.
58    pub fn from_action(action: &str) -> Option<Right> {
59        Right::ALL.into_iter().find(|r| r.action() == action)
60    }
61
62    fn bit(self) -> u8 {
63        1 << (self as u8)
64    }
65}
66
67impl fmt::Display for Right {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        f.write_str(self.action())
70    }
71}
72
73/// The rights one subject holds on one resource, closed under implication.
74///
75/// Implication (§4.2): `own ⇒ maintain ⇒ commit` on the same resource, and
76/// `ns.admin ⇒ create` plus `own` on every repository in the namespace. The
77/// VTC evaluates this before projecting; it is repeated here so an adapter
78/// handed a partial set (say, `own` alone) still maps it correctly.
79///
80/// **Implication decides what a person may do, not which forge role they
81/// get.** A namespace admin gets no role on the forge (decided 2026-09-25):
82/// `git.ns.admin` is exercised through the VTC and the bridge, never as an
83/// organisation owner or a repository role. So the rights `ns.admin` implies
84/// are [held](EffectiveRights::holds) but never
85/// [projected](EffectiveRights::forge_tier); only a repository right granted
86/// in its own name (`own`, `maintain`, `commit.sign`) reaches a forge role.
87///
88/// Two values are equal when they hold the same rights *and* project the
89/// same ones — i.e. when their [canonical grants](EffectiveRights::granted)
90/// are equal. `[ns.admin]` and `[ns.admin, own]` hold the same rights but
91/// are different values: only the second projects `own`. Serialised as the
92/// canonical grants, so a round trip is the identity.
93#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
94pub struct EffectiveRights {
95    /// Every right held, directly or by implication.
96    held: u8,
97    /// The rights that may become a forge role: the closure of the
98    /// repository rights granted, without what `ns.admin` implies.
99    projectable: u8,
100}
101
102impl EffectiveRights {
103    /// No rights at all.
104    pub const NONE: EffectiveRights = EffectiveRights {
105        held: 0,
106        projectable: 0,
107    };
108
109    /// The closure of `granted` under implication.
110    pub fn from_granted(granted: impl IntoIterator<Item = Right>) -> Self {
111        let mut rights = EffectiveRights::NONE;
112        for right in granted {
113            rights.insert(right);
114        }
115        rights
116    }
117
118    /// Add a right and everything it implies.
119    pub fn insert(&mut self, right: Right) {
120        let closure = Self::closure(right);
121        self.held |= closure;
122        match right {
123            // Namespace rights never project: `ns.admin`'s implied `own`
124            // lets its holder act through the VTC, not on the forge.
125            Right::NsAdmin | Right::RepoCreate => {}
126            Right::RepoOwn | Right::RepoMaintain | Right::CommitSign => {
127                self.projectable |= closure;
128            }
129        }
130    }
131
132    /// `right` and everything it implies, as bits.
133    fn closure(right: Right) -> u8 {
134        right.bit()
135            | match right {
136                Right::NsAdmin => Self::closure(Right::RepoCreate) | Self::closure(Right::RepoOwn),
137                Right::RepoOwn => Self::closure(Right::RepoMaintain),
138                Right::RepoMaintain => Self::closure(Right::CommitSign),
139                Right::RepoCreate | Right::CommitSign => 0,
140            }
141    }
142
143    /// Whether `right` is held (directly or by implication).
144    pub fn holds(self, right: Right) -> bool {
145        self.held & right.bit() != 0
146    }
147
148    /// Whether nothing is held.
149    pub fn is_empty(self) -> bool {
150        self.held == 0
151    }
152
153    /// Held rights, broadest first.
154    pub fn iter(self) -> impl Iterator<Item = Right> {
155        Right::ALL.into_iter().filter(move |r| self.holds(*r))
156    }
157
158    /// The repository-level tier held, by any route (including `ns.admin`'s
159    /// implied `own`): own, then maintain, then commit. `None` when none of
160    /// those is held. For authorisation; the forge role comes from
161    /// [`EffectiveRights::forge_tier`].
162    pub fn repo_tier(self) -> Option<Right> {
163        Self::tier(self.held)
164    }
165
166    /// The repository-level tier that decides the forge role: own, then
167    /// maintain, then commit, from repository rights granted in their own
168    /// name. `ns.admin` alone gives `None` — a namespace admin gets no forge
169    /// role.
170    pub fn forge_tier(self) -> Option<Right> {
171        Self::tier(self.projectable)
172    }
173
174    /// The smallest set of grants this value is the closure of, broadest
175    /// first: `ns.admin` if held; `repo.create` if held and not implied by
176    /// `ns.admin`; and the highest repository right granted in its own name.
177    /// `EffectiveRights::from_granted(x.granted()) == x` for every `x`.
178    pub fn granted(self) -> Vec<Right> {
179        let mut out = Vec::new();
180        if self.holds(Right::NsAdmin) {
181            out.push(Right::NsAdmin);
182        } else if self.holds(Right::RepoCreate) {
183            out.push(Right::RepoCreate);
184        }
185        out.extend(self.forge_tier());
186        out
187    }
188
189    fn tier(bits: u8) -> Option<Right> {
190        [Right::RepoOwn, Right::RepoMaintain, Right::CommitSign]
191            .into_iter()
192            .find(|r| bits & r.bit() != 0)
193    }
194}
195
196/// As the [canonical grants](EffectiveRights::granted), not every held
197/// right: writing the rights `ns.admin` implies would read back as
198/// repository rights granted in their own name, and project.
199impl Serialize for EffectiveRights {
200    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
201        s.collect_seq(self.granted())
202    }
203}
204
205impl<'de> Deserialize<'de> for EffectiveRights {
206    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
207        Ok(EffectiveRights::from_granted(Vec::<Right>::deserialize(d)?))
208    }
209}
210
211/// A person's role on a repository, on the forge's side, as a point on the
212/// common ladder. Ordered: `None < Read < … < Admin`.
213///
214/// GitHub offers every level; Forgejo only `Read`, `Write` and `Admin`; a
215/// GitHub personal account only `Write` collaborators. Each adapter declares
216/// its ladder in [`crate::Capabilities::role_levels`].
217#[derive(
218    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
219)]
220#[serde(rename_all = "lowercase")]
221#[non_exhaustive]
222pub enum ForgeRole {
223    /// No direct role (fork-based contribution).
224    #[default]
225    None,
226    /// Read.
227    Read,
228    /// Triage (GitHub).
229    Triage,
230    /// Write / push.
231    Write,
232    /// Maintain (GitHub): merge and manage without admin settings.
233    Maintain,
234    /// Admin.
235    Admin,
236}
237
238impl fmt::Display for ForgeRole {
239    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240        f.write_str(match self {
241            ForgeRole::None => "none",
242            ForgeRole::Read => "read",
243            ForgeRole::Triage => "triage",
244            ForgeRole::Write => "write",
245            ForgeRole::Maintain => "maintain",
246            ForgeRole::Admin => "admin",
247        })
248    }
249}
250
251/// Which forge role each repository tier asks for, before the forge's ladder
252/// is applied (§4.2's "GitHub projection (org)" column is the default).
253///
254/// This is the community hook of §5.8 layer 3: a bridge, a namespace or a
255/// repository may override the map (`maintain → write` on Forgejo instead of
256/// `write` plus the merge allow-list, or committers get `write` on a
257/// repository that opts in to branch-based contribution) without code.
258///
259/// **There is no entry for `git.ns.admin`, by design** (decided 2026-09-25):
260/// a namespace admin gets no forge role, so no map can give them one. An
261/// `nsAdmin` key is refused when a map is read.
262///
263/// A map is always ordered — `own ≥ maintain ≥ commit` — and **only `own`
264/// may map to [`ForgeRole::Admin`]** (`git-ns/bridge/job` 0.4): `maintain`
265/// and `commit` are rights their holder may grant themselves, so either at
266/// `admin` would let someone make themselves an administrator of the
267/// repository on the forge on their own authority. A committer gets at most
268/// `write`: the check, not the forge role, decides whose commits land, and a
269/// committer with merge rights would be a maintainer. [`RoleMap::new`] and
270/// deserialisation both enforce this, and the fields are private so that no
271/// map can be changed afterwards to one they would refuse.
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
273#[serde(rename_all = "camelCase", try_from = "RawRoleMap")]
274pub struct RoleMap {
275    own: ForgeRole,
276    maintain: ForgeRole,
277    commit: ForgeRole,
278}
279
280/// The unchecked wire form of [`RoleMap`].
281#[derive(Deserialize)]
282#[serde(rename_all = "camelCase", deny_unknown_fields)]
283struct RawRoleMap {
284    own: ForgeRole,
285    maintain: ForgeRole,
286    commit: ForgeRole,
287}
288
289impl TryFrom<RawRoleMap> for RoleMap {
290    type Error = String;
291    fn try_from(r: RawRoleMap) -> Result<Self, String> {
292        RoleMap::new(r.own, r.maintain, r.commit)
293    }
294}
295
296impl Default for RoleMap {
297    fn default() -> Self {
298        RoleMap {
299            own: ForgeRole::Admin,
300            maintain: ForgeRole::Maintain,
301            commit: ForgeRole::None,
302        }
303    }
304}
305
306impl RoleMap {
307    /// The highest role a committer may be given.
308    pub const MAX_COMMIT: ForgeRole = ForgeRole::Write;
309
310    /// A map giving `own`, `maintain` and `commit` to the three tiers.
311    /// Refused unless `own ≥ maintain ≥ commit`, `maintain` is below
312    /// `admin` and `commit ≤ write`.
313    pub fn new(own: ForgeRole, maintain: ForgeRole, commit: ForgeRole) -> Result<Self, String> {
314        for (tier, role) in [("a maintainer", maintain), ("a committer", commit)] {
315            if role >= ForgeRole::Admin {
316                return Err(format!(
317                    "{tier} may not get `{role}`: only an owner (`own`) may map to the forge's \
318                     administrator role, since maintain and commit are rights their holder may \
319                     grant themselves"
320                ));
321            }
322        }
323        if maintain > own {
324            return Err(format!(
325                "a maintainer (`{maintain}`) may not get more than an owner (`{own}`)"
326            ));
327        }
328        if commit > maintain {
329            return Err(format!(
330                "a committer (`{commit}`) may not get more than a maintainer (`{maintain}`)"
331            ));
332        }
333        if commit > Self::MAX_COMMIT {
334            return Err(format!(
335                "a committer may get at most `{}`, not `{commit}`: the check decides whose \
336                 commits land, and merging is a maintainer's",
337                Self::MAX_COMMIT
338            ));
339        }
340        Ok(RoleMap {
341            own,
342            maintain,
343            commit,
344        })
345    }
346
347    /// Role for `git.repo.own`.
348    pub fn own(&self) -> ForgeRole {
349        self.own
350    }
351
352    /// Role for `git.repo.maintain`. Never [`ForgeRole::Admin`].
353    pub fn maintain(&self) -> ForgeRole {
354        self.maintain
355    }
356
357    /// Role for `git.commit.sign`. `None` by default: committers contribute
358    /// through fork PRs, and the required check — not a forge role — decides
359    /// whether their commits land. At most [`RoleMap::MAX_COMMIT`].
360    pub fn commit(&self) -> ForgeRole {
361        self.commit
362    }
363
364    /// The default map with committers given `write` — for a repository that
365    /// opts in to branch-based contribution.
366    pub fn with_committer_write() -> Self {
367        RoleMap {
368            commit: ForgeRole::Write,
369            ..RoleMap::default()
370        }
371    }
372
373    /// The role the rights ask for, before any ladder is applied. Only
374    /// repository rights granted in their own name count
375    /// ([`EffectiveRights::forge_tier`]): `ns.admin` alone asks for
376    /// [`ForgeRole::None`].
377    pub fn requested(&self, rights: EffectiveRights) -> ForgeRole {
378        match rights.forge_tier() {
379            Some(Right::RepoOwn) => self.own,
380            Some(Right::RepoMaintain) => self.maintain,
381            Some(Right::CommitSign) => self.commit,
382            _ => ForgeRole::None,
383        }
384    }
385}
386
387/// Fit `requested` onto a forge's ladder: the highest level on the ladder
388/// that does not exceed it, or [`ForgeRole::None`] when every level does.
389///
390/// Rounding down is the security property: a forge with fewer levels gives
391/// less than the community asked for, never more. `ladder` need not be
392/// sorted.
393pub fn collapse_to_ladder(requested: ForgeRole, ladder: &[ForgeRole]) -> ForgeRole {
394    ladder
395        .iter()
396        .copied()
397        .filter(|level| *level <= requested)
398        .max()
399        .unwrap_or(ForgeRole::None)
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn implication_closes_own_to_commit_and_admin_to_own() {
408        let own = EffectiveRights::from_granted([Right::RepoOwn]);
409        assert!(own.holds(Right::RepoMaintain) && own.holds(Right::CommitSign));
410        assert!(!own.holds(Right::NsAdmin) && !own.holds(Right::RepoCreate));
411
412        let admin = EffectiveRights::from_granted([Right::NsAdmin]);
413        assert_eq!(admin.iter().count(), 5);
414        assert_eq!(admin.repo_tier(), Some(Right::RepoOwn));
415        assert_eq!(admin.forge_tier(), None, "ns.admin never projects");
416
417        let commit = EffectiveRights::from_granted([Right::CommitSign]);
418        assert_eq!(commit.iter().collect::<Vec<_>>(), vec![Right::CommitSign]);
419        assert_eq!(commit.repo_tier(), Some(Right::CommitSign));
420
421        let create = EffectiveRights::from_granted([Right::RepoCreate]);
422        assert_eq!(create.repo_tier(), None);
423    }
424
425    #[test]
426    fn actions_round_trip() {
427        for r in Right::ALL {
428            assert_eq!(Right::from_action(r.action()), Some(r));
429        }
430        assert_eq!(Right::from_action("vtc.member"), None);
431        let json =
432            serde_json::to_string(&EffectiveRights::from_granted([Right::RepoMaintain])).unwrap();
433        assert_eq!(json, r#"["git.repo.maintain"]"#);
434    }
435
436    #[test]
437    fn default_map_matches_the_org_projection() {
438        let map = RoleMap::default();
439        let r = |x| EffectiveRights::from_granted([x]);
440        assert_eq!(map.requested(r(Right::NsAdmin)), ForgeRole::None);
441        assert_eq!(map.requested(r(Right::RepoCreate)), ForgeRole::None);
442        assert_eq!(map.requested(r(Right::RepoOwn)), ForgeRole::Admin);
443        assert_eq!(map.requested(r(Right::RepoMaintain)), ForgeRole::Maintain);
444        assert_eq!(map.requested(r(Right::CommitSign)), ForgeRole::None);
445        assert_eq!(
446            RoleMap::with_committer_write().requested(r(Right::CommitSign)),
447            ForgeRole::Write
448        );
449        assert_eq!(map.requested(EffectiveRights::NONE), ForgeRole::None);
450    }
451
452    #[test]
453    fn a_namespace_admin_gets_no_forge_role_under_any_map() {
454        let admin = EffectiveRights::from_granted([Right::NsAdmin]);
455        let everything =
456            RoleMap::new(ForgeRole::Admin, ForgeRole::Maintain, ForgeRole::Write).unwrap();
457        for map in [
458            RoleMap::default(),
459            RoleMap::with_committer_write(),
460            everything,
461        ] {
462            assert_eq!(map.requested(admin), ForgeRole::None);
463        }
464        // A repository right granted in its own name still projects, whatever
465        // the holder also has on the namespace.
466        let both = EffectiveRights::from_granted([Right::NsAdmin, Right::RepoMaintain]);
467        assert!(both.holds(Right::RepoOwn));
468        assert_eq!(RoleMap::default().requested(both), ForgeRole::Maintain);
469    }
470
471    #[test]
472    fn serde_round_trips_are_the_identity() {
473        use Right::*;
474        let cases: &[(&[Right], &str)] = &[
475            (&[], "[]"),
476            (&[NsAdmin], r#"["git.ns.admin"]"#),
477            (&[NsAdmin, RepoOwn], r#"["git.ns.admin","git.repo.own"]"#),
478            (
479                &[NsAdmin, RepoMaintain],
480                r#"["git.ns.admin","git.repo.maintain"]"#,
481            ),
482            (&[NsAdmin, RepoCreate], r#"["git.ns.admin"]"#),
483            (
484                &[RepoCreate, CommitSign],
485                r#"["git.repo.create","git.commit.sign"]"#,
486            ),
487            (&[RepoOwn, RepoMaintain, CommitSign], r#"["git.repo.own"]"#),
488        ];
489        for (granted, want) in cases {
490            let x = EffectiveRights::from_granted(granted.iter().copied());
491            let json = serde_json::to_string(&x).unwrap();
492            assert_eq!(json, *want, "{granted:?}");
493            let back: EffectiveRights = serde_json::from_str(&json).unwrap();
494            assert_eq!(back, x, "{granted:?}");
495            assert_eq!(back.forge_tier(), x.forge_tier(), "{granted:?}");
496            assert_eq!(EffectiveRights::from_granted(x.granted()), x);
497        }
498        // An ns.admin-only value stays one: it never reads back as an owner.
499        let admin: EffectiveRights = serde_json::from_str(
500            &serde_json::to_string(&EffectiveRights::from_granted([NsAdmin])).unwrap(),
501        )
502        .unwrap();
503        assert_eq!(RoleMap::default().requested(admin), ForgeRole::None);
504        // Same rights held, different projection: not equal.
505        assert_ne!(
506            EffectiveRights::from_granted([NsAdmin]),
507            EffectiveRights::from_granted([NsAdmin, RepoOwn])
508        );
509        assert_eq!(
510            EffectiveRights::from_granted([RepoOwn]),
511            EffectiveRights::from_granted([RepoOwn, CommitSign])
512        );
513    }
514
515    #[test]
516    fn a_role_map_is_ordered_and_committers_stop_at_write() {
517        use ForgeRole::*;
518        assert!(RoleMap::new(Admin, Maintain, Write).is_ok());
519        assert!(RoleMap::new(Admin, Write, Write).is_ok());
520        assert!(RoleMap::new(Write, Write, None).is_ok());
521        assert!(RoleMap::new(Maintain, Write, None).is_ok());
522        assert!(RoleMap::new(Write, Maintain, None).is_err());
523        assert!(RoleMap::new(Admin, Write, Maintain).is_err());
524        let ok: RoleMap =
525            serde_json::from_str(r#"{"own":"admin","maintain":"write","commit":"write"}"#).unwrap();
526        assert_eq!(ok.maintain(), Write);
527        for bad in [
528            r#"{"own":"write","maintain":"maintain","commit":"none"}"#,
529            r#"{"own":"admin","maintain":"write","commit":"maintain"}"#,
530            r#"{"own":"admin","maintain":"maintain","commit":"none","nsAdmin":"admin"}"#,
531        ] {
532            assert!(serde_json::from_str::<RoleMap>(bad).is_err(), "{bad}");
533        }
534    }
535
536    #[test]
537    fn only_an_owner_may_map_to_admin() {
538        use ForgeRole::*;
539        // Maintain or commit at admin is refused, whatever else the map says.
540        for (own, maintain, commit) in [
541            (Admin, Admin, None),
542            (Admin, Admin, Write),
543            (Admin, Admin, Admin),
544            (Admin, Maintain, Admin),
545            (Admin, Write, Admin),
546        ] {
547            let err = RoleMap::new(own, maintain, commit).unwrap_err();
548            assert!(
549                err.contains("only an owner"),
550                "{own}/{maintain}/{commit}: {err}"
551            );
552        }
553        // Deserialisation takes the same path.
554        for bad in [
555            r#"{"own":"admin","maintain":"admin","commit":"none"}"#,
556            r#"{"own":"admin","maintain":"admin","commit":"write"}"#,
557            r#"{"own":"admin","maintain":"maintain","commit":"admin"}"#,
558        ] {
559            let err = serde_json::from_str::<RoleMap>(bad)
560                .unwrap_err()
561                .to_string();
562            assert!(err.contains("only an owner"), "{bad}: {err}");
563        }
564        // Every map that exists gives admin to nobody but an owner.
565        for map in [RoleMap::default(), RoleMap::with_committer_write()] {
566            assert!(map.maintain() < Admin && map.commit() < Admin);
567        }
568        assert_eq!(RoleMap::default().own(), Admin);
569    }
570
571    #[test]
572    fn collapsing_rounds_down_never_up() {
573        let forgejo = [ForgeRole::Read, ForgeRole::Write, ForgeRole::Admin];
574        assert_eq!(
575            collapse_to_ladder(ForgeRole::Maintain, &forgejo),
576            ForgeRole::Write
577        );
578        assert_eq!(
579            collapse_to_ladder(ForgeRole::Admin, &forgejo),
580            ForgeRole::Admin
581        );
582        assert_eq!(
583            collapse_to_ladder(ForgeRole::Triage, &forgejo),
584            ForgeRole::Read
585        );
586
587        let personal = [ForgeRole::Write];
588        assert_eq!(
589            collapse_to_ladder(ForgeRole::Admin, &personal),
590            ForgeRole::Write
591        );
592        assert_eq!(
593            collapse_to_ladder(ForgeRole::Read, &personal),
594            ForgeRole::None
595        );
596        assert_eq!(
597            collapse_to_ladder(ForgeRole::None, &personal),
598            ForgeRole::None
599        );
600    }
601}