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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
80pub struct EffectiveRights(u8);
81
82impl EffectiveRights {
83    /// No rights at all.
84    pub const NONE: EffectiveRights = EffectiveRights(0);
85
86    /// The closure of `granted` under implication.
87    pub fn from_granted(granted: impl IntoIterator<Item = Right>) -> Self {
88        let mut rights = EffectiveRights::NONE;
89        for right in granted {
90            rights.insert(right);
91        }
92        rights
93    }
94
95    /// Add a right and everything it implies.
96    pub fn insert(&mut self, right: Right) {
97        self.0 |= right.bit();
98        match right {
99            Right::NsAdmin => {
100                self.insert(Right::RepoCreate);
101                self.insert(Right::RepoOwn);
102            }
103            Right::RepoOwn => self.insert(Right::RepoMaintain),
104            Right::RepoMaintain => self.insert(Right::CommitSign),
105            Right::RepoCreate | Right::CommitSign => {}
106        }
107    }
108
109    /// Whether `right` is held (directly or by implication).
110    pub fn holds(self, right: Right) -> bool {
111        self.0 & right.bit() != 0
112    }
113
114    /// Whether nothing is held.
115    pub fn is_empty(self) -> bool {
116        self.0 == 0
117    }
118
119    /// Held rights, broadest first.
120    pub fn iter(self) -> impl Iterator<Item = Right> {
121        Right::ALL.into_iter().filter(move |r| self.holds(*r))
122    }
123
124    /// The repository-level tier that decides the forge role: own, then
125    /// maintain, then commit. `None` when none of those is held.
126    pub fn repo_tier(self) -> Option<Right> {
127        [Right::RepoOwn, Right::RepoMaintain, Right::CommitSign]
128            .into_iter()
129            .find(|r| self.holds(*r))
130    }
131}
132
133impl Serialize for EffectiveRights {
134    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
135        s.collect_seq(self.iter())
136    }
137}
138
139impl<'de> Deserialize<'de> for EffectiveRights {
140    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
141        Ok(EffectiveRights::from_granted(Vec::<Right>::deserialize(d)?))
142    }
143}
144
145/// A person's role on a repository, on the forge's side, as a point on the
146/// common ladder. Ordered: `None < Read < … < Admin`.
147///
148/// GitHub offers every level; Forgejo only `Read`, `Write` and `Admin`; a
149/// GitHub personal account only `Write` collaborators. Each adapter declares
150/// its ladder in [`crate::Capabilities::role_levels`].
151#[derive(
152    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
153)]
154#[serde(rename_all = "lowercase")]
155#[non_exhaustive]
156pub enum ForgeRole {
157    /// No direct role (fork-based contribution).
158    #[default]
159    None,
160    /// Read.
161    Read,
162    /// Triage (GitHub).
163    Triage,
164    /// Write / push.
165    Write,
166    /// Maintain (GitHub): merge and manage without admin settings.
167    Maintain,
168    /// Admin.
169    Admin,
170}
171
172impl fmt::Display for ForgeRole {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        f.write_str(match self {
175            ForgeRole::None => "none",
176            ForgeRole::Read => "read",
177            ForgeRole::Triage => "triage",
178            ForgeRole::Write => "write",
179            ForgeRole::Maintain => "maintain",
180            ForgeRole::Admin => "admin",
181        })
182    }
183}
184
185/// Which forge role each repository tier asks for, before the forge's ladder
186/// is applied (§4.2's "GitHub projection (org)" column is the default).
187///
188/// This is the community hook of §5.8 layer 3: a namespace may override the
189/// map (`maintain → admin` on Forgejo, or committers get `write` on a repo
190/// that opts in) without code.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(rename_all = "camelCase")]
193#[non_exhaustive]
194pub struct RoleMap {
195    /// Role for `git.repo.own`.
196    pub own: ForgeRole,
197    /// Role for `git.repo.maintain`.
198    pub maintain: ForgeRole,
199    /// Role for `git.commit.sign`. `None` by default: committers contribute
200    /// through fork PRs, and the required check — not a forge role — decides
201    /// whether their commits land.
202    pub commit: ForgeRole,
203}
204
205impl Default for RoleMap {
206    fn default() -> Self {
207        RoleMap {
208            own: ForgeRole::Admin,
209            maintain: ForgeRole::Maintain,
210            commit: ForgeRole::None,
211        }
212    }
213}
214
215impl RoleMap {
216    /// The default map with committers given `write` — for a repository that
217    /// opts in to branch-based contribution.
218    pub fn with_committer_write() -> Self {
219        RoleMap {
220            commit: ForgeRole::Write,
221            ..RoleMap::default()
222        }
223    }
224
225    /// The role the rights ask for, before any ladder is applied.
226    pub fn requested(&self, rights: EffectiveRights) -> ForgeRole {
227        match rights.repo_tier() {
228            Some(Right::RepoOwn) => self.own,
229            Some(Right::RepoMaintain) => self.maintain,
230            Some(Right::CommitSign) => self.commit,
231            _ => ForgeRole::None,
232        }
233    }
234}
235
236/// Fit `requested` onto a forge's ladder: the highest level on the ladder
237/// that does not exceed it, or [`ForgeRole::None`] when every level does.
238///
239/// Rounding down is the security property: a forge with fewer levels gives
240/// less than the community asked for, never more. `ladder` need not be
241/// sorted.
242pub fn collapse_to_ladder(requested: ForgeRole, ladder: &[ForgeRole]) -> ForgeRole {
243    ladder
244        .iter()
245        .copied()
246        .filter(|level| *level <= requested)
247        .max()
248        .unwrap_or(ForgeRole::None)
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn implication_closes_own_to_commit_and_admin_to_own() {
257        let own = EffectiveRights::from_granted([Right::RepoOwn]);
258        assert!(own.holds(Right::RepoMaintain) && own.holds(Right::CommitSign));
259        assert!(!own.holds(Right::NsAdmin) && !own.holds(Right::RepoCreate));
260
261        let admin = EffectiveRights::from_granted([Right::NsAdmin]);
262        assert_eq!(admin.iter().count(), 5);
263
264        let commit = EffectiveRights::from_granted([Right::CommitSign]);
265        assert_eq!(commit.iter().collect::<Vec<_>>(), vec![Right::CommitSign]);
266        assert_eq!(commit.repo_tier(), Some(Right::CommitSign));
267
268        let create = EffectiveRights::from_granted([Right::RepoCreate]);
269        assert_eq!(create.repo_tier(), None);
270    }
271
272    #[test]
273    fn actions_round_trip() {
274        for r in Right::ALL {
275            assert_eq!(Right::from_action(r.action()), Some(r));
276        }
277        assert_eq!(Right::from_action("vtc.member"), None);
278        let json =
279            serde_json::to_string(&EffectiveRights::from_granted([Right::RepoMaintain])).unwrap();
280        assert_eq!(json, r#"["git.repo.maintain","git.commit.sign"]"#);
281    }
282
283    #[test]
284    fn default_map_matches_the_org_projection() {
285        let map = RoleMap::default();
286        let r = |x| EffectiveRights::from_granted([x]);
287        assert_eq!(map.requested(r(Right::NsAdmin)), ForgeRole::Admin);
288        assert_eq!(map.requested(r(Right::RepoOwn)), ForgeRole::Admin);
289        assert_eq!(map.requested(r(Right::RepoMaintain)), ForgeRole::Maintain);
290        assert_eq!(map.requested(r(Right::CommitSign)), ForgeRole::None);
291        assert_eq!(
292            RoleMap::with_committer_write().requested(r(Right::CommitSign)),
293            ForgeRole::Write
294        );
295        assert_eq!(map.requested(EffectiveRights::NONE), ForgeRole::None);
296    }
297
298    #[test]
299    fn collapsing_rounds_down_never_up() {
300        let forgejo = [ForgeRole::Read, ForgeRole::Write, ForgeRole::Admin];
301        assert_eq!(
302            collapse_to_ladder(ForgeRole::Maintain, &forgejo),
303            ForgeRole::Write
304        );
305        assert_eq!(
306            collapse_to_ladder(ForgeRole::Admin, &forgejo),
307            ForgeRole::Admin
308        );
309        assert_eq!(
310            collapse_to_ladder(ForgeRole::Triage, &forgejo),
311            ForgeRole::Read
312        );
313
314        let personal = [ForgeRole::Write];
315        assert_eq!(
316            collapse_to_ladder(ForgeRole::Admin, &personal),
317            ForgeRole::Write
318        );
319        assert_eq!(
320            collapse_to_ladder(ForgeRole::Read, &personal),
321            ForgeRole::None
322        );
323        assert_eq!(
324            collapse_to_ladder(ForgeRole::None, &personal),
325            ForgeRole::None
326        );
327    }
328}