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