flagon-io/g1t

public

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/services/identity/src/access.rs

1,310 lines53,276 bytesCodeBlame
1//! 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//!
27//! **Teams** slot in as another `principal_kind` in `repo_grants`,
28//! resolved into the same `RepoGrant`s for each person in the team.
29
30use g1t_contracts::access::*;
31use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
32use g1t_contracts::events::{NewEvent, Publish, RepoCollaborator};
33use g1t_contracts::repos::{GetArgs, Repo, RepoPath};
34use g1t_contracts::time::{SQL_NOW, rfc3339};
35use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, Viewer, new_id};
36use g1t_kit::now_ms;
37use serde::{Deserialize, Serialize};
38use worker::Result;
39use worker::wasm_bindgen::JsValue;
40
41use crate::Identity;
42use crate::invites::normalize_email;
43
44/// How long an invitation to someone with an account waits for an answer.
45pub const INVITATION_DAYS: u64 = 7;
46/// The most direct grants one person carries on every request.
47const MAX_GRANTS: u32 = 1000;
48/// The most people or invitations one list shows.
49const LIST_LIMIT: u32 = 500;
50
51const PEOPLE_ONLY: &str =
52 "Only a person can change who has access to a repository, signed in as themselves; never an agent's or a workspace's token.";
53const CONFIRM_FIRST: &str = "Confirm your email address before changing who has access.";
54const NO_SUCH_USER: &str = "There is no account with that username.";
55
56/// What was typed into "Add people".
57#[derive(Debug, PartialEq, Eq)]
58pub enum Invitee {
59 Username(String),
60 Email(String),
61}
62
63/// A username, or an email address, as typed; `None` if it is neither.
64pub fn invitee(text: &str) -> Option<Invitee> {
65 let text = text.trim().trim_start_matches('@');
66 if text.contains('@') {
67 return normalize_email(text).map(Invitee::Email);
68 }
69 let name = text.to_lowercase();
70 g1t_contracts::is_valid_namespace(&name).then_some(Invitee::Username(name))
71}
72
73/// A person's role on a repository, and where it comes from: ownership,
74/// the base permission, or a direct grant. A direct grant at least as high
75/// as the base is shown as direct, so it can be changed where it was given.
76pub fn effective(owner: bool, base: Option<RepoRole>, direct: Option<RepoRole>) -> Option<(RepoRole, AccessSource)> {
77 if owner {
78 return Some((RepoRole::Admin, AccessSource::Owner));
79 }
80 match (base, direct) {
81 (base, Some(direct)) if base.is_none_or(|base| direct >= base) => Some((direct, AccessSource::Direct)),
82 (Some(base), _) => Some((base, AccessSource::Base)),
83 (None, None) => None,
84 (None, Some(_)) => unreachable!("handled above"),
85 }
86}
87
88/// Where an invitation stands at `now`.
89pub fn invitation_status(row: &InvitationRow, now: &str) -> RepoInvitationStatus {
90 if row.accepted_at.is_some() {
91 RepoInvitationStatus::Accepted
92 } else if row.declined_at.is_some() {
93 RepoInvitationStatus::Declined
94 } else if row.revoked_at.is_some() {
95 RepoInvitationStatus::Revoked
96 } else if row.expires_at.as_str() <= now {
97 RepoInvitationStatus::Expired
98 } else {
99 RepoInvitationStatus::Pending
100 }
101}
102
103#[derive(Deserialize)]
104struct GrantRow {
105 repo_id: String,
106 workspace: String,
107 role: String,
108}
109
110#[derive(Clone, Debug, Default, Deserialize)]
111pub struct InvitationRow {
112 pub id: String,
113 pub repo_id: String,
114 pub workspace: String,
115 pub workspace_id: String,
116 pub repo_name: String,
117 pub invitee: Option<String>,
118 pub email: Option<String>,
119 pub invite_id: Option<String>,
120 pub role: String,
121 pub inviter_id: Option<String>,
122 pub inviter: Option<String>,
123 #[serde(default)]
124 pub inviter_avatar: Option<String>,
125 pub created_at: String,
126 pub expires_at: String,
127 pub accepted_at: Option<String>,
128 pub declined_at: Option<String>,
129 pub revoked_at: Option<String>,
130}
131
132impl InvitationRow {
133 fn role(&self) -> RepoRole {
134 RepoRole::parse(&self.role).unwrap_or(RepoRole::Read)
135 }
136
137 fn shown(&self, now: &str, with_email: bool) -> RepoInvitation {
138 RepoInvitation {
139 id: self.id.clone(),
140 repo: format!("{}/{}", self.workspace, self.repo_name),
141 repo_id: self.repo_id.clone(),
142 invitee: self.invitee.clone(),
143 email: if with_email { self.email.clone() } else { None },
144 role: self.role(),
145 invited_by: self.inviter.clone(),
146 inviter_avatar: self.inviter_avatar.clone(),
147 status: invitation_status(self, now),
148 created_at: self.created_at.clone(),
149 expires_at: self.expires_at.clone(),
150 }
151 }
152}
153
154const INVITATION_COLUMNS: &str = "ri.id, ri.repo_id, w.slug AS workspace, ri.workspace_id, ri.repo_name,
155 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,
156 ri.created_at, ri.expires_at, ri.accepted_at, ri.declined_at, ri.revoked_at
157 FROM repo_invitations ri
158 JOIN workspaces w ON w.id = ri.workspace_id
159 LEFT JOIN users invitee ON invitee.id = ri.invitee_id
160 LEFT JOIN users inviter ON inviter.id = ri.inviter_id";
161
162/// A person as an access list shows them.
163#[derive(Deserialize)]
164struct PersonRow {
165 username: String,
166 name: Option<String>,
167 avatar: Option<String>,
168 /// `owner` or `member`; null when they are not in the workspace.
169 #[serde(default)]
170 workspace_role: Option<String>,
171 /// Their direct grant on the repository, if any.
172 #[serde(default)]
173 direct: Option<String>,
174}
175
176#[derive(Deserialize)]
177struct Id {
178 id: String,
179}
180
181#[derive(Deserialize)]
182struct Base {
183 base_permission: String,
184}
185
186/// A repository by id and path: what events and the audit log name.
187#[derive(Clone, Copy)]
188struct Named<'a> {
189 id: &'a str,
190 namespace: &'a str,
191 name: &'a str,
192}
193
194impl<'a> From<&'a Repo> for Named<'a> {
195 fn from(repo: &'a Repo) -> Self {
196 Named {
197 id: &repo.id,
198 namespace: &repo.namespace,
199 name: &repo.name,
200 }
201 }
202}
203
204/// A repository someone may manage the access of, with its workspace's id.
205struct Target {
206 repo: Repo,
207 workspace_id: String,
208}
209
210fn opt(value: Option<&str>) -> JsValue {
211 value.map_or(JsValue::NULL, JsValue::from)
212}
213
214fn full_name(repo: &Repo) -> String {
215 format!("{}/{}", repo.namespace, repo.name)
216}
217
218impl Identity {
219 /// Every repository `user_id` has a role on directly, with the slug of
220 /// its workspace now. Attached to every user resolved from credentials.
221 pub async fn grants_of(&self, user_id: &str) -> Result<Vec<RepoGrant>> {
222 let rows = self
223 .db
224 .prepare(format!(
225 "SELECT g.repo_id, w.slug AS workspace, g.role FROM repo_grants g
226 JOIN workspaces w ON w.id = g.workspace_id
227 WHERE g.principal_kind = 'user' AND g.principal_id = ?
228 ORDER BY g.created_at LIMIT {MAX_GRANTS}"
229 ))
230 .bind(&[user_id.into()])?
231 .all()
232 .await?
233 .results::<GrantRow>()?;
234 Ok(rows
235 .into_iter()
236 .filter_map(|row| {
237 Some(RepoGrant {
238 repo_id: row.repo_id,
239 workspace: row.workspace,
240 role: RepoRole::parse(&row.role)?,
241 })
242 })
243 .collect())
244 }
245
246 /// The repository at `path` as `viewer` sees it: missing when they
247 /// cannot read it. Asked of repos, which owns visibility.
248 async fn repo_for(&self, path: &RepoPath, viewer: &Viewer) -> Result<Option<Repo>> {
249 let repos = self.env.service("REPOS")?;
250 let found: Outcome<Repo> = g1t_kit::call(
251 &repos,
252 "get",
253 &GetArgs {
254 path: path.clone(),
255 viewer: viewer.clone(),
256 },
257 )
258 .await?;
259 Ok(match found {
260 // A pull request's working copy has no access of its own.
261 Outcome::Ok(repo) if repo.fork_of.is_none() => Some(repo),
262 _ => None,
263 })
264 }
265
266 pub(crate) async fn workspace_id_of(&self, slug: &str) -> Result<Option<String>> {
267 Ok(self
268 .db
269 .prepare("SELECT id FROM workspaces WHERE slug = ?")
270 .bind(&[slug.to_lowercase().into()])?
271 .first::<Id>(None)
272 .await?
273 .map(|row| row.id))
274 }
275
276 async fn base_of(&self, workspace_id: &str) -> Result<BasePermission> {
277 Ok(self
278 .db
279 .prepare("SELECT base_permission FROM workspaces WHERE id = ?")
280 .bind(&[workspace_id.into()])?
281 .first::<Base>(None)
282 .await?
283 .and_then(|row| BasePermission::parse(&row.base_permission))
284 .unwrap_or_default())
285 }
286
287 /// The repository at `path`, if `actor` may change who has access to it.
288 async fn manageable(&self, actor: &User, path: &RepoPath) -> Result<Outcome<Target>> {
289 if !crate::security::is_person(actor) {
290 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
291 }
292 let viewer = Some(actor.clone());
293 let Some(repo) = self.repo_for(path, &viewer).await? else {
294 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
295 };
296 if !can(Some(actor), &repo, Capability::ManageAccess) {
297 return Ok(Outcome::fail(
298 FailureCode::Forbidden,
299 needs(Capability::ManageAccess, &full_name(&repo)),
300 ));
301 }
302 if !actor.verified {
303 return Ok(Outcome::fail(FailureCode::Forbidden, CONFIRM_FIRST));
304 }
305 let Some(workspace_id) = self.workspace_id_of(&repo.namespace).await? else {
306 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
307 };
308 Ok(Outcome::Ok(Target { repo, workspace_id }))
309 }
310
311 /// The members of the repository's workspace and the people with a
312 /// direct grant on it, each once.
313 async fn people_rows(&self, repo_id: &str, workspace_id: &str) -> Result<Vec<PersonRow>> {
314 self.db
315 .prepare(format!(
316 "SELECT u.id, u.username, u.display_name AS name, u.avatar,
317 m.role AS workspace_role, g.role AS direct
318 FROM users u
319 LEFT JOIN workspace_members m ON m.user_id = u.id AND m.workspace_id = ?2
320 LEFT JOIN repo_grants g ON g.principal_kind = 'user' AND g.principal_id = u.id AND g.repo_id = ?1
321 WHERE m.user_id IS NOT NULL OR g.principal_id IS NOT NULL
322 ORDER BY u.username LIMIT {LIST_LIMIT}"
323 ))
324 .bind(&[repo_id.into(), workspace_id.into()])?
325 .all()
326 .await?
327 .results::<PersonRow>()
328 }
329
330 fn collaborator(row: PersonRow, base: BasePermission) -> Option<Collaborator> {
331 let workspace_role = match row.workspace_role.as_deref() {
332 Some("owner") => Some(Role::Owner),
333 Some(_) => Some(Role::Member),
334 None => None,
335 };
336 let direct = row.direct.as_deref().and_then(RepoRole::parse);
337 let base_role = workspace_role.and(base.role());
338 let (role, source) = effective(workspace_role == Some(Role::Owner), base_role, direct)?;
339 Some(Collaborator {
340 username: row.username,
341 name: row.name,
342 avatar: row.avatar,
343 role,
344 source,
345 direct,
346 workspace_role,
347 })
348 }
349
350 async fn invitations(&self, filter: &str, binds: &[JsValue]) -> Result<Vec<InvitationRow>> {
351 self.db
352 .prepare(format!("SELECT {INVITATION_COLUMNS} {filter} ORDER BY ri.created_at DESC LIMIT {LIST_LIMIT}"))
353 .bind(binds)?
354 .all()
355 .await?
356 .results::<InvitationRow>()
357 }
358
359 async fn pending_invitations(&self, filter: &str, binds: &[JsValue]) -> Result<Vec<InvitationRow>> {
360 let filter = format!(
361 "{filter} AND ri.accepted_at IS NULL AND ri.declined_at IS NULL AND ri.revoked_at IS NULL
362 AND ri.expires_at > {SQL_NOW}"
363 );
364 self.invitations(&filter, binds).await
365 }
366
367 pub async fn repo_access(&self, a: RepoAccessArgs) -> Result<Outcome<RepoAccess>> {
368 let Some(repo) = self.repo_for(&a.path, &a.viewer).await? else {
369 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
370 };
371 let viewer_role = a.viewer.as_ref().and_then(|viewer| granted(viewer, (&repo).into()));
372 // Like the list of collaborators: for those who can push.
373 if !viewer_role.is_some_and(|role| role >= RepoRole::Write) {
374 return Ok(Outcome::fail(
375 FailureCode::Forbidden,
376 format!("You need the Write role or higher on {} to see who has access.", full_name(&repo)),
377 ));
378 }
379 let can_manage = can(a.viewer.as_ref(), &repo, Capability::ManageAccess);
380 let Some(workspace_id) = self.workspace_id_of(&repo.namespace).await? else {
381 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
382 };
383 let base = self.base_of(&workspace_id).await?;
384 let mut people: Vec<Collaborator> = self
385 .people_rows(&repo.id, &workspace_id)
386 .await?
387 .into_iter()
388 .filter_map(|row| Self::collaborator(row, base))
389 .collect();
390 people.sort_by(|a, b| b.role.cmp(&a.role).then_with(|| a.username.cmp(&b.username)));
391 let invitations = if can_manage {
392 let now = rfc3339(now_ms());
393 self.pending_invitations("WHERE ri.repo_id = ?", &[repo.id.as_str().into()])
394 .await?
395 .iter()
396 .map(|row| row.shown(&now, true))
397 .collect()
398 } else {
399 Vec::new()
400 };
401 Ok(Outcome::Ok(RepoAccess {
402 repo: full_name(&repo),
403 base_permission: base,
404 people,
405 invitations,
406 viewer_role,
407 can_manage,
408 }))
409 }
410
411 /// One person's place on the repository, as the access list shows it.
412 async fn collaborator_on(&self, repo: &Repo, workspace_id: &str, user_id: &str) -> Result<Option<Collaborator>> {
413 let base = self.base_of(workspace_id).await?;
414 let row = self
415 .db
416 .prepare(
417 "SELECT u.id, u.username, u.display_name AS name, u.avatar,
418 m.role AS workspace_role, g.role AS direct
419 FROM users u
420 LEFT JOIN workspace_members m ON m.user_id = u.id AND m.workspace_id = ?2
421 LEFT JOIN repo_grants g ON g.principal_kind = 'user' AND g.principal_id = u.id AND g.repo_id = ?1
422 WHERE u.id = ?3",
423 )
424 .bind(&[repo.id.as_str().into(), workspace_id.into(), user_id.into()])?
425 .first::<PersonRow>(None)
426 .await?;
427 Ok(row.and_then(|row| Self::collaborator(row, base)))
428 }
429
430 async fn person_by_username(&self, username: &str) -> Result<Option<(String, String)>> {
431 #[derive(Deserialize)]
432 struct Person {
433 id: String,
434 username: String,
435 }
436 Ok(self
437 .db
438 .prepare("SELECT id, username FROM users WHERE username = ?")
439 .bind(&[username.trim().trim_start_matches('@').to_lowercase().into()])?
440 .first::<Person>(None)
441 .await?
442 .map(|person| (person.id, person.username)))
443 }
444
445 async fn is_member_of(&self, workspace_id: &str, user_id: &str) -> Result<bool> {
446 Ok(self
447 .db
448 .prepare("SELECT user_id AS id FROM workspace_members WHERE workspace_id = ? AND user_id = ?")
449 .bind(&[workspace_id.into(), user_id.into()])?
450 .first::<Id>(None)
451 .await?
452 .is_some())
453 }
454
455 async fn direct_role(&self, repo_id: &str, user_id: &str) -> Result<Option<RepoRole>> {
456 #[derive(Deserialize)]
457 struct RoleRow {
458 role: String,
459 }
460 Ok(self
461 .db
462 .prepare("SELECT role FROM repo_grants WHERE repo_id = ? AND principal_kind = 'user' AND principal_id = ?")
463 .bind(&[repo_id.into(), user_id.into()])?
464 .first::<RoleRow>(None)
465 .await?
466 .and_then(|row| RepoRole::parse(&row.role)))
467 }
468
469 /// Gives `user_id` `role` on the repository, or changes the role they
470 /// have; returns the role they had before.
471 async fn put_grant(
472 &self,
473 repo_id: &str,
474 workspace_id: &str,
475 repo_name: &str,
476 user_id: &str,
477 role: RepoRole,
478 granted_by: Option<&str>,
479 ) -> Result<Option<RepoRole>> {
480 let previous = self.direct_role(repo_id, user_id).await?;
481 let now = rfc3339(now_ms());
482 self.db
483 .prepare(
484 "INSERT INTO repo_grants
485 (repo_id, principal_kind, principal_id, workspace_id, repo_name, role, granted_by, created_at, updated_at)
486 VALUES (?1, 'user', ?2, ?3, ?4, ?5, ?6, ?7, ?7)
487 ON CONFLICT (repo_id, principal_kind, principal_id)
488 DO UPDATE SET role = excluded.role, workspace_id = excluded.workspace_id,
489 repo_name = excluded.repo_name, updated_at = excluded.updated_at",
490 )
491 .bind(&[
492 repo_id.into(),
493 user_id.into(),
494 workspace_id.into(),
495 repo_name.into(),
496 role.as_str().into(),
497 opt(granted_by),
498 now.as_str().into(),
499 ])?
500 .run()
501 .await?;
502 Ok(previous)
503 }
504
505 pub async fn add_collaborator(&self, a: AddCollaboratorArgs) -> Result<Outcome<Added>> {
506 let Target { repo, workspace_id } = match self.manageable(&a.actor, &a.path).await? {
507 Outcome::Ok(target) => target,
508 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
509 };
510 let surface = a.surface.unwrap_or(Surface::Web);
511 let invitee = match invitee(&a.invitee) {
512 Some(invitee) => invitee,
513 None => return Ok(Outcome::fail(FailureCode::Invalid, "Enter a username or an email address.")),
514 };
515 // Who it names: an account by username, or by a confirmed address.
516 let person = match &invitee {
517 Invitee::Username(name) => match self.person_by_username(name).await? {
518 Some(person) => Some(person),
519 None => return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER)),
520 },
521 Invitee::Email(email) => match self.user_with_verified_email(email).await? {
522 Some(id) => self
523 .find_public_user(
524 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ?",
525 &id,
526 )
527 .await?
528 .map(|user| (user.id, user.username)),
529 None => None,
530 },
531 };
532 let Some((user_id, username)) = person else {
533 let Invitee::Email(email) = invitee else {
534 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
535 };
536 return self.invite_address(&a.actor, &repo, &workspace_id, &email, a.role, surface).await;
537 };
538 // What the workspace asks of anyone with access to it (security.rs).
539 if let Some(why) = self.policy_refusal(&user_id, &repo.namespace).await? {
540 return Ok(Outcome::fail(FailureCode::Forbidden, why));
541 }
542 if self.is_member_of(&workspace_id, &user_id).await? {
543 let previous = self
544 .put_grant(&repo.id, &workspace_id, &repo.name, &user_id, a.role, Some(&a.actor.id))
545 .await?;
546 self.changed(&a.actor, (&repo).into(), &username, Some(a.role), previous, surface).await;
547 let Some(collaborator) = self.collaborator_on(&repo, &workspace_id, &user_id).await? else {
548 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
549 };
550 return Ok(Outcome::Ok(Added::Granted { collaborator }));
551 }
552 if self.direct_role(&repo.id, &user_id).await?.is_some() {
553 return Ok(Outcome::fail(
554 FailureCode::Conflict,
555 format!("{username} already has access to {}. Change their role instead.", full_name(&repo)),
556 ));
557 }
558 let pending = self
559 .pending_invitations(
560 "WHERE ri.repo_id = ? AND ri.invitee_id = ?",
561 &[repo.id.as_str().into(), user_id.as_str().into()],
562 )
563 .await?;
564 if !pending.is_empty() {
565 return Ok(Outcome::fail(
566 FailureCode::Conflict,
567 format!("{username} already has a pending invitation to {}. Change its role, or revoke it to send a new one.", full_name(&repo)),
568 ));
569 }
570 let id = self
571 .insert_invitation(&repo, &workspace_id, Some(&user_id), None, None, a.role, &a.actor.id, INVITATION_DAYS)
572 .await?;
573 let now = rfc3339(now_ms());
574 let Some(row) = self.invitation_by_id(&id).await? else {
575 return Ok(Outcome::fail(FailureCode::NotFound, "Invitation not found."));
576 };
577 // Told by email, at their primary address and the one typed.
578 let mut to = self.notice_recipients(&user_id, false).await.unwrap_or_default();
579 if let Invitee::Email(email) = &invitee
580 && !to.iter().any(|address| address.eq_ignore_ascii_case(email))
581 {
582 to.push(email.clone());
583 }
584 for address in to.iter().take(2) {
585 if let Err(error) = crate::email::send_repo_invite(
586 &self.env,
587 address,
588 &a.actor.username,
589 &full_name(&repo),
590 a.role.label(),
591 None,
592 INVITATION_DAYS,
593 )
594 .await
595 {
596 worker::console_error!("repository invitation email failed: {error}");
597 }
598 }
599 self.audit(&a.actor, "repo.invitation_created", (&repo).into(), surface, format!("Invited {username} as {}", a.role.label()))
600 .await;
601 Ok(Outcome::Ok(Added::Invited {
602 invitation: row.shown(&now, true),
603 }))
604 }
605
606 /// An address without an account: an invite code that makes it and
607 /// accepts (invites.rs).
608 async fn invite_address(
609 &self,
610 actor: &User,
611 repo: &Repo,
612 workspace_id: &str,
613 email: &str,
614 role: RepoRole,
615 surface: Surface,
616 ) -> Result<Outcome<Added>> {
617 let pending = self
618 .pending_invitations(
619 "WHERE ri.repo_id = ? AND ri.email = ?",
620 &[repo.id.as_str().into(), email.into()],
621 )
622 .await?;
623 if !pending.is_empty() {
624 return Ok(Outcome::fail(
625 FailureCode::Conflict,
626 "That address already has a pending invitation to this repository. Revoke it to send a new one.",
627 ));
628 }
629 let invite = match self.repo_invite_code(actor, email, workspace_id).await? {
630 Outcome::Ok(invite) => invite,
631 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
632 };
633 let days = self.invite_days();
634 let id = self
635 .insert_invitation(repo, workspace_id, None, Some(email), Some(&invite.id), role, &actor.id, days)
636 .await?;
637 if let Some(code) = &invite.code
638 && let Err(error) = crate::email::send_repo_invite(
639 &self.env,
640 email,
641 &actor.username,
642 &full_name(repo),
643 role.label(),
644 Some(code),
645 days,
646 )
647 .await
648 {
649 worker::console_error!("repository invitation email failed: {error}");
650 }
651 self.audit(
652 actor,
653 "repo.invitation_created",
654 repo.into(),
655 surface,
656 format!("Invited {} as {}", crate::invites::mask_email(email), role.label()),
657 )
658 .await;
659 let now = rfc3339(now_ms());
660 Ok(match self.invitation_by_id(&id).await? {
661 Some(row) => Outcome::Ok(Added::Invited {
662 invitation: row.shown(&now, true),
663 }),
664 None => Outcome::fail(FailureCode::NotFound, "Invitation not found."),
665 })
666 }
667
668 #[allow(clippy::too_many_arguments)]
669 async fn insert_invitation(
670 &self,
671 repo: &Repo,
672 workspace_id: &str,
673 invitee_id: Option<&str>,
674 email: Option<&str>,
675 invite_id: Option<&str>,
676 role: RepoRole,
677 inviter_id: &str,
678 days: u64,
679 ) -> Result<String> {
680 let now = now_ms();
681 let id = new_id("rin", now);
682 self.db
683 .prepare(
684 "INSERT INTO repo_invitations
685 (id, repo_id, workspace_id, repo_name, invitee_id, email, invite_id, role, inviter_id, created_at, expires_at)
686 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
687 )
688 .bind(&[
689 id.as_str().into(),
690 repo.id.as_str().into(),
691 workspace_id.into(),
692 repo.name.as_str().into(),
693 opt(invitee_id),
694 opt(email),
695 opt(invite_id),
696 role.as_str().into(),
697 inviter_id.into(),
698 rfc3339(now).into(),
699 rfc3339(now + days * 86_400_000).into(),
700 ])?
701 .run()
702 .await?;
703 Ok(id)
704 }
705
706 async fn invitation_by_id(&self, id: &str) -> Result<Option<InvitationRow>> {
707 Ok(self
708 .invitations("WHERE ri.id = ?", &[id.into()])
709 .await?
710 .into_iter()
711 .next())
712 }
713
714 fn invite_days(&self) -> u64 {
715 self.env
716 .var("INVITE_TTL_DAYS")
717 .ok()
718 .and_then(|value| value.to_string().parse().ok())
719 .unwrap_or(g1t_contracts::identity::INVITE_TTL_DAYS)
720 }
721
722 pub async fn set_collaborator_role(&self, a: SetCollaboratorRoleArgs) -> Result<Outcome<Collaborator>> {
723 let Target { repo, workspace_id } = match self.manageable(&a.actor, &a.path).await? {
724 Outcome::Ok(target) => target,
725 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
726 };
727 let Some((user_id, username)) = self.person_by_username(&a.username).await? else {
728 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
729 };
730 let surface = a.surface.unwrap_or(Surface::Web);
731 match self.direct_role(&repo.id, &user_id).await? {
732 Some(previous) => {
733 self.put_grant(&repo.id, &workspace_id, &repo.name, &user_id, a.role, Some(&a.actor.id))
734 .await?;
735 if previous != a.role {
736 self.changed(&a.actor, (&repo).into(), &username, Some(a.role), Some(previous), surface).await;
737 }
738 }
739 None => {
740 // A pending invitation's role changes until it is answered.
741 let changed = self
742 .db
743 .prepare(format!(
744 "UPDATE repo_invitations SET role = ?1
745 WHERE repo_id = ?2 AND invitee_id = ?3 AND accepted_at IS NULL AND declined_at IS NULL
746 AND revoked_at IS NULL AND expires_at > {SQL_NOW}
747 RETURNING id"
748 ))
749 .bind(&[a.role.as_str().into(), repo.id.as_str().into(), user_id.as_str().into()])?
750 .first::<Id>(None)
751 .await?;
752 if changed.is_none() {
753 return Ok(Outcome::fail(
754 FailureCode::NotFound,
755 format!(
756 "{username} has no role of their own on {}. Owners have Admin, and members the base permission; add them to give them more.",
757 full_name(&repo)
758 ),
759 ));
760 }
761 }
762 }
763 Ok(match self.collaborator_on(&repo, &workspace_id, &user_id).await? {
764 Some(collaborator) => Outcome::Ok(collaborator),
765 // Invited, not yet a collaborator: say what they will be.
766 None => Outcome::Ok(Collaborator {
767 username,
768 name: None,
769 avatar: None,
770 role: a.role,
771 source: AccessSource::Direct,
772 direct: Some(a.role),
773 workspace_role: None,
774 }),
775 })
776 }
777
778 pub async fn remove_collaborator(&self, a: RemoveCollaboratorArgs) -> Result<Outcome<bool>> {
779 let Some((user_id, username)) = self.person_by_username(&a.username).await? else {
780 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
781 };
782 let surface = a.surface.unwrap_or(Surface::Web);
783 // Anyone may give up their own role; otherwise, Admin only.
784 let leaving = crate::security::is_person(&a.actor) && a.actor.id == user_id;
785 let repo = if leaving {
786 match self.repo_for(&a.path, &Some(a.actor.clone())).await? {
787 Some(repo) => repo,
788 None => return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found.")),
789 }
790 } else {
791 match self.manageable(&a.actor, &a.path).await? {
792 Outcome::Ok(target) => target.repo,
793 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
794 }
795 };
796 let Some(previous) = self.direct_role(&repo.id, &user_id).await? else {
797 return Ok(Outcome::fail(
798 FailureCode::NotFound,
799 format!(
800 "{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.",
801 full_name(&repo)
802 ),
803 ));
804 };
805 self.db
806 .batch(vec![
807 self.db
808 .prepare("DELETE FROM repo_grants WHERE repo_id = ? AND principal_kind = 'user' AND principal_id = ?")
809 .bind(&[repo.id.as_str().into(), user_id.as_str().into()])?,
810 self.db
811 .prepare(format!(
812 "UPDATE repo_invitations SET revoked_at = {SQL_NOW}
813 WHERE repo_id = ? AND invitee_id = ? AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL"
814 ))
815 .bind(&[repo.id.as_str().into(), user_id.as_str().into()])?,
816 ])
817 .await?;
818 self.changed(&a.actor, (&repo).into(), &username, None, Some(previous), surface).await;
819 Ok(Outcome::Ok(true))
820 }
821
822 pub async fn collaborator_permission(&self, a: CollaboratorPermissionArgs) -> Result<Outcome<PermissionInfo>> {
823 let Some(repo) = self.repo_for(&a.path, &a.viewer).await? else {
824 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
825 };
826 let asking_about_self = a
827 .viewer
828 .as_ref()
829 .is_some_and(|viewer| viewer.username.eq_ignore_ascii_case(a.username.trim()));
830 if !asking_about_self && !can(a.viewer.as_ref(), &repo, Capability::Push) {
831 return Ok(Outcome::fail(
832 FailureCode::Forbidden,
833 format!("You need the Write role or higher on {} to see others' permissions.", full_name(&repo)),
834 ));
835 }
836 let Some((user_id, username)) = self.person_by_username(&a.username).await? else {
837 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
838 };
839 let Some(workspace_id) = self.workspace_id_of(&repo.namespace).await? else {
840 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
841 };
842 // Held to the workspace's policy as their requests are.
843 let within = self.policy_refusal(&user_id, &repo.namespace).await?.is_none();
844 let place = if within {
845 self.collaborator_on(&repo, &workspace_id, &user_id).await?
846 } else {
847 None
848 };
849 let role = place.as_ref().map(|place| place.role);
850 Ok(Outcome::Ok(PermissionInfo {
851 username,
852 role,
853 source: place.map(|place| place.source),
854 capabilities: capabilities_of(role),
855 }))
856 }
857
858 pub async fn my_repo_invitations(&self, a: MyRepoInvitationsArgs) -> Result<Vec<RepoInvitation>> {
859 if !crate::security::is_person(&a.user) {
860 return Ok(Vec::new());
861 }
862 let now = rfc3339(now_ms());
863 Ok(self
864 .invitations_for(&a.user, None)
865 .await?
866 .iter()
867 .map(|row| row.shown(&now, false))
868 .collect())
869 }
870
871 /// The pending invitations for `user`: sent to them, or to one of
872 /// their confirmed addresses before they had an account (and made it
873 /// some other way than with the code). `id` narrows it to one.
874 async fn invitations_for(&self, user: &User, id: Option<&str>) -> Result<Vec<InvitationRow>> {
875 let emails = serde_json::to_string(&self.verified_emails(&user.id).await?)?;
876 let mut filter = "WHERE (ri.invitee_id = ? OR (ri.invitee_id IS NULL AND ri.email IN (SELECT value FROM json_each(?))))".to_owned();
877 let mut binds = vec![JsValue::from(user.id.as_str()), emails.into()];
878 if let Some(id) = id {
879 filter.push_str(" AND ri.id = ?");
880 binds.push(id.into());
881 }
882 self.pending_invitations(&filter, &binds).await
883 }
884
885 pub async fn respond_repo_invitation(&self, a: RespondRepoInvitationArgs) -> Result<Outcome<RepoInvitation>> {
886 if !crate::security::is_person(&a.user) {
887 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can answer an invitation."));
888 }
889 let now = rfc3339(now_ms());
890 let row = self
891 .invitations_for(&a.user, Some(&a.id))
892 .await?
893 .into_iter()
894 .next();
895 let Some(row) = row else {
896 return Ok(Outcome::fail(
897 FailureCode::NotFound,
898 "There is no pending invitation of yours with that id. It may have expired or been revoked.",
899 ));
900 };
901 if !a.accept {
902 self.db
903 .prepare(format!("UPDATE repo_invitations SET declined_at = {SQL_NOW} WHERE id = ?"))
904 .bind(&[row.id.as_str().into()])?
905 .run()
906 .await?;
907 let mut shown = row.shown(&now, false);
908 shown.status = RepoInvitationStatus::Declined;
909 return Ok(Outcome::Ok(shown));
910 }
911 if let Some(why) = self.policy_refusal(&a.user.id, &row.workspace).await? {
912 return Ok(Outcome::fail(FailureCode::Forbidden, why));
913 }
914 self.accept(&row, &a.user).await?;
915 let mut shown = row.shown(&now, false);
916 shown.status = RepoInvitationStatus::Accepted;
917 Ok(Outcome::Ok(shown))
918 }
919
920 /// Turns an invitation into a grant, once.
921 async fn accept(&self, row: &InvitationRow, user: &User) -> Result<()> {
922 let claimed = self
923 .db
924 .prepare(format!(
925 "UPDATE repo_invitations SET accepted_at = {SQL_NOW}, invitee_id = ?1
926 WHERE id = ?2 AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL
927 RETURNING id"
928 ))
929 .bind(&[user.id.as_str().into(), row.id.as_str().into()])?
930 .first::<Id>(None)
931 .await?;
932 if claimed.is_none() {
933 return Ok(());
934 }
935 let role = row.role();
936 // Never lowers a role they already have.
937 let current = self.direct_role(&row.repo_id, &user.id).await?;
938 let role = current.map_or(role, |current| current.max(role));
939 let previous = self
940 .put_grant(&row.repo_id, &row.workspace_id, &row.repo_name, &user.id, role, row.inviter_id.as_deref())
941 .await?;
942 let repo = Named {
943 id: &row.repo_id,
944 namespace: &row.workspace,
945 name: &row.repo_name,
946 };
947 self.changed(user, repo, &user.username, Some(role), previous, Surface::Web).await;
948 Ok(())
949 }
950
951 /// The repository an invite code was sent with, for the invite's page
952 /// (invites.rs): whatever became of the invitation since.
953 pub(crate) async fn repository_of_code(
954 &self,
955 invite_id: &str,
956 ) -> Result<Option<g1t_contracts::identity::InviteRepository>> {
957 Ok(self
958 .invitations("WHERE ri.invite_id = ?", &[invite_id.into()])
959 .await?
960 .into_iter()
961 .next()
962 .map(|row| g1t_contracts::identity::InviteRepository {
963 name: format!("{}/{}", row.workspace, row.repo_name),
964 role: row.role().as_str().to_owned(),
965 }))
966 }
967
968 /// Accepts the repository invitations sent with an invite code, once
969 /// the code made `user`'s account (invites.rs).
970 pub(crate) async fn accept_invitations_of_code(&self, invite_id: &str, user: &User) -> Result<()> {
971 let rows = self
972 .pending_invitations("WHERE ri.invite_id = ?", &[invite_id.into()])
973 .await?;
974 for row in rows {
975 if self.policy_refusal(&user.id, &row.workspace).await?.is_some() {
976 continue;
977 }
978 self.accept(&row, user).await?;
979 }
980 Ok(())
981 }
982
983 pub async fn revoke_repo_invitation(&self, a: RevokeRepoInvitationArgs) -> Result<Outcome<RepoInvitation>> {
984 let Target { repo, .. } = match self.manageable(&a.actor, &a.path).await? {
985 Outcome::Ok(target) => target,
986 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
987 };
988 let revoked = self
989 .db
990 .prepare(format!(
991 "UPDATE repo_invitations SET revoked_at = {SQL_NOW}
992 WHERE id = ? AND repo_id = ? AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL
993 RETURNING id"
994 ))
995 .bind(&[a.id.as_str().into(), repo.id.as_str().into()])?
996 .first::<Id>(None)
997 .await?;
998 if revoked.is_none() {
999 return Ok(Outcome::fail(FailureCode::NotFound, "There is no pending invitation with that id."));
1000 }
1001 let Some(row) = self.invitation_by_id(&a.id).await? else {
1002 return Ok(Outcome::fail(FailureCode::NotFound, "Invitation not found."));
1003 };
1004 if let Some(invite_id) = &row.invite_id {
1005 self.revoke_code(invite_id).await?;
1006 }
1007 let who = row
1008 .invitee
1009 .clone()
1010 .or_else(|| row.email.as_deref().map(crate::invites::mask_email))
1011 .unwrap_or_default();
1012 self.audit(
1013 &a.actor,
1014 "repo.invitation_revoked",
1015 (&repo).into(),
1016 a.surface.unwrap_or(Surface::Web),
1017 format!("Revoked the invitation to {who}"),
1018 )
1019 .await;
1020 Ok(Outcome::Ok(row.shown(&rfc3339(now_ms()), true)))
1021 }
1022
1023 pub async fn set_base_permission(&self, a: SetBasePermissionArgs) -> Result<Outcome<BasePermission>> {
1024 let slug = a.slug.trim().to_lowercase();
1025 if !crate::security::is_person(&a.actor) || a.actor.role_in(&slug) != Some(Role::Owner) {
1026 return Ok(Outcome::fail(
1027 FailureCode::Forbidden,
1028 "Only an owner can change what members get on every repository.",
1029 ));
1030 }
1031 if !a.actor.verified {
1032 return Ok(Outcome::fail(FailureCode::Forbidden, CONFIRM_FIRST));
1033 }
1034 let Some(workspace_id) = self.workspace_id_of(&slug).await? else {
1035 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1036 };
1037 let previous = self.base_of(&workspace_id).await?;
1038 self.db
1039 .prepare("UPDATE workspaces SET base_permission = ? WHERE id = ?")
1040 .bind(&[a.base_permission.as_str().into(), workspace_id.as_str().into()])?
1041 .run()
1042 .await?;
1043 if previous != a.base_permission {
1044 self.audit_workspace(
1045 &a.actor,
1046 "workspace.base_permission_changed",
1047 &slug,
1048 a.surface.unwrap_or(Surface::Web),
1049 format!(
1050 "Changed the base permission from {} to {}",
1051 previous.as_str(),
1052 a.base_permission.as_str()
1053 ),
1054 )
1055 .await;
1056 self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await;
1057 }
1058 Ok(Outcome::Ok(a.base_permission))
1059 }
1060
1061 pub async fn outside_collaborators(&self, a: OutsideCollaboratorsArgs) -> Result<Outcome<Vec<OutsideCollaborator>>> {
1062 let slug = a.slug.trim().to_lowercase();
1063 if !a.viewer.as_ref().is_some_and(|viewer| viewer.role_in(&slug) == Some(Role::Owner)) {
1064 return Ok(Outcome::fail(FailureCode::Forbidden, "Only owners can see a workspace's outside collaborators."));
1065 }
1066 let Some(workspace_id) = self.workspace_id_of(&slug).await? else {
1067 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1068 };
1069 #[derive(Deserialize)]
1070 struct Row {
1071 username: String,
1072 name: Option<String>,
1073 avatar: Option<String>,
1074 repo_name: String,
1075 role: String,
1076 }
1077 let rows = self
1078 .db
1079 .prepare(format!(
1080 "SELECT u.username, u.display_name AS name, u.avatar, g.repo_name, g.role
1081 FROM repo_grants g JOIN users u ON u.id = g.principal_id
1082 WHERE g.workspace_id = ?1 AND g.principal_kind = 'user'
1083 AND NOT EXISTS (SELECT 1 FROM workspace_members m WHERE m.workspace_id = ?1 AND m.user_id = g.principal_id)
1084 ORDER BY u.username, g.repo_name LIMIT {LIST_LIMIT}"
1085 ))
1086 .bind(&[workspace_id.as_str().into()])?
1087 .all()
1088 .await?
1089 .results::<Row>()?;
1090 let mut people: Vec<OutsideCollaborator> = Vec::new();
1091 for row in rows {
1092 let Some(role) = RepoRole::parse(&row.role) else {
1093 continue;
1094 };
1095 let repo = CollaboratorRepo {
1096 repo: format!("{slug}/{}", row.repo_name),
1097 role,
1098 };
1099 match people.last_mut().filter(|person| person.username == row.username) {
1100 Some(person) => person.repos.push(repo),
1101 None => people.push(OutsideCollaborator {
1102 username: row.username,
1103 name: row.name,
1104 avatar: row.avatar,
1105 repos: vec![repo],
1106 }),
1107 }
1108 }
1109 Ok(Outcome::Ok(people))
1110 }
1111
1112 pub async fn forget_repo_access(&self, a: ForgetRepoAccessArgs) -> Result<bool> {
1113 self.db
1114 .batch(vec![
1115 self.db
1116 .prepare("DELETE FROM repo_grants WHERE repo_id = ?")
1117 .bind(&[a.repo_id.as_str().into()])?,
1118 self.db
1119 .prepare("DELETE FROM repo_invitations WHERE repo_id = ?")
1120 .bind(&[a.repo_id.as_str().into()])?,
1121 ])
1122 .await?;
1123 Ok(true)
1124 }
1125
1126 /// A repository moved or was renamed: its grants and invitations follow
1127 /// it (deletion.rs, `transfer_repo_scopes`).
1128 pub(crate) async fn move_repo_access(&self, from: &RepoPath, to: &RepoPath) -> Result<()> {
1129 let (Some(from_id), Some(to_id)) = (
1130 self.workspace_id_of(&from.namespace).await?,
1131 self.workspace_id_of(&to.namespace).await?,
1132 ) else {
1133 return Ok(());
1134 };
1135 let binds = [
1136 JsValue::from(to_id.as_str()),
1137 to.name.to_lowercase().into(),
1138 from_id.as_str().into(),
1139 from.name.to_lowercase().into(),
1140 ];
1141 self.db
1142 .batch(vec![
1143 self.db
1144 .prepare("UPDATE repo_grants SET workspace_id = ?1, repo_name = ?2 WHERE workspace_id = ?3 AND repo_name = ?4")
1145 .bind(&binds)?,
1146 self.db
1147 .prepare("UPDATE repo_invitations SET workspace_id = ?1, repo_name = ?2 WHERE workspace_id = ?3 AND repo_name = ?4")
1148 .bind(&binds)?,
1149 ])
1150 .await?;
1151 Ok(())
1152 }
1153
1154 // --- Telling others ---
1155
1156 /// Publishes the change of a person's own role, and records it in the
1157 /// workspace's audit log.
1158 async fn changed(
1159 &self,
1160 actor: &User,
1161 repo: Named<'_>,
1162 username: &str,
1163 role: Option<RepoRole>,
1164 previous: Option<RepoRole>,
1165 surface: Surface,
1166 ) {
1167 let (kind, message) = match (previous, role) {
1168 (None, Some(role)) => ("repo.collaborator_added", format!("Gave {username} the {} role", role.label())),
1169 (Some(previous), Some(role)) => (
1170 "repo.collaborator_role_changed",
1171 format!("Changed {username}'s role from {} to {}", previous.label(), role.label()),
1172 ),
1173 (Some(previous), None) => ("repo.collaborator_removed", format!("Removed {username}'s {} role", previous.label())),
1174 (None, None) => return,
1175 };
1176 self.publish_repo(
1177 kind,
1178 repo.id,
1179 &actor.id,
1180 RepoCollaborator {
1181 repo_id: repo.id.to_owned(),
1182 namespace: repo.namespace.to_owned(),
1183 name: repo.name.to_owned(),
1184 username: username.to_owned(),
1185 role,
1186 previous_role: previous,
1187 },
1188 )
1189 .await;
1190 self.audit(actor, kind, repo, surface, message).await;
1191 }
1192
1193 async fn publish_repo<T: Serialize>(&self, kind: &'static str, repo_id: &str, actor: &str, data: T) {
1194 let Ok(events) = self.env.service("EVENTS") else {
1195 return;
1196 };
1197 let publish = Publish {
1198 events: vec![NewEvent {
1199 kind,
1200 source: "identity",
1201 repo_id: Some(repo_id.to_owned()),
1202 actor: Some(actor.to_owned()),
1203 data,
1204 }],
1205 };
1206 if let Err(error) = g1t_kit::call::<_, serde_json::Value>(&events, "publish", &publish).await {
1207 worker::console_error!("{kind} not published: {error}");
1208 }
1209 }
1210
1211 async fn audit(&self, actor: &User, action: &str, repo: Named<'_>, surface: Surface, message: String) {
1212 let full = format!("{}/{}", repo.namespace, repo.name);
1213 self.record(actor, action, repo.namespace, Some(full), surface, message).await;
1214 }
1215
1216 async fn audit_workspace(&self, actor: &User, action: &str, slug: &str, surface: Surface, message: String) {
1217 self.record(actor, action, slug, None, surface, message).await;
1218 }
1219
1220 async fn record(&self, actor: &User, action: &str, workspace: &str, repo: Option<String>, surface: Surface, message: String) {
1221 let Ok(events) = self.env.service("EVENTS") else {
1222 return;
1223 };
1224 let entry = NewAuditEntry {
1225 actor: AuditActor::of(actor),
1226 action: action.to_owned(),
1227 surface,
1228 target: AuditTarget {
1229 workspace: workspace.to_lowercase(),
1230 repo,
1231 ..AuditTarget::default()
1232 },
1233 outcome: AuditOutcome::Allowed,
1234 rule: if actor.kind == PrincipalKind::User { "access" } else { "access:token" }.to_owned(),
1235 result: Some("ok".to_owned()),
1236 message: Some(message),
1237 request_id: new_id("req", now_ms()),
1238 };
1239 let recorded: Result<u32> =
1240 g1t_kit::call(&events, "audit_record", &RecordAuditArgs { entries: vec![entry] }).await;
1241 if let Err(error) = recorded {
1242 worker::console_error!("{action} not recorded: {error}");
1243 }
1244 }
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249 use super::*;
1250
1251 #[test]
1252 fn people_are_added_by_username_or_address() {
1253 assert_eq!(invitee(" Ada "), Some(Invitee::Username("ada".into())));
1254 assert_eq!(invitee("@ada"), Some(Invitee::Username("ada".into())));
1255 assert_eq!(invitee("Ada@Example.com"), Some(Invitee::Email("ada@example.com".into())));
1256 assert_eq!(invitee("not a name"), None);
1257 assert_eq!(invitee("ada@"), None);
1258 }
1259
1260 #[test]
1261 fn a_role_comes_from_ownership_the_base_or_a_grant() {
1262 use AccessSource::*;
1263 use RepoRole::*;
1264 assert_eq!(effective(true, Some(Read), Some(Write)), Some((Admin, Owner)));
1265 assert_eq!(effective(false, Some(Write), None), Some((Write, Base)));
1266 assert_eq!(effective(false, Some(Write), Some(Maintain)), Some((Maintain, Direct)));
1267 // A grant as high as the base is shown as direct, where it can be changed.
1268 assert_eq!(effective(false, Some(Write), Some(Write)), Some((Write, Direct)));
1269 assert_eq!(effective(false, Some(Admin), Some(Read)), Some((Admin, Base)));
1270 // An outside collaborator.
1271 assert_eq!(effective(false, None, Some(Triage)), Some((Triage, Direct)));
1272 // A member of a workspace whose base is none, with no grant.
1273 assert_eq!(effective(false, None, None), None);
1274 }
1275
1276 #[test]
1277 fn an_invitation_is_pending_until_answered_revoked_or_expired() {
1278 let row = InvitationRow {
1279 expires_at: "2026-10-12T00:00:00.000Z".into(),
1280 ..InvitationRow::default()
1281 };
1282 let now = "2026-10-05T00:00:00.000Z";
1283 assert_eq!(invitation_status(&row, now), RepoInvitationStatus::Pending);
1284 assert_eq!(invitation_status(&row, "2026-10-12T00:00:00.000Z"), RepoInvitationStatus::Expired);
1285 let accepted = InvitationRow { accepted_at: Some(now.into()), ..row.clone() };
1286 assert_eq!(invitation_status(&accepted, now), RepoInvitationStatus::Accepted);
1287 let declined = InvitationRow { declined_at: Some(now.into()), ..row.clone() };
1288 assert_eq!(invitation_status(&declined, now), RepoInvitationStatus::Declined);
1289 let revoked = InvitationRow { revoked_at: Some(now.into()), ..row };
1290 assert_eq!(invitation_status(&revoked, now), RepoInvitationStatus::Revoked);
1291 }
1292
1293 #[test]
1294 fn invitations_show_addresses_only_to_those_who_manage_access() {
1295 let row = InvitationRow {
1296 id: "rin_1".into(),
1297 workspace: "acme".into(),
1298 repo_name: "rocket".into(),
1299 email: Some("ada@example.com".into()),
1300 role: "triage".into(),
1301 expires_at: "2099-01-01T00:00:00.000Z".into(),
1302 ..InvitationRow::default()
1303 };
1304 let now = "2026-10-05T00:00:00.000Z";
1305 assert_eq!(row.shown(now, true).email.as_deref(), Some("ada@example.com"));
1306 assert_eq!(row.shown(now, false).email, None);
1307 assert_eq!(row.shown(now, false).repo, "acme/rocket");
1308 assert_eq!(row.shown(now, false).role, RepoRole::Triage);
1309 }
1310}