Skip to content

g1t/crates/contracts/src/members.rs

379 lines16,651 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA1//! A workspace's members: owners and members, the roles that add to a
2//! member (billing manager, security manager), what members are allowed to
3//! do (member privileges), and the rules for changing who owns it.
4//!
5//! **Owners and members.** Every workspace has at least one owner. An
6//! owner can make a member an owner and an owner a member, hand the
7//! workspace to another member ([`TransferOwnershipArgs`]), and remove
8//! anyone else. Anyone can leave ([`LeaveWorkspaceArgs`]). Whatever the
9//! change, it is refused when it would leave the workspace without an
10//! owner ([`last_owner_refusal`]).
11//!
12//! **Roles that add to a member** ([`OrgRole`]). A billing manager manages
13//! the workspace's billing as an owner does: the budget, AI credit, cards,
14//! invoices and billing details. It gives nothing on repositories. A
15//! security manager reads every repository and sees and manages every
16//! security alert and security setting on them, as
17//! `access::SECURITY_MANAGER` lists. Owners have both already.
18//!
19//! **Member privileges** ([`MemberPrivileges`]): who may create
20//! repositories, and whether repository admins who are members may change
21//! a repository's visibility, delete or transfer it, and invite outside
22//! collaborators. Owners can always do all of it. Identity attaches them to
23//! each membership ([`crate::Membership::privileges`]) so the repos service
24//! and identity enforce them without asking again.
25//!
26//! Every method is served by identity at `POST /rpc/<method>`; changing
27//! members is for people, signed in or with a personal access token, never
28//! an agent's or a workspace's token.
29
30use serde::{Deserialize, Serialize};
31
32use crate::{Role, User};
33
34/// A role a member can hold besides owner or member. Owners have what
35/// every one of them gives.
36#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum OrgRole {
39 /// Manages billing: budget, AI credit, payment, invoices and billing
40 /// details. Nothing on repositories by itself.
41 BillingManager,
42 /// Reads every repository, and sees and manages every security alert
43 /// and security setting on them.
44 SecurityManager,
45}
46
47impl OrgRole {
48 pub const ALL: [OrgRole; 2] = [OrgRole::BillingManager, OrgRole::SecurityManager];
49
50 pub fn as_str(self) -> &'static str {
51 match self {
52 OrgRole::BillingManager => "billing_manager",
53 OrgRole::SecurityManager => "security_manager",
54 }
55 }
56
57 pub fn parse(text: &str) -> Option<OrgRole> {
58 let text = text.trim().to_ascii_lowercase().replace([' ', '-'], "_");
59 OrgRole::ALL.into_iter().find(|role| role.as_str() == text)
60 }
61
62 /// How people are shown it.
63 pub fn label(self) -> &'static str {
64 match self {
65 OrgRole::BillingManager => "Billing manager",
66 OrgRole::SecurityManager => "Security manager",
67 }
68 }
69}
70
71/// What a workspace lets its members do, set by its owners on Settings →
72/// Member privileges. The names are the ones the REST API uses. Stored as
73/// JSON in `workspaces.member_privileges`; NULL, or a field left out,
74/// means its default.
75#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(default)]
77pub struct MemberPrivileges {
78 /// Members may create public repositories. Default on.
79 pub members_can_create_public_repositories: bool,
80 /// Members may create private repositories. Default on.
81 pub members_can_create_private_repositories: bool,
82 /// Members with the Admin role on a repository may change its
83 /// visibility. Default on. Off: owners only.
84 pub members_can_change_repo_visibility: bool,
85 /// Members with the Admin role on a repository may delete or transfer
86 /// it. Default off: owners only, as before this setting existed.
87 pub members_can_delete_repositories: bool,
88 /// Members with the Admin role on a repository may give a role on it
89 /// to someone outside the workspace. Default on. Off: owners only.
90 pub members_can_invite_outside_collaborators: bool,
91}
92
93impl Default for MemberPrivileges {
94 fn default() -> Self {
95 MemberPrivileges {
96 members_can_create_public_repositories: true,
97 members_can_create_private_repositories: true,
98 members_can_change_repo_visibility: true,
99 members_can_delete_repositories: false,
100 members_can_invite_outside_collaborators: true,
101 }
102 }
103}
104
105impl MemberPrivileges {
106 /// Whether someone with `role` in the workspace may create a
107 /// repository that is `private` or not.
108 pub fn may_create(&self, role: Role, private: bool) -> bool {
109 role == Role::Owner
110 || if private {
111 self.members_can_create_private_repositories
112 } else {
113 self.members_can_create_public_repositories
114 }
115 }
116
117 /// The refusal for creating a repository, when there is one.
118 pub fn creation_refusal(&self, role: Role, private: bool, slug: &str) -> Option<String> {
119 if self.may_create(role, private) {
120 return None;
121 }
122 let kind = if private { "private" } else { "public" };
123 Some(if !self.members_can_create_public_repositories && !self.members_can_create_private_repositories {
124 format!("Only owners of {slug} can create repositories in it.")
125 } else {
126 format!("Only owners of {slug} can create {kind} repositories in it.")
127 })
128 }
129
130 /// Parses the JSON stored in `workspaces.member_privileges`.
131 pub fn from_stored(text: Option<&str>) -> MemberPrivileges {
132 text.and_then(|text| serde_json::from_str(text).ok()).unwrap_or_default()
133 }
134
135 /// Each setting with its name, value and what it means, in the order
136 /// Settings shows them.
137 pub fn listed(&self) -> [(&'static str, bool); 5] {
138 [
139 ("members_can_create_public_repositories", self.members_can_create_public_repositories),
140 ("members_can_create_private_repositories", self.members_can_create_private_repositories),
141 ("members_can_change_repo_visibility", self.members_can_change_repo_visibility),
142 ("members_can_delete_repositories", self.members_can_delete_repositories),
143 ("members_can_invite_outside_collaborators", self.members_can_invite_outside_collaborators),
144 ]
145 }
146
147 /// The setting's sentence for the audit log.
148 pub fn describe(name: &str) -> &'static str {
149 match name {
150 "members_can_create_public_repositories" => "members can create public repositories",
151 "members_can_create_private_repositories" => "members can create private repositories",
152 "members_can_change_repo_visibility" => "repository admins can change visibility",
153 "members_can_delete_repositories" => "repository admins can delete and transfer repositories",
154 "members_can_invite_outside_collaborators" => "repository admins can invite outside collaborators",
155 _ => "a member privilege",
156 }
157 }
158}
159
160/// A change to some of a workspace's member privileges: each field given
161/// is set, the rest are kept.
162#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(default)]
164pub struct MemberPrivilegesPatch {
165 pub members_can_create_public_repositories: Option<bool>,
166 pub members_can_create_private_repositories: Option<bool>,
167 pub members_can_change_repo_visibility: Option<bool>,
168 pub members_can_delete_repositories: Option<bool>,
169 pub members_can_invite_outside_collaborators: Option<bool>,
170}
171
172impl MemberPrivilegesPatch {
173 pub fn is_empty(&self) -> bool {
174 *self == MemberPrivilegesPatch::default()
175 }
176
177 pub fn apply(&self, to: MemberPrivileges) -> MemberPrivileges {
178 MemberPrivileges {
179 members_can_create_public_repositories: self
180 .members_can_create_public_repositories
181 .unwrap_or(to.members_can_create_public_repositories),
182 members_can_create_private_repositories: self
183 .members_can_create_private_repositories
184 .unwrap_or(to.members_can_create_private_repositories),
185 members_can_change_repo_visibility: self
186 .members_can_change_repo_visibility
187 .unwrap_or(to.members_can_change_repo_visibility),
188 members_can_delete_repositories: self.members_can_delete_repositories.unwrap_or(to.members_can_delete_repositories),
189 members_can_invite_outside_collaborators: self
190 .members_can_invite_outside_collaborators
191 .unwrap_or(to.members_can_invite_outside_collaborators),
192 }
193 }
194
195 /// Reads the fields of a JSON object, such as an API request's body,
196 /// by their names. A field that is there and not a boolean is named in
197 /// the error.
198 pub fn from_json(input: &serde_json::Value) -> Result<MemberPrivilegesPatch, String> {
199 let field = |name: &str| -> Result<Option<bool>, String> {
200 match input.get(name) {
201 None | Some(serde_json::Value::Null) => Ok(None),
202 Some(serde_json::Value::Bool(value)) => Ok(Some(*value)),
203 Some(_) => Err(format!("{name} is true or false.")),
204 }
205 };
206 Ok(MemberPrivilegesPatch {
207 members_can_create_public_repositories: field("members_can_create_public_repositories")?,
208 members_can_create_private_repositories: field("members_can_create_private_repositories")?,
209 members_can_change_repo_visibility: field("members_can_change_repo_visibility")?,
210 members_can_delete_repositories: field("members_can_delete_repositories")?,
211 members_can_invite_outside_collaborators: field("members_can_invite_outside_collaborators")?,
212 })
213 }
214}
215
216/// A workspace a person belongs to and cannot use yet, and why.
217#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
218pub struct PolicyHold {
219 pub slug: String,
220 /// What to tell them, such as "acme needs members to turn on
221 /// two-factor authentication."
222 pub reason: String,
223 /// `two_factor`, `verified_email` or `email_domain`.
224 pub gap: String,
225}
226
227/// Why a change to who owns a workspace would leave it with none, or
228/// `None` when it would not. `owners` is how many owners it has now;
229/// `losing` is whether the change takes one away.
230pub fn last_owner_refusal(owners: usize, losing: bool) -> Option<&'static str> {
231 (losing && owners <= 1).then_some(
232 "A workspace needs at least one owner. Make another member an owner first, or delete the workspace.",
233 )
234}
235
236/// `update_member`: change a member's role (owner or member) and the roles
237/// they hold besides it. Each field given is set. Owners only, as a person;
238/// never leaves the workspace without an owner. Returns
239/// `Outcome<identity::Member>`.
240#[derive(Debug, Serialize, Deserialize)]
241pub struct UpdateMemberArgs {
242 pub actor: User,
243 pub slug: String,
244 pub username: String,
245 #[serde(default)]
246 pub role: Option<Role>,
247 #[serde(default)]
248 pub org_roles: Option<Vec<OrgRole>>,
249 #[serde(default)]
250 pub surface: Option<crate::audit::Surface>,
251}
252
253/// `transfer_ownership`: makes `username`, a member, an owner, and the
254/// owner asking a member, in one step. Owners only, as a person. Returns
255/// `Outcome<bool>`.
256#[derive(Debug, Serialize, Deserialize)]
257pub struct TransferOwnershipArgs {
258 pub actor: User,
259 pub slug: String,
260 pub username: String,
261 #[serde(default)]
262 pub surface: Option<crate::audit::Surface>,
263}
264
265/// `leave_workspace`: the person asking leaves. Their roles on its
266/// repositories and their place in its teams go too. Refused for the last
267/// owner. Returns `Outcome<bool>`.
268#[derive(Debug, Serialize, Deserialize)]
269pub struct LeaveWorkspaceArgs {
270 pub user: User,
271 pub slug: String,
272 #[serde(default)]
273 pub surface: Option<crate::audit::Surface>,
274}
275
276/// `set_member_privileges`: owners only, as a person. Returns
277/// `Outcome<MemberPrivileges>`, all of them as they are now.
278#[derive(Debug, Serialize, Deserialize)]
279pub struct SetMemberPrivilegesArgs {
280 pub actor: User,
281 pub slug: String,
282 pub privileges: MemberPrivilegesPatch,
283 #[serde(default)]
284 pub surface: Option<crate::audit::Surface>,
285}
286
287/// `set_two_factor_requirement`: whether the workspace requires two-factor
288/// authentication of its members and outside collaborators. Owners only,
289/// as a person who has it on themselves. Those without it keep their
290/// membership but cannot use it until they turn it on. Returns
291/// `Outcome<bool>`.
292#[derive(Debug, Serialize, Deserialize)]
293pub struct SetTwoFactorRequirementArgs {
294 pub actor: User,
295 pub slug: String,
296 pub required: bool,
297 #[serde(default)]
298 pub surface: Option<crate::audit::Surface>,
299}
300
301/// `grant_creator`: the repos service made a repository; its creator gets
302/// the Admin role on it directly, as a grant, so they keep it whatever the
303/// base permission. For the repos service. Returns `bool`.
304#[derive(Debug, Serialize, Deserialize)]
305pub struct GrantCreatorArgs {
306 pub repo_id: String,
307 /// The workspace's slug.
308 pub namespace: String,
309 pub name: String,
310 pub user_id: String,
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn a_workspace_never_loses_its_last_owner() {
319 assert!(last_owner_refusal(1, true).is_some());
320 assert!(last_owner_refusal(0, true).is_some());
321 assert_eq!(last_owner_refusal(2, true), None);
322 assert_eq!(last_owner_refusal(1, false), None);
323 }
324
325 #[test]
326 fn the_defaults_keep_what_members_could_do() {
327 let defaults = MemberPrivileges::default();
328 assert!(defaults.may_create(Role::Member, true));
329 assert!(defaults.may_create(Role::Member, false));
330 assert!(defaults.members_can_change_repo_visibility);
331 assert!(!defaults.members_can_delete_repositories);
332 assert!(defaults.members_can_invite_outside_collaborators);
333 // Nothing stored, or an unreadable value, is the defaults.
334 assert_eq!(MemberPrivileges::from_stored(None), defaults);
335 assert_eq!(MemberPrivileges::from_stored(Some("nope")), defaults);
336 // A field left out keeps its default.
337 let stored = MemberPrivileges::from_stored(Some(r#"{"members_can_create_public_repositories":false}"#));
338 assert!(!stored.members_can_create_public_repositories);
339 assert!(stored.members_can_create_private_repositories);
340 }
341
342 #[test]
343 fn owners_create_whatever_the_privileges_say() {
344 let closed = MemberPrivileges {
345 members_can_create_public_repositories: false,
346 members_can_create_private_repositories: false,
347 ..MemberPrivileges::default()
348 };
349 assert!(closed.may_create(Role::Owner, true));
350 assert!(!closed.may_create(Role::Member, true));
351 assert!(closed.creation_refusal(Role::Member, false, "acme").unwrap().contains("Only owners of acme can create repositories"));
352 let private_only = MemberPrivileges { members_can_create_public_repositories: false, ..MemberPrivileges::default() };
353 assert!(private_only.may_create(Role::Member, true));
354 assert!(private_only.creation_refusal(Role::Member, false, "acme").unwrap().contains("public"));
355 assert_eq!(private_only.creation_refusal(Role::Member, true, "acme"), None);
356 }
357
358 #[test]
359 fn a_patch_sets_only_what_it_names() {
360 let patch = MemberPrivilegesPatch::from_json(&serde_json::json!({ "members_can_delete_repositories": true, "name": "x" })).unwrap();
361 assert!(!patch.is_empty());
362 let applied = patch.apply(MemberPrivileges::default());
363 assert!(applied.members_can_delete_repositories);
364 assert!(applied.members_can_create_public_repositories);
365 assert!(MemberPrivilegesPatch::from_json(&serde_json::json!({})).unwrap().is_empty());
366 assert!(MemberPrivilegesPatch::from_json(&serde_json::json!({ "members_can_delete_repositories": "yes" })).is_err());
367 }
368
369 #[test]
370 fn org_roles_read_and_write_as_words() {
371 for role in OrgRole::ALL {
372 assert_eq!(OrgRole::parse(role.as_str()), Some(role));
373 assert_eq!(serde_json::to_value(role).unwrap(), role.as_str());
374 }
375 assert_eq!(OrgRole::parse("Billing manager"), Some(OrgRole::BillingManager));
376 assert_eq!(OrgRole::parse("security-manager"), Some(OrgRole::SecurityManager));
377 assert_eq!(OrgRole::parse("owner"), None);
378 }
379}

This file's history is long; its oldest lines are credited to the oldest commit read.