Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

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