Skip to main content

ephemeris_forge/access/
collaborator.rs

1use std::collections::BTreeMap;
2use std::fmt;
3
4use super::{Access, Caller, Lockout, Observed, UserName};
5use crate::{Change, FieldValue, Mode, Report};
6
7/// One step toward the declared collaborator access.
8#[derive(Clone, Debug, Eq, PartialEq)]
9pub enum CollaboratorDecision<P> {
10    /// Grant the user access with this permission.
11    Add {
12        /// The user to grant.
13        user: UserName,
14        /// The permission to grant.
15        permission: P,
16    },
17    /// The user has access with a different permission.
18    Change {
19        /// The user whose grant differs.
20        user: UserName,
21        /// The declared permission.
22        permission: P,
23        /// The permission the user has now.
24        actual: Observed<P>,
25    },
26    /// Remove the user's direct grant.
27    Remove {
28        /// The user to remove.
29        user: UserName,
30    },
31    /// Removing the user's direct grant would remove the caller's own access.
32    SelfRemoval {
33        /// The caller.
34        user: UserName,
35    },
36    /// The user already has the declared permission.
37    Matches {
38        /// The user that needs no change.
39        user: UserName,
40    },
41}
42
43impl<P: Clone + PartialEq> Access<P> {
44    /// Compare the declared collaborators with the direct grants the forge
45    /// reports.
46    ///
47    /// Declared users come first in name order. In owned mode, the removals
48    /// of undeclared direct grants follow in name order. The removal of the
49    /// caller's own grant is a self removal.
50    #[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    /// Record the decision in `report`.
94    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}