Skip to content

g1t/services/identity/src/access.rs

1,555 lines64,451 bytesCodeBlame

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.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1//! Who has access to a repository: roles given on one repository, the
2//! invitations that offer them, and each workspace's base permission.
3//!
4//! The rules (which role may do what, and how a person's role is worked
5//! out) live in `g1t_contracts::access`; this is where the roles are kept.
6//! Every user identity resolves carries their grants ([`Identity::grants_of`],
7//! under the workspace's policy) beside their memberships, so services
8//! decide with `access::can` and never call here to authorize.
9//!
10//! **Adding someone** to a repository (Admin only, a person, never an
11//! agent's or a workspace's token):
12//!
13//! - a member of its workspace gets the role at once: it only matters when
14//! it is higher than the base permission;
15//! - anyone else with an account (by username, or a confirmed address) is
16//! sent an invitation, which they accept or decline; it lasts
17//! [`INVITATION_DAYS`];
18//! - an address without an account is sent an invite code (invites.rs,
19//! charged as a workspace invite is) that makes the account and accepts.
20//!
21//! Accepting is checked against the workspace's policy (security.rs), as
22//! joining it is. Removing someone from a workspace takes away their roles
23//! on its repositories (workspaces.rs); a repository that is purged takes
24//! its grants and invitations with it (`forget_repo_access`); a transfer or
25//! rename keeps them (deletion.rs, `transfer_repo_scopes`).
26//!
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar27//! **Teams** are another `principal_kind` in `repo_grants` (teams.rs):
28//! [`Identity::grants_of`] resolves a team's grants into the same
29//! `RepoGrant`s for each person in the team and in its child teams, and
30//! the access list shows where such a role comes from.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look31
32use g1t_contracts::access::*;
33use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
34use g1t_contracts::events::{NewEvent, Publish, RepoCollaborator};
35use g1t_contracts::repos::{GetArgs, Repo, RepoPath};
36use g1t_contracts::time::{SQL_NOW, rfc3339};
37use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, Viewer, new_id};
38use g1t_kit::now_ms;
39use serde::{Deserialize, Serialize};
40use worker::Result;
41use worker::wasm_bindgen::JsValue;
42
43use crate::Identity;
44use crate::invites::normalize_email;
45
46/// How long an invitation to someone with an account waits for an answer.
47pub const INVITATION_DAYS: u64 = 7;
48/// The most direct grants one person carries on every request.
49const MAX_GRANTS: u32 = 1000;
50/// The most people or invitations one list shows.
51const LIST_LIMIT: u32 = 500;
52
53const PEOPLE_ONLY: &str =
54 "Only a person can change who has access to a repository, signed in as themselves; never an agent's or a workspace's token.";
55const CONFIRM_FIRST: &str = "Confirm your email address before changing who has access.";
56const NO_SUCH_USER: &str = "There is no account with that username.";
57
58/// What was typed into "Add people".
59#[derive(Debug, PartialEq, Eq)]
60pub enum Invitee {
61 Username(String),
62 Email(String),
63}
64
65/// A username, or an email address, as typed; `None` if it is neither.
66pub fn invitee(text: &str) -> Option<Invitee> {
67 let text = text.trim().trim_start_matches('@');
68 if text.contains('@') {
69 return normalize_email(text).map(Invitee::Email);
70 }
71 let name = text.to_lowercase();
72 g1t_contracts::is_valid_namespace(&name).then_some(Invitee::Username(name))
73}
74
75/// A person's role on a repository, and where it comes from: ownership,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar76/// the base permission, a team's grant, or a direct grant. The highest
77/// wins; on a tie a direct grant is shown first, then a team's, so a role
78/// is shown where it can be changed.
79pub fn effective(
80 owner: bool,
81 base: Option<RepoRole>,
82 direct: Option<RepoRole>,
83 team: Option<RepoRole>,
84) -> Option<(RepoRole, AccessSource)> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look85 if owner {
86 return Some((RepoRole::Admin, AccessSource::Owner));
87 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar88 let mut best = base.map(|role| (role, AccessSource::Base));
89 for (role, source) in [(team, AccessSource::Team), (direct, AccessSource::Direct)] {
90 if let Some(role) = role
91 && best.is_none_or(|(had, _)| role >= had)
92 {
93 best = Some((role, source));
94 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look95 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar96 best
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look97}
98
99/// Where an invitation stands at `now`.
100pub fn invitation_status(row: &InvitationRow, now: &str) -> RepoInvitationStatus {
101 if row.accepted_at.is_some() {
102 RepoInvitationStatus::Accepted
103 } else if row.declined_at.is_some() {
104 RepoInvitationStatus::Declined
105 } else if row.revoked_at.is_some() {
106 RepoInvitationStatus::Revoked
107 } else if row.expires_at.as_str() <= now {
108 RepoInvitationStatus::Expired
109 } else {
110 RepoInvitationStatus::Pending
111 }
112}
113
114#[derive(Deserialize)]
115struct GrantRow {
116 repo_id: String,
117 workspace: String,
118 role: String,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar119 #[serde(default)]
120 team: Option<String>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look121}
122
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar123/// The highest role a team gives each person on a repository, and that
124/// team's slug, by user id.
125pub(crate) type TeamRoles = std::collections::HashMap<String, (RepoRole, String)>;
126
127/// Folds (user id, role, team slug) rows into each person's highest; on a
128/// tie, the first slug, so the answer never flips.
129pub(crate) fn highest_team_roles(rows: impl IntoIterator<Item = (String, RepoRole, String)>) -> TeamRoles {
130 let mut roles = TeamRoles::new();
131 for (user_id, role, team) in rows {
132 let entry = roles.entry(user_id).or_insert((role, team.clone()));
133 if role > entry.0 || (role == entry.0 && team < entry.1) {
134 *entry = (role, team);
135 }
136 }
137 roles
138}
139
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look140#[derive(Clone, Debug, Default, Deserialize)]
141pub struct InvitationRow {
142 pub id: String,
143 pub repo_id: String,
144 pub workspace: String,
145 pub workspace_id: String,
146 pub repo_name: String,
147 pub invitee: Option<String>,
148 pub email: Option<String>,
149 pub invite_id: Option<String>,
150 pub role: String,
151 pub inviter_id: Option<String>,
152 pub inviter: Option<String>,
153 #[serde(default)]
154 pub inviter_avatar: Option<String>,
155 pub created_at: String,
156 pub expires_at: String,
157 pub accepted_at: Option<String>,
158 pub declined_at: Option<String>,
159 pub revoked_at: Option<String>,
160}
161
162impl InvitationRow {
163 fn role(&self) -> RepoRole {
164 RepoRole::parse(&self.role).unwrap_or(RepoRole::Read)
165 }
166
167 fn shown(&self, now: &str, with_email: bool) -> RepoInvitation {
168 RepoInvitation {
169 id: self.id.clone(),
170 repo: format!("{}/{}", self.workspace, self.repo_name),
171 repo_id: self.repo_id.clone(),
172 invitee: self.invitee.clone(),
173 email: if with_email { self.email.clone() } else { None },
174 role: self.role(),
175 invited_by: self.inviter.clone(),
176 inviter_avatar: self.inviter_avatar.clone(),
177 status: invitation_status(self, now),
178 created_at: self.created_at.clone(),
179 expires_at: self.expires_at.clone(),
180 }
181 }
182}
183
184const INVITATION_COLUMNS: &str = "ri.id, ri.repo_id, w.slug AS workspace, ri.workspace_id, ri.repo_name,
185 ri.invitee_id, invitee.username AS invitee, ri.email, ri.invite_id, ri.role, ri.inviter_id, inviter.username AS inviter, inviter.avatar AS inviter_avatar,
186 ri.created_at, ri.expires_at, ri.accepted_at, ri.declined_at, ri.revoked_at
187 FROM repo_invitations ri
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member188 JOIN workspaces w ON w.id = ri.workspace_id AND w.deleted_at IS NULL
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look189 LEFT JOIN users invitee ON invitee.id = ri.invitee_id
190 LEFT JOIN users inviter ON inviter.id = ri.inviter_id";
191
192/// A person as an access list shows them.
193#[derive(Deserialize)]
194struct PersonRow {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar195 id: String,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look196 username: String,
197 name: Option<String>,
198 avatar: Option<String>,
199 /// `owner` or `member`; null when they are not in the workspace.
200 #[serde(default)]
201 workspace_role: Option<String>,
202 /// Their direct grant on the repository, if any.
203 #[serde(default)]
204 direct: Option<String>,
205}
206
207#[derive(Deserialize)]
208struct Id {
209 id: String,
210}
211
212#[derive(Deserialize)]
213struct Base {
214 base_permission: String,
215}
216
217/// A repository by id and path: what events and the audit log name.
218#[derive(Clone, Copy)]
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar219pub(crate) struct Named<'a> {
220 pub id: &'a str,
221 pub namespace: &'a str,
222 pub name: &'a str,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look223}
224
225impl<'a> From<&'a Repo> for Named<'a> {
226 fn from(repo: &'a Repo) -> Self {
227 Named {
228 id: &repo.id,
229 namespace: &repo.namespace,
230 name: &repo.name,
231 }
232 }
233}
234
235/// A repository someone may manage the access of, with its workspace's id.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar236pub(crate) struct Target {
237 pub repo: Repo,
238 pub workspace_id: String,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look239}
240
241fn opt(value: Option<&str>) -> JsValue {
242 value.map_or(JsValue::NULL, JsValue::from)
243}
244
245fn full_name(repo: &Repo) -> String {
246 format!("{}/{}", repo.namespace, repo.name)
247}
248
249impl Identity {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar250 /// Every repository `user_id` has a role on, directly or through a
251 /// team they are in (or through that team's parents, whose roles child
252 /// teams inherit), with the slug of its workspace now. Attached to
253 /// every user resolved from credentials.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look254 pub async fn grants_of(&self, user_id: &str) -> Result<Vec<RepoGrant>> {
255 let rows = self
256 .db
257 .prepare(format!(
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar258 "WITH RECURSIVE mine(id) AS (
259 SELECT team_id FROM team_members WHERE user_id = ?1
260 UNION
261 SELECT t.parent_id FROM teams t JOIN mine ON t.id = mine.id WHERE t.parent_id IS NOT NULL
262 )
263 SELECT g.repo_id, w.slug AS workspace, g.role, NULL AS team FROM repo_grants g
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member264 JOIN workspaces w ON w.id = g.workspace_id AND w.deleted_at IS NULL
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar265 WHERE g.principal_kind = 'user' AND g.principal_id = ?1
266 UNION ALL
267 SELECT g.repo_id, w.slug AS workspace, g.role, t.slug AS team FROM repo_grants g
268 JOIN mine ON g.principal_kind = 'team' AND g.principal_id = mine.id
269 JOIN teams t ON t.id = g.principal_id
270 JOIN workspaces w ON w.id = g.workspace_id AND w.deleted_at IS NULL
271 LIMIT {MAX_GRANTS}"
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look272 ))
273 .bind(&[user_id.into()])?
274 .all()
275 .await?
276 .results::<GrantRow>()?;
277 Ok(rows
278 .into_iter()
279 .filter_map(|row| {
280 Some(RepoGrant {
281 repo_id: row.repo_id,
282 workspace: row.workspace,
283 role: RepoRole::parse(&row.role)?,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar284 team: row.team,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look285 })
286 })
287 .collect())
288 }
289
290 /// The repository at `path` as `viewer` sees it: missing when they
291 /// cannot read it. Asked of repos, which owns visibility.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar292 pub(crate) async fn repo_for(&self, path: &RepoPath, viewer: &Viewer) -> Result<Option<Repo>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look293 let repos = self.env.service("REPOS")?;
294 let found: Outcome<Repo> = g1t_kit::call(
295 &repos,
296 "get",
297 &GetArgs {
298 path: path.clone(),
299 viewer: viewer.clone(),
300 },
301 )
302 .await?;
303 Ok(match found {
304 // A pull request's working copy has no access of its own.
305 Outcome::Ok(repo) if repo.fork_of.is_none() => Some(repo),
306 _ => None,
307 })
308 }
309
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar310 /// The highest role a team gives each person on a repository: the
311 /// teams with a grant on it, and their child teams, whose people
312 /// inherit it. `user_id` narrows it to one person.
313 pub(crate) async fn team_roles_on(&self, repo_id: &str, user_id: Option<&str>) -> Result<TeamRoles> {
314 #[derive(Deserialize)]
315 struct Row {
316 user_id: String,
317 role: String,
318 via: String,
319 }
320 let rows = self
321 .db
322 .prepare(
323 "WITH RECURSIVE reach(id, role, via) AS (
324 SELECT g.principal_id, g.role, t.slug FROM repo_grants g JOIN teams t ON t.id = g.principal_id
325 WHERE g.repo_id = ?1 AND g.principal_kind = 'team'
326 UNION
327 SELECT c.id, reach.role, reach.via FROM teams c JOIN reach ON c.parent_id = reach.id
328 )
329 SELECT tm.user_id, reach.role, reach.via FROM reach JOIN team_members tm ON tm.team_id = reach.id
330 WHERE ?2 IS NULL OR tm.user_id = ?2",
331 )
332 .bind(&[repo_id.into(), opt(user_id)])?
333 .all()
334 .await?
335 .results::<Row>()?;
336 Ok(highest_team_roles(
337 rows.into_iter()
338 .filter_map(|row| Some((row.user_id, RepoRole::parse(&row.role)?, row.via))),
339 ))
340 }
341
342 /// The teams with a role of their own on a repository.
343 pub(crate) async fn teams_on(&self, repo_id: &str) -> Result<Vec<g1t_contracts::teams::RepoTeam>> {
344 #[derive(Deserialize)]
345 struct Row {
346 slug: String,
347 name: String,
348 role: String,
349 visibility: String,
350 members_count: u32,
351 }
352 let rows = self
353 .db
354 .prepare(format!(
355 "SELECT t.slug, t.name, g.role, t.visibility,
356 (SELECT count(*) FROM team_members tm WHERE tm.team_id = t.id) AS members_count
357 FROM repo_grants g JOIN teams t ON t.id = g.principal_id
358 WHERE g.repo_id = ? AND g.principal_kind = 'team'
359 ORDER BY t.name LIMIT {LIST_LIMIT}"
360 ))
361 .bind(&[repo_id.into()])?
362 .all()
363 .await?
364 .results::<Row>()?;
365 Ok(rows
366 .into_iter()
367 .filter_map(|row| {
368 Some(g1t_contracts::teams::RepoTeam {
369 slug: row.slug,
370 name: row.name,
371 role: RepoRole::parse(&row.role)?,
372 members_count: row.members_count,
373 visibility: g1t_contracts::teams::TeamVisibility::parse(&row.visibility).unwrap_or_default(),
374 })
375 })
376 .collect())
377 }
378
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily379 pub(crate) async fn workspace_id_of(&self, slug: &str) -> Result<Option<String>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look380 Ok(self
381 .db
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member382 // A deleted workspace's repositories are nobody's to share.
383 .prepare("SELECT id FROM workspaces WHERE slug = ? AND deleted_at IS NULL")
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look384 .bind(&[slug.to_lowercase().into()])?
385 .first::<Id>(None)
386 .await?
387 .map(|row| row.id))
388 }
389
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar390 pub(crate) async fn base_of(&self, workspace_id: &str) -> Result<BasePermission> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look391 Ok(self
392 .db
393 .prepare("SELECT base_permission FROM workspaces WHERE id = ?")
394 .bind(&[workspace_id.into()])?
395 .first::<Base>(None)
396 .await?
397 .and_then(|row| BasePermission::parse(&row.base_permission))
398 .unwrap_or_default())
399 }
400
401 /// The repository at `path`, if `actor` may change who has access to it.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar402 pub(crate) async fn manageable(&self, actor: &User, path: &RepoPath) -> Result<Outcome<Target>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look403 if !crate::security::is_person(actor) {
404 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
405 }
406 let viewer = Some(actor.clone());
407 let Some(repo) = self.repo_for(path, &viewer).await? else {
408 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
409 };
410 if !can(Some(actor), &repo, Capability::ManageAccess) {
411 return Ok(Outcome::fail(
412 FailureCode::Forbidden,
413 needs(Capability::ManageAccess, &full_name(&repo)),
414 ));
415 }
416 if !actor.verified {
417 return Ok(Outcome::fail(FailureCode::Forbidden, CONFIRM_FIRST));
418 }
419 let Some(workspace_id) = self.workspace_id_of(&repo.namespace).await? else {
420 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
421 };
422 Ok(Outcome::Ok(Target { repo, workspace_id }))
423 }
424
425 /// The members of the repository's workspace and the people with a
426 /// direct grant on it, each once.
427 async fn people_rows(&self, repo_id: &str, workspace_id: &str) -> Result<Vec<PersonRow>> {
428 self.db
429 .prepare(format!(
430 "SELECT u.id, u.username, u.display_name AS name, u.avatar,
431 m.role AS workspace_role, g.role AS direct
432 FROM users u
433 LEFT JOIN workspace_members m ON m.user_id = u.id AND m.workspace_id = ?2
434 LEFT JOIN repo_grants g ON g.principal_kind = 'user' AND g.principal_id = u.id AND g.repo_id = ?1
435 WHERE m.user_id IS NOT NULL OR g.principal_id IS NOT NULL
436 ORDER BY u.username LIMIT {LIST_LIMIT}"
437 ))
438 .bind(&[repo_id.into(), workspace_id.into()])?
439 .all()
440 .await?
441 .results::<PersonRow>()
442 }
443
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar444 fn collaborator(row: PersonRow, base: BasePermission, teams: &TeamRoles) -> Option<Collaborator> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look445 let workspace_role = match row.workspace_role.as_deref() {
446 Some("owner") => Some(Role::Owner),
447 Some(_) => Some(Role::Member),
448 None => None,
449 };
450 let direct = row.direct.as_deref().and_then(RepoRole::parse);
451 let base_role = workspace_role.and(base.role());
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar452 let team = teams.get(&row.id).cloned();
453 let (role, source) = effective(
454 workspace_role == Some(Role::Owner),
455 base_role,
456 direct,
457 team.as_ref().map(|(role, _)| *role),
458 )?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look459 Some(Collaborator {
460 username: row.username,
461 name: row.name,
462 avatar: row.avatar,
463 role,
464 source,
465 direct,
466 workspace_role,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar467 team_role: team.as_ref().map(|(role, _)| *role),
468 team: team.map(|(_, slug)| slug),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look469 })
470 }
471
472 async fn invitations(&self, filter: &str, binds: &[JsValue]) -> Result<Vec<InvitationRow>> {
473 self.db
474 .prepare(format!("SELECT {INVITATION_COLUMNS} {filter} ORDER BY ri.created_at DESC LIMIT {LIST_LIMIT}"))
475 .bind(binds)?
476 .all()
477 .await?
478 .results::<InvitationRow>()
479 }
480
481 async fn pending_invitations(&self, filter: &str, binds: &[JsValue]) -> Result<Vec<InvitationRow>> {
482 let filter = format!(
483 "{filter} AND ri.accepted_at IS NULL AND ri.declined_at IS NULL AND ri.revoked_at IS NULL
484 AND ri.expires_at > {SQL_NOW}"
485 );
486 self.invitations(&filter, binds).await
487 }
488
489 pub async fn repo_access(&self, a: RepoAccessArgs) -> Result<Outcome<RepoAccess>> {
490 let Some(repo) = self.repo_for(&a.path, &a.viewer).await? else {
491 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
492 };
493 let viewer_role = a.viewer.as_ref().and_then(|viewer| granted(viewer, (&repo).into()));
494 // Like the list of collaborators: for those who can push.
495 if !viewer_role.is_some_and(|role| role >= RepoRole::Write) {
496 return Ok(Outcome::fail(
497 FailureCode::Forbidden,
498 format!("You need the Write role or higher on {} to see who has access.", full_name(&repo)),
499 ));
500 }
501 let can_manage = can(a.viewer.as_ref(), &repo, Capability::ManageAccess);
502 let Some(workspace_id) = self.workspace_id_of(&repo.namespace).await? else {
503 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
504 };
505 let base = self.base_of(&workspace_id).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar506 let rows = self.people_rows(&repo.id, &workspace_id).await?;
507 let team_roles = self.team_roles_on(&repo.id, None).await?;
508 let teams = self.teams_on(&repo.id).await?;
509 let mut people: Vec<Collaborator> = rows
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look510 .into_iter()
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar511 .filter_map(|row| Self::collaborator(row, base, &team_roles))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look512 .collect();
513 people.sort_by(|a, b| b.role.cmp(&a.role).then_with(|| a.username.cmp(&b.username)));
514 let invitations = if can_manage {
515 let now = rfc3339(now_ms());
516 self.pending_invitations("WHERE ri.repo_id = ?", &[repo.id.as_str().into()])
517 .await?
518 .iter()
519 .map(|row| row.shown(&now, true))
520 .collect()
521 } else {
522 Vec::new()
523 };
524 Ok(Outcome::Ok(RepoAccess {
525 repo: full_name(&repo),
526 base_permission: base,
527 people,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar528 teams,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look529 invitations,
530 viewer_role,
531 can_manage,
532 }))
533 }
534
535 /// One person's place on the repository, as the access list shows it.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar536 pub(crate) async fn collaborator_on(&self, repo: &Repo, workspace_id: &str, user_id: &str) -> Result<Option<Collaborator>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look537 let base = self.base_of(workspace_id).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar538 let teams = self.team_roles_on(&repo.id, Some(user_id)).await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look539 let row = self
540 .db
541 .prepare(
542 "SELECT u.id, u.username, u.display_name AS name, u.avatar,
543 m.role AS workspace_role, g.role AS direct
544 FROM users u
545 LEFT JOIN workspace_members m ON m.user_id = u.id AND m.workspace_id = ?2
546 LEFT JOIN repo_grants g ON g.principal_kind = 'user' AND g.principal_id = u.id AND g.repo_id = ?1
547 WHERE u.id = ?3",
548 )
549 .bind(&[repo.id.as_str().into(), workspace_id.into(), user_id.into()])?
550 .first::<PersonRow>(None)
551 .await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar552 Ok(row.and_then(|row| Self::collaborator(row, base, &teams)))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look553 }
554
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar555 pub(crate) async fn person_by_username(&self, username: &str) -> Result<Option<(String, String)>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look556 #[derive(Deserialize)]
557 struct Person {
558 id: String,
559 username: String,
560 }
561 Ok(self
562 .db
563 .prepare("SELECT id, username FROM users WHERE username = ?")
564 .bind(&[username.trim().trim_start_matches('@').to_lowercase().into()])?
565 .first::<Person>(None)
566 .await?
567 .map(|person| (person.id, person.username)))
568 }
569
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar570 pub(crate) async fn is_member_of(&self, workspace_id: &str, user_id: &str) -> Result<bool> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look571 Ok(self
572 .db
573 .prepare("SELECT user_id AS id FROM workspace_members WHERE workspace_id = ? AND user_id = ?")
574 .bind(&[workspace_id.into(), user_id.into()])?
575 .first::<Id>(None)
576 .await?
577 .is_some())
578 }
579
580 async fn direct_role(&self, repo_id: &str, user_id: &str) -> Result<Option<RepoRole>> {
581 #[derive(Deserialize)]
582 struct RoleRow {
583 role: String,
584 }
585 Ok(self
586 .db
587 .prepare("SELECT role FROM repo_grants WHERE repo_id = ? AND principal_kind = 'user' AND principal_id = ?")
588 .bind(&[repo_id.into(), user_id.into()])?
589 .first::<RoleRow>(None)
590 .await?
591 .and_then(|row| RepoRole::parse(&row.role)))
592 }
593
594 /// Gives `user_id` `role` on the repository, or changes the role they
595 /// have; returns the role they had before.
596 async fn put_grant(
597 &self,
598 repo_id: &str,
599 workspace_id: &str,
600 repo_name: &str,
601 user_id: &str,
602 role: RepoRole,
603 granted_by: Option<&str>,
604 ) -> Result<Option<RepoRole>> {
605 let previous = self.direct_role(repo_id, user_id).await?;
606 let now = rfc3339(now_ms());
607 self.db
608 .prepare(
609 "INSERT INTO repo_grants
610 (repo_id, principal_kind, principal_id, workspace_id, repo_name, role, granted_by, created_at, updated_at)
611 VALUES (?1, 'user', ?2, ?3, ?4, ?5, ?6, ?7, ?7)
612 ON CONFLICT (repo_id, principal_kind, principal_id)
613 DO UPDATE SET role = excluded.role, workspace_id = excluded.workspace_id,
614 repo_name = excluded.repo_name, updated_at = excluded.updated_at",
615 )
616 .bind(&[
617 repo_id.into(),
618 user_id.into(),
619 workspace_id.into(),
620 repo_name.into(),
621 role.as_str().into(),
622 opt(granted_by),
623 now.as_str().into(),
624 ])?
625 .run()
626 .await?;
627 Ok(previous)
628 }
629
630 pub async fn add_collaborator(&self, a: AddCollaboratorArgs) -> Result<Outcome<Added>> {
631 let Target { repo, workspace_id } = match self.manageable(&a.actor, &a.path).await? {
632 Outcome::Ok(target) => target,
633 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
634 };
635 let surface = a.surface.unwrap_or(Surface::Web);
636 let invitee = match invitee(&a.invitee) {
637 Some(invitee) => invitee,
638 None => return Ok(Outcome::fail(FailureCode::Invalid, "Enter a username or an email address.")),
639 };
640 // Who it names: an account by username, or by a confirmed address.
641 let person = match &invitee {
642 Invitee::Username(name) => match self.person_by_username(name).await? {
643 Some(person) => Some(person),
644 None => return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER)),
645 },
646 Invitee::Email(email) => match self.user_with_verified_email(email).await? {
647 Some(id) => self
648 .find_public_user(
649 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ?",
650 &id,
651 )
652 .await?
653 .map(|user| (user.id, user.username)),
654 None => None,
655 },
656 };
657 let Some((user_id, username)) = person else {
658 let Invitee::Email(email) = invitee else {
659 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
660 };
Merge Stripe Tax, the card fee on card payments, and one free workspace per person661 // An address is always someone from outside: a free workspace
662 // invites no one (paid.rs).
663 if let Some(refused) = self.free_workspace_refusal(&repo.namespace).await? {
664 return Ok(refused);
665 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look666 return self.invite_address(&a.actor, &repo, &workspace_id, &email, a.role, surface).await;
667 };
668 // What the workspace asks of anyone with access to it (security.rs).
669 if let Some(why) = self.policy_refusal(&user_id, &repo.namespace).await? {
670 return Ok(Outcome::fail(FailureCode::Forbidden, why));
671 }
672 if self.is_member_of(&workspace_id, &user_id).await? {
673 let previous = self
674 .put_grant(&repo.id, &workspace_id, &repo.name, &user_id, a.role, Some(&a.actor.id))
675 .await?;
676 self.changed(&a.actor, (&repo).into(), &username, Some(a.role), previous, surface).await;
677 let Some(collaborator) = self.collaborator_on(&repo, &workspace_id, &user_id).await? else {
678 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
679 };
680 return Ok(Outcome::Ok(Added::Granted { collaborator }));
681 }
682 if self.direct_role(&repo.id, &user_id).await?.is_some() {
683 return Ok(Outcome::fail(
684 FailureCode::Conflict,
685 format!("{username} already has access to {}. Change their role instead.", full_name(&repo)),
686 ));
687 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person688 // An outside collaborator is someone added: a free workspace adds
689 // no one (paid.rs). Its members' roles above are its own business,
690 // and g1t's agent is never someone added.
691 if !crate::paid::is_g1t(&username)
692 && let Some(refused) = self.free_workspace_refusal(&repo.namespace).await?
693 {
694 return Ok(refused);
695 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look696 let pending = self
697 .pending_invitations(
698 "WHERE ri.repo_id = ? AND ri.invitee_id = ?",
699 &[repo.id.as_str().into(), user_id.as_str().into()],
700 )
701 .await?;
702 if !pending.is_empty() {
703 return Ok(Outcome::fail(
704 FailureCode::Conflict,
705 format!("{username} already has a pending invitation to {}. Change its role, or revoke it to send a new one.", full_name(&repo)),
706 ));
707 }
708 let id = self
709 .insert_invitation(&repo, &workspace_id, Some(&user_id), None, None, a.role, &a.actor.id, INVITATION_DAYS)
710 .await?;
711 let now = rfc3339(now_ms());
712 let Some(row) = self.invitation_by_id(&id).await? else {
713 return Ok(Outcome::fail(FailureCode::NotFound, "Invitation not found."));
714 };
715 // Told by email, at their primary address and the one typed.
716 let mut to = self.notice_recipients(&user_id, false).await.unwrap_or_default();
717 if let Invitee::Email(email) = &invitee
718 && !to.iter().any(|address| address.eq_ignore_ascii_case(email))
719 {
720 to.push(email.clone());
721 }
722 for address in to.iter().take(2) {
723 if let Err(error) = crate::email::send_repo_invite(
724 &self.env,
725 address,
726 &a.actor.username,
727 &full_name(&repo),
728 a.role.label(),
729 None,
730 INVITATION_DAYS,
731 )
732 .await
733 {
734 worker::console_error!("repository invitation email failed: {error}");
735 }
736 }
737 self.audit(&a.actor, "repo.invitation_created", (&repo).into(), surface, format!("Invited {username} as {}", a.role.label()))
738 .await;
739 Ok(Outcome::Ok(Added::Invited {
740 invitation: row.shown(&now, true),
741 }))
742 }
743
744 /// An address without an account: an invite code that makes it and
745 /// accepts (invites.rs).
746 async fn invite_address(
747 &self,
748 actor: &User,
749 repo: &Repo,
750 workspace_id: &str,
751 email: &str,
752 role: RepoRole,
753 surface: Surface,
754 ) -> Result<Outcome<Added>> {
755 let pending = self
756 .pending_invitations(
757 "WHERE ri.repo_id = ? AND ri.email = ?",
758 &[repo.id.as_str().into(), email.into()],
759 )
760 .await?;
761 if !pending.is_empty() {
762 return Ok(Outcome::fail(
763 FailureCode::Conflict,
764 "That address already has a pending invitation to this repository. Revoke it to send a new one.",
765 ));
766 }
767 let invite = match self.repo_invite_code(actor, email, workspace_id).await? {
768 Outcome::Ok(invite) => invite,
769 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
770 };
771 let days = self.invite_days();
772 let id = self
773 .insert_invitation(repo, workspace_id, None, Some(email), Some(&invite.id), role, &actor.id, days)
774 .await?;
775 if let Some(code) = &invite.code
776 && let Err(error) = crate::email::send_repo_invite(
777 &self.env,
778 email,
779 &actor.username,
780 &full_name(repo),
781 role.label(),
782 Some(code),
783 days,
784 )
785 .await
786 {
787 worker::console_error!("repository invitation email failed: {error}");
788 }
789 self.audit(
790 actor,
791 "repo.invitation_created",
792 repo.into(),
793 surface,
794 format!("Invited {} as {}", crate::invites::mask_email(email), role.label()),
795 )
796 .await;
797 let now = rfc3339(now_ms());
798 Ok(match self.invitation_by_id(&id).await? {
799 Some(row) => Outcome::Ok(Added::Invited {
800 invitation: row.shown(&now, true),
801 }),
802 None => Outcome::fail(FailureCode::NotFound, "Invitation not found."),
803 })
804 }
805
806 #[allow(clippy::too_many_arguments)]
807 async fn insert_invitation(
808 &self,
809 repo: &Repo,
810 workspace_id: &str,
811 invitee_id: Option<&str>,
812 email: Option<&str>,
813 invite_id: Option<&str>,
814 role: RepoRole,
815 inviter_id: &str,
816 days: u64,
817 ) -> Result<String> {
818 let now = now_ms();
819 let id = new_id("rin", now);
820 self.db
821 .prepare(
822 "INSERT INTO repo_invitations
823 (id, repo_id, workspace_id, repo_name, invitee_id, email, invite_id, role, inviter_id, created_at, expires_at)
824 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
825 )
826 .bind(&[
827 id.as_str().into(),
828 repo.id.as_str().into(),
829 workspace_id.into(),
830 repo.name.as_str().into(),
831 opt(invitee_id),
832 opt(email),
833 opt(invite_id),
834 role.as_str().into(),
835 inviter_id.into(),
836 rfc3339(now).into(),
837 rfc3339(now + days * 86_400_000).into(),
838 ])?
839 .run()
840 .await?;
841 Ok(id)
842 }
843
844 async fn invitation_by_id(&self, id: &str) -> Result<Option<InvitationRow>> {
845 Ok(self
846 .invitations("WHERE ri.id = ?", &[id.into()])
847 .await?
848 .into_iter()
849 .next())
850 }
851
852 fn invite_days(&self) -> u64 {
853 self.env
854 .var("INVITE_TTL_DAYS")
855 .ok()
856 .and_then(|value| value.to_string().parse().ok())
857 .unwrap_or(g1t_contracts::identity::INVITE_TTL_DAYS)
858 }
859
860 pub async fn set_collaborator_role(&self, a: SetCollaboratorRoleArgs) -> Result<Outcome<Collaborator>> {
861 let Target { repo, workspace_id } = match self.manageable(&a.actor, &a.path).await? {
862 Outcome::Ok(target) => target,
863 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
864 };
865 let Some((user_id, username)) = self.person_by_username(&a.username).await? else {
866 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
867 };
868 let surface = a.surface.unwrap_or(Surface::Web);
869 match self.direct_role(&repo.id, &user_id).await? {
870 Some(previous) => {
871 self.put_grant(&repo.id, &workspace_id, &repo.name, &user_id, a.role, Some(&a.actor.id))
872 .await?;
873 if previous != a.role {
874 self.changed(&a.actor, (&repo).into(), &username, Some(a.role), Some(previous), surface).await;
875 }
876 }
877 None => {
878 // A pending invitation's role changes until it is answered.
879 let changed = self
880 .db
881 .prepare(format!(
882 "UPDATE repo_invitations SET role = ?1
883 WHERE repo_id = ?2 AND invitee_id = ?3 AND accepted_at IS NULL AND declined_at IS NULL
884 AND revoked_at IS NULL AND expires_at > {SQL_NOW}
885 RETURNING id"
886 ))
887 .bind(&[a.role.as_str().into(), repo.id.as_str().into(), user_id.as_str().into()])?
888 .first::<Id>(None)
889 .await?;
890 if changed.is_none() {
891 return Ok(Outcome::fail(
892 FailureCode::NotFound,
893 format!(
894 "{username} has no role of their own on {}. Owners have Admin, and members the base permission; add them to give them more.",
895 full_name(&repo)
896 ),
897 ));
898 }
899 }
900 }
901 Ok(match self.collaborator_on(&repo, &workspace_id, &user_id).await? {
902 Some(collaborator) => Outcome::Ok(collaborator),
903 // Invited, not yet a collaborator: say what they will be.
904 None => Outcome::Ok(Collaborator {
905 username,
906 name: None,
907 avatar: None,
908 role: a.role,
909 source: AccessSource::Direct,
910 direct: Some(a.role),
911 workspace_role: None,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar912 team_role: None,
913 team: None,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look914 }),
915 })
916 }
917
918 pub async fn remove_collaborator(&self, a: RemoveCollaboratorArgs) -> Result<Outcome<bool>> {
919 let Some((user_id, username)) = self.person_by_username(&a.username).await? else {
920 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
921 };
922 let surface = a.surface.unwrap_or(Surface::Web);
923 // Anyone may give up their own role; otherwise, Admin only.
924 let leaving = crate::security::is_person(&a.actor) && a.actor.id == user_id;
925 let repo = if leaving {
926 match self.repo_for(&a.path, &Some(a.actor.clone())).await? {
927 Some(repo) => repo,
928 None => return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found.")),
929 }
930 } else {
931 match self.manageable(&a.actor, &a.path).await? {
932 Outcome::Ok(target) => target.repo,
933 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
934 }
935 };
936 let Some(previous) = self.direct_role(&repo.id, &user_id).await? else {
937 return Ok(Outcome::fail(
938 FailureCode::NotFound,
939 format!(
940 "{username} has no role of their own on {}. To take away a member's access, change the base permission or remove them from the workspace.",
941 full_name(&repo)
942 ),
943 ));
944 };
945 self.db
946 .batch(vec![
947 self.db
948 .prepare("DELETE FROM repo_grants WHERE repo_id = ? AND principal_kind = 'user' AND principal_id = ?")
949 .bind(&[repo.id.as_str().into(), user_id.as_str().into()])?,
950 self.db
951 .prepare(format!(
952 "UPDATE repo_invitations SET revoked_at = {SQL_NOW}
953 WHERE repo_id = ? AND invitee_id = ? AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL"
954 ))
955 .bind(&[repo.id.as_str().into(), user_id.as_str().into()])?,
956 ])
957 .await?;
958 self.changed(&a.actor, (&repo).into(), &username, None, Some(previous), surface).await;
959 Ok(Outcome::Ok(true))
960 }
961
962 pub async fn collaborator_permission(&self, a: CollaboratorPermissionArgs) -> Result<Outcome<PermissionInfo>> {
963 let Some(repo) = self.repo_for(&a.path, &a.viewer).await? else {
964 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
965 };
966 let asking_about_self = a
967 .viewer
968 .as_ref()
969 .is_some_and(|viewer| viewer.username.eq_ignore_ascii_case(a.username.trim()));
970 if !asking_about_self && !can(a.viewer.as_ref(), &repo, Capability::Push) {
971 return Ok(Outcome::fail(
972 FailureCode::Forbidden,
973 format!("You need the Write role or higher on {} to see others' permissions.", full_name(&repo)),
974 ));
975 }
976 let Some((user_id, username)) = self.person_by_username(&a.username).await? else {
977 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
978 };
979 let Some(workspace_id) = self.workspace_id_of(&repo.namespace).await? else {
980 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
981 };
982 // Held to the workspace's policy as their requests are.
983 let within = self.policy_refusal(&user_id, &repo.namespace).await?.is_none();
984 let place = if within {
985 self.collaborator_on(&repo, &workspace_id, &user_id).await?
986 } else {
987 None
988 };
989 let role = place.as_ref().map(|place| place.role);
990 Ok(Outcome::Ok(PermissionInfo {
991 username,
992 role,
993 source: place.map(|place| place.source),
994 capabilities: capabilities_of(role),
995 }))
996 }
997
998 pub async fn my_repo_invitations(&self, a: MyRepoInvitationsArgs) -> Result<Vec<RepoInvitation>> {
999 if !crate::security::is_person(&a.user) {
1000 return Ok(Vec::new());
1001 }
1002 let now = rfc3339(now_ms());
1003 Ok(self
1004 .invitations_for(&a.user, None)
1005 .await?
1006 .iter()
1007 .map(|row| row.shown(&now, false))
1008 .collect())
1009 }
1010
1011 /// The pending invitations for `user`: sent to them, or to one of
1012 /// their confirmed addresses before they had an account (and made it
1013 /// some other way than with the code). `id` narrows it to one.
1014 async fn invitations_for(&self, user: &User, id: Option<&str>) -> Result<Vec<InvitationRow>> {
1015 let emails = serde_json::to_string(&self.verified_emails(&user.id).await?)?;
1016 let mut filter = "WHERE (ri.invitee_id = ? OR (ri.invitee_id IS NULL AND ri.email IN (SELECT value FROM json_each(?))))".to_owned();
1017 let mut binds = vec![JsValue::from(user.id.as_str()), emails.into()];
1018 if let Some(id) = id {
1019 filter.push_str(" AND ri.id = ?");
1020 binds.push(id.into());
1021 }
1022 self.pending_invitations(&filter, &binds).await
1023 }
1024
1025 pub async fn respond_repo_invitation(&self, a: RespondRepoInvitationArgs) -> Result<Outcome<RepoInvitation>> {
1026 if !crate::security::is_person(&a.user) {
1027 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can answer an invitation."));
1028 }
1029 let now = rfc3339(now_ms());
1030 let row = self
1031 .invitations_for(&a.user, Some(&a.id))
1032 .await?
1033 .into_iter()
1034 .next();
1035 let Some(row) = row else {
1036 return Ok(Outcome::fail(
1037 FailureCode::NotFound,
1038 "There is no pending invitation of yours with that id. It may have expired or been revoked.",
1039 ));
1040 };
1041 if !a.accept {
1042 self.db
1043 .prepare(format!("UPDATE repo_invitations SET declined_at = {SQL_NOW} WHERE id = ?"))
1044 .bind(&[row.id.as_str().into()])?
1045 .run()
1046 .await?;
1047 let mut shown = row.shown(&now, false);
1048 shown.status = RepoInvitationStatus::Declined;
1049 return Ok(Outcome::Ok(shown));
1050 }
1051 if let Some(why) = self.policy_refusal(&a.user.id, &row.workspace).await? {
1052 return Ok(Outcome::fail(FailureCode::Forbidden, why));
1053 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1054 // Sent before the workspace was free, or before this rule: it waits
1055 // until the workspace starts the plan (paid.rs).
1056 if let Some(refused) = self.free_workspace_refusal(&row.workspace).await? {
1057 return Ok(refused);
1058 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1059 self.accept(&row, &a.user).await?;
1060 let mut shown = row.shown(&now, false);
1061 shown.status = RepoInvitationStatus::Accepted;
1062 Ok(Outcome::Ok(shown))
1063 }
1064
1065 /// Turns an invitation into a grant, once.
1066 async fn accept(&self, row: &InvitationRow, user: &User) -> Result<()> {
1067 let claimed = self
1068 .db
1069 .prepare(format!(
1070 "UPDATE repo_invitations SET accepted_at = {SQL_NOW}, invitee_id = ?1
1071 WHERE id = ?2 AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL
1072 RETURNING id"
1073 ))
1074 .bind(&[user.id.as_str().into(), row.id.as_str().into()])?
1075 .first::<Id>(None)
1076 .await?;
1077 if claimed.is_none() {
1078 return Ok(());
1079 }
1080 let role = row.role();
1081 // Never lowers a role they already have.
1082 let current = self.direct_role(&row.repo_id, &user.id).await?;
1083 let role = current.map_or(role, |current| current.max(role));
1084 let previous = self
1085 .put_grant(&row.repo_id, &row.workspace_id, &row.repo_name, &user.id, role, row.inviter_id.as_deref())
1086 .await?;
1087 let repo = Named {
1088 id: &row.repo_id,
1089 namespace: &row.workspace,
1090 name: &row.repo_name,
1091 };
1092 self.changed(user, repo, &user.username, Some(role), previous, Surface::Web).await;
1093 Ok(())
1094 }
1095
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1096 /// The repository an invite code was sent with, for the invite's page
1097 /// (invites.rs): whatever became of the invitation since.
1098 pub(crate) async fn repository_of_code(
1099 &self,
1100 invite_id: &str,
1101 ) -> Result<Option<g1t_contracts::identity::InviteRepository>> {
1102 Ok(self
1103 .invitations("WHERE ri.invite_id = ?", &[invite_id.into()])
1104 .await?
1105 .into_iter()
1106 .next()
1107 .map(|row| g1t_contracts::identity::InviteRepository {
1108 name: format!("{}/{}", row.workspace, row.repo_name),
1109 role: row.role().as_str().to_owned(),
1110 }))
1111 }
1112
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1113 /// Accepts the repository invitations sent with an invite code, once
1114 /// the code made `user`'s account (invites.rs).
1115 pub(crate) async fn accept_invitations_of_code(&self, invite_id: &str, user: &User) -> Result<()> {
1116 let rows = self
1117 .pending_invitations("WHERE ri.invite_id = ?", &[invite_id.into()])
1118 .await?;
1119 for row in rows {
1120 if self.policy_refusal(&user.id, &row.workspace).await?.is_some() {
1121 continue;
1122 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1123 // A free workspace adds no one (paid.rs): the invitation stays
1124 // pending until it starts the plan.
1125 if self.is_free_workspace(&row.workspace).await {
1126 continue;
1127 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1128 self.accept(&row, user).await?;
1129 }
1130 Ok(())
1131 }
1132
1133 pub async fn revoke_repo_invitation(&self, a: RevokeRepoInvitationArgs) -> Result<Outcome<RepoInvitation>> {
1134 let Target { repo, .. } = match self.manageable(&a.actor, &a.path).await? {
1135 Outcome::Ok(target) => target,
1136 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1137 };
1138 let revoked = self
1139 .db
1140 .prepare(format!(
1141 "UPDATE repo_invitations SET revoked_at = {SQL_NOW}
1142 WHERE id = ? AND repo_id = ? AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL
1143 RETURNING id"
1144 ))
1145 .bind(&[a.id.as_str().into(), repo.id.as_str().into()])?
1146 .first::<Id>(None)
1147 .await?;
1148 if revoked.is_none() {
1149 return Ok(Outcome::fail(FailureCode::NotFound, "There is no pending invitation with that id."));
1150 }
1151 let Some(row) = self.invitation_by_id(&a.id).await? else {
1152 return Ok(Outcome::fail(FailureCode::NotFound, "Invitation not found."));
1153 };
1154 if let Some(invite_id) = &row.invite_id {
1155 self.revoke_code(invite_id).await?;
1156 }
1157 let who = row
1158 .invitee
1159 .clone()
1160 .or_else(|| row.email.as_deref().map(crate::invites::mask_email))
1161 .unwrap_or_default();
1162 self.audit(
1163 &a.actor,
1164 "repo.invitation_revoked",
1165 (&repo).into(),
1166 a.surface.unwrap_or(Surface::Web),
1167 format!("Revoked the invitation to {who}"),
1168 )
1169 .await;
1170 Ok(Outcome::Ok(row.shown(&rfc3339(now_ms()), true)))
1171 }
1172
1173 pub async fn set_base_permission(&self, a: SetBasePermissionArgs) -> Result<Outcome<BasePermission>> {
1174 let slug = a.slug.trim().to_lowercase();
1175 if !crate::security::is_person(&a.actor) || a.actor.role_in(&slug) != Some(Role::Owner) {
1176 return Ok(Outcome::fail(
1177 FailureCode::Forbidden,
1178 "Only an owner can change what members get on every repository.",
1179 ));
1180 }
1181 if !a.actor.verified {
1182 return Ok(Outcome::fail(FailureCode::Forbidden, CONFIRM_FIRST));
1183 }
1184 let Some(workspace_id) = self.workspace_id_of(&slug).await? else {
1185 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1186 };
1187 let previous = self.base_of(&workspace_id).await?;
1188 self.db
1189 .prepare("UPDATE workspaces SET base_permission = ? WHERE id = ?")
1190 .bind(&[a.base_permission.as_str().into(), workspace_id.as_str().into()])?
1191 .run()
1192 .await?;
1193 if previous != a.base_permission {
1194 self.audit_workspace(
1195 &a.actor,
1196 "workspace.base_permission_changed",
1197 &slug,
1198 a.surface.unwrap_or(Surface::Web),
1199 format!(
1200 "Changed the base permission from {} to {}",
1201 previous.as_str(),
1202 a.base_permission.as_str()
1203 ),
1204 )
1205 .await;
1206 self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await;
1207 }
1208 Ok(Outcome::Ok(a.base_permission))
1209 }
1210
Merge branch 'worktree-agent-a2013627e5ea4ab13'1211 /// `workspace_residency`: where a workspace keeps its repositories'
1212 /// git data, for the repos service as it places a new one, and for its
1213 /// settings page. Null when there is no such workspace.
1214 pub async fn workspace_residency(&self, a: g1t_contracts::identity::SlugArgs) -> Result<Option<g1t_contracts::identity::DataResidency>> {
1215 #[derive(Deserialize)]
1216 struct Row {
1217 #[serde(default)]
1218 data_residency: Option<String>,
1219 }
1220 let row = self
1221 .db
1222 .prepare("SELECT data_residency FROM workspaces WHERE slug = ? AND deleted_at IS NULL")
1223 .bind(&[a.slug.trim().to_lowercase().into()])?
1224 .first::<Row>(None)
1225 .await?;
1226 Ok(row.map(|row| {
1227 row.data_residency
1228 .as_deref()
1229 .and_then(g1t_contracts::identity::DataResidency::parse)
1230 .unwrap_or_default()
1231 }))
1232 }
1233
1234 /// `set_workspace_residency`: owners only. Applies to repositories
1235 /// made from then on; those it has stay where they are. Whether the EU
1236 /// can be chosen is the repos service's to say (`storage_options`);
1237 /// the site offers it only then, and the repos service refuses to
1238 /// place an EU workspace's repository anywhere else.
1239 pub async fn set_workspace_residency(
1240 &self,
1241 a: g1t_contracts::identity::SetResidencyArgs,
1242 ) -> Result<Outcome<g1t_contracts::identity::DataResidency>> {
1243 let slug = a.slug.trim().to_lowercase();
1244 if !crate::security::is_person(&a.actor) || a.actor.role_in(&slug) != Some(Role::Owner) {
1245 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can change where a workspace keeps its data."));
1246 }
1247 if !a.actor.verified {
1248 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address before changing where the workspace keeps its data."));
1249 }
1250 let Some(previous) = self.workspace_residency(g1t_contracts::identity::SlugArgs { slug: slug.clone() }).await? else {
1251 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1252 };
1253 if previous == a.residency {
1254 return Ok(Outcome::Ok(previous));
1255 }
1256 let stored = match a.residency {
1257 g1t_contracts::identity::DataResidency::Anywhere => JsValue::NULL,
1258 other => other.as_str().into(),
1259 };
1260 self.db
1261 .prepare("UPDATE workspaces SET data_residency = ? WHERE slug = ? AND deleted_at IS NULL")
1262 .bind(&[stored, slug.as_str().into()])?
1263 .run()
1264 .await?;
1265 self.audit_workspace(
1266 &a.actor,
1267 "workspace.residency_changed",
1268 &slug,
1269 Surface::Web,
1270 format!("Changed where new repositories keep their data from {} to {}", previous.as_str(), a.residency.as_str()),
1271 )
1272 .await;
1273 Ok(Outcome::Ok(a.residency))
1274 }
1275
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1276 pub async fn outside_collaborators(&self, a: OutsideCollaboratorsArgs) -> Result<Outcome<Vec<OutsideCollaborator>>> {
1277 let slug = a.slug.trim().to_lowercase();
1278 if !a.viewer.as_ref().is_some_and(|viewer| viewer.role_in(&slug) == Some(Role::Owner)) {
1279 return Ok(Outcome::fail(FailureCode::Forbidden, "Only owners can see a workspace's outside collaborators."));
1280 }
1281 let Some(workspace_id) = self.workspace_id_of(&slug).await? else {
1282 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1283 };
1284 #[derive(Deserialize)]
1285 struct Row {
1286 username: String,
1287 name: Option<String>,
1288 avatar: Option<String>,
1289 repo_name: String,
1290 role: String,
1291 }
1292 let rows = self
1293 .db
1294 .prepare(format!(
1295 "SELECT u.username, u.display_name AS name, u.avatar, g.repo_name, g.role
1296 FROM repo_grants g JOIN users u ON u.id = g.principal_id
1297 WHERE g.workspace_id = ?1 AND g.principal_kind = 'user'
1298 AND NOT EXISTS (SELECT 1 FROM workspace_members m WHERE m.workspace_id = ?1 AND m.user_id = g.principal_id)
1299 ORDER BY u.username, g.repo_name LIMIT {LIST_LIMIT}"
1300 ))
1301 .bind(&[workspace_id.as_str().into()])?
1302 .all()
1303 .await?
1304 .results::<Row>()?;
1305 let mut people: Vec<OutsideCollaborator> = Vec::new();
1306 for row in rows {
1307 let Some(role) = RepoRole::parse(&row.role) else {
1308 continue;
1309 };
1310 let repo = CollaboratorRepo {
1311 repo: format!("{slug}/{}", row.repo_name),
1312 role,
1313 };
1314 match people.last_mut().filter(|person| person.username == row.username) {
1315 Some(person) => person.repos.push(repo),
1316 None => people.push(OutsideCollaborator {
1317 username: row.username,
1318 name: row.name,
1319 avatar: row.avatar,
1320 repos: vec![repo],
1321 }),
1322 }
1323 }
1324 Ok(Outcome::Ok(people))
1325 }
1326
1327 pub async fn forget_repo_access(&self, a: ForgetRepoAccessArgs) -> Result<bool> {
1328 self.db
1329 .batch(vec![
1330 self.db
1331 .prepare("DELETE FROM repo_grants WHERE repo_id = ?")
1332 .bind(&[a.repo_id.as_str().into()])?,
1333 self.db
1334 .prepare("DELETE FROM repo_invitations WHERE repo_id = ?")
1335 .bind(&[a.repo_id.as_str().into()])?,
1336 ])
1337 .await?;
1338 Ok(true)
1339 }
1340
1341 /// A repository moved or was renamed: its grants and invitations follow
1342 /// it (deletion.rs, `transfer_repo_scopes`).
1343 pub(crate) async fn move_repo_access(&self, from: &RepoPath, to: &RepoPath) -> Result<()> {
1344 let (Some(from_id), Some(to_id)) = (
1345 self.workspace_id_of(&from.namespace).await?,
1346 self.workspace_id_of(&to.namespace).await?,
1347 ) else {
1348 return Ok(());
1349 };
1350 let binds = [
1351 JsValue::from(to_id.as_str()),
1352 to.name.to_lowercase().into(),
1353 from_id.as_str().into(),
1354 from.name.to_lowercase().into(),
1355 ];
1356 self.db
1357 .batch(vec![
1358 self.db
1359 .prepare("UPDATE repo_grants SET workspace_id = ?1, repo_name = ?2 WHERE workspace_id = ?3 AND repo_name = ?4")
1360 .bind(&binds)?,
1361 self.db
1362 .prepare("UPDATE repo_invitations SET workspace_id = ?1, repo_name = ?2 WHERE workspace_id = ?3 AND repo_name = ?4")
1363 .bind(&binds)?,
1364 ])
1365 .await?;
1366 Ok(())
1367 }
1368
1369 // --- Telling others ---
1370
1371 /// Publishes the change of a person's own role, and records it in the
1372 /// workspace's audit log.
1373 async fn changed(
1374 &self,
1375 actor: &User,
1376 repo: Named<'_>,
1377 username: &str,
1378 role: Option<RepoRole>,
1379 previous: Option<RepoRole>,
1380 surface: Surface,
1381 ) {
1382 let (kind, message) = match (previous, role) {
1383 (None, Some(role)) => ("repo.collaborator_added", format!("Gave {username} the {} role", role.label())),
1384 (Some(previous), Some(role)) => (
1385 "repo.collaborator_role_changed",
1386 format!("Changed {username}'s role from {} to {}", previous.label(), role.label()),
1387 ),
1388 (Some(previous), None) => ("repo.collaborator_removed", format!("Removed {username}'s {} role", previous.label())),
1389 (None, None) => return,
1390 };
1391 self.publish_repo(
1392 kind,
1393 repo.id,
1394 &actor.id,
1395 RepoCollaborator {
1396 repo_id: repo.id.to_owned(),
1397 namespace: repo.namespace.to_owned(),
1398 name: repo.name.to_owned(),
1399 username: username.to_owned(),
1400 role,
1401 previous_role: previous,
1402 },
1403 )
1404 .await;
1405 self.audit(actor, kind, repo, surface, message).await;
1406 }
1407
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1408 pub(crate) async fn publish_repo<T: Serialize>(&self, kind: &'static str, repo_id: &str, actor: &str, data: T) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1409 let Ok(events) = self.env.service("EVENTS") else {
1410 return;
1411 };
1412 let publish = Publish {
1413 events: vec![NewEvent {
1414 kind,
1415 source: "identity",
1416 repo_id: Some(repo_id.to_owned()),
1417 actor: Some(actor.to_owned()),
1418 data,
1419 }],
1420 };
1421 if let Err(error) = g1t_kit::call::<_, serde_json::Value>(&events, "publish", &publish).await {
1422 worker::console_error!("{kind} not published: {error}");
1423 }
1424 }
1425
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1426 pub(crate) async fn audit(&self, actor: &User, action: &str, repo: Named<'_>, surface: Surface, message: String) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1427 let full = format!("{}/{}", repo.namespace, repo.name);
1428 self.record(actor, action, repo.namespace, Some(full), surface, message).await;
1429 }
1430
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1431 pub(crate) async fn audit_workspace(&self, actor: &User, action: &str, slug: &str, surface: Surface, message: String) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1432 self.record(actor, action, slug, None, surface, message).await;
1433 }
1434
1435 async fn record(&self, actor: &User, action: &str, workspace: &str, repo: Option<String>, surface: Surface, message: String) {
1436 let Ok(events) = self.env.service("EVENTS") else {
1437 return;
1438 };
1439 let entry = NewAuditEntry {
1440 actor: AuditActor::of(actor),
1441 action: action.to_owned(),
1442 surface,
1443 target: AuditTarget {
1444 workspace: workspace.to_lowercase(),
1445 repo,
1446 ..AuditTarget::default()
1447 },
1448 outcome: AuditOutcome::Allowed,
1449 rule: if actor.kind == PrincipalKind::User { "access" } else { "access:token" }.to_owned(),
1450 result: Some("ok".to_owned()),
1451 message: Some(message),
1452 request_id: new_id("req", now_ms()),
1453 };
1454 let recorded: Result<u32> =
1455 g1t_kit::call(&events, "audit_record", &RecordAuditArgs { entries: vec![entry] }).await;
1456 if let Err(error) = recorded {
1457 worker::console_error!("{action} not recorded: {error}");
1458 }
1459 }
1460}
1461
1462#[cfg(test)]
1463mod tests {
1464 use super::*;
1465
1466 #[test]
1467 fn people_are_added_by_username_or_address() {
1468 assert_eq!(invitee(" Ada "), Some(Invitee::Username("ada".into())));
1469 assert_eq!(invitee("@ada"), Some(Invitee::Username("ada".into())));
1470 assert_eq!(invitee("Ada@Example.com"), Some(Invitee::Email("ada@example.com".into())));
1471 assert_eq!(invitee("not a name"), None);
1472 assert_eq!(invitee("ada@"), None);
1473 }
1474
1475 #[test]
1476 fn a_role_comes_from_ownership_the_base_or_a_grant() {
1477 use AccessSource::*;
1478 use RepoRole::*;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1479 assert_eq!(effective(true, Some(Read), Some(Write), None), Some((Admin, Owner)));
1480 assert_eq!(effective(false, Some(Write), None, None), Some((Write, Base)));
1481 assert_eq!(effective(false, Some(Write), Some(Maintain), None), Some((Maintain, Direct)));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1482 // A grant as high as the base is shown as direct, where it can be changed.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1483 assert_eq!(effective(false, Some(Write), Some(Write), None), Some((Write, Direct)));
1484 assert_eq!(effective(false, Some(Admin), Some(Read), None), Some((Admin, Base)));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1485 // An outside collaborator.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1486 assert_eq!(effective(false, None, Some(Triage), None), Some((Triage, Direct)));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1487 // A member of a workspace whose base is none, with no grant.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1488 assert_eq!(effective(false, None, None, None), None);
1489 }
1490
1491 #[test]
1492 fn a_teams_role_counts_where_it_is_the_highest() {
1493 use AccessSource::*;
1494 use RepoRole::*;
1495 assert_eq!(effective(false, Some(Read), None, Some(Maintain)), Some((Maintain, Team)));
1496 assert_eq!(effective(false, None, None, Some(Triage)), Some((Triage, Team)));
1497 // Lower than the base: the base.
1498 assert_eq!(effective(false, Some(Write), None, Some(Read)), Some((Write, Base)));
1499 // As high as the base: shown as the team's, where it can be changed.
1500 assert_eq!(effective(false, Some(Write), None, Some(Write)), Some((Write, Team)));
1501 // A direct grant as high as the team's is shown as direct.
1502 assert_eq!(effective(false, Some(Read), Some(Admin), Some(Admin)), Some((Admin, Direct)));
1503 assert_eq!(effective(false, Some(Read), Some(Write), Some(Admin)), Some((Admin, Team)));
1504 // Owners are owners.
1505 assert_eq!(effective(true, None, None, Some(Write)), Some((Admin, Owner)));
1506 }
1507
1508 #[test]
1509 fn each_person_keeps_the_highest_role_any_team_gives() {
1510 let roles = highest_team_roles([
1511 ("usr_a".to_owned(), RepoRole::Read, "docs".to_owned()),
1512 ("usr_a".to_owned(), RepoRole::Maintain, "platform".to_owned()),
1513 ("usr_a".to_owned(), RepoRole::Write, "backend".to_owned()),
1514 ("usr_b".to_owned(), RepoRole::Write, "web".to_owned()),
1515 ("usr_b".to_owned(), RepoRole::Write, "api".to_owned()),
1516 ]);
1517 assert_eq!(roles["usr_a"], (RepoRole::Maintain, "platform".to_owned()));
1518 assert_eq!(roles["usr_b"], (RepoRole::Write, "api".to_owned()));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1519 }
1520
1521 #[test]
1522 fn an_invitation_is_pending_until_answered_revoked_or_expired() {
1523 let row = InvitationRow {
1524 expires_at: "2026-10-12T00:00:00.000Z".into(),
1525 ..InvitationRow::default()
1526 };
1527 let now = "2026-10-05T00:00:00.000Z";
1528 assert_eq!(invitation_status(&row, now), RepoInvitationStatus::Pending);
1529 assert_eq!(invitation_status(&row, "2026-10-12T00:00:00.000Z"), RepoInvitationStatus::Expired);
1530 let accepted = InvitationRow { accepted_at: Some(now.into()), ..row.clone() };
1531 assert_eq!(invitation_status(&accepted, now), RepoInvitationStatus::Accepted);
1532 let declined = InvitationRow { declined_at: Some(now.into()), ..row.clone() };
1533 assert_eq!(invitation_status(&declined, now), RepoInvitationStatus::Declined);
1534 let revoked = InvitationRow { revoked_at: Some(now.into()), ..row };
1535 assert_eq!(invitation_status(&revoked, now), RepoInvitationStatus::Revoked);
1536 }
1537
1538 #[test]
1539 fn invitations_show_addresses_only_to_those_who_manage_access() {
1540 let row = InvitationRow {
1541 id: "rin_1".into(),
1542 workspace: "acme".into(),
1543 repo_name: "rocket".into(),
1544 email: Some("ada@example.com".into()),
1545 role: "triage".into(),
1546 expires_at: "2099-01-01T00:00:00.000Z".into(),
1547 ..InvitationRow::default()
1548 };
1549 let now = "2026-10-05T00:00:00.000Z";
1550 assert_eq!(row.shown(now, true).email.as_deref(), Some("ada@example.com"));
1551 assert_eq!(row.shown(now, false).email, None);
1552 assert_eq!(row.shown(now, false).repo, "acme/rocket");
1553 assert_eq!(row.shown(now, false).role, RepoRole::Triage);
1554 }
1555}

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