Skip to content
514 linesCodeBlameRaw
1//! Who owns and belongs to a workspace, and what its members may do. See
2//! `g1t_contracts::members` for the rules.
3//!
4//! - **Owners.** `update_member` makes a member an owner or an owner a
5//! member, and gives or takes away billing manager and security manager;
6//! `transfer_ownership` hands the workspace over in one step;
7//! `leave_workspace` is anyone leaving. None of them leaves a workspace
8//! without an owner.
9//! - **Member privileges.** `set_member_privileges` stores them as JSON in
10//! `workspaces.member_privileges`; every membership identity resolves
11//! carries them (workspaces.rs), and the repos service enforces them.
12//! - **Two-factor requirement.** `set_two_factor_requirement` sets
13//! `workspaces.require_two_factor`, which security.rs enforces.
14//! - **Creators.** `grant_creator` gives whoever creates a repository the
15//! Admin role on it, and [`Identity::backfill_creator_grants`] did the
16//! same, once, for repositories made before.
17//!
18//! Every change is recorded in the workspace's audit log.
19
20use g1t_contracts::audit::Surface;
21use g1t_contracts::identity::Member;
22use g1t_contracts::members::*;
23use g1t_contracts::repos::{AllIdsArgs, CreatorPage};
24use g1t_contracts::time::rfc3339;
25use g1t_contracts::{FailureCode, Outcome, Role, User};
26use g1t_kit::now_ms;
27use serde::Deserialize;
28use worker::Result;
29use worker::wasm_bindgen::JsValue;
30
31use crate::Identity;
32use crate::security::is_person;
33
34const OWNERS_ONLY: &str = "Only an owner can change a workspace's members.";
35const CONFIRM_FIRST: &str = "Confirm your email address before changing the workspace's members.";
36const NOT_A_MEMBER: &str = "That person is not a member of this workspace.";
37
38/// A member's row, as the rules here need it.
39#[derive(Deserialize)]
40pub(crate) struct MemberRow {
41 pub user_id: String,
42 pub username: String,
43 #[serde(default)]
44 pub display_username: Option<String>,
45 pub role: Role,
46 #[serde(default)]
47 pub name: Option<String>,
48 #[serde(default)]
49 pub avatar: Option<String>,
50 #[serde(default)]
51 pub billing_manager: u8,
52 #[serde(default)]
53 pub security_manager: u8,
54}
55
56impl MemberRow {
57 pub fn org_roles(&self) -> Vec<OrgRole> {
58 let mut roles = Vec::new();
59 if self.billing_manager != 0 {
60 roles.push(OrgRole::BillingManager);
61 }
62 if self.security_manager != 0 {
63 roles.push(OrgRole::SecurityManager);
64 }
65 roles
66 }
67
68 pub fn member(&self, two_factor: Option<bool>) -> Member {
69 Member {
70 username: self.username.clone(),
71 display_username: self
72 .display_username
73 .clone()
74 .filter(|display| display.eq_ignore_ascii_case(&self.username) && *display != self.username),
75 role: self.role,
76 org_roles: self.org_roles(),
77 two_factor,
78 name: self.name.clone(),
79 avatar: self.avatar.clone(),
80 }
81 }
82}
83
84/// How a role change reads in the audit log.
85fn role_words(role: Role) -> &'static str {
86 match role {
87 Role::Owner => "owner",
88 Role::Member => "member",
89 }
90}
91
92impl Identity {
93 /// The workspace's id, if `actor` is a verified person who owns it; the
94 /// refusal otherwise.
95 async fn owners_workspace(&self, actor: &User, slug: &str) -> Result<Outcome<String>> {
96 if !is_person(actor) || actor.role_in(slug) != Some(Role::Owner) {
97 return Ok(Outcome::fail(FailureCode::Forbidden, OWNERS_ONLY));
98 }
99 if !actor.verified {
100 return Ok(Outcome::fail(FailureCode::Forbidden, CONFIRM_FIRST));
101 }
102 Ok(match self.workspace_id_of(slug).await? {
103 Some(id) => Outcome::Ok(id),
104 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
105 })
106 }
107
108 /// One member of a workspace, by username.
109 pub(crate) async fn member_row(&self, workspace_id: &str, username: &str) -> Result<Option<MemberRow>> {
110 self.db
111 .prepare(
112 "SELECT m.user_id, u.username, u.display_username, m.role, u.display_name AS name, u.avatar,
113 m.billing_manager, m.security_manager
114 FROM workspace_members m JOIN users u ON u.id = m.user_id
115 WHERE m.workspace_id = ? AND u.username = ?",
116 )
117 .bind(&[workspace_id.into(), username.trim().trim_start_matches('@').to_lowercase().into()])?
118 .first::<MemberRow>(None)
119 .await
120 }
121
122 /// How many owners a workspace has.
123 pub(crate) async fn owner_count(&self, workspace_id: &str) -> Result<usize> {
124 #[derive(Deserialize)]
125 struct Count {
126 owners: u32,
127 }
128 Ok(self
129 .db
130 .prepare("SELECT count(*) AS owners FROM workspace_members WHERE workspace_id = ? AND role = 'owner'")
131 .bind(&[workspace_id.into()])?
132 .first::<Count>(None)
133 .await?
134 .map_or(0, |count| count.owners as usize))
135 }
136
137 /// `update_member`: see [`UpdateMemberArgs`].
138 pub async fn update_member(&self, a: UpdateMemberArgs) -> Result<Outcome<Member>> {
139 let slug = a.slug.trim().to_lowercase();
140 let workspace_id = match self.owners_workspace(&a.actor, &slug).await? {
141 Outcome::Ok(id) => id,
142 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
143 };
144 let Some(row) = self.member_row(&workspace_id, &a.username).await? else {
145 return Ok(Outcome::fail(FailureCode::NotFound, NOT_A_MEMBER));
146 };
147 if a.role.is_none() && a.org_roles.is_none() {
148 return Ok(Outcome::fail(FailureCode::Invalid, "Give role or org_roles to change."));
149 }
150 let surface = a.surface.unwrap_or(Surface::Web);
151 let role = a.role.unwrap_or(row.role);
152 if row.role == Role::Owner
153 && role == Role::Member
154 && let Some(why) = last_owner_refusal(self.owner_count(&workspace_id).await?, true)
155 {
156 return Ok(Outcome::fail(FailureCode::Conflict, why));
157 }
158 let mut org_roles = a.org_roles.clone().unwrap_or_else(|| row.org_roles());
159 org_roles.sort();
160 org_roles.dedup();
161 let billing = org_roles.contains(&OrgRole::BillingManager);
162 let security = org_roles.contains(&OrgRole::SecurityManager);
163 self.db
164 .prepare(
165 "UPDATE workspace_members SET role = ?, billing_manager = ?, security_manager = ?
166 WHERE workspace_id = ? AND user_id = ?",
167 )
168 .bind(&[
169 role_words(role).into(),
170 (billing as u8).into(),
171 (security as u8).into(),
172 workspace_id.as_str().into(),
173 row.user_id.as_str().into(),
174 ])?
175 .run()
176 .await?;
177 if role != row.role {
178 self.audit_workspace(
179 &a.actor,
180 "member.role_changed",
181 &slug,
182 surface,
183 format!("Changed {}'s role from {} to {}", row.username, role_words(row.role), role_words(role)),
184 )
185 .await;
186 }
187 let before = row.org_roles();
188 for changed in OrgRole::ALL {
189 let (had, has) = (before.contains(&changed), org_roles.contains(&changed));
190 if had != has {
191 self.audit_workspace(
192 &a.actor,
193 if has { "member.org_role_added" } else { "member.org_role_removed" },
194 &slug,
195 surface,
196 if has {
197 format!("Made {} a {}", row.username, changed.label().to_lowercase())
198 } else {
199 format!("Took {} off as {}", row.username, changed.label().to_lowercase())
200 },
201 )
202 .await;
203 }
204 }
205 let updated = MemberRow { role, billing_manager: billing as u8, security_manager: security as u8, ..row };
206 Ok(Outcome::Ok(updated.member(None)))
207 }
208
209 /// `transfer_ownership`: see [`TransferOwnershipArgs`].
210 pub async fn transfer_ownership(&self, a: TransferOwnershipArgs) -> Result<Outcome<bool>> {
211 let slug = a.slug.trim().to_lowercase();
212 let workspace_id = match self.owners_workspace(&a.actor, &slug).await? {
213 Outcome::Ok(id) => id,
214 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
215 };
216 let Some(row) = self.member_row(&workspace_id, &a.username).await? else {
217 return Ok(Outcome::fail(FailureCode::NotFound, NOT_A_MEMBER));
218 };
219 if row.user_id == a.actor.id {
220 return Ok(Outcome::fail(FailureCode::Invalid, "Choose another member to hand the workspace to."));
221 }
222 // The new owner must be able to use the workspace: its policy holds
223 // for owners too.
224 if let Some(why) = self.policy_refusal(&row.user_id, &slug).await? {
225 return Ok(Outcome::fail(FailureCode::Conflict, format!("{} cannot own {slug} yet: {why}", row.username)));
226 }
227 self.db
228 .batch(vec![
229 self.db
230 .prepare("UPDATE workspace_members SET role = 'owner' WHERE workspace_id = ? AND user_id = ?")
231 .bind(&[workspace_id.as_str().into(), row.user_id.as_str().into()])?,
232 self.db
233 .prepare("UPDATE workspace_members SET role = 'member' WHERE workspace_id = ? AND user_id = ?")
234 .bind(&[workspace_id.as_str().into(), a.actor.id.as_str().into()])?,
235 ])
236 .await?;
237 self.audit_workspace(
238 &a.actor,
239 "workspace.ownership_transferred",
240 &slug,
241 a.surface.unwrap_or(Surface::Web),
242 format!("Handed {slug} to {}: they are an owner, and {} a member", row.username, a.actor.username),
243 )
244 .await;
245 Ok(Outcome::Ok(true))
246 }
247
248 /// `leave_workspace`: see [`LeaveWorkspaceArgs`].
249 pub async fn leave_workspace(&self, a: LeaveWorkspaceArgs) -> Result<Outcome<bool>> {
250 let slug = a.slug.trim().to_lowercase();
251 if !is_person(&a.user) {
252 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can leave a workspace, signed in as themselves."));
253 }
254 let Some(workspace_id) = self.workspace_id_of(&slug).await? else {
255 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
256 };
257 // Read from the table, not the resolved user: someone held out by
258 // the workspace's policy can still leave it.
259 let Some(row) = self.member_row(&workspace_id, &a.user.username).await? else {
260 return Ok(Outcome::fail(FailureCode::NotFound, "You are not a member of this workspace."));
261 };
262 if row.role == Role::Owner
263 && let Some(why) = last_owner_refusal(self.owner_count(&workspace_id).await?, true)
264 {
265 return Ok(Outcome::fail(FailureCode::Conflict, why));
266 }
267 self.drop_member(&workspace_id, &row.user_id).await?;
268 self.audit_workspace(&a.user, "member.left", &slug, a.surface.unwrap_or(Surface::Web), format!("{} left {slug}", row.username))
269 .await;
270 Ok(Outcome::Ok(true))
271 }
272
273 /// Takes a person out of a workspace and every way into it: their roles
274 /// on its repositories (access.rs) and their place in its teams
275 /// (teams.rs). To keep them on a repository, add them to it again as an
276 /// outside collaborator.
277 pub(crate) async fn drop_member(&self, workspace_id: &str, user_id: &str) -> Result<()> {
278 self.db
279 .batch(vec![
280 self.db
281 .prepare("DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ?")
282 .bind(&[workspace_id.into(), user_id.into()])?,
283 self.db
284 .prepare(
285 "DELETE FROM repo_grants
286 WHERE workspace_id = ? AND principal_kind = 'user' AND principal_id = ?",
287 )
288 .bind(&[workspace_id.into(), user_id.into()])?,
289 self.db
290 .prepare(
291 "DELETE FROM team_members
292 WHERE team_id IN (SELECT id FROM teams WHERE workspace_id = ?) AND user_id = ?",
293 )
294 .bind(&[workspace_id.into(), user_id.into()])?,
295 ])
296 .await?;
297 Ok(())
298 }
299
300 /// A workspace's member privileges, by its id.
301 pub(crate) async fn privileges_of(&self, workspace_id: &str) -> Result<MemberPrivileges> {
302 #[derive(Deserialize)]
303 struct Row {
304 member_privileges: Option<String>,
305 }
306 Ok(self
307 .db
308 .prepare("SELECT member_privileges FROM workspaces WHERE id = ?")
309 .bind(&[workspace_id.into()])?
310 .first::<Row>(None)
311 .await?
312 .map(|row| MemberPrivileges::from_stored(row.member_privileges.as_deref()))
313 .unwrap_or_default())
314 }
315
316 /// `set_member_privileges`: see [`SetMemberPrivilegesArgs`].
317 pub async fn set_member_privileges(&self, a: SetMemberPrivilegesArgs) -> Result<Outcome<MemberPrivileges>> {
318 let slug = a.slug.trim().to_lowercase();
319 let workspace_id = match self.owners_workspace(&a.actor, &slug).await? {
320 Outcome::Ok(id) => id,
321 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
322 };
323 let before = self.privileges_of(&workspace_id).await?;
324 let after = a.privileges.apply(before);
325 if after == before {
326 return Ok(Outcome::Ok(before));
327 }
328 let stored = serde_json::to_string(&after).unwrap_or_default();
329 self.db
330 .prepare("UPDATE workspaces SET member_privileges = ? WHERE id = ?")
331 .bind(&[stored.into(), workspace_id.as_str().into()])?
332 .run()
333 .await?;
334 for ((name, was), (_, now)) in before.listed().into_iter().zip(after.listed()) {
335 if was != now {
336 self.audit_workspace(
337 &a.actor,
338 "workspace.member_privileges_changed",
339 &slug,
340 a.surface.unwrap_or(Surface::Web),
341 format!("Turned {} {}", if now { "on" } else { "off" }, MemberPrivileges::describe(name)),
342 )
343 .await;
344 }
345 }
346 self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await;
347 Ok(Outcome::Ok(after))
348 }
349
350 /// `set_two_factor_requirement`: see [`SetTwoFactorRequirementArgs`].
351 pub async fn set_two_factor_requirement(&self, a: SetTwoFactorRequirementArgs) -> Result<Outcome<bool>> {
352 let slug = a.slug.trim().to_lowercase();
353 let workspace_id = match self.owners_workspace(&a.actor, &slug).await? {
354 Outcome::Ok(id) => id,
355 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
356 };
357 if a.required && !self.two_factor_enabled(&a.actor.id).await? {
358 return Ok(Outcome::fail(
359 FailureCode::Conflict,
360 "Turn on two-factor authentication for your own account before requiring it of everyone.",
361 ));
362 }
363 let before = self.workspace_policy(&slug).await?.require_two_factor;
364 if before == a.required {
365 return Ok(Outcome::Ok(before));
366 }
367 self.db
368 .prepare("UPDATE workspaces SET require_two_factor = ? WHERE id = ?")
369 .bind(&[(a.required as u8).into(), workspace_id.as_str().into()])?
370 .run()
371 .await?;
372 self.audit_workspace(
373 &a.actor,
374 if a.required { "workspace.two_factor_required" } else { "workspace.two_factor_not_required" },
375 &slug,
376 a.surface.unwrap_or(Surface::Web),
377 if a.required {
378 "Required two-factor authentication of members and outside collaborators".to_owned()
379 } else {
380 "Stopped requiring two-factor authentication".to_owned()
381 },
382 )
383 .await;
384 self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await;
385 Ok(Outcome::Ok(a.required))
386 }
387
388 /// `grant_creator`: see [`GrantCreatorArgs`].
389 pub async fn grant_creator(&self, a: GrantCreatorArgs) -> Result<bool> {
390 let Some(workspace_id) = self.workspace_id_of(&a.namespace.to_lowercase()).await? else {
391 return Ok(false);
392 };
393 if !self.is_member_of(&workspace_id, &a.user_id).await? {
394 return Ok(false);
395 }
396 self.insert_creator_grant(&a.repo_id, &workspace_id, &a.name, &a.user_id).await?;
397 Ok(true)
398 }
399
400 async fn insert_creator_grant(&self, repo_id: &str, workspace_id: &str, name: &str, user_id: &str) -> Result<()> {
401 let now = rfc3339(now_ms());
402 // Never lowers a role someone already gave them.
403 self.db
404 .prepare(
405 "INSERT INTO repo_grants
406 (repo_id, principal_kind, principal_id, workspace_id, repo_name, role, granted_by, created_at, updated_at)
407 VALUES (?1, 'user', ?2, ?3, ?4, 'admin', NULL, ?5, ?5)
408 ON CONFLICT (repo_id, principal_kind, principal_id)
409 DO UPDATE SET role = 'admin', updated_at = excluded.updated_at",
410 )
411 .bind(&[repo_id.into(), user_id.into(), workspace_id.into(), name.into(), now.as_str().into()])?
412 .run()
413 .await?;
414 Ok(())
415 }
416
417 /// Once, a page per run of the scheduled handler: the person who
418 /// created each repository made before creators were given Admin gets
419 /// it, if they are still a member of its workspace and lack it.
420 pub async fn backfill_creator_grants(&self) -> Result<()> {
421 #[derive(Deserialize)]
422 struct Job {
423 cursor: Option<String>,
424 done_at: Option<String>,
425 }
426 let Some(job) = self
427 .db
428 .prepare("SELECT cursor, done_at FROM identity_jobs WHERE name = 'creator_grants'")
429 .first::<Job>(None)
430 .await?
431 else {
432 return Ok(());
433 };
434 if job.done_at.is_some() {
435 return Ok(());
436 }
437 let page: CreatorPage = g1t_kit::call(
438 &self.env.service("REPOS")?,
439 "repo_creators",
440 &AllIdsArgs { after: job.cursor.clone(), limit: 200 },
441 )
442 .await?;
443 #[derive(Deserialize)]
444 struct Standing {
445 workspace_id: String,
446 role: String,
447 base_permission: String,
448 direct: Option<String>,
449 }
450 for repo in &page.repos {
451 let standing = self
452 .db
453 .prepare(
454 "SELECT w.id AS workspace_id, m.role, w.base_permission,
455 (SELECT g.role FROM repo_grants g WHERE g.repo_id = ?1 AND g.principal_kind = 'user' AND g.principal_id = ?2) AS direct
456 FROM workspaces w JOIN workspace_members m ON m.workspace_id = w.id AND m.user_id = ?2
457 WHERE w.slug = ?3 AND w.deleted_at IS NULL",
458 )
459 .bind(&[repo.id.as_str().into(), repo.owner_id.as_str().into(), repo.namespace.to_lowercase().into()])?
460 .first::<Standing>(None)
461 .await?;
462 let Some(standing) = standing else { continue };
463 let has_admin = standing.role == "owner" || standing.base_permission == "admin" || standing.direct.as_deref() == Some("admin");
464 if !has_admin {
465 self.insert_creator_grant(&repo.id, &standing.workspace_id, &repo.name, &repo.owner_id).await?;
466 }
467 }
468 let (cursor, done): (JsValue, JsValue) = match &page.next {
469 Some(next) => (next.as_str().into(), JsValue::NULL),
470 None => (job.cursor.as_deref().map_or(JsValue::NULL, Into::into), rfc3339(now_ms()).into()),
471 };
472 self.db
473 .prepare("UPDATE identity_jobs SET cursor = ?, done_at = ? WHERE name = 'creator_grants'")
474 .bind(&[cursor, done])?
475 .run()
476 .await?;
477 Ok(())
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 fn row(billing: u8, security: u8) -> MemberRow {
486 MemberRow {
487 user_id: "usr_1".into(),
488 username: "ada".into(),
489 display_username: Some("Ada".into()),
490 role: Role::Member,
491 name: None,
492 avatar: None,
493 billing_manager: billing,
494 security_manager: security,
495 }
496 }
497
498 #[test]
499 fn a_member_keeps_their_usernames_chosen_case() {
500 assert_eq!(row(0, 0).member(None).display_username.as_deref(), Some("Ada"));
501 let plain = MemberRow { display_username: Some("ada".into()), ..row(0, 0) };
502 assert_eq!(plain.member(None).display_username, None);
503 }
504
505 #[test]
506 fn a_rows_flags_are_its_org_roles() {
507 assert!(row(0, 0).org_roles().is_empty());
508 assert_eq!(row(1, 0).org_roles(), vec![OrgRole::BillingManager]);
509 assert_eq!(row(1, 1).org_roles(), vec![OrgRole::BillingManager, OrgRole::SecurityManager]);
510 let member = row(0, 1).member(Some(true));
511 assert_eq!(member.org_roles, vec![OrgRole::SecurityManager]);
512 assert_eq!(member.two_factor, Some(true));
513 }
514}