Skip to content
521 linesCodeBlameRaw

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 main (membership, two-factor, GitHub repo roles) into tokens1//! 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,
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar43 #[serde(default)]
44 pub display_username: Option<String>,
Merge main (membership, two-factor, GitHub repo roles) into tokens45 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(),
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar71 display_username: self
72 .display_username
73 .clone()
74 .filter(|display| display.eq_ignore_ascii_case(&self.username) && *display != self.username),
Merge main (membership, two-factor, GitHub repo roles) into tokens75 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(
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar112 "SELECT m.user_id, u.username, u.display_username, m.role, u.display_name AS name, u.avatar,
Merge main (membership, two-factor, GitHub repo roles) into tokens113 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()])?,
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.295 // No one reports to them here any more, and they lead no team.
296 self.db
297 .prepare("UPDATE workspace_members SET manager_id = NULL WHERE workspace_id = ? AND manager_id = ?")
298 .bind(&[workspace_id.into(), user_id.into()])?,
299 self.db
300 .prepare("UPDATE teams SET lead_kind = NULL, lead_id = NULL WHERE workspace_id = ? AND lead_kind = 'user' AND lead_id = ?")
301 .bind(&[workspace_id.into(), user_id.into()])?,
Merge main (membership, two-factor, GitHub repo roles) into tokens302 ])
303 .await?;
304 Ok(())
305 }
306
307 /// A workspace's member privileges, by its id.
308 pub(crate) async fn privileges_of(&self, workspace_id: &str) -> Result<MemberPrivileges> {
309 #[derive(Deserialize)]
310 struct Row {
311 member_privileges: Option<String>,
312 }
313 Ok(self
314 .db
315 .prepare("SELECT member_privileges FROM workspaces WHERE id = ?")
316 .bind(&[workspace_id.into()])?
317 .first::<Row>(None)
318 .await?
319 .map(|row| MemberPrivileges::from_stored(row.member_privileges.as_deref()))
320 .unwrap_or_default())
321 }
322
323 /// `set_member_privileges`: see [`SetMemberPrivilegesArgs`].
324 pub async fn set_member_privileges(&self, a: SetMemberPrivilegesArgs) -> Result<Outcome<MemberPrivileges>> {
325 let slug = a.slug.trim().to_lowercase();
326 let workspace_id = match self.owners_workspace(&a.actor, &slug).await? {
327 Outcome::Ok(id) => id,
328 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
329 };
330 let before = self.privileges_of(&workspace_id).await?;
331 let after = a.privileges.apply(before);
332 if after == before {
333 return Ok(Outcome::Ok(before));
334 }
335 let stored = serde_json::to_string(&after).unwrap_or_default();
336 self.db
337 .prepare("UPDATE workspaces SET member_privileges = ? WHERE id = ?")
338 .bind(&[stored.into(), workspace_id.as_str().into()])?
339 .run()
340 .await?;
341 for ((name, was), (_, now)) in before.listed().into_iter().zip(after.listed()) {
342 if was != now {
343 self.audit_workspace(
344 &a.actor,
345 "workspace.member_privileges_changed",
346 &slug,
347 a.surface.unwrap_or(Surface::Web),
348 format!("Turned {} {}", if now { "on" } else { "off" }, MemberPrivileges::describe(name)),
349 )
350 .await;
351 }
352 }
353 self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await;
354 Ok(Outcome::Ok(after))
355 }
356
357 /// `set_two_factor_requirement`: see [`SetTwoFactorRequirementArgs`].
358 pub async fn set_two_factor_requirement(&self, a: SetTwoFactorRequirementArgs) -> Result<Outcome<bool>> {
359 let slug = a.slug.trim().to_lowercase();
360 let workspace_id = match self.owners_workspace(&a.actor, &slug).await? {
361 Outcome::Ok(id) => id,
362 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
363 };
364 if a.required && !self.two_factor_enabled(&a.actor.id).await? {
365 return Ok(Outcome::fail(
366 FailureCode::Conflict,
367 "Turn on two-factor authentication for your own account before requiring it of everyone.",
368 ));
369 }
370 let before = self.workspace_policy(&slug).await?.require_two_factor;
371 if before == a.required {
372 return Ok(Outcome::Ok(before));
373 }
374 self.db
375 .prepare("UPDATE workspaces SET require_two_factor = ? WHERE id = ?")
376 .bind(&[(a.required as u8).into(), workspace_id.as_str().into()])?
377 .run()
378 .await?;
379 self.audit_workspace(
380 &a.actor,
381 if a.required { "workspace.two_factor_required" } else { "workspace.two_factor_not_required" },
382 &slug,
383 a.surface.unwrap_or(Surface::Web),
384 if a.required {
385 "Required two-factor authentication of members and outside collaborators".to_owned()
386 } else {
387 "Stopped requiring two-factor authentication".to_owned()
388 },
389 )
390 .await;
391 self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await;
392 Ok(Outcome::Ok(a.required))
393 }
394
395 /// `grant_creator`: see [`GrantCreatorArgs`].
396 pub async fn grant_creator(&self, a: GrantCreatorArgs) -> Result<bool> {
397 let Some(workspace_id) = self.workspace_id_of(&a.namespace.to_lowercase()).await? else {
398 return Ok(false);
399 };
400 if !self.is_member_of(&workspace_id, &a.user_id).await? {
401 return Ok(false);
402 }
403 self.insert_creator_grant(&a.repo_id, &workspace_id, &a.name, &a.user_id).await?;
404 Ok(true)
405 }
406
407 async fn insert_creator_grant(&self, repo_id: &str, workspace_id: &str, name: &str, user_id: &str) -> Result<()> {
408 let now = rfc3339(now_ms());
409 // Never lowers a role someone already gave them.
410 self.db
411 .prepare(
412 "INSERT INTO repo_grants
413 (repo_id, principal_kind, principal_id, workspace_id, repo_name, role, granted_by, created_at, updated_at)
414 VALUES (?1, 'user', ?2, ?3, ?4, 'admin', NULL, ?5, ?5)
415 ON CONFLICT (repo_id, principal_kind, principal_id)
416 DO UPDATE SET role = 'admin', updated_at = excluded.updated_at",
417 )
418 .bind(&[repo_id.into(), user_id.into(), workspace_id.into(), name.into(), now.as_str().into()])?
419 .run()
420 .await?;
421 Ok(())
422 }
423
424 /// Once, a page per run of the scheduled handler: the person who
425 /// created each repository made before creators were given Admin gets
426 /// it, if they are still a member of its workspace and lack it.
427 pub async fn backfill_creator_grants(&self) -> Result<()> {
428 #[derive(Deserialize)]
429 struct Job {
430 cursor: Option<String>,
431 done_at: Option<String>,
432 }
433 let Some(job) = self
434 .db
435 .prepare("SELECT cursor, done_at FROM identity_jobs WHERE name = 'creator_grants'")
436 .first::<Job>(None)
437 .await?
438 else {
439 return Ok(());
440 };
441 if job.done_at.is_some() {
442 return Ok(());
443 }
444 let page: CreatorPage = g1t_kit::call(
445 &self.env.service("REPOS")?,
446 "repo_creators",
447 &AllIdsArgs { after: job.cursor.clone(), limit: 200 },
448 )
449 .await?;
450 #[derive(Deserialize)]
451 struct Standing {
452 workspace_id: String,
453 role: String,
454 base_permission: String,
455 direct: Option<String>,
456 }
457 for repo in &page.repos {
458 let standing = self
459 .db
460 .prepare(
461 "SELECT w.id AS workspace_id, m.role, w.base_permission,
462 (SELECT g.role FROM repo_grants g WHERE g.repo_id = ?1 AND g.principal_kind = 'user' AND g.principal_id = ?2) AS direct
463 FROM workspaces w JOIN workspace_members m ON m.workspace_id = w.id AND m.user_id = ?2
464 WHERE w.slug = ?3 AND w.deleted_at IS NULL",
465 )
466 .bind(&[repo.id.as_str().into(), repo.owner_id.as_str().into(), repo.namespace.to_lowercase().into()])?
467 .first::<Standing>(None)
468 .await?;
469 let Some(standing) = standing else { continue };
470 let has_admin = standing.role == "owner" || standing.base_permission == "admin" || standing.direct.as_deref() == Some("admin");
471 if !has_admin {
472 self.insert_creator_grant(&repo.id, &standing.workspace_id, &repo.name, &repo.owner_id).await?;
473 }
474 }
475 let (cursor, done): (JsValue, JsValue) = match &page.next {
476 Some(next) => (next.as_str().into(), JsValue::NULL),
477 None => (job.cursor.as_deref().map_or(JsValue::NULL, Into::into), rfc3339(now_ms()).into()),
478 };
479 self.db
480 .prepare("UPDATE identity_jobs SET cursor = ?, done_at = ? WHERE name = 'creator_grants'")
481 .bind(&[cursor, done])?
482 .run()
483 .await?;
484 Ok(())
485 }
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 fn row(billing: u8, security: u8) -> MemberRow {
493 MemberRow {
494 user_id: "usr_1".into(),
495 username: "ada".into(),
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar496 display_username: Some("Ada".into()),
Merge main (membership, two-factor, GitHub repo roles) into tokens497 role: Role::Member,
498 name: None,
499 avatar: None,
500 billing_manager: billing,
501 security_manager: security,
502 }
503 }
504
505 #[test]
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar506 fn a_member_keeps_their_usernames_chosen_case() {
507 assert_eq!(row(0, 0).member(None).display_username.as_deref(), Some("Ada"));
508 let plain = MemberRow { display_username: Some("ada".into()), ..row(0, 0) };
509 assert_eq!(plain.member(None).display_username, None);
510 }
511
512 #[test]
Merge main (membership, two-factor, GitHub repo roles) into tokens513 fn a_rows_flags_are_its_org_roles() {
514 assert!(row(0, 0).org_roles().is_empty());
515 assert_eq!(row(1, 0).org_roles(), vec![OrgRole::BillingManager]);
516 assert_eq!(row(1, 1).org_roles(), vec![OrgRole::BillingManager, OrgRole::SecurityManager]);
517 let member = row(0, 1).member(Some(true));
518 assert_eq!(member.org_roles, vec![OrgRole::SecurityManager]);
519 assert_eq!(member.two_factor, Some(true));
520 }
521}

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