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