Skip to main content

ephemeris_forge/access/
decision.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3
4use super::{Access, Caller, Lockout, Observed, TeamName};
5use crate::{Change, FieldValue, Limitation, Mode, Report};
6
7/// Where a forge stores a team's permission.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum PermissionScope {
10    /// Each repository grant carries its own permission.
11    Repository,
12    /// The permission belongs to the team and applies to every repository.
13    Organization,
14}
15
16/// What the forge reports about team access to one repository.
17#[derive(Clone, Copy, Debug)]
18pub struct Grants<'a, P> {
19    /// Whether a grant can carry its own permission.
20    pub scope: PermissionScope,
21    /// Direct repository grants.
22    pub direct: &'a BTreeMap<TeamName, Observed<P>>,
23    /// Teams that reach the repository through an all-repositories team and
24    /// cannot be removed from it.
25    pub inherited: &'a BTreeSet<TeamName>,
26    /// Organization teams that can be assigned to the repository.
27    pub organization: &'a BTreeMap<TeamName, Observed<P>>,
28    /// The account the plan runs as, when known.
29    pub caller: Option<&'a Caller>,
30}
31
32/// One step toward the declared team access.
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub enum AccessDecision<P> {
35    /// Grant the team access with this permission.
36    Add {
37        /// The team to grant.
38        team: TeamName,
39        /// The permission to grant.
40        permission: P,
41    },
42    /// Remove the team's direct grant.
43    Remove {
44        /// The team to remove.
45        team: TeamName,
46    },
47    /// Removing the team's direct grant would remove the caller's own access.
48    SelfRemoval {
49        /// The team the caller belongs to.
50        team: TeamName,
51    },
52    /// The team has access with a different permission.
53    PermissionMismatch {
54        /// The team whose grant differs.
55        team: TeamName,
56        /// The declared permission.
57        expected: P,
58        /// The permission the team has now.
59        actual: Observed<P>,
60    },
61    /// The organization has no team with this name.
62    NotInOrganization {
63        /// The declared team.
64        team: TeamName,
65    },
66    /// The team exists, but its organization-wide permission is not the
67    /// declared one.
68    OrganizationPermission {
69        /// The declared team.
70        team: TeamName,
71        /// The declared permission.
72        expected: P,
73        /// The organization-wide permission of the team.
74        actual: Observed<P>,
75    },
76    /// The team already has the declared permission.
77    Matches {
78        /// The team that needs no change.
79        team: TeamName,
80    },
81    /// An undeclared grant that the forge does not let this repository remove.
82    Inherited {
83        /// The team with the inherited grant.
84        team: TeamName,
85    },
86}
87
88impl<P: Clone + fmt::Display + PartialEq> Access<P> {
89    /// Compare the declared access with what the forge reports.
90    ///
91    /// Declared teams come first in name order. In owned mode, the removals
92    /// of undeclared direct grants follow in name order. A removal of a team
93    /// the caller belongs to is a self removal.
94    #[must_use]
95    pub fn plan(&self, grants: &Grants<'_, P>) -> Vec<AccessDecision<P>> {
96        let mut decisions = Vec::new();
97        for (team, expected) in &self.teams {
98            let decision = match grants.direct.get(team) {
99                None => match grants.organization.get(team) {
100                    Some(actual)
101                        if grants.scope == PermissionScope::Repository || actual == expected =>
102                    {
103                        AccessDecision::Add {
104                            team: team.clone(),
105                            permission: expected.clone(),
106                        }
107                    }
108                    Some(actual) => AccessDecision::OrganizationPermission {
109                        team: team.clone(),
110                        expected: expected.clone(),
111                        actual: actual.clone(),
112                    },
113                    None => AccessDecision::NotInOrganization { team: team.clone() },
114                },
115                Some(actual) if actual == expected => {
116                    AccessDecision::Matches { team: team.clone() }
117                }
118                Some(actual) => AccessDecision::PermissionMismatch {
119                    team: team.clone(),
120                    expected: expected.clone(),
121                    actual: actual.clone(),
122                },
123            };
124            decisions.push(decision);
125        }
126        if self.mode == Mode::Owned {
127            for team in grants
128                .direct
129                .keys()
130                .filter(|team| !self.teams.contains_key(*team))
131            {
132                let team = team.clone();
133                decisions.push(if grants.inherited.contains(&team) {
134                    AccessDecision::Inherited { team }
135                } else if grants.caller.is_some_and(|caller| caller.belongs_to(&team)) {
136                    AccessDecision::SelfRemoval { team }
137                } else {
138                    AccessDecision::Remove { team }
139                });
140            }
141        }
142        decisions
143    }
144}
145
146impl<P: fmt::Display> AccessDecision<P> {
147    /// Record the decision in `report`. `forge` names the forge in messages.
148    pub fn record(&self, report: &mut Report, forge: &'static str) {
149        match self {
150            Self::Add {
151                team,
152                permission: _,
153            } => {
154                report
155                    .changes
156                    .insert(format!("team {team}"), Change::Create);
157            }
158            Self::Remove { team } => {
159                report
160                    .changes
161                    .insert(format!("team {team}"), Change::Delete);
162            }
163            Self::PermissionMismatch {
164                team,
165                expected,
166                actual,
167            } => {
168                report.changes.insert(
169                    format!("team {team}"),
170                    Change::field(
171                        "permission",
172                        FieldValue::text(actual),
173                        FieldValue::text(expected),
174                    ),
175                );
176            }
177            Self::NotInOrganization { team } => {
178                report.limitations.push(Limitation::TeamNotInOrganization {
179                    team: team.clone(),
180                    forge,
181                });
182            }
183            Self::OrganizationPermission {
184                team,
185                expected,
186                actual,
187            } => report
188                .limitations
189                .push(Limitation::TeamOrganizationPermission {
190                    team: team.clone(),
191                    actual: actual.to_string(),
192                    expected: expected.to_string(),
193                }),
194            Self::Matches { team } => {
195                report
196                    .unchanged
197                    .insert(format!("team {team}"), vec!["permission".to_owned()]);
198            }
199            Self::SelfRemoval { team } => report.lockouts.push(Lockout::Team(team.clone())),
200            Self::Inherited { team } => report.limitations.push(Limitation::TeamInherited {
201                team: team.clone(),
202                forge,
203            }),
204        }
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::UserName;
212
213    const OWNED: &str = r#"{"mode":"owned","teams":{},"collaborators":{}}"#;
214
215    fn team(name: &str) -> TeamName {
216        TeamName::try_from(name.to_owned()).expect("valid")
217    }
218
219    fn known(permission: &str) -> Observed<String> {
220        Observed::Known(permission.to_owned())
221    }
222
223    fn plan(
224        input: &str,
225        direct: &[&str],
226        inherited: &[&str],
227        caller: &[&str],
228    ) -> Vec<AccessDecision<String>> {
229        let access: Access<String> = facet_json::from_str(input).expect("access parses");
230        let direct = direct
231            .iter()
232            .map(|name| (team(name), known("write")))
233            .collect();
234        let inherited = inherited.iter().map(|name| team(name)).collect();
235        let caller = (!caller.is_empty()).then(|| Caller::User {
236            login: UserName::try_from("bot".to_owned()).expect("valid"),
237            teams: caller.iter().map(|name| team(name)).collect(),
238        });
239        access.plan(&Grants {
240            scope: PermissionScope::Repository,
241            direct: &direct,
242            inherited: &inherited,
243            organization: &BTreeMap::new(),
244            caller: caller.as_ref(),
245        })
246    }
247
248    #[test]
249    fn removing_a_team_of_the_caller_is_a_self_removal() {
250        assert_eq!(
251            plan(OWNED, &["ops", "legacy"], &[], &["ops"]),
252            [
253                AccessDecision::Remove {
254                    team: team("legacy")
255                },
256                AccessDecision::SelfRemoval { team: team("ops") },
257            ]
258        );
259    }
260
261    #[test]
262    fn a_declared_team_of_the_caller_is_unaffected() {
263        let input = r#"{"mode":"owned","teams":{"ops":"write"},"collaborators":{}}"#;
264        assert_eq!(
265            plan(input, &["ops"], &[], &["ops"]),
266            [AccessDecision::Matches { team: team("ops") }]
267        );
268    }
269
270    #[test]
271    fn an_inherited_team_of_the_caller_stays_inherited() {
272        assert_eq!(
273            plan(OWNED, &["ops"], &["ops"], &["ops"]),
274            [AccessDecision::Inherited { team: team("ops") }]
275        );
276    }
277
278    #[test]
279    fn additive_mode_never_removes_a_team_of_the_caller() {
280        let input = r#"{"mode":"additive","teams":{},"collaborators":{}}"#;
281        assert_eq!(plan(input, &["ops"], &[], &["ops"]), []);
282    }
283
284    #[test]
285    fn an_app_caller_gets_plain_removals() {
286        let access: Access<String> = facet_json::from_str(OWNED).expect("access parses");
287        let direct = [(team("ops"), known("write"))].into();
288        let caller = Caller::App {
289            id: 7,
290            slug: "bot".to_owned(),
291            admin: false,
292        };
293        let decisions = access.plan(&Grants {
294            scope: PermissionScope::Repository,
295            direct: &direct,
296            inherited: &BTreeSet::new(),
297            organization: &BTreeMap::new(),
298            caller: Some(&caller),
299        });
300        assert_eq!(decisions, [AccessDecision::Remove { team: team("ops") }]);
301    }
302
303    #[test]
304    fn a_self_removal_is_recorded_as_a_lockout() {
305        let mut report = Report::default();
306        AccessDecision::<String>::SelfRemoval { team: team("ops") }.record(&mut report, "Forgejo");
307        assert_eq!(report.lockouts, [Lockout::Team(team("ops"))]);
308        assert!(report.changes.is_empty());
309    }
310
311    #[test]
312    fn a_missing_team_and_a_permission_difference_are_distinct_limitations() {
313        let access: Access<String> = facet_json::from_str(
314            r#"{"mode":"additive","teams":{"ops":"write","dev":"write"},"collaborators":{}}"#,
315        )
316        .expect("access parses");
317        let organization = [(team("dev"), known("read"))].into();
318        let decisions = access.plan(&Grants {
319            scope: PermissionScope::Organization,
320            direct: &BTreeMap::new(),
321            inherited: &BTreeSet::new(),
322            organization: &organization,
323            caller: None,
324        });
325        let mut report = Report::default();
326        for decision in &decisions {
327            decision.record(&mut report, "GitHub");
328        }
329        assert_eq!(
330            report.limitations,
331            [
332                Limitation::TeamOrganizationPermission {
333                    team: team("dev"),
334                    actual: "read".to_owned(),
335                    expected: "write".to_owned(),
336                },
337                Limitation::TeamNotInOrganization {
338                    team: team("ops"),
339                    forge: "GitHub",
340                },
341            ]
342        );
343    }
344}