Skip to content
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.

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>,
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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look124}
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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look143#[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
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member191 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 look192 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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look199 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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look226}
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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look242}
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)>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look261 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
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member270 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}"
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look278 ))
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 ))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look295 })
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>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look302 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
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily388 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 look389 Ok(self
390 .db
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member391 // A deleted workspace's repositories are nobody's to share.
392 .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 look393 .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> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look400 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>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look412 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> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look454 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 )?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look468 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),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look478 })
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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look519 .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))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look521 .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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look538 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>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look546 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?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look548 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)))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look562 }
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)>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look565 #[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")
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look573 .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> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look580 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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look656 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 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look695 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 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look728 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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look763 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 }
800 let invite = match self.repo_invite_code(actor, email, workspace_id).await? {
801 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);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look810 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(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look819 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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look950 }),
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 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1095 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
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1132 /// 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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1149 /// 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 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1164 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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1312 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) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1445 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) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1463 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) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1468 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)));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1518 // 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)));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1521 // 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)));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1523 // 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()));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1555 }
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.