ephemeris_forge/access/
collaborator.rs1use std::collections::BTreeMap;
2use std::fmt;
3
4use super::{Access, Caller, Lockout, Observed, UserName};
5use crate::{Change, FieldValue, Mode, Report};
6
7#[derive(Clone, Debug, Eq, PartialEq)]
9pub enum CollaboratorDecision<P> {
10 Add {
12 user: UserName,
14 permission: P,
16 },
17 Change {
19 user: UserName,
21 permission: P,
23 actual: Observed<P>,
25 },
26 Remove {
28 user: UserName,
30 },
31 SelfRemoval {
33 user: UserName,
35 },
36 Matches {
38 user: UserName,
40 },
41}
42
43impl<P: Clone + PartialEq> Access<P> {
44 #[must_use]
51 pub fn plan_collaborators(
52 &self,
53 direct: &BTreeMap<UserName, Observed<P>>,
54 caller: Option<&Caller>,
55 ) -> Vec<CollaboratorDecision<P>> {
56 let mut decisions = Vec::new();
57 for (user, permission) in &self.collaborators {
58 decisions.push(match direct.get(user) {
59 None => CollaboratorDecision::Add {
60 user: user.clone(),
61 permission: permission.clone(),
62 },
63 Some(actual) if actual == permission => {
64 CollaboratorDecision::Matches { user: user.clone() }
65 }
66 Some(actual) => CollaboratorDecision::Change {
67 user: user.clone(),
68 permission: permission.clone(),
69 actual: actual.clone(),
70 },
71 });
72 }
73 if self.mode == Mode::Owned {
74 decisions.extend(
75 direct
76 .keys()
77 .filter(|user| !self.collaborators.contains_key(*user))
78 .map(|user| {
79 let user = user.clone();
80 if caller.is_some_and(|caller| caller.is_user(&user)) {
81 CollaboratorDecision::SelfRemoval { user }
82 } else {
83 CollaboratorDecision::Remove { user }
84 }
85 }),
86 );
87 }
88 decisions
89 }
90}
91
92impl<P: fmt::Display> CollaboratorDecision<P> {
93 pub fn record(&self, report: &mut Report) {
95 match self {
96 Self::Add {
97 user,
98 permission: _,
99 } => {
100 report
101 .changes
102 .insert(format!("collaborator {user}"), Change::Create);
103 }
104 Self::Remove { user } => {
105 report
106 .changes
107 .insert(format!("collaborator {user}"), Change::Delete);
108 }
109 Self::Change {
110 user,
111 permission,
112 actual,
113 } => {
114 report.changes.insert(
115 format!("collaborator {user}"),
116 Change::field(
117 "permission",
118 FieldValue::text(actual),
119 FieldValue::text(permission),
120 ),
121 );
122 }
123 Self::SelfRemoval { user } => report.lockouts.push(Lockout::Collaborator(user.clone())),
124 Self::Matches { user } => {
125 report.unchanged.insert(
126 format!("collaborator {user}"),
127 vec!["permission".to_owned()],
128 );
129 }
130 }
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use std::collections::BTreeSet;
137
138 use super::*;
139
140 const OWNED: &str = r#"{"mode":"owned","teams":{},"collaborators":{}}"#;
141
142 fn user(name: &str) -> UserName {
143 UserName::try_from(name.to_owned()).expect("valid")
144 }
145
146 fn plan(
147 input: &str,
148 direct: &[&str],
149 caller: Option<&str>,
150 ) -> Vec<CollaboratorDecision<String>> {
151 let access: Access<String> = facet_json::from_str(input).expect("access parses");
152 let direct = direct
153 .iter()
154 .map(|name| (user(name), Observed::Known("write".to_owned())))
155 .collect();
156 let caller = caller.map(|name| Caller::User {
157 login: user(name),
158 teams: BTreeSet::new(),
159 });
160 access.plan_collaborators(&direct, caller.as_ref())
161 }
162
163 #[test]
164 fn removing_the_caller_is_a_self_removal() {
165 assert_eq!(
166 plan(OWNED, &["bot", "stale"], Some("Bot")),
167 [
168 CollaboratorDecision::SelfRemoval { user: user("bot") },
169 CollaboratorDecision::Remove {
170 user: user("stale")
171 },
172 ]
173 );
174 }
175
176 #[test]
177 fn a_declared_caller_is_unaffected() {
178 let input = r#"{"mode":"owned","teams":{},"collaborators":{"bot":"write"}}"#;
179 assert_eq!(
180 plan(input, &["bot"], Some("bot")),
181 [CollaboratorDecision::Matches { user: user("bot") }]
182 );
183 }
184
185 #[test]
186 fn without_a_caller_the_grant_is_removed() {
187 assert_eq!(
188 plan(OWNED, &["bot"], None),
189 [CollaboratorDecision::Remove { user: user("bot") }]
190 );
191 }
192
193 #[test]
194 fn an_app_caller_gets_plain_removals() {
195 let access: Access<String> = facet_json::from_str(OWNED).expect("access parses");
196 let direct = [(user("bot"), Observed::Known("write".to_owned()))].into();
197 let caller = Caller::App {
198 id: 7,
199 slug: "bot".to_owned(),
200 admin: false,
201 };
202 assert_eq!(
203 access.plan_collaborators(&direct, Some(&caller)),
204 [CollaboratorDecision::Remove { user: user("bot") }]
205 );
206 }
207
208 #[test]
209 fn a_self_removal_is_recorded_as_a_lockout() {
210 let mut report = Report::default();
211 CollaboratorDecision::<String>::SelfRemoval { user: user("bot") }.record(&mut report);
212 assert_eq!(report.lockouts, [Lockout::Collaborator(user("bot"))]);
213 assert!(report.changes.is_empty());
214 }
215}