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
1,591 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.

Fixes from the page sweep1//! 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.
Fixes from the page sweep31
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)> {
Fixes from the page sweep85 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 }
Fixes from the page sweep95 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar96 best
Fixes from the page sweep97}
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>,
Merge main (membership, two-factor, GitHub repo roles) into tokens121 /// Whether the grant's workspace requires two-factor authentication.
122 #[serde(default)]
123 require_two_factor: u8,
Fixes from the page sweep124}
125
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar126/// The highest role a team gives each person on a repository, and that
127/// team's slug, by user id.
128pub(crate) type TeamRoles = std::collections::HashMap<String, (RepoRole, String)>;
129
130/// Folds (user id, role, team slug) rows into each person's highest; on a
131/// tie, the first slug, so the answer never flips.
132pub(crate) fn highest_team_roles(rows: impl IntoIterator<Item = (String, RepoRole, String)>) -> TeamRoles {
133 let mut roles = TeamRoles::new();
134 for (user_id, role, team) in rows {
135 let entry = roles.entry(user_id).or_insert((role, team.clone()));
136 if role > entry.0 || (role == entry.0 && team < entry.1) {
137 *entry = (role, team);
138 }
139 }
140 roles
141}
142
Fixes from the page sweep143#[derive(Clone, Debug, Default, Deserialize)]
144pub struct InvitationRow {
145 pub id: String,
146 pub repo_id: String,
147 pub workspace: String,
148 pub workspace_id: String,
149 pub repo_name: String,
150 pub invitee: Option<String>,
151 pub email: Option<String>,
152 pub invite_id: Option<String>,
153 pub role: String,
154 pub inviter_id: Option<String>,
155 pub inviter: Option<String>,
156 #[serde(default)]
157 pub inviter_avatar: Option<String>,
158 pub created_at: String,
159 pub expires_at: String,
160 pub accepted_at: Option<String>,
161 pub declined_at: Option<String>,
162 pub revoked_at: Option<String>,
163}
164
165impl InvitationRow {
166 fn role(&self) -> RepoRole {
167 RepoRole::parse(&self.role).unwrap_or(RepoRole::Read)
168 }
169
170 fn shown(&self, now: &str, with_email: bool) -> RepoInvitation {
171 RepoInvitation {
172 id: self.id.clone(),
173 repo: format!("{}/{}", self.workspace, self.repo_name),
174 repo_id: self.repo_id.clone(),
175 invitee: self.invitee.clone(),
176 email: if with_email { self.email.clone() } else { None },
177 role: self.role(),
178 invited_by: self.inviter.clone(),
179 inviter_avatar: self.inviter_avatar.clone(),
180 status: invitation_status(self, now),
181 created_at: self.created_at.clone(),
182 expires_at: self.expires_at.clone(),
183 }
184 }
185}
186
187const INVITATION_COLUMNS: &str = "ri.id, ri.repo_id, w.slug AS workspace, ri.workspace_id, ri.repo_name,
188 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,
189 ri.created_at, ri.expires_at, ri.accepted_at, ri.declined_at, ri.revoked_at
190 FROM repo_invitations ri
191 JOIN workspaces w ON w.id = ri.workspace_id AND w.deleted_at IS NULL
192 LEFT JOIN users invitee ON invitee.id = ri.invitee_id
193 LEFT JOIN users inviter ON inviter.id = ri.inviter_id";
194
195/// A person as an access list shows them.
196#[derive(Deserialize)]
197struct PersonRow {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar198 id: String,
Fixes from the page sweep199 username: String,
200 name: Option<String>,
201 avatar: Option<String>,
202 /// `owner` or `member`; null when they are not in the workspace.
203 #[serde(default)]
204 workspace_role: Option<String>,
205 /// Their direct grant on the repository, if any.
206 #[serde(default)]
207 direct: Option<String>,
208}
209
210#[derive(Deserialize)]
211struct Id {
212 id: String,
213}
214
215#[derive(Deserialize)]
216struct Base {
217 base_permission: String,
218}
219
220/// A repository by id and path: what events and the audit log name.
221#[derive(Clone, Copy)]
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar222pub(crate) struct Named<'a> {
223 pub id: &'a str,
224 pub namespace: &'a str,
225 pub name: &'a str,
Fixes from the page sweep226}
227
228impl<'a> From<&'a Repo> for Named<'a> {
229 fn from(repo: &'a Repo) -> Self {
230 Named {
231 id: &repo.id,
232 namespace: &repo.namespace,
233 name: &repo.name,
234 }
235 }
236}
237
238/// 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 bar239pub(crate) struct Target {
240 pub repo: Repo,
241 pub workspace_id: String,
Fixes from the page sweep242}
243
244fn opt(value: Option<&str>) -> JsValue {
245 value.map_or(JsValue::NULL, JsValue::from)
246}
247
248fn full_name(repo: &Repo) -> String {
249 format!("{}/{}", repo.namespace, repo.name)
250}
251
252impl Identity {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar253 /// Every repository `user_id` has a role on, directly or through a
254 /// team they are in (or through that team's parents, whose roles child
255 /// teams inherit), with the slug of its workspace now. Attached to
256 /// every user resolved from credentials.
Merge main (membership, two-factor, GitHub repo roles) into tokens257 ///
258 /// Each comes with whether its workspace requires two-factor
259 /// authentication (security.rs).
260 pub async fn grants_of(&self, user_id: &str) -> Result<Vec<(RepoGrant, bool)>> {
Fixes from the page sweep261 let rows = self
262 .db
263 .prepare(format!(
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar264 "WITH RECURSIVE mine(id) AS (
265 SELECT team_id FROM team_members WHERE user_id = ?1
266 UNION
267 SELECT t.parent_id FROM teams t JOIN mine ON t.id = mine.id WHERE t.parent_id IS NOT NULL
268 )
Merge main (membership, two-factor, GitHub repo roles) into tokens269 SELECT g.repo_id, w.slug AS workspace, g.role, NULL AS team, w.require_two_factor FROM repo_grants g
Fixes from the page sweep270 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 bar271 WHERE g.principal_kind = 'user' AND g.principal_id = ?1
272 UNION ALL
Merge main (membership, two-factor, GitHub repo roles) into tokens273 SELECT g.repo_id, w.slug AS workspace, g.role, t.slug AS team, w.require_two_factor FROM repo_grants g
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar274 JOIN mine ON g.principal_kind = 'team' AND g.principal_id = mine.id
275 JOIN teams t ON t.id = g.principal_id
276 JOIN workspaces w ON w.id = g.workspace_id AND w.deleted_at IS NULL
277 LIMIT {MAX_GRANTS}"
Fixes from the page sweep278 ))
279 .bind(&[user_id.into()])?
280 .all()
281 .await?
282 .results::<GrantRow>()?;
283 Ok(rows
284 .into_iter()
285 .filter_map(|row| {
Merge main (membership, two-factor, GitHub repo roles) into tokens286 Some((
287 RepoGrant {
288 repo_id: row.repo_id,
289 workspace: row.workspace,
290 role: RepoRole::parse(&row.role)?,
291 team: row.team,
292 },
293 row.require_two_factor != 0,
294 ))
Fixes from the page sweep295 })
296 .collect())
297 }
298
299 /// The repository at `path` as `viewer` sees it: missing when they
300 /// cannot read it. Asked of repos, which owns visibility.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar301 pub(crate) async fn repo_for(&self, path: &RepoPath, viewer: &Viewer) -> Result<Option<Repo>> {
Fixes from the page sweep302 let repos = self.env.service("REPOS")?;
303 let found: Outcome<Repo> = g1t_kit::call(
304 &repos,
305 "get",
306 &GetArgs {
307 path: path.clone(),
308 viewer: viewer.clone(),
309 },
310 )
311 .await?;
312 Ok(match found {
313 // A pull request's working copy has no access of its own.
314 Outcome::Ok(repo) if repo.fork_of.is_none() => Some(repo),
315 _ => None,
316 })
317 }
318
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar319 /// The highest role a team gives each person on a repository: the
320 /// teams with a grant on it, and their child teams, whose people
321 /// inherit it. `user_id` narrows it to one person.
322 pub(crate) async fn team_roles_on(&self, repo_id: &str, user_id: Option<&str>) -> Result<TeamRoles> {
323 #[derive(Deserialize)]
324 struct Row {
325 user_id: String,
326 role: String,
327 via: String,
328 }
329 let rows = self
330 .db
331 .prepare(
332 "WITH RECURSIVE reach(id, role, via) AS (
333 SELECT g.principal_id, g.role, t.slug FROM repo_grants g JOIN teams t ON t.id = g.principal_id
334 WHERE g.repo_id = ?1 AND g.principal_kind = 'team'
335 UNION
336 SELECT c.id, reach.role, reach.via FROM teams c JOIN reach ON c.parent_id = reach.id
337 )
338 SELECT tm.user_id, reach.role, reach.via FROM reach JOIN team_members tm ON tm.team_id = reach.id
339 WHERE ?2 IS NULL OR tm.user_id = ?2",
340 )
341 .bind(&[repo_id.into(), opt(user_id)])?
342 .all()
343 .await?
344 .results::<Row>()?;
345 Ok(highest_team_roles(
346 rows.into_iter()
347 .filter_map(|row| Some((row.user_id, RepoRole::parse(&row.role)?, row.via))),
348 ))
349 }
350
351 /// The teams with a role of their own on a repository.
352 pub(crate) async fn teams_on(&self, repo_id: &str) -> Result<Vec<g1t_contracts::teams::RepoTeam>> {
353 #[derive(Deserialize)]
354 struct Row {
355 slug: String,
356 name: String,
357 role: String,
358 visibility: String,
359 members_count: u32,
360 }
361 let rows = self
362 .db
363 .prepare(format!(
364 "SELECT t.slug, t.name, g.role, t.visibility,
365 (SELECT count(*) FROM team_members tm WHERE tm.team_id = t.id) AS members_count
366 FROM repo_grants g JOIN teams t ON t.id = g.principal_id
367 WHERE g.repo_id = ? AND g.principal_kind = 'team'
368 ORDER BY t.name LIMIT {LIST_LIMIT}"
369 ))
370 .bind(&[repo_id.into()])?
371 .all()
372 .await?
373 .results::<Row>()?;
374 Ok(rows
375 .into_iter()
376 .filter_map(|row| {
377 Some(g1t_contracts::teams::RepoTeam {
378 slug: row.slug,
379 name: row.name,
380 role: RepoRole::parse(&row.role)?,
381 members_count: row.members_count,
382 visibility: g1t_contracts::teams::TeamVisibility::parse(&row.visibility).unwrap_or_default(),
383 })
384 })
385 .collect())
386 }
387
Fixes from the page sweep388 pub(crate) async fn workspace_id_of(&self, slug: &str) -> Result<Option<String>> {
389 Ok(self
390 .db
391 // A deleted workspace's repositories are nobody's to share.
392 .prepare("SELECT id FROM workspaces WHERE slug = ? AND deleted_at IS NULL")
393 .bind(&[slug.to_lowercase().into()])?
394 .first::<Id>(None)
395 .await?
396 .map(|row| row.id))
397 }
398
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar399 pub(crate) async fn base_of(&self, workspace_id: &str) -> Result<BasePermission> {
Fixes from the page sweep400 Ok(self
401 .db
402 .prepare("SELECT base_permission FROM workspaces WHERE id = ?")
403 .bind(&[workspace_id.into()])?
404 .first::<Base>(None)
405 .await?
406 .and_then(|row| BasePermission::parse(&row.base_permission))
407 .unwrap_or_default())
408 }
409
410 /// 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 bar411 pub(crate) async fn manageable(&self, actor: &User, path: &RepoPath) -> Result<Outcome<Target>> {
Fixes from the page sweep412 if !crate::security::is_person(actor) {
413 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
414 }
415 let viewer = Some(actor.clone());
416 let Some(repo) = self.repo_for(path, &viewer).await? else {
417 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
418 };
419 if !can(Some(actor), &repo, Capability::ManageAccess) {
420 return Ok(Outcome::fail(
421 FailureCode::Forbidden,
422 needs(Capability::ManageAccess, &full_name(&repo)),
423 ));
424 }
425 if !actor.verified {
426 return Ok(Outcome::fail(FailureCode::Forbidden, CONFIRM_FIRST));
427 }
428 let Some(workspace_id) = self.workspace_id_of(&repo.namespace).await? else {
429 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
430 };
431 Ok(Outcome::Ok(Target { repo, workspace_id }))
432 }
433
434 /// The members of the repository's workspace and the people with a
435 /// direct grant on it, each once.
436 async fn people_rows(&self, repo_id: &str, workspace_id: &str) -> Result<Vec<PersonRow>> {
437 self.db
438 .prepare(format!(
439 "SELECT u.id, u.username, u.display_name AS name, u.avatar,
440 m.role AS workspace_role, g.role AS direct
441 FROM users u
442 LEFT JOIN workspace_members m ON m.user_id = u.id AND m.workspace_id = ?2
443 LEFT JOIN repo_grants g ON g.principal_kind = 'user' AND g.principal_id = u.id AND g.repo_id = ?1
444 WHERE m.user_id IS NOT NULL OR g.principal_id IS NOT NULL
445 ORDER BY u.username LIMIT {LIST_LIMIT}"
446 ))
447 .bind(&[repo_id.into(), workspace_id.into()])?
448 .all()
449 .await?
450 .results::<PersonRow>()
451 }
452
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar453 fn collaborator(row: PersonRow, base: BasePermission, teams: &TeamRoles) -> Option<Collaborator> {
Fixes from the page sweep454 let workspace_role = match row.workspace_role.as_deref() {
455 Some("owner") => Some(Role::Owner),
456 Some(_) => Some(Role::Member),
457 None => None,
458 };
459 let direct = row.direct.as_deref().and_then(RepoRole::parse);
460 let base_role = workspace_role.and(base.role());
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar461 let team = teams.get(&row.id).cloned();
462 let (role, source) = effective(
463 workspace_role == Some(Role::Owner),
464 base_role,
465 direct,
466 team.as_ref().map(|(role, _)| *role),
467 )?;
Fixes from the page sweep468 Some(Collaborator {
469 username: row.username,
470 name: row.name,
471 avatar: row.avatar,
472 role,
473 source,
474 direct,
475 workspace_role,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar476 team_role: team.as_ref().map(|(role, _)| *role),
477 team: team.map(|(_, slug)| slug),
Fixes from the page sweep478 })
479 }
480
481 async fn invitations(&self, filter: &str, binds: &[JsValue]) -> Result<Vec<InvitationRow>> {
482 self.db
483 .prepare(format!("SELECT {INVITATION_COLUMNS} {filter} ORDER BY ri.created_at DESC LIMIT {LIST_LIMIT}"))
484 .bind(binds)?
485 .all()
486 .await?
487 .results::<InvitationRow>()
488 }
489
490 async fn pending_invitations(&self, filter: &str, binds: &[JsValue]) -> Result<Vec<InvitationRow>> {
491 let filter = format!(
492 "{filter} AND ri.accepted_at IS NULL AND ri.declined_at IS NULL AND ri.revoked_at IS NULL
493 AND ri.expires_at > {SQL_NOW}"
494 );
495 self.invitations(&filter, binds).await
496 }
497
498 pub async fn repo_access(&self, a: RepoAccessArgs) -> Result<Outcome<RepoAccess>> {
499 let Some(repo) = self.repo_for(&a.path, &a.viewer).await? else {
500 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
501 };
502 let viewer_role = a.viewer.as_ref().and_then(|viewer| granted(viewer, (&repo).into()));
503 // Like the list of collaborators: for those who can push.
504 if !viewer_role.is_some_and(|role| role >= RepoRole::Write) {
505 return Ok(Outcome::fail(
506 FailureCode::Forbidden,
507 format!("You need the Write role or higher on {} to see who has access.", full_name(&repo)),
508 ));
509 }
510 let can_manage = can(a.viewer.as_ref(), &repo, Capability::ManageAccess);
511 let Some(workspace_id) = self.workspace_id_of(&repo.namespace).await? else {
512 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
513 };
514 let base = self.base_of(&workspace_id).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar515 let rows = self.people_rows(&repo.id, &workspace_id).await?;
516 let team_roles = self.team_roles_on(&repo.id, None).await?;
517 let teams = self.teams_on(&repo.id).await?;
518 let mut people: Vec<Collaborator> = rows
Fixes from the page sweep519 .into_iter()
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar520 .filter_map(|row| Self::collaborator(row, base, &team_roles))
Fixes from the page sweep521 .collect();
522 people.sort_by(|a, b| b.role.cmp(&a.role).then_with(|| a.username.cmp(&b.username)));
523 let invitations = if can_manage {
524 let now = rfc3339(now_ms());
525 self.pending_invitations("WHERE ri.repo_id = ?", &[repo.id.as_str().into()])
526 .await?
527 .iter()
528 .map(|row| row.shown(&now, true))
529 .collect()
530 } else {
531 Vec::new()
532 };
533 Ok(Outcome::Ok(RepoAccess {
534 repo: full_name(&repo),
535 base_permission: base,
536 people,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar537 teams,
Fixes from the page sweep538 invitations,
539 viewer_role,
540 can_manage,
541 }))
542 }
543
544 /// 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 bar545 pub(crate) async fn collaborator_on(&self, repo: &Repo, workspace_id: &str, user_id: &str) -> Result<Option<Collaborator>> {
Fixes from the page sweep546 let base = self.base_of(workspace_id).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar547 let teams = self.team_roles_on(&repo.id, Some(user_id)).await?;
Fixes from the page sweep548 let row = self
549 .db
550 .prepare(
551 "SELECT u.id, u.username, u.display_name AS name, u.avatar,
552 m.role AS workspace_role, g.role AS direct
553 FROM users u
554 LEFT JOIN workspace_members m ON m.user_id = u.id AND m.workspace_id = ?2
555 LEFT JOIN repo_grants g ON g.principal_kind = 'user' AND g.principal_id = u.id AND g.repo_id = ?1
556 WHERE u.id = ?3",
557 )
558 .bind(&[repo.id.as_str().into(), workspace_id.into(), user_id.into()])?
559 .first::<PersonRow>(None)
560 .await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar561 Ok(row.and_then(|row| Self::collaborator(row, base, &teams)))
Fixes from the page sweep562 }
563
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar564 pub(crate) async fn person_by_username(&self, username: &str) -> Result<Option<(String, String)>> {
Fixes from the page sweep565 #[derive(Deserialize)]
566 struct Person {
567 id: String,
568 username: String,
569 }
570 Ok(self
571 .db
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)572 .prepare("SELECT id, username FROM users WHERE username = ? AND deleted_at IS NULL")
Fixes from the page sweep573 .bind(&[username.trim().trim_start_matches('@').to_lowercase().into()])?
574 .first::<Person>(None)
575 .await?
576 .map(|person| (person.id, person.username)))
577 }
578
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar579 pub(crate) async fn is_member_of(&self, workspace_id: &str, user_id: &str) -> Result<bool> {
Fixes from the page sweep580 Ok(self
581 .db
582 .prepare("SELECT user_id AS id FROM workspace_members WHERE workspace_id = ? AND user_id = ?")
583 .bind(&[workspace_id.into(), user_id.into()])?
584 .first::<Id>(None)
585 .await?
586 .is_some())
587 }
588
589 async fn direct_role(&self, repo_id: &str, user_id: &str) -> Result<Option<RepoRole>> {
590 #[derive(Deserialize)]
591 struct RoleRow {
592 role: String,
593 }
594 Ok(self
595 .db
596 .prepare("SELECT role FROM repo_grants WHERE repo_id = ? AND principal_kind = 'user' AND principal_id = ?")
597 .bind(&[repo_id.into(), user_id.into()])?
598 .first::<RoleRow>(None)
599 .await?
600 .and_then(|row| RepoRole::parse(&row.role)))
601 }
602
603 /// Gives `user_id` `role` on the repository, or changes the role they
604 /// have; returns the role they had before.
605 async fn put_grant(
606 &self,
607 repo_id: &str,
608 workspace_id: &str,
609 repo_name: &str,
610 user_id: &str,
611 role: RepoRole,
612 granted_by: Option<&str>,
613 ) -> Result<Option<RepoRole>> {
614 let previous = self.direct_role(repo_id, user_id).await?;
615 let now = rfc3339(now_ms());
616 self.db
617 .prepare(
618 "INSERT INTO repo_grants
619 (repo_id, principal_kind, principal_id, workspace_id, repo_name, role, granted_by, created_at, updated_at)
620 VALUES (?1, 'user', ?2, ?3, ?4, ?5, ?6, ?7, ?7)
621 ON CONFLICT (repo_id, principal_kind, principal_id)
622 DO UPDATE SET role = excluded.role, workspace_id = excluded.workspace_id,
623 repo_name = excluded.repo_name, updated_at = excluded.updated_at",
624 )
625 .bind(&[
626 repo_id.into(),
627 user_id.into(),
628 workspace_id.into(),
629 repo_name.into(),
630 role.as_str().into(),
631 opt(granted_by),
632 now.as_str().into(),
633 ])?
634 .run()
635 .await?;
636 Ok(previous)
637 }
638
Merge main (membership, two-factor, GitHub repo roles) into tokens639 /// Why `actor` may not give someone outside the workspace a role on one
640 /// of its repositories: its member privileges leave that to owners
641 /// (`members_can_invite_outside_collaborators`). Read from the workspace
642 /// itself, so an outside collaborator with Admin is held to it too.
643 async fn outside_refusal<T>(&self, actor: &User, namespace: &str, workspace_id: &str) -> Result<Option<Outcome<T>>> {
644 if actor.role_in(&namespace.to_lowercase()) == Some(Role::Owner) || actor.kind == PrincipalKind::Workspace {
645 return Ok(None);
646 }
647 if self.privileges_of(workspace_id).await?.members_can_invite_outside_collaborators {
648 return Ok(None);
649 }
650 Ok(Some(Outcome::fail(
651 FailureCode::Forbidden,
652 format!("Only owners of {namespace} can add people from outside the workspace to its repositories."),
653 )))
654 }
655
Fixes from the page sweep656 pub async fn add_collaborator(&self, a: AddCollaboratorArgs) -> Result<Outcome<Added>> {
657 let Target { repo, workspace_id } = match self.manageable(&a.actor, &a.path).await? {
658 Outcome::Ok(target) => target,
659 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
660 };
661 let surface = a.surface.unwrap_or(Surface::Web);
662 let invitee = match invitee(&a.invitee) {
663 Some(invitee) => invitee,
664 None => return Ok(Outcome::fail(FailureCode::Invalid, "Enter a username or an email address.")),
665 };
666 // Who it names: an account by username, or by a confirmed address.
667 let person = match &invitee {
668 Invitee::Username(name) => match self.person_by_username(name).await? {
669 Some(person) => Some(person),
670 None => return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER)),
671 },
672 Invitee::Email(email) => match self.user_with_verified_email(email).await? {
673 Some(id) => self
674 .find_public_user(
675 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ?",
676 &id,
677 )
678 .await?
679 .map(|user| (user.id, user.username)),
680 None => None,
681 },
682 };
683 let Some((user_id, username)) = person else {
684 let Invitee::Email(email) = invitee else {
685 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
686 };
Merge main (membership, two-factor, GitHub repo roles) into tokens687 if let Some(refused) = self.outside_refusal(&a.actor, &repo.namespace, &workspace_id).await? {
688 return Ok(refused);
689 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person690 // An address is always someone from outside: a free workspace
691 // invites no one (paid.rs).
692 if let Some(refused) = self.free_workspace_refusal(&repo.namespace).await? {
693 return Ok(refused);
694 }
Fixes from the page sweep695 return self.invite_address(&a.actor, &repo, &workspace_id, &email, a.role, surface).await;
696 };
697 // What the workspace asks of anyone with access to it (security.rs).
698 if let Some(why) = self.policy_refusal(&user_id, &repo.namespace).await? {
699 return Ok(Outcome::fail(FailureCode::Forbidden, why));
700 }
701 if self.is_member_of(&workspace_id, &user_id).await? {
702 let previous = self
703 .put_grant(&repo.id, &workspace_id, &repo.name, &user_id, a.role, Some(&a.actor.id))
704 .await?;
705 self.changed(&a.actor, (&repo).into(), &username, Some(a.role), previous, surface).await;
706 let Some(collaborator) = self.collaborator_on(&repo, &workspace_id, &user_id).await? else {
707 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
708 };
709 return Ok(Outcome::Ok(Added::Granted { collaborator }));
710 }
711 if self.direct_role(&repo.id, &user_id).await?.is_some() {
712 return Ok(Outcome::fail(
713 FailureCode::Conflict,
714 format!("{username} already has access to {}. Change their role instead.", full_name(&repo)),
715 ));
716 }
Merge main (membership, two-factor, GitHub repo roles) into tokens717 if let Some(refused) = self.outside_refusal(&a.actor, &repo.namespace, &workspace_id).await? {
718 return Ok(refused);
719 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person720 // An outside collaborator is someone added: a free workspace adds
721 // no one (paid.rs). Its members' roles above are its own business,
722 // and g1t's agent is never someone added.
723 if !crate::paid::is_g1t(&username)
724 && let Some(refused) = self.free_workspace_refusal(&repo.namespace).await?
725 {
726 return Ok(refused);
727 }
Fixes from the page sweep728 let pending = self
729 .pending_invitations(
730 "WHERE ri.repo_id = ? AND ri.invitee_id = ?",
731 &[repo.id.as_str().into(), user_id.as_str().into()],
732 )
733 .await?;
734 if !pending.is_empty() {
735 return Ok(Outcome::fail(
736 FailureCode::Conflict,
737 format!("{username} already has a pending invitation to {}. Change its role, or revoke it to send a new one.", full_name(&repo)),
738 ));
739 }
740 let id = self
741 .insert_invitation(&repo, &workspace_id, Some(&user_id), None, None, a.role, &a.actor.id, INVITATION_DAYS)
742 .await?;
743 let now = rfc3339(now_ms());
744 let Some(row) = self.invitation_by_id(&id).await? else {
745 return Ok(Outcome::fail(FailureCode::NotFound, "Invitation not found."));
746 };
747 // Told by email, at their primary address and the one typed.
748 let mut to = self.notice_recipients(&user_id, false).await.unwrap_or_default();
749 if let Invitee::Email(email) = &invitee
750 && !to.iter().any(|address| address.eq_ignore_ascii_case(email))
751 {
752 to.push(email.clone());
753 }
754 for address in to.iter().take(2) {
755 if let Err(error) = crate::email::send_repo_invite(
756 &self.env,
757 address,
758 &a.actor.username,
759 &full_name(&repo),
760 a.role.label(),
761 None,
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm762 None,
Fixes from the page sweep763 INVITATION_DAYS,
764 )
765 .await
766 {
767 worker::console_error!("repository invitation email failed: {error}");
768 }
769 }
770 self.audit(&a.actor, "repo.invitation_created", (&repo).into(), surface, format!("Invited {username} as {}", a.role.label()))
771 .await;
772 Ok(Outcome::Ok(Added::Invited {
773 invitation: row.shown(&now, true),
774 }))
775 }
776
777 /// An address without an account: an invite code that makes it and
778 /// accepts (invites.rs).
779 async fn invite_address(
780 &self,
781 actor: &User,
782 repo: &Repo,
783 workspace_id: &str,
784 email: &str,
785 role: RepoRole,
786 surface: Surface,
787 ) -> Result<Outcome<Added>> {
788 let pending = self
789 .pending_invitations(
790 "WHERE ri.repo_id = ? AND ri.email = ?",
791 &[repo.id.as_str().into(), email.into()],
792 )
793 .await?;
794 if !pending.is_empty() {
795 return Ok(Outcome::fail(
796 FailureCode::Conflict,
797 "That address already has a pending invitation to this repository. Revoke it to send a new one.",
798 ));
799 }
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.800 let invite = match self.repo_invite_code(actor, email).await? {
Fixes from the page sweep801 Outcome::Ok(invite) => invite,
802 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
803 };
804 let days = self.invite_days();
805 let id = self
806 .insert_invitation(repo, workspace_id, None, Some(email), Some(&invite.id), role, &actor.id, days)
807 .await?;
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm808 // The email's link proves the address, as any invite email's does.
809 let proof = self.email_proof_for(&invite.id, email);
Fixes from the page sweep810 if let Some(code) = &invite.code
811 && let Err(error) = crate::email::send_repo_invite(
812 &self.env,
813 email,
814 &actor.username,
815 &full_name(repo),
816 role.label(),
817 Some(code),
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm818 proof.as_deref(),
Fixes from the page sweep819 days,
820 )
821 .await
822 {
823 worker::console_error!("repository invitation email failed: {error}");
824 }
825 self.audit(
826 actor,
827 "repo.invitation_created",
828 repo.into(),
829 surface,
830 format!("Invited {} as {}", crate::invites::mask_email(email), role.label()),
831 )
832 .await;
833 let now = rfc3339(now_ms());
834 Ok(match self.invitation_by_id(&id).await? {
835 Some(row) => Outcome::Ok(Added::Invited {
836 invitation: row.shown(&now, true),
837 }),
838 None => Outcome::fail(FailureCode::NotFound, "Invitation not found."),
839 })
840 }
841
842 #[allow(clippy::too_many_arguments)]
843 async fn insert_invitation(
844 &self,
845 repo: &Repo,
846 workspace_id: &str,
847 invitee_id: Option<&str>,
848 email: Option<&str>,
849 invite_id: Option<&str>,
850 role: RepoRole,
851 inviter_id: &str,
852 days: u64,
853 ) -> Result<String> {
854 let now = now_ms();
855 let id = new_id("rin", now);
856 self.db
857 .prepare(
858 "INSERT INTO repo_invitations
859 (id, repo_id, workspace_id, repo_name, invitee_id, email, invite_id, role, inviter_id, created_at, expires_at)
860 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
861 )
862 .bind(&[
863 id.as_str().into(),
864 repo.id.as_str().into(),
865 workspace_id.into(),
866 repo.name.as_str().into(),
867 opt(invitee_id),
868 opt(email),
869 opt(invite_id),
870 role.as_str().into(),
871 inviter_id.into(),
872 rfc3339(now).into(),
873 rfc3339(now + days * 86_400_000).into(),
874 ])?
875 .run()
876 .await?;
877 Ok(id)
878 }
879
880 async fn invitation_by_id(&self, id: &str) -> Result<Option<InvitationRow>> {
881 Ok(self
882 .invitations("WHERE ri.id = ?", &[id.into()])
883 .await?
884 .into_iter()
885 .next())
886 }
887
888 fn invite_days(&self) -> u64 {
889 self.env
890 .var("INVITE_TTL_DAYS")
891 .ok()
892 .and_then(|value| value.to_string().parse().ok())
893 .unwrap_or(g1t_contracts::identity::INVITE_TTL_DAYS)
894 }
895
896 pub async fn set_collaborator_role(&self, a: SetCollaboratorRoleArgs) -> Result<Outcome<Collaborator>> {
897 let Target { repo, workspace_id } = match self.manageable(&a.actor, &a.path).await? {
898 Outcome::Ok(target) => target,
899 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
900 };
901 let Some((user_id, username)) = self.person_by_username(&a.username).await? else {
902 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
903 };
904 let surface = a.surface.unwrap_or(Surface::Web);
905 match self.direct_role(&repo.id, &user_id).await? {
906 Some(previous) => {
907 self.put_grant(&repo.id, &workspace_id, &repo.name, &user_id, a.role, Some(&a.actor.id))
908 .await?;
909 if previous != a.role {
910 self.changed(&a.actor, (&repo).into(), &username, Some(a.role), Some(previous), surface).await;
911 }
912 }
913 None => {
914 // A pending invitation's role changes until it is answered.
915 let changed = self
916 .db
917 .prepare(format!(
918 "UPDATE repo_invitations SET role = ?1
919 WHERE repo_id = ?2 AND invitee_id = ?3 AND accepted_at IS NULL AND declined_at IS NULL
920 AND revoked_at IS NULL AND expires_at > {SQL_NOW}
921 RETURNING id"
922 ))
923 .bind(&[a.role.as_str().into(), repo.id.as_str().into(), user_id.as_str().into()])?
924 .first::<Id>(None)
925 .await?;
926 if changed.is_none() {
927 return Ok(Outcome::fail(
928 FailureCode::NotFound,
929 format!(
930 "{username} has no role of their own on {}. Owners have Admin, and members the base permission; add them to give them more.",
931 full_name(&repo)
932 ),
933 ));
934 }
935 }
936 }
937 Ok(match self.collaborator_on(&repo, &workspace_id, &user_id).await? {
938 Some(collaborator) => Outcome::Ok(collaborator),
939 // Invited, not yet a collaborator: say what they will be.
940 None => Outcome::Ok(Collaborator {
941 username,
942 name: None,
943 avatar: None,
944 role: a.role,
945 source: AccessSource::Direct,
946 direct: Some(a.role),
947 workspace_role: None,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar948 team_role: None,
949 team: None,
Fixes from the page sweep950 }),
951 })
952 }
953
954 pub async fn remove_collaborator(&self, a: RemoveCollaboratorArgs) -> Result<Outcome<bool>> {
955 let Some((user_id, username)) = self.person_by_username(&a.username).await? else {
956 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
957 };
958 let surface = a.surface.unwrap_or(Surface::Web);
959 // Anyone may give up their own role; otherwise, Admin only.
960 let leaving = crate::security::is_person(&a.actor) && a.actor.id == user_id;
961 let repo = if leaving {
962 match self.repo_for(&a.path, &Some(a.actor.clone())).await? {
963 Some(repo) => repo,
964 None => return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found.")),
965 }
966 } else {
967 match self.manageable(&a.actor, &a.path).await? {
968 Outcome::Ok(target) => target.repo,
969 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
970 }
971 };
972 let Some(previous) = self.direct_role(&repo.id, &user_id).await? else {
973 return Ok(Outcome::fail(
974 FailureCode::NotFound,
975 format!(
976 "{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.",
977 full_name(&repo)
978 ),
979 ));
980 };
981 self.db
982 .batch(vec![
983 self.db
984 .prepare("DELETE FROM repo_grants WHERE repo_id = ? AND principal_kind = 'user' AND principal_id = ?")
985 .bind(&[repo.id.as_str().into(), user_id.as_str().into()])?,
986 self.db
987 .prepare(format!(
988 "UPDATE repo_invitations SET revoked_at = {SQL_NOW}
989 WHERE repo_id = ? AND invitee_id = ? AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL"
990 ))
991 .bind(&[repo.id.as_str().into(), user_id.as_str().into()])?,
992 ])
993 .await?;
994 self.changed(&a.actor, (&repo).into(), &username, None, Some(previous), surface).await;
995 Ok(Outcome::Ok(true))
996 }
997
998 pub async fn collaborator_permission(&self, a: CollaboratorPermissionArgs) -> Result<Outcome<PermissionInfo>> {
999 let Some(repo) = self.repo_for(&a.path, &a.viewer).await? else {
1000 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
1001 };
1002 let asking_about_self = a
1003 .viewer
1004 .as_ref()
1005 .is_some_and(|viewer| viewer.username.eq_ignore_ascii_case(a.username.trim()));
1006 if !asking_about_self && !can(a.viewer.as_ref(), &repo, Capability::Push) {
1007 return Ok(Outcome::fail(
1008 FailureCode::Forbidden,
1009 format!("You need the Write role or higher on {} to see others' permissions.", full_name(&repo)),
1010 ));
1011 }
1012 let Some((user_id, username)) = self.person_by_username(&a.username).await? else {
1013 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
1014 };
1015 let Some(workspace_id) = self.workspace_id_of(&repo.namespace).await? else {
1016 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
1017 };
1018 // Held to the workspace's policy as their requests are.
1019 let within = self.policy_refusal(&user_id, &repo.namespace).await?.is_none();
1020 let place = if within {
1021 self.collaborator_on(&repo, &workspace_id, &user_id).await?
1022 } else {
1023 None
1024 };
1025 let role = place.as_ref().map(|place| place.role);
1026 Ok(Outcome::Ok(PermissionInfo {
1027 username,
1028 role,
1029 source: place.map(|place| place.source),
1030 capabilities: capabilities_of(role),
1031 }))
1032 }
1033
1034 pub async fn my_repo_invitations(&self, a: MyRepoInvitationsArgs) -> Result<Vec<RepoInvitation>> {
1035 if !crate::security::is_person(&a.user) {
1036 return Ok(Vec::new());
1037 }
1038 let now = rfc3339(now_ms());
1039 Ok(self
1040 .invitations_for(&a.user, None)
1041 .await?
1042 .iter()
1043 .map(|row| row.shown(&now, false))
1044 .collect())
1045 }
1046
1047 /// The pending invitations for `user`: sent to them, or to one of
1048 /// their confirmed addresses before they had an account (and made it
1049 /// some other way than with the code). `id` narrows it to one.
1050 async fn invitations_for(&self, user: &User, id: Option<&str>) -> Result<Vec<InvitationRow>> {
1051 let emails = serde_json::to_string(&self.verified_emails(&user.id).await?)?;
1052 let mut filter = "WHERE (ri.invitee_id = ? OR (ri.invitee_id IS NULL AND ri.email IN (SELECT value FROM json_each(?))))".to_owned();
1053 let mut binds = vec![JsValue::from(user.id.as_str()), emails.into()];
1054 if let Some(id) = id {
1055 filter.push_str(" AND ri.id = ?");
1056 binds.push(id.into());
1057 }
1058 self.pending_invitations(&filter, &binds).await
1059 }
1060
1061 pub async fn respond_repo_invitation(&self, a: RespondRepoInvitationArgs) -> Result<Outcome<RepoInvitation>> {
1062 if !crate::security::is_person(&a.user) {
1063 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can answer an invitation."));
1064 }
1065 let now = rfc3339(now_ms());
1066 let row = self
1067 .invitations_for(&a.user, Some(&a.id))
1068 .await?
1069 .into_iter()
1070 .next();
1071 let Some(row) = row else {
1072 return Ok(Outcome::fail(
1073 FailureCode::NotFound,
1074 "There is no pending invitation of yours with that id. It may have expired or been revoked.",
1075 ));
1076 };
1077 if !a.accept {
1078 self.db
1079 .prepare(format!("UPDATE repo_invitations SET declined_at = {SQL_NOW} WHERE id = ?"))
1080 .bind(&[row.id.as_str().into()])?
1081 .run()
1082 .await?;
1083 let mut shown = row.shown(&now, false);
1084 shown.status = RepoInvitationStatus::Declined;
1085 return Ok(Outcome::Ok(shown));
1086 }
1087 if let Some(why) = self.policy_refusal(&a.user.id, &row.workspace).await? {
1088 return Ok(Outcome::fail(FailureCode::Forbidden, why));
1089 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1090 // Sent before the workspace was free, or before this rule: it waits
1091 // until the workspace starts the plan (paid.rs).
1092 if let Some(refused) = self.free_workspace_refusal(&row.workspace).await? {
1093 return Ok(refused);
1094 }
Fixes from the page sweep1095 self.accept(&row, &a.user).await?;
1096 let mut shown = row.shown(&now, false);
1097 shown.status = RepoInvitationStatus::Accepted;
1098 Ok(Outcome::Ok(shown))
1099 }
1100
1101 /// Turns an invitation into a grant, once.
1102 async fn accept(&self, row: &InvitationRow, user: &User) -> Result<()> {
1103 let claimed = self
1104 .db
1105 .prepare(format!(
1106 "UPDATE repo_invitations SET accepted_at = {SQL_NOW}, invitee_id = ?1
1107 WHERE id = ?2 AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL
1108 RETURNING id"
1109 ))
1110 .bind(&[user.id.as_str().into(), row.id.as_str().into()])?
1111 .first::<Id>(None)
1112 .await?;
1113 if claimed.is_none() {
1114 return Ok(());
1115 }
1116 let role = row.role();
1117 // Never lowers a role they already have.
1118 let current = self.direct_role(&row.repo_id, &user.id).await?;
1119 let role = current.map_or(role, |current| current.max(role));
1120 let previous = self
1121 .put_grant(&row.repo_id, &row.workspace_id, &row.repo_name, &user.id, role, row.inviter_id.as_deref())
1122 .await?;
1123 let repo = Named {
1124 id: &row.repo_id,
1125 namespace: &row.workspace,
1126 name: &row.repo_name,
1127 };
1128 self.changed(user, repo, &user.username, Some(role), previous, Surface::Web).await;
1129 Ok(())
1130 }
1131
1132 /// The repository an invite code was sent with, for the invite's page
1133 /// (invites.rs): whatever became of the invitation since.
1134 pub(crate) async fn repository_of_code(
1135 &self,
1136 invite_id: &str,
1137 ) -> Result<Option<g1t_contracts::identity::InviteRepository>> {
1138 Ok(self
1139 .invitations("WHERE ri.invite_id = ?", &[invite_id.into()])
1140 .await?
1141 .into_iter()
1142 .next()
1143 .map(|row| g1t_contracts::identity::InviteRepository {
1144 name: format!("{}/{}", row.workspace, row.repo_name),
1145 role: row.role().as_str().to_owned(),
1146 }))
1147 }
1148
1149 /// Accepts the repository invitations sent with an invite code, once
1150 /// the code made `user`'s account (invites.rs).
1151 pub(crate) async fn accept_invitations_of_code(&self, invite_id: &str, user: &User) -> Result<()> {
1152 let rows = self
1153 .pending_invitations("WHERE ri.invite_id = ?", &[invite_id.into()])
1154 .await?;
1155 for row in rows {
1156 if self.policy_refusal(&user.id, &row.workspace).await?.is_some() {
1157 continue;
1158 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1159 // A free workspace adds no one (paid.rs): the invitation stays
1160 // pending until it starts the plan.
1161 if self.is_free_workspace(&row.workspace).await {
1162 continue;
1163 }
Fixes from the page sweep1164 self.accept(&row, user).await?;
1165 }
1166 Ok(())
1167 }
1168
1169 pub async fn revoke_repo_invitation(&self, a: RevokeRepoInvitationArgs) -> Result<Outcome<RepoInvitation>> {
1170 let Target { repo, .. } = match self.manageable(&a.actor, &a.path).await? {
1171 Outcome::Ok(target) => target,
1172 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1173 };
1174 let revoked = self
1175 .db
1176 .prepare(format!(
1177 "UPDATE repo_invitations SET revoked_at = {SQL_NOW}
1178 WHERE id = ? AND repo_id = ? AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL
1179 RETURNING id"
1180 ))
1181 .bind(&[a.id.as_str().into(), repo.id.as_str().into()])?
1182 .first::<Id>(None)
1183 .await?;
1184 if revoked.is_none() {
1185 return Ok(Outcome::fail(FailureCode::NotFound, "There is no pending invitation with that id."));
1186 }
1187 let Some(row) = self.invitation_by_id(&a.id).await? else {
1188 return Ok(Outcome::fail(FailureCode::NotFound, "Invitation not found."));
1189 };
1190 if let Some(invite_id) = &row.invite_id {
1191 self.revoke_code(invite_id).await?;
1192 }
1193 let who = row
1194 .invitee
1195 .clone()
1196 .or_else(|| row.email.as_deref().map(crate::invites::mask_email))
1197 .unwrap_or_default();
1198 self.audit(
1199 &a.actor,
1200 "repo.invitation_revoked",
1201 (&repo).into(),
1202 a.surface.unwrap_or(Surface::Web),
1203 format!("Revoked the invitation to {who}"),
1204 )
1205 .await;
1206 Ok(Outcome::Ok(row.shown(&rfc3339(now_ms()), true)))
1207 }
1208
1209 pub async fn set_base_permission(&self, a: SetBasePermissionArgs) -> Result<Outcome<BasePermission>> {
1210 let slug = a.slug.trim().to_lowercase();
1211 if !crate::security::is_person(&a.actor) || a.actor.role_in(&slug) != Some(Role::Owner) {
1212 return Ok(Outcome::fail(
1213 FailureCode::Forbidden,
1214 "Only an owner can change what members get on every repository.",
1215 ));
1216 }
1217 if !a.actor.verified {
1218 return Ok(Outcome::fail(FailureCode::Forbidden, CONFIRM_FIRST));
1219 }
1220 let Some(workspace_id) = self.workspace_id_of(&slug).await? else {
1221 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1222 };
1223 let previous = self.base_of(&workspace_id).await?;
1224 self.db
1225 .prepare("UPDATE workspaces SET base_permission = ? WHERE id = ?")
1226 .bind(&[a.base_permission.as_str().into(), workspace_id.as_str().into()])?
1227 .run()
1228 .await?;
1229 if previous != a.base_permission {
1230 self.audit_workspace(
1231 &a.actor,
1232 "workspace.base_permission_changed",
1233 &slug,
1234 a.surface.unwrap_or(Surface::Web),
1235 format!(
1236 "Changed the base permission from {} to {}",
1237 previous.as_str(),
1238 a.base_permission.as_str()
1239 ),
1240 )
1241 .await;
1242 self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await;
1243 }
1244 Ok(Outcome::Ok(a.base_permission))
1245 }
1246
Merge branch 'worktree-agent-a2013627e5ea4ab13'1247 /// `workspace_residency`: where a workspace keeps its repositories'
1248 /// git data, for the repos service as it places a new one, and for its
1249 /// settings page. Null when there is no such workspace.
1250 pub async fn workspace_residency(&self, a: g1t_contracts::identity::SlugArgs) -> Result<Option<g1t_contracts::identity::DataResidency>> {
1251 #[derive(Deserialize)]
1252 struct Row {
1253 #[serde(default)]
1254 data_residency: Option<String>,
1255 }
1256 let row = self
1257 .db
1258 .prepare("SELECT data_residency FROM workspaces WHERE slug = ? AND deleted_at IS NULL")
1259 .bind(&[a.slug.trim().to_lowercase().into()])?
1260 .first::<Row>(None)
1261 .await?;
1262 Ok(row.map(|row| {
1263 row.data_residency
1264 .as_deref()
1265 .and_then(g1t_contracts::identity::DataResidency::parse)
1266 .unwrap_or_default()
1267 }))
1268 }
1269
1270 /// `set_workspace_residency`: owners only. Applies to repositories
1271 /// made from then on; those it has stay where they are. Whether the EU
1272 /// can be chosen is the repos service's to say (`storage_options`);
1273 /// the site offers it only then, and the repos service refuses to
1274 /// place an EU workspace's repository anywhere else.
1275 pub async fn set_workspace_residency(
1276 &self,
1277 a: g1t_contracts::identity::SetResidencyArgs,
1278 ) -> Result<Outcome<g1t_contracts::identity::DataResidency>> {
1279 let slug = a.slug.trim().to_lowercase();
1280 if !crate::security::is_person(&a.actor) || a.actor.role_in(&slug) != Some(Role::Owner) {
1281 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can change where a workspace keeps its data."));
1282 }
1283 if !a.actor.verified {
1284 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address before changing where the workspace keeps its data."));
1285 }
1286 let Some(previous) = self.workspace_residency(g1t_contracts::identity::SlugArgs { slug: slug.clone() }).await? else {
1287 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1288 };
1289 if previous == a.residency {
1290 return Ok(Outcome::Ok(previous));
1291 }
1292 let stored = match a.residency {
1293 g1t_contracts::identity::DataResidency::Anywhere => JsValue::NULL,
1294 other => other.as_str().into(),
1295 };
1296 self.db
1297 .prepare("UPDATE workspaces SET data_residency = ? WHERE slug = ? AND deleted_at IS NULL")
1298 .bind(&[stored, slug.as_str().into()])?
1299 .run()
1300 .await?;
1301 self.audit_workspace(
1302 &a.actor,
1303 "workspace.residency_changed",
1304 &slug,
1305 Surface::Web,
1306 format!("Changed where new repositories keep their data from {} to {}", previous.as_str(), a.residency.as_str()),
1307 )
1308 .await;
1309 Ok(Outcome::Ok(a.residency))
1310 }
1311
Fixes from the page sweep1312 pub async fn outside_collaborators(&self, a: OutsideCollaboratorsArgs) -> Result<Outcome<Vec<OutsideCollaborator>>> {
1313 let slug = a.slug.trim().to_lowercase();
1314 if !a.viewer.as_ref().is_some_and(|viewer| viewer.role_in(&slug) == Some(Role::Owner)) {
1315 return Ok(Outcome::fail(FailureCode::Forbidden, "Only owners can see a workspace's outside collaborators."));
1316 }
1317 let Some(workspace_id) = self.workspace_id_of(&slug).await? else {
1318 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1319 };
1320 #[derive(Deserialize)]
1321 struct Row {
1322 username: String,
1323 name: Option<String>,
1324 avatar: Option<String>,
1325 repo_name: String,
1326 role: String,
1327 }
1328 let rows = self
1329 .db
1330 .prepare(format!(
1331 "SELECT u.username, u.display_name AS name, u.avatar, g.repo_name, g.role
1332 FROM repo_grants g JOIN users u ON u.id = g.principal_id
1333 WHERE g.workspace_id = ?1 AND g.principal_kind = 'user'
1334 AND NOT EXISTS (SELECT 1 FROM workspace_members m WHERE m.workspace_id = ?1 AND m.user_id = g.principal_id)
1335 ORDER BY u.username, g.repo_name LIMIT {LIST_LIMIT}"
1336 ))
1337 .bind(&[workspace_id.as_str().into()])?
1338 .all()
1339 .await?
1340 .results::<Row>()?;
1341 let mut people: Vec<OutsideCollaborator> = Vec::new();
1342 for row in rows {
1343 let Some(role) = RepoRole::parse(&row.role) else {
1344 continue;
1345 };
1346 let repo = CollaboratorRepo {
1347 repo: format!("{slug}/{}", row.repo_name),
1348 role,
1349 };
1350 match people.last_mut().filter(|person| person.username == row.username) {
1351 Some(person) => person.repos.push(repo),
1352 None => people.push(OutsideCollaborator {
1353 username: row.username,
1354 name: row.name,
1355 avatar: row.avatar,
1356 repos: vec![repo],
1357 }),
1358 }
1359 }
1360 Ok(Outcome::Ok(people))
1361 }
1362
1363 pub async fn forget_repo_access(&self, a: ForgetRepoAccessArgs) -> Result<bool> {
1364 self.db
1365 .batch(vec![
1366 self.db
1367 .prepare("DELETE FROM repo_grants WHERE repo_id = ?")
1368 .bind(&[a.repo_id.as_str().into()])?,
1369 self.db
1370 .prepare("DELETE FROM repo_invitations WHERE repo_id = ?")
1371 .bind(&[a.repo_id.as_str().into()])?,
1372 ])
1373 .await?;
1374 Ok(true)
1375 }
1376
1377 /// A repository moved or was renamed: its grants and invitations follow
1378 /// it (deletion.rs, `transfer_repo_scopes`).
1379 pub(crate) async fn move_repo_access(&self, from: &RepoPath, to: &RepoPath) -> Result<()> {
1380 let (Some(from_id), Some(to_id)) = (
1381 self.workspace_id_of(&from.namespace).await?,
1382 self.workspace_id_of(&to.namespace).await?,
1383 ) else {
1384 return Ok(());
1385 };
1386 let binds = [
1387 JsValue::from(to_id.as_str()),
1388 to.name.to_lowercase().into(),
1389 from_id.as_str().into(),
1390 from.name.to_lowercase().into(),
1391 ];
1392 self.db
1393 .batch(vec![
1394 self.db
1395 .prepare("UPDATE repo_grants SET workspace_id = ?1, repo_name = ?2 WHERE workspace_id = ?3 AND repo_name = ?4")
1396 .bind(&binds)?,
1397 self.db
1398 .prepare("UPDATE repo_invitations SET workspace_id = ?1, repo_name = ?2 WHERE workspace_id = ?3 AND repo_name = ?4")
1399 .bind(&binds)?,
1400 ])
1401 .await?;
1402 Ok(())
1403 }
1404
1405 // --- Telling others ---
1406
1407 /// Publishes the change of a person's own role, and records it in the
1408 /// workspace's audit log.
1409 async fn changed(
1410 &self,
1411 actor: &User,
1412 repo: Named<'_>,
1413 username: &str,
1414 role: Option<RepoRole>,
1415 previous: Option<RepoRole>,
1416 surface: Surface,
1417 ) {
1418 let (kind, message) = match (previous, role) {
1419 (None, Some(role)) => ("repo.collaborator_added", format!("Gave {username} the {} role", role.label())),
1420 (Some(previous), Some(role)) => (
1421 "repo.collaborator_role_changed",
1422 format!("Changed {username}'s role from {} to {}", previous.label(), role.label()),
1423 ),
1424 (Some(previous), None) => ("repo.collaborator_removed", format!("Removed {username}'s {} role", previous.label())),
1425 (None, None) => return,
1426 };
1427 self.publish_repo(
1428 kind,
1429 repo.id,
1430 &actor.id,
1431 RepoCollaborator {
1432 repo_id: repo.id.to_owned(),
1433 namespace: repo.namespace.to_owned(),
1434 name: repo.name.to_owned(),
1435 username: username.to_owned(),
1436 role,
1437 previous_role: previous,
1438 },
1439 )
1440 .await;
1441 self.audit(actor, kind, repo, surface, message).await;
1442 }
1443
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1444 pub(crate) async fn publish_repo<T: Serialize>(&self, kind: &'static str, repo_id: &str, actor: &str, data: T) {
Fixes from the page sweep1445 let Ok(events) = self.env.service("EVENTS") else {
1446 return;
1447 };
1448 let publish = Publish {
1449 events: vec![NewEvent {
1450 kind,
1451 source: "identity",
1452 repo_id: Some(repo_id.to_owned()),
1453 actor: Some(actor.to_owned()),
1454 data,
1455 }],
1456 };
1457 if let Err(error) = g1t_kit::call::<_, serde_json::Value>(&events, "publish", &publish).await {
1458 worker::console_error!("{kind} not published: {error}");
1459 }
1460 }
1461
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1462 pub(crate) async fn audit(&self, actor: &User, action: &str, repo: Named<'_>, surface: Surface, message: String) {
Fixes from the page sweep1463 let full = format!("{}/{}", repo.namespace, repo.name);
1464 self.record(actor, action, repo.namespace, Some(full), surface, message).await;
1465 }
1466
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1467 pub(crate) async fn audit_workspace(&self, actor: &User, action: &str, slug: &str, surface: Surface, message: String) {
Fixes from the page sweep1468 self.record(actor, action, slug, None, surface, message).await;
1469 }
1470
1471 async fn record(&self, actor: &User, action: &str, workspace: &str, repo: Option<String>, surface: Surface, message: String) {
1472 let Ok(events) = self.env.service("EVENTS") else {
1473 return;
1474 };
1475 let entry = NewAuditEntry {
1476 actor: AuditActor::of(actor),
1477 action: action.to_owned(),
1478 surface,
1479 target: AuditTarget {
1480 workspace: workspace.to_lowercase(),
1481 repo,
1482 ..AuditTarget::default()
1483 },
1484 outcome: AuditOutcome::Allowed,
1485 rule: if actor.kind == PrincipalKind::User { "access" } else { "access:token" }.to_owned(),
1486 result: Some("ok".to_owned()),
1487 message: Some(message),
1488 request_id: new_id("req", now_ms()),
1489 };
1490 let recorded: Result<u32> =
1491 g1t_kit::call(&events, "audit_record", &RecordAuditArgs { entries: vec![entry] }).await;
1492 if let Err(error) = recorded {
1493 worker::console_error!("{action} not recorded: {error}");
1494 }
1495 }
1496}
1497
1498#[cfg(test)]
1499mod tests {
1500 use super::*;
1501
1502 #[test]
1503 fn people_are_added_by_username_or_address() {
1504 assert_eq!(invitee(" Ada "), Some(Invitee::Username("ada".into())));
1505 assert_eq!(invitee("@ada"), Some(Invitee::Username("ada".into())));
1506 assert_eq!(invitee("Ada@Example.com"), Some(Invitee::Email("ada@example.com".into())));
1507 assert_eq!(invitee("not a name"), None);
1508 assert_eq!(invitee("ada@"), None);
1509 }
1510
1511 #[test]
1512 fn a_role_comes_from_ownership_the_base_or_a_grant() {
1513 use AccessSource::*;
1514 use RepoRole::*;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1515 assert_eq!(effective(true, Some(Read), Some(Write), None), Some((Admin, Owner)));
1516 assert_eq!(effective(false, Some(Write), None, None), Some((Write, Base)));
1517 assert_eq!(effective(false, Some(Write), Some(Maintain), None), Some((Maintain, Direct)));
Fixes from the page sweep1518 // 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 bar1519 assert_eq!(effective(false, Some(Write), Some(Write), None), Some((Write, Direct)));
1520 assert_eq!(effective(false, Some(Admin), Some(Read), None), Some((Admin, Base)));
Fixes from the page sweep1521 // An outside collaborator.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1522 assert_eq!(effective(false, None, Some(Triage), None), Some((Triage, Direct)));
Fixes from the page sweep1523 // 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 bar1524 assert_eq!(effective(false, None, None, None), None);
1525 }
1526
1527 #[test]
1528 fn a_teams_role_counts_where_it_is_the_highest() {
1529 use AccessSource::*;
1530 use RepoRole::*;
1531 assert_eq!(effective(false, Some(Read), None, Some(Maintain)), Some((Maintain, Team)));
1532 assert_eq!(effective(false, None, None, Some(Triage)), Some((Triage, Team)));
1533 // Lower than the base: the base.
1534 assert_eq!(effective(false, Some(Write), None, Some(Read)), Some((Write, Base)));
1535 // As high as the base: shown as the team's, where it can be changed.
1536 assert_eq!(effective(false, Some(Write), None, Some(Write)), Some((Write, Team)));
1537 // A direct grant as high as the team's is shown as direct.
1538 assert_eq!(effective(false, Some(Read), Some(Admin), Some(Admin)), Some((Admin, Direct)));
1539 assert_eq!(effective(false, Some(Read), Some(Write), Some(Admin)), Some((Admin, Team)));
1540 // Owners are owners.
1541 assert_eq!(effective(true, None, None, Some(Write)), Some((Admin, Owner)));
1542 }
1543
1544 #[test]
1545 fn each_person_keeps_the_highest_role_any_team_gives() {
1546 let roles = highest_team_roles([
1547 ("usr_a".to_owned(), RepoRole::Read, "docs".to_owned()),
1548 ("usr_a".to_owned(), RepoRole::Maintain, "platform".to_owned()),
1549 ("usr_a".to_owned(), RepoRole::Write, "backend".to_owned()),
1550 ("usr_b".to_owned(), RepoRole::Write, "web".to_owned()),
1551 ("usr_b".to_owned(), RepoRole::Write, "api".to_owned()),
1552 ]);
1553 assert_eq!(roles["usr_a"], (RepoRole::Maintain, "platform".to_owned()));
1554 assert_eq!(roles["usr_b"], (RepoRole::Write, "api".to_owned()));
Fixes from the page sweep1555 }
1556
1557 #[test]
1558 fn an_invitation_is_pending_until_answered_revoked_or_expired() {
1559 let row = InvitationRow {
1560 expires_at: "2026-10-12T00:00:00.000Z".into(),
1561 ..InvitationRow::default()
1562 };
1563 let now = "2026-10-05T00:00:00.000Z";
1564 assert_eq!(invitation_status(&row, now), RepoInvitationStatus::Pending);
1565 assert_eq!(invitation_status(&row, "2026-10-12T00:00:00.000Z"), RepoInvitationStatus::Expired);
1566 let accepted = InvitationRow { accepted_at: Some(now.into()), ..row.clone() };
1567 assert_eq!(invitation_status(&accepted, now), RepoInvitationStatus::Accepted);
1568 let declined = InvitationRow { declined_at: Some(now.into()), ..row.clone() };
1569 assert_eq!(invitation_status(&declined, now), RepoInvitationStatus::Declined);
1570 let revoked = InvitationRow { revoked_at: Some(now.into()), ..row };
1571 assert_eq!(invitation_status(&revoked, now), RepoInvitationStatus::Revoked);
1572 }
1573
1574 #[test]
1575 fn invitations_show_addresses_only_to_those_who_manage_access() {
1576 let row = InvitationRow {
1577 id: "rin_1".into(),
1578 workspace: "acme".into(),
1579 repo_name: "rocket".into(),
1580 email: Some("ada@example.com".into()),
1581 role: "triage".into(),
1582 expires_at: "2099-01-01T00:00:00.000Z".into(),
1583 ..InvitationRow::default()
1584 };
1585 let now = "2026-10-05T00:00:00.000Z";
1586 assert_eq!(row.shown(now, true).email.as_deref(), Some("ada@example.com"));
1587 assert_eq!(row.shown(now, false).email, None);
1588 assert_eq!(row.shown(now, false).repo, "acme/rocket");
1589 assert_eq!(row.shown(now, false).role, RepoRole::Triage);
1590 }
1591}

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