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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum PermissionScope {
10 Repository,
12 Organization,
14}
15
16#[derive(Clone, Copy, Debug)]
18pub struct Grants<'a, P> {
19 pub scope: PermissionScope,
21 pub direct: &'a BTreeMap<TeamName, Observed<P>>,
23 pub inherited: &'a BTreeSet<TeamName>,
26 pub organization: &'a BTreeMap<TeamName, Observed<P>>,
28 pub caller: Option<&'a Caller>,
30}
31
32#[derive(Clone, Debug, Eq, PartialEq)]
34pub enum AccessDecision<P> {
35 Add {
37 team: TeamName,
39 permission: P,
41 },
42 Remove {
44 team: TeamName,
46 },
47 SelfRemoval {
49 team: TeamName,
51 },
52 PermissionMismatch {
54 team: TeamName,
56 expected: P,
58 actual: Observed<P>,
60 },
61 NotInOrganization {
63 team: TeamName,
65 },
66 OrganizationPermission {
69 team: TeamName,
71 expected: P,
73 actual: Observed<P>,
75 },
76 Matches {
78 team: TeamName,
80 },
81 Inherited {
83 team: TeamName,
85 },
86}
87
88impl<P: Clone + fmt::Display + PartialEq> Access<P> {
89 #[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 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}