Skip to content

g1t/services/identity/src/members.rs

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