g1t/services/identity/src/access.rs

1,376 lines56,407 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 AND w.deleted_at IS NULL
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 AND w.deleted_at IS NULL
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 // A deleted workspace's repositories are nobody's to share.
270 .prepare("SELECT id FROM workspaces WHERE slug = ? AND deleted_at IS NULL")
271 .bind(&[slug.to_lowercase().into()])?
272 .first::<Id>(None)
273 .await?
274 .map(|row| row.id))
275 }
276
277 async fn base_of(&self, workspace_id: &str) -> Result<BasePermission> {
278 Ok(self
279 .db
280 .prepare("SELECT base_permission FROM workspaces WHERE id = ?")
281 .bind(&[workspace_id.into()])?
282 .first::<Base>(None)
283 .await?
284 .and_then(|row| BasePermission::parse(&row.base_permission))
285 .unwrap_or_default())
286 }
287
288 /// The repository at `path`, if `actor` may change who has access to it.
289 async fn manageable(&self, actor: &User, path: &RepoPath) -> Result<Outcome<Target>> {
290 if !crate::security::is_person(actor) {
291 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
292 }
293 let viewer = Some(actor.clone());
294 let Some(repo) = self.repo_for(path, &viewer).await? else {
295 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
296 };
297 if !can(Some(actor), &repo, Capability::ManageAccess) {
298 return Ok(Outcome::fail(
299 FailureCode::Forbidden,
300 needs(Capability::ManageAccess, &full_name(&repo)),
301 ));
302 }
303 if !actor.verified {
304 return Ok(Outcome::fail(FailureCode::Forbidden, CONFIRM_FIRST));
305 }
306 let Some(workspace_id) = self.workspace_id_of(&repo.namespace).await? else {
307 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
308 };
309 Ok(Outcome::Ok(Target { repo, workspace_id }))
310 }
311
312 /// The members of the repository's workspace and the people with a
313 /// direct grant on it, each once.
314 async fn people_rows(&self, repo_id: &str, workspace_id: &str) -> Result<Vec<PersonRow>> {
315 self.db
316 .prepare(format!(
317 "SELECT u.id, u.username, u.display_name AS name, u.avatar,
318 m.role AS workspace_role, g.role AS direct
319 FROM users u
320 LEFT JOIN workspace_members m ON m.user_id = u.id AND m.workspace_id = ?2
321 LEFT JOIN repo_grants g ON g.principal_kind = 'user' AND g.principal_id = u.id AND g.repo_id = ?1
322 WHERE m.user_id IS NOT NULL OR g.principal_id IS NOT NULL
323 ORDER BY u.username LIMIT {LIST_LIMIT}"
324 ))
325 .bind(&[repo_id.into(), workspace_id.into()])?
326 .all()
327 .await?
328 .results::<PersonRow>()
329 }
330
331 fn collaborator(row: PersonRow, base: BasePermission) -> Option<Collaborator> {
332 let workspace_role = match row.workspace_role.as_deref() {
333 Some("owner") => Some(Role::Owner),
334 Some(_) => Some(Role::Member),
335 None => None,
336 };
337 let direct = row.direct.as_deref().and_then(RepoRole::parse);
338 let base_role = workspace_role.and(base.role());
339 let (role, source) = effective(workspace_role == Some(Role::Owner), base_role, direct)?;
340 Some(Collaborator {
341 username: row.username,
342 name: row.name,
343 avatar: row.avatar,
344 role,
345 source,
346 direct,
347 workspace_role,
348 })
349 }
350
351 async fn invitations(&self, filter: &str, binds: &[JsValue]) -> Result<Vec<InvitationRow>> {
352 self.db
353 .prepare(format!("SELECT {INVITATION_COLUMNS} {filter} ORDER BY ri.created_at DESC LIMIT {LIST_LIMIT}"))
354 .bind(binds)?
355 .all()
356 .await?
357 .results::<InvitationRow>()
358 }
359
360 async fn pending_invitations(&self, filter: &str, binds: &[JsValue]) -> Result<Vec<InvitationRow>> {
361 let filter = format!(
362 "{filter} AND ri.accepted_at IS NULL AND ri.declined_at IS NULL AND ri.revoked_at IS NULL
363 AND ri.expires_at > {SQL_NOW}"
364 );
365 self.invitations(&filter, binds).await
366 }
367
368 pub async fn repo_access(&self, a: RepoAccessArgs) -> Result<Outcome<RepoAccess>> {
369 let Some(repo) = self.repo_for(&a.path, &a.viewer).await? else {
370 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
371 };
372 let viewer_role = a.viewer.as_ref().and_then(|viewer| granted(viewer, (&repo).into()));
373 // Like the list of collaborators: for those who can push.
374 if !viewer_role.is_some_and(|role| role >= RepoRole::Write) {
375 return Ok(Outcome::fail(
376 FailureCode::Forbidden,
377 format!("You need the Write role or higher on {} to see who has access.", full_name(&repo)),
378 ));
379 }
380 let can_manage = can(a.viewer.as_ref(), &repo, Capability::ManageAccess);
381 let Some(workspace_id) = self.workspace_id_of(&repo.namespace).await? else {
382 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
383 };
384 let base = self.base_of(&workspace_id).await?;
385 let mut people: Vec<Collaborator> = self
386 .people_rows(&repo.id, &workspace_id)
387 .await?
388 .into_iter()
389 .filter_map(|row| Self::collaborator(row, base))
390 .collect();
391 people.sort_by(|a, b| b.role.cmp(&a.role).then_with(|| a.username.cmp(&b.username)));
392 let invitations = if can_manage {
393 let now = rfc3339(now_ms());
394 self.pending_invitations("WHERE ri.repo_id = ?", &[repo.id.as_str().into()])
395 .await?
396 .iter()
397 .map(|row| row.shown(&now, true))
398 .collect()
399 } else {
400 Vec::new()
401 };
402 Ok(Outcome::Ok(RepoAccess {
403 repo: full_name(&repo),
404 base_permission: base,
405 people,
406 invitations,
407 viewer_role,
408 can_manage,
409 }))
410 }
411
412 /// One person's place on the repository, as the access list shows it.
413 async fn collaborator_on(&self, repo: &Repo, workspace_id: &str, user_id: &str) -> Result<Option<Collaborator>> {
414 let base = self.base_of(workspace_id).await?;
415 let row = self
416 .db
417 .prepare(
418 "SELECT u.id, u.username, u.display_name AS name, u.avatar,
419 m.role AS workspace_role, g.role AS direct
420 FROM users u
421 LEFT JOIN workspace_members m ON m.user_id = u.id AND m.workspace_id = ?2
422 LEFT JOIN repo_grants g ON g.principal_kind = 'user' AND g.principal_id = u.id AND g.repo_id = ?1
423 WHERE u.id = ?3",
424 )
425 .bind(&[repo.id.as_str().into(), workspace_id.into(), user_id.into()])?
426 .first::<PersonRow>(None)
427 .await?;
428 Ok(row.and_then(|row| Self::collaborator(row, base)))
429 }
430
431 async fn person_by_username(&self, username: &str) -> Result<Option<(String, String)>> {
432 #[derive(Deserialize)]
433 struct Person {
434 id: String,
435 username: String,
436 }
437 Ok(self
438 .db
439 .prepare("SELECT id, username FROM users WHERE username = ?")
440 .bind(&[username.trim().trim_start_matches('@').to_lowercase().into()])?
441 .first::<Person>(None)
442 .await?
443 .map(|person| (person.id, person.username)))
444 }
445
446 async fn is_member_of(&self, workspace_id: &str, user_id: &str) -> Result<bool> {
447 Ok(self
448 .db
449 .prepare("SELECT user_id AS id FROM workspace_members WHERE workspace_id = ? AND user_id = ?")
450 .bind(&[workspace_id.into(), user_id.into()])?
451 .first::<Id>(None)
452 .await?
453 .is_some())
454 }
455
456 async fn direct_role(&self, repo_id: &str, user_id: &str) -> Result<Option<RepoRole>> {
457 #[derive(Deserialize)]
458 struct RoleRow {
459 role: String,
460 }
461 Ok(self
462 .db
463 .prepare("SELECT role FROM repo_grants WHERE repo_id = ? AND principal_kind = 'user' AND principal_id = ?")
464 .bind(&[repo_id.into(), user_id.into()])?
465 .first::<RoleRow>(None)
466 .await?
467 .and_then(|row| RepoRole::parse(&row.role)))
468 }
469
470 /// Gives `user_id` `role` on the repository, or changes the role they
471 /// have; returns the role they had before.
472 async fn put_grant(
473 &self,
474 repo_id: &str,
475 workspace_id: &str,
476 repo_name: &str,
477 user_id: &str,
478 role: RepoRole,
479 granted_by: Option<&str>,
480 ) -> Result<Option<RepoRole>> {
481 let previous = self.direct_role(repo_id, user_id).await?;
482 let now = rfc3339(now_ms());
483 self.db
484 .prepare(
485 "INSERT INTO repo_grants
486 (repo_id, principal_kind, principal_id, workspace_id, repo_name, role, granted_by, created_at, updated_at)
487 VALUES (?1, 'user', ?2, ?3, ?4, ?5, ?6, ?7, ?7)
488 ON CONFLICT (repo_id, principal_kind, principal_id)
489 DO UPDATE SET role = excluded.role, workspace_id = excluded.workspace_id,
490 repo_name = excluded.repo_name, updated_at = excluded.updated_at",
491 )
492 .bind(&[
493 repo_id.into(),
494 user_id.into(),
495 workspace_id.into(),
496 repo_name.into(),
497 role.as_str().into(),
498 opt(granted_by),
499 now.as_str().into(),
500 ])?
501 .run()
502 .await?;
503 Ok(previous)
504 }
505
506 pub async fn add_collaborator(&self, a: AddCollaboratorArgs) -> Result<Outcome<Added>> {
507 let Target { repo, workspace_id } = match self.manageable(&a.actor, &a.path).await? {
508 Outcome::Ok(target) => target,
509 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
510 };
511 let surface = a.surface.unwrap_or(Surface::Web);
512 let invitee = match invitee(&a.invitee) {
513 Some(invitee) => invitee,
514 None => return Ok(Outcome::fail(FailureCode::Invalid, "Enter a username or an email address.")),
515 };
516 // Who it names: an account by username, or by a confirmed address.
517 let person = match &invitee {
518 Invitee::Username(name) => match self.person_by_username(name).await? {
519 Some(person) => Some(person),
520 None => return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER)),
521 },
522 Invitee::Email(email) => match self.user_with_verified_email(email).await? {
523 Some(id) => self
524 .find_public_user(
525 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ?",
526 &id,
527 )
528 .await?
529 .map(|user| (user.id, user.username)),
530 None => None,
531 },
532 };
533 let Some((user_id, username)) = person else {
534 let Invitee::Email(email) = invitee else {
535 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
536 };
537 return self.invite_address(&a.actor, &repo, &workspace_id, &email, a.role, surface).await;
538 };
539 // What the workspace asks of anyone with access to it (security.rs).
540 if let Some(why) = self.policy_refusal(&user_id, &repo.namespace).await? {
541 return Ok(Outcome::fail(FailureCode::Forbidden, why));
542 }
543 if self.is_member_of(&workspace_id, &user_id).await? {
544 let previous = self
545 .put_grant(&repo.id, &workspace_id, &repo.name, &user_id, a.role, Some(&a.actor.id))
546 .await?;
547 self.changed(&a.actor, (&repo).into(), &username, Some(a.role), previous, surface).await;
548 let Some(collaborator) = self.collaborator_on(&repo, &workspace_id, &user_id).await? else {
549 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
550 };
551 return Ok(Outcome::Ok(Added::Granted { collaborator }));
552 }
553 if self.direct_role(&repo.id, &user_id).await?.is_some() {
554 return Ok(Outcome::fail(
555 FailureCode::Conflict,
556 format!("{username} already has access to {}. Change their role instead.", full_name(&repo)),
557 ));
558 }
559 let pending = self
560 .pending_invitations(
561 "WHERE ri.repo_id = ? AND ri.invitee_id = ?",
562 &[repo.id.as_str().into(), user_id.as_str().into()],
563 )
564 .await?;
565 if !pending.is_empty() {
566 return Ok(Outcome::fail(
567 FailureCode::Conflict,
568 format!("{username} already has a pending invitation to {}. Change its role, or revoke it to send a new one.", full_name(&repo)),
569 ));
570 }
571 let id = self
572 .insert_invitation(&repo, &workspace_id, Some(&user_id), None, None, a.role, &a.actor.id, INVITATION_DAYS)
573 .await?;
574 let now = rfc3339(now_ms());
575 let Some(row) = self.invitation_by_id(&id).await? else {
576 return Ok(Outcome::fail(FailureCode::NotFound, "Invitation not found."));
577 };
578 // Told by email, at their primary address and the one typed.
579 let mut to = self.notice_recipients(&user_id, false).await.unwrap_or_default();
580 if let Invitee::Email(email) = &invitee
581 && !to.iter().any(|address| address.eq_ignore_ascii_case(email))
582 {
583 to.push(email.clone());
584 }
585 for address in to.iter().take(2) {
586 if let Err(error) = crate::email::send_repo_invite(
587 &self.env,
588 address,
589 &a.actor.username,
590 &full_name(&repo),
591 a.role.label(),
592 None,
593 INVITATION_DAYS,
594 )
595 .await
596 {
597 worker::console_error!("repository invitation email failed: {error}");
598 }
599 }
600 self.audit(&a.actor, "repo.invitation_created", (&repo).into(), surface, format!("Invited {username} as {}", a.role.label()))
601 .await;
602 Ok(Outcome::Ok(Added::Invited {
603 invitation: row.shown(&now, true),
604 }))
605 }
606
607 /// An address without an account: an invite code that makes it and
608 /// accepts (invites.rs).
609 async fn invite_address(
610 &self,
611 actor: &User,
612 repo: &Repo,
613 workspace_id: &str,
614 email: &str,
615 role: RepoRole,
616 surface: Surface,
617 ) -> Result<Outcome<Added>> {
618 let pending = self
619 .pending_invitations(
620 "WHERE ri.repo_id = ? AND ri.email = ?",
621 &[repo.id.as_str().into(), email.into()],
622 )
623 .await?;
624 if !pending.is_empty() {
625 return Ok(Outcome::fail(
626 FailureCode::Conflict,
627 "That address already has a pending invitation to this repository. Revoke it to send a new one.",
628 ));
629 }
630 let invite = match self.repo_invite_code(actor, email, workspace_id).await? {
631 Outcome::Ok(invite) => invite,
632 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
633 };
634 let days = self.invite_days();
635 let id = self
636 .insert_invitation(repo, workspace_id, None, Some(email), Some(&invite.id), role, &actor.id, days)
637 .await?;
638 if let Some(code) = &invite.code
639 && let Err(error) = crate::email::send_repo_invite(
640 &self.env,
641 email,
642 &actor.username,
643 &full_name(repo),
644 role.label(),
645 Some(code),
646 days,
647 )
648 .await
649 {
650 worker::console_error!("repository invitation email failed: {error}");
651 }
652 self.audit(
653 actor,
654 "repo.invitation_created",
655 repo.into(),
656 surface,
657 format!("Invited {} as {}", crate::invites::mask_email(email), role.label()),
658 )
659 .await;
660 let now = rfc3339(now_ms());
661 Ok(match self.invitation_by_id(&id).await? {
662 Some(row) => Outcome::Ok(Added::Invited {
663 invitation: row.shown(&now, true),
664 }),
665 None => Outcome::fail(FailureCode::NotFound, "Invitation not found."),
666 })
667 }
668
669 #[allow(clippy::too_many_arguments)]
670 async fn insert_invitation(
671 &self,
672 repo: &Repo,
673 workspace_id: &str,
674 invitee_id: Option<&str>,
675 email: Option<&str>,
676 invite_id: Option<&str>,
677 role: RepoRole,
678 inviter_id: &str,
679 days: u64,
680 ) -> Result<String> {
681 let now = now_ms();
682 let id = new_id("rin", now);
683 self.db
684 .prepare(
685 "INSERT INTO repo_invitations
686 (id, repo_id, workspace_id, repo_name, invitee_id, email, invite_id, role, inviter_id, created_at, expires_at)
687 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
688 )
689 .bind(&[
690 id.as_str().into(),
691 repo.id.as_str().into(),
692 workspace_id.into(),
693 repo.name.as_str().into(),
694 opt(invitee_id),
695 opt(email),
696 opt(invite_id),
697 role.as_str().into(),
698 inviter_id.into(),
699 rfc3339(now).into(),
700 rfc3339(now + days * 86_400_000).into(),
701 ])?
702 .run()
703 .await?;
704 Ok(id)
705 }
706
707 async fn invitation_by_id(&self, id: &str) -> Result<Option<InvitationRow>> {
708 Ok(self
709 .invitations("WHERE ri.id = ?", &[id.into()])
710 .await?
711 .into_iter()
712 .next())
713 }
714
715 fn invite_days(&self) -> u64 {
716 self.env
717 .var("INVITE_TTL_DAYS")
718 .ok()
719 .and_then(|value| value.to_string().parse().ok())
720 .unwrap_or(g1t_contracts::identity::INVITE_TTL_DAYS)
721 }
722
723 pub async fn set_collaborator_role(&self, a: SetCollaboratorRoleArgs) -> Result<Outcome<Collaborator>> {
724 let Target { repo, workspace_id } = match self.manageable(&a.actor, &a.path).await? {
725 Outcome::Ok(target) => target,
726 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
727 };
728 let Some((user_id, username)) = self.person_by_username(&a.username).await? else {
729 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
730 };
731 let surface = a.surface.unwrap_or(Surface::Web);
732 match self.direct_role(&repo.id, &user_id).await? {
733 Some(previous) => {
734 self.put_grant(&repo.id, &workspace_id, &repo.name, &user_id, a.role, Some(&a.actor.id))
735 .await?;
736 if previous != a.role {
737 self.changed(&a.actor, (&repo).into(), &username, Some(a.role), Some(previous), surface).await;
738 }
739 }
740 None => {
741 // A pending invitation's role changes until it is answered.
742 let changed = self
743 .db
744 .prepare(format!(
745 "UPDATE repo_invitations SET role = ?1
746 WHERE repo_id = ?2 AND invitee_id = ?3 AND accepted_at IS NULL AND declined_at IS NULL
747 AND revoked_at IS NULL AND expires_at > {SQL_NOW}
748 RETURNING id"
749 ))
750 .bind(&[a.role.as_str().into(), repo.id.as_str().into(), user_id.as_str().into()])?
751 .first::<Id>(None)
752 .await?;
753 if changed.is_none() {
754 return Ok(Outcome::fail(
755 FailureCode::NotFound,
756 format!(
757 "{username} has no role of their own on {}. Owners have Admin, and members the base permission; add them to give them more.",
758 full_name(&repo)
759 ),
760 ));
761 }
762 }
763 }
764 Ok(match self.collaborator_on(&repo, &workspace_id, &user_id).await? {
765 Some(collaborator) => Outcome::Ok(collaborator),
766 // Invited, not yet a collaborator: say what they will be.
767 None => Outcome::Ok(Collaborator {
768 username,
769 name: None,
770 avatar: None,
771 role: a.role,
772 source: AccessSource::Direct,
773 direct: Some(a.role),
774 workspace_role: None,
775 }),
776 })
777 }
778
779 pub async fn remove_collaborator(&self, a: RemoveCollaboratorArgs) -> Result<Outcome<bool>> {
780 let Some((user_id, username)) = self.person_by_username(&a.username).await? else {
781 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
782 };
783 let surface = a.surface.unwrap_or(Surface::Web);
784 // Anyone may give up their own role; otherwise, Admin only.
785 let leaving = crate::security::is_person(&a.actor) && a.actor.id == user_id;
786 let repo = if leaving {
787 match self.repo_for(&a.path, &Some(a.actor.clone())).await? {
788 Some(repo) => repo,
789 None => return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found.")),
790 }
791 } else {
792 match self.manageable(&a.actor, &a.path).await? {
793 Outcome::Ok(target) => target.repo,
794 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
795 }
796 };
797 let Some(previous) = self.direct_role(&repo.id, &user_id).await? else {
798 return Ok(Outcome::fail(
799 FailureCode::NotFound,
800 format!(
801 "{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.",
802 full_name(&repo)
803 ),
804 ));
805 };
806 self.db
807 .batch(vec![
808 self.db
809 .prepare("DELETE FROM repo_grants WHERE repo_id = ? AND principal_kind = 'user' AND principal_id = ?")
810 .bind(&[repo.id.as_str().into(), user_id.as_str().into()])?,
811 self.db
812 .prepare(format!(
813 "UPDATE repo_invitations SET revoked_at = {SQL_NOW}
814 WHERE repo_id = ? AND invitee_id = ? AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL"
815 ))
816 .bind(&[repo.id.as_str().into(), user_id.as_str().into()])?,
817 ])
818 .await?;
819 self.changed(&a.actor, (&repo).into(), &username, None, Some(previous), surface).await;
820 Ok(Outcome::Ok(true))
821 }
822
823 pub async fn collaborator_permission(&self, a: CollaboratorPermissionArgs) -> Result<Outcome<PermissionInfo>> {
824 let Some(repo) = self.repo_for(&a.path, &a.viewer).await? else {
825 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
826 };
827 let asking_about_self = a
828 .viewer
829 .as_ref()
830 .is_some_and(|viewer| viewer.username.eq_ignore_ascii_case(a.username.trim()));
831 if !asking_about_self && !can(a.viewer.as_ref(), &repo, Capability::Push) {
832 return Ok(Outcome::fail(
833 FailureCode::Forbidden,
834 format!("You need the Write role or higher on {} to see others' permissions.", full_name(&repo)),
835 ));
836 }
837 let Some((user_id, username)) = self.person_by_username(&a.username).await? else {
838 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
839 };
840 let Some(workspace_id) = self.workspace_id_of(&repo.namespace).await? else {
841 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
842 };
843 // Held to the workspace's policy as their requests are.
844 let within = self.policy_refusal(&user_id, &repo.namespace).await?.is_none();
845 let place = if within {
846 self.collaborator_on(&repo, &workspace_id, &user_id).await?
847 } else {
848 None
849 };
850 let role = place.as_ref().map(|place| place.role);
851 Ok(Outcome::Ok(PermissionInfo {
852 username,
853 role,
854 source: place.map(|place| place.source),
855 capabilities: capabilities_of(role),
856 }))
857 }
858
859 pub async fn my_repo_invitations(&self, a: MyRepoInvitationsArgs) -> Result<Vec<RepoInvitation>> {
860 if !crate::security::is_person(&a.user) {
861 return Ok(Vec::new());
862 }
863 let now = rfc3339(now_ms());
864 Ok(self
865 .invitations_for(&a.user, None)
866 .await?
867 .iter()
868 .map(|row| row.shown(&now, false))
869 .collect())
870 }
871
872 /// The pending invitations for `user`: sent to them, or to one of
873 /// their confirmed addresses before they had an account (and made it
874 /// some other way than with the code). `id` narrows it to one.
875 async fn invitations_for(&self, user: &User, id: Option<&str>) -> Result<Vec<InvitationRow>> {
876 let emails = serde_json::to_string(&self.verified_emails(&user.id).await?)?;
877 let mut filter = "WHERE (ri.invitee_id = ? OR (ri.invitee_id IS NULL AND ri.email IN (SELECT value FROM json_each(?))))".to_owned();
878 let mut binds = vec![JsValue::from(user.id.as_str()), emails.into()];
879 if let Some(id) = id {
880 filter.push_str(" AND ri.id = ?");
881 binds.push(id.into());
882 }
883 self.pending_invitations(&filter, &binds).await
884 }
885
886 pub async fn respond_repo_invitation(&self, a: RespondRepoInvitationArgs) -> Result<Outcome<RepoInvitation>> {
887 if !crate::security::is_person(&a.user) {
888 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can answer an invitation."));
889 }
890 let now = rfc3339(now_ms());
891 let row = self
892 .invitations_for(&a.user, Some(&a.id))
893 .await?
894 .into_iter()
895 .next();
896 let Some(row) = row else {
897 return Ok(Outcome::fail(
898 FailureCode::NotFound,
899 "There is no pending invitation of yours with that id. It may have expired or been revoked.",
900 ));
901 };
902 if !a.accept {
903 self.db
904 .prepare(format!("UPDATE repo_invitations SET declined_at = {SQL_NOW} WHERE id = ?"))
905 .bind(&[row.id.as_str().into()])?
906 .run()
907 .await?;
908 let mut shown = row.shown(&now, false);
909 shown.status = RepoInvitationStatus::Declined;
910 return Ok(Outcome::Ok(shown));
911 }
912 if let Some(why) = self.policy_refusal(&a.user.id, &row.workspace).await? {
913 return Ok(Outcome::fail(FailureCode::Forbidden, why));
914 }
915 self.accept(&row, &a.user).await?;
916 let mut shown = row.shown(&now, false);
917 shown.status = RepoInvitationStatus::Accepted;
918 Ok(Outcome::Ok(shown))
919 }
920
921 /// Turns an invitation into a grant, once.
922 async fn accept(&self, row: &InvitationRow, user: &User) -> Result<()> {
923 let claimed = self
924 .db
925 .prepare(format!(
926 "UPDATE repo_invitations SET accepted_at = {SQL_NOW}, invitee_id = ?1
927 WHERE id = ?2 AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL
928 RETURNING id"
929 ))
930 .bind(&[user.id.as_str().into(), row.id.as_str().into()])?
931 .first::<Id>(None)
932 .await?;
933 if claimed.is_none() {
934 return Ok(());
935 }
936 let role = row.role();
937 // Never lowers a role they already have.
938 let current = self.direct_role(&row.repo_id, &user.id).await?;
939 let role = current.map_or(role, |current| current.max(role));
940 let previous = self
941 .put_grant(&row.repo_id, &row.workspace_id, &row.repo_name, &user.id, role, row.inviter_id.as_deref())
942 .await?;
943 let repo = Named {
944 id: &row.repo_id,
945 namespace: &row.workspace,
946 name: &row.repo_name,
947 };
948 self.changed(user, repo, &user.username, Some(role), previous, Surface::Web).await;
949 Ok(())
950 }
951
952 /// The repository an invite code was sent with, for the invite's page
953 /// (invites.rs): whatever became of the invitation since.
954 pub(crate) async fn repository_of_code(
955 &self,
956 invite_id: &str,
957 ) -> Result<Option<g1t_contracts::identity::InviteRepository>> {
958 Ok(self
959 .invitations("WHERE ri.invite_id = ?", &[invite_id.into()])
960 .await?
961 .into_iter()
962 .next()
963 .map(|row| g1t_contracts::identity::InviteRepository {
964 name: format!("{}/{}", row.workspace, row.repo_name),
965 role: row.role().as_str().to_owned(),
966 }))
967 }
968
969 /// Accepts the repository invitations sent with an invite code, once
970 /// the code made `user`'s account (invites.rs).
971 pub(crate) async fn accept_invitations_of_code(&self, invite_id: &str, user: &User) -> Result<()> {
972 let rows = self
973 .pending_invitations("WHERE ri.invite_id = ?", &[invite_id.into()])
974 .await?;
975 for row in rows {
976 if self.policy_refusal(&user.id, &row.workspace).await?.is_some() {
977 continue;
978 }
979 self.accept(&row, user).await?;
980 }
981 Ok(())
982 }
983
984 pub async fn revoke_repo_invitation(&self, a: RevokeRepoInvitationArgs) -> Result<Outcome<RepoInvitation>> {
985 let Target { repo, .. } = match self.manageable(&a.actor, &a.path).await? {
986 Outcome::Ok(target) => target,
987 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
988 };
989 let revoked = self
990 .db
991 .prepare(format!(
992 "UPDATE repo_invitations SET revoked_at = {SQL_NOW}
993 WHERE id = ? AND repo_id = ? AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL
994 RETURNING id"
995 ))
996 .bind(&[a.id.as_str().into(), repo.id.as_str().into()])?
997 .first::<Id>(None)
998 .await?;
999 if revoked.is_none() {
1000 return Ok(Outcome::fail(FailureCode::NotFound, "There is no pending invitation with that id."));
1001 }
1002 let Some(row) = self.invitation_by_id(&a.id).await? else {
1003 return Ok(Outcome::fail(FailureCode::NotFound, "Invitation not found."));
1004 };
1005 if let Some(invite_id) = &row.invite_id {
1006 self.revoke_code(invite_id).await?;
1007 }
1008 let who = row
1009 .invitee
1010 .clone()
1011 .or_else(|| row.email.as_deref().map(crate::invites::mask_email))
1012 .unwrap_or_default();
1013 self.audit(
1014 &a.actor,
1015 "repo.invitation_revoked",
1016 (&repo).into(),
1017 a.surface.unwrap_or(Surface::Web),
1018 format!("Revoked the invitation to {who}"),
1019 )
1020 .await;
1021 Ok(Outcome::Ok(row.shown(&rfc3339(now_ms()), true)))
1022 }
1023
1024 pub async fn set_base_permission(&self, a: SetBasePermissionArgs) -> Result<Outcome<BasePermission>> {
1025 let slug = a.slug.trim().to_lowercase();
1026 if !crate::security::is_person(&a.actor) || a.actor.role_in(&slug) != Some(Role::Owner) {
1027 return Ok(Outcome::fail(
1028 FailureCode::Forbidden,
1029 "Only an owner can change what members get on every repository.",
1030 ));
1031 }
1032 if !a.actor.verified {
1033 return Ok(Outcome::fail(FailureCode::Forbidden, CONFIRM_FIRST));
1034 }
1035 let Some(workspace_id) = self.workspace_id_of(&slug).await? else {
1036 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1037 };
1038 let previous = self.base_of(&workspace_id).await?;
1039 self.db
1040 .prepare("UPDATE workspaces SET base_permission = ? WHERE id = ?")
1041 .bind(&[a.base_permission.as_str().into(), workspace_id.as_str().into()])?
1042 .run()
1043 .await?;
1044 if previous != a.base_permission {
1045 self.audit_workspace(
1046 &a.actor,
1047 "workspace.base_permission_changed",
1048 &slug,
1049 a.surface.unwrap_or(Surface::Web),
1050 format!(
1051 "Changed the base permission from {} to {}",
1052 previous.as_str(),
1053 a.base_permission.as_str()
1054 ),
1055 )
1056 .await;
1057 self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await;
1058 }
1059 Ok(Outcome::Ok(a.base_permission))
1060 }
1061
1062 /// `workspace_residency`: where a workspace keeps its repositories'
1063 /// git data, for the repos service as it places a new one, and for its
1064 /// settings page. Null when there is no such workspace.
1065 pub async fn workspace_residency(&self, a: g1t_contracts::identity::SlugArgs) -> Result<Option<g1t_contracts::identity::DataResidency>> {
1066 #[derive(Deserialize)]
1067 struct Row {
1068 #[serde(default)]
1069 data_residency: Option<String>,
1070 }
1071 let row = self
1072 .db
1073 .prepare("SELECT data_residency FROM workspaces WHERE slug = ? AND deleted_at IS NULL")
1074 .bind(&[a.slug.trim().to_lowercase().into()])?
1075 .first::<Row>(None)
1076 .await?;
1077 Ok(row.map(|row| {
1078 row.data_residency
1079 .as_deref()
1080 .and_then(g1t_contracts::identity::DataResidency::parse)
1081 .unwrap_or_default()
1082 }))
1083 }
1084
1085 /// `set_workspace_residency`: owners only. Applies to repositories
1086 /// made from then on; those it has stay where they are. Whether the EU
1087 /// can be chosen is the repos service's to say (`storage_options`);
1088 /// the site offers it only then, and the repos service refuses to
1089 /// place an EU workspace's repository anywhere else.
1090 pub async fn set_workspace_residency(
1091 &self,
1092 a: g1t_contracts::identity::SetResidencyArgs,
1093 ) -> Result<Outcome<g1t_contracts::identity::DataResidency>> {
1094 let slug = a.slug.trim().to_lowercase();
1095 if !crate::security::is_person(&a.actor) || a.actor.role_in(&slug) != Some(Role::Owner) {
1096 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can change where a workspace keeps its data."));
1097 }
1098 if !a.actor.verified {
1099 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address before changing where the workspace keeps its data."));
1100 }
1101 let Some(previous) = self.workspace_residency(g1t_contracts::identity::SlugArgs { slug: slug.clone() }).await? else {
1102 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1103 };
1104 if previous == a.residency {
1105 return Ok(Outcome::Ok(previous));
1106 }
1107 let stored = match a.residency {
1108 g1t_contracts::identity::DataResidency::Anywhere => JsValue::NULL,
1109 other => other.as_str().into(),
1110 };
1111 self.db
1112 .prepare("UPDATE workspaces SET data_residency = ? WHERE slug = ? AND deleted_at IS NULL")
1113 .bind(&[stored, slug.as_str().into()])?
1114 .run()
1115 .await?;
1116 self.audit_workspace(
1117 &a.actor,
1118 "workspace.residency_changed",
1119 &slug,
1120 Surface::Web,
1121 format!("Changed where new repositories keep their data from {} to {}", previous.as_str(), a.residency.as_str()),
1122 )
1123 .await;
1124 Ok(Outcome::Ok(a.residency))
1125 }
1126
1127 pub async fn outside_collaborators(&self, a: OutsideCollaboratorsArgs) -> Result<Outcome<Vec<OutsideCollaborator>>> {
1128 let slug = a.slug.trim().to_lowercase();
1129 if !a.viewer.as_ref().is_some_and(|viewer| viewer.role_in(&slug) == Some(Role::Owner)) {
1130 return Ok(Outcome::fail(FailureCode::Forbidden, "Only owners can see a workspace's outside collaborators."));
1131 }
1132 let Some(workspace_id) = self.workspace_id_of(&slug).await? else {
1133 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1134 };
1135 #[derive(Deserialize)]
1136 struct Row {
1137 username: String,
1138 name: Option<String>,
1139 avatar: Option<String>,
1140 repo_name: String,
1141 role: String,
1142 }
1143 let rows = self
1144 .db
1145 .prepare(format!(
1146 "SELECT u.username, u.display_name AS name, u.avatar, g.repo_name, g.role
1147 FROM repo_grants g JOIN users u ON u.id = g.principal_id
1148 WHERE g.workspace_id = ?1 AND g.principal_kind = 'user'
1149 AND NOT EXISTS (SELECT 1 FROM workspace_members m WHERE m.workspace_id = ?1 AND m.user_id = g.principal_id)
1150 ORDER BY u.username, g.repo_name LIMIT {LIST_LIMIT}"
1151 ))
1152 .bind(&[workspace_id.as_str().into()])?
1153 .all()
1154 .await?
1155 .results::<Row>()?;
1156 let mut people: Vec<OutsideCollaborator> = Vec::new();
1157 for row in rows {
1158 let Some(role) = RepoRole::parse(&row.role) else {
1159 continue;
1160 };
1161 let repo = CollaboratorRepo {
1162 repo: format!("{slug}/{}", row.repo_name),
1163 role,
1164 };
1165 match people.last_mut().filter(|person| person.username == row.username) {
1166 Some(person) => person.repos.push(repo),
1167 None => people.push(OutsideCollaborator {
1168 username: row.username,
1169 name: row.name,
1170 avatar: row.avatar,
1171 repos: vec![repo],
1172 }),
1173 }
1174 }
1175 Ok(Outcome::Ok(people))
1176 }
1177
1178 pub async fn forget_repo_access(&self, a: ForgetRepoAccessArgs) -> Result<bool> {
1179 self.db
1180 .batch(vec![
1181 self.db
1182 .prepare("DELETE FROM repo_grants WHERE repo_id = ?")
1183 .bind(&[a.repo_id.as_str().into()])?,
1184 self.db
1185 .prepare("DELETE FROM repo_invitations WHERE repo_id = ?")
1186 .bind(&[a.repo_id.as_str().into()])?,
1187 ])
1188 .await?;
1189 Ok(true)
1190 }
1191
1192 /// A repository moved or was renamed: its grants and invitations follow
1193 /// it (deletion.rs, `transfer_repo_scopes`).
1194 pub(crate) async fn move_repo_access(&self, from: &RepoPath, to: &RepoPath) -> Result<()> {
1195 let (Some(from_id), Some(to_id)) = (
1196 self.workspace_id_of(&from.namespace).await?,
1197 self.workspace_id_of(&to.namespace).await?,
1198 ) else {
1199 return Ok(());
1200 };
1201 let binds = [
1202 JsValue::from(to_id.as_str()),
1203 to.name.to_lowercase().into(),
1204 from_id.as_str().into(),
1205 from.name.to_lowercase().into(),
1206 ];
1207 self.db
1208 .batch(vec![
1209 self.db
1210 .prepare("UPDATE repo_grants SET workspace_id = ?1, repo_name = ?2 WHERE workspace_id = ?3 AND repo_name = ?4")
1211 .bind(&binds)?,
1212 self.db
1213 .prepare("UPDATE repo_invitations SET workspace_id = ?1, repo_name = ?2 WHERE workspace_id = ?3 AND repo_name = ?4")
1214 .bind(&binds)?,
1215 ])
1216 .await?;
1217 Ok(())
1218 }
1219
1220 // --- Telling others ---
1221
1222 /// Publishes the change of a person's own role, and records it in the
1223 /// workspace's audit log.
1224 async fn changed(
1225 &self,
1226 actor: &User,
1227 repo: Named<'_>,
1228 username: &str,
1229 role: Option<RepoRole>,
1230 previous: Option<RepoRole>,
1231 surface: Surface,
1232 ) {
1233 let (kind, message) = match (previous, role) {
1234 (None, Some(role)) => ("repo.collaborator_added", format!("Gave {username} the {} role", role.label())),
1235 (Some(previous), Some(role)) => (
1236 "repo.collaborator_role_changed",
1237 format!("Changed {username}'s role from {} to {}", previous.label(), role.label()),
1238 ),
1239 (Some(previous), None) => ("repo.collaborator_removed", format!("Removed {username}'s {} role", previous.label())),
1240 (None, None) => return,
1241 };
1242 self.publish_repo(
1243 kind,
1244 repo.id,
1245 &actor.id,
1246 RepoCollaborator {
1247 repo_id: repo.id.to_owned(),
1248 namespace: repo.namespace.to_owned(),
1249 name: repo.name.to_owned(),
1250 username: username.to_owned(),
1251 role,
1252 previous_role: previous,
1253 },
1254 )
1255 .await;
1256 self.audit(actor, kind, repo, surface, message).await;
1257 }
1258
1259 async fn publish_repo<T: Serialize>(&self, kind: &'static str, repo_id: &str, actor: &str, data: T) {
1260 let Ok(events) = self.env.service("EVENTS") else {
1261 return;
1262 };
1263 let publish = Publish {
1264 events: vec![NewEvent {
1265 kind,
1266 source: "identity",
1267 repo_id: Some(repo_id.to_owned()),
1268 actor: Some(actor.to_owned()),
1269 data,
1270 }],
1271 };
1272 if let Err(error) = g1t_kit::call::<_, serde_json::Value>(&events, "publish", &publish).await {
1273 worker::console_error!("{kind} not published: {error}");
1274 }
1275 }
1276
1277 async fn audit(&self, actor: &User, action: &str, repo: Named<'_>, surface: Surface, message: String) {
1278 let full = format!("{}/{}", repo.namespace, repo.name);
1279 self.record(actor, action, repo.namespace, Some(full), surface, message).await;
1280 }
1281
1282 async fn audit_workspace(&self, actor: &User, action: &str, slug: &str, surface: Surface, message: String) {
1283 self.record(actor, action, slug, None, surface, message).await;
1284 }
1285
1286 async fn record(&self, actor: &User, action: &str, workspace: &str, repo: Option<String>, surface: Surface, message: String) {
1287 let Ok(events) = self.env.service("EVENTS") else {
1288 return;
1289 };
1290 let entry = NewAuditEntry {
1291 actor: AuditActor::of(actor),
1292 action: action.to_owned(),
1293 surface,
1294 target: AuditTarget {
1295 workspace: workspace.to_lowercase(),
1296 repo,
1297 ..AuditTarget::default()
1298 },
1299 outcome: AuditOutcome::Allowed,
1300 rule: if actor.kind == PrincipalKind::User { "access" } else { "access:token" }.to_owned(),
1301 result: Some("ok".to_owned()),
1302 message: Some(message),
1303 request_id: new_id("req", now_ms()),
1304 };
1305 let recorded: Result<u32> =
1306 g1t_kit::call(&events, "audit_record", &RecordAuditArgs { entries: vec![entry] }).await;
1307 if let Err(error) = recorded {
1308 worker::console_error!("{action} not recorded: {error}");
1309 }
1310 }
1311}
1312
1313#[cfg(test)]
1314mod tests {
1315 use super::*;
1316
1317 #[test]
1318 fn people_are_added_by_username_or_address() {
1319 assert_eq!(invitee(" Ada "), Some(Invitee::Username("ada".into())));
1320 assert_eq!(invitee("@ada"), Some(Invitee::Username("ada".into())));
1321 assert_eq!(invitee("Ada@Example.com"), Some(Invitee::Email("ada@example.com".into())));
1322 assert_eq!(invitee("not a name"), None);
1323 assert_eq!(invitee("ada@"), None);
1324 }
1325
1326 #[test]
1327 fn a_role_comes_from_ownership_the_base_or_a_grant() {
1328 use AccessSource::*;
1329 use RepoRole::*;
1330 assert_eq!(effective(true, Some(Read), Some(Write)), Some((Admin, Owner)));
1331 assert_eq!(effective(false, Some(Write), None), Some((Write, Base)));
1332 assert_eq!(effective(false, Some(Write), Some(Maintain)), Some((Maintain, Direct)));
1333 // A grant as high as the base is shown as direct, where it can be changed.
1334 assert_eq!(effective(false, Some(Write), Some(Write)), Some((Write, Direct)));
1335 assert_eq!(effective(false, Some(Admin), Some(Read)), Some((Admin, Base)));
1336 // An outside collaborator.
1337 assert_eq!(effective(false, None, Some(Triage)), Some((Triage, Direct)));
1338 // A member of a workspace whose base is none, with no grant.
1339 assert_eq!(effective(false, None, None), None);
1340 }
1341
1342 #[test]
1343 fn an_invitation_is_pending_until_answered_revoked_or_expired() {
1344 let row = InvitationRow {
1345 expires_at: "2026-10-12T00:00:00.000Z".into(),
1346 ..InvitationRow::default()
1347 };
1348 let now = "2026-10-05T00:00:00.000Z";
1349 assert_eq!(invitation_status(&row, now), RepoInvitationStatus::Pending);
1350 assert_eq!(invitation_status(&row, "2026-10-12T00:00:00.000Z"), RepoInvitationStatus::Expired);
1351 let accepted = InvitationRow { accepted_at: Some(now.into()), ..row.clone() };
1352 assert_eq!(invitation_status(&accepted, now), RepoInvitationStatus::Accepted);
1353 let declined = InvitationRow { declined_at: Some(now.into()), ..row.clone() };
1354 assert_eq!(invitation_status(&declined, now), RepoInvitationStatus::Declined);
1355 let revoked = InvitationRow { revoked_at: Some(now.into()), ..row };
1356 assert_eq!(invitation_status(&revoked, now), RepoInvitationStatus::Revoked);
1357 }
1358
1359 #[test]
1360 fn invitations_show_addresses_only_to_those_who_manage_access() {
1361 let row = InvitationRow {
1362 id: "rin_1".into(),
1363 workspace: "acme".into(),
1364 repo_name: "rocket".into(),
1365 email: Some("ada@example.com".into()),
1366 role: "triage".into(),
1367 expires_at: "2099-01-01T00:00:00.000Z".into(),
1368 ..InvitationRow::default()
1369 };
1370 let now = "2026-10-05T00:00:00.000Z";
1371 assert_eq!(row.shown(now, true).email.as_deref(), Some("ada@example.com"));
1372 assert_eq!(row.shown(now, false).email, None);
1373 assert_eq!(row.shown(now, false).repo, "acme/rocket");
1374 assert_eq!(row.shown(now, false).role, RepoRole::Triage);
1375 }
1376}