Identity: workspace aliases, set by staff, seeded with g1t for flagon-io
A workspace alias is a name staff point at a workspace (workspace_aliases, migration 0029), by its id so it follows renames. It must have a namespace's shape, must not be a site route, a username, a workspace's slug or another alias; reserved names such as g1t can be. resolve_slug now also resolves aliases, resolve_alias answers git, admin_aliases/admin_set_alias/admin_remove_alias are staff only and logged in sudo's audit log. An alias holds its name against registration and renames, and goes when its workspace is purged. Seeds g1t -> flagon-io.
8 files+546−270/8 viewed
| 560 | 560 | // `resolve_slug` takes `SlugArgs` and returns `Option<String>`: the | |
| 561 | 561 | // workspace's current slug when `slug` is one it was renamed from within | |
| 562 | 562 | // the last `SLUG_HOLD_DAYS`, and null otherwise (including for a slug that | |
| 563 | − | // is in use). | |
| 563 | + | // is in use), or the workspace's slug when `slug` is one of its aliases. | |
| 564 | + | ||
| 565 | + | // `resolve_alias` takes `SlugArgs` and returns `Option<String>`: the slug | |
| 566 | + | // now of the workspace `slug` is an alias of, and null when it is none. | |
| 567 | + | // Aliases are set by g1t's staff only: `g1t` is Flagon, Inc.'s `flagon-io`. | |
| 568 | + | // An alias follows its workspace through renames. | |
| 569 | + | ||
| 570 | + | /// `admin_aliases` takes no arguments (`{}`) and returns | |
| 571 | + | /// `Vec<WorkspaceAlias>`, by alias. Staff only. | |
| 572 | + | /// | |
| 573 | + | /// A name staff point at a workspace, so that its addresses (pages, git, | |
| 574 | + | /// the API, packages) lead to the workspace under its own name. | |
| 575 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 576 | + | #[serde(rename_all = "camelCase")] | |
| 577 | + | pub struct WorkspaceAlias { | |
| 578 | + | pub alias: String, | |
| 579 | + | pub workspace_id: String, | |
| 580 | + | /// The workspace's slug and name now. | |
| 581 | + | pub workspace: String, | |
| 582 | + | pub workspace_name: String, | |
| 583 | + | /// Why it exists, as staff wrote it. | |
| 584 | + | pub note: String, | |
| 585 | + | /// The staff member who set it, or `migration`. | |
| 586 | + | pub created_by: String, | |
| 587 | + | /// RFC 3339. | |
| 588 | + | pub created_at: String, | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | /// `admin_set_alias`: points `alias` at the workspace whose slug is | |
| 592 | + | /// `workspace`. The alias must have a namespace's shape, must not be one of | |
| 593 | + | /// the site's routes, and must not be anyone's username, a workspace's slug | |
| 594 | + | /// (deleted, or held after a rename) or another alias. `note` is required: | |
| 595 | + | /// it is the reason, kept with the alias and in sudo's audit log. Staff | |
| 596 | + | /// only. Returns `Outcome<WorkspaceAlias>`. | |
| 597 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 598 | + | #[serde(rename_all = "camelCase")] | |
| 599 | + | pub struct AdminSetAliasArgs { | |
| 600 | + | pub alias: String, | |
| 601 | + | pub workspace: String, | |
| 602 | + | pub note: String, | |
| 603 | + | pub staff: String, | |
| 604 | + | } | |
| 605 | + | ||
| 606 | + | /// `admin_remove_alias`: the alias stops leading anywhere, and the name is | |
| 607 | + | /// nobody's again unless it is reserved. `reason` goes in sudo's audit log. | |
| 608 | + | /// Staff only. Returns `Outcome<bool>`. | |
| 609 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 610 | + | #[serde(rename_all = "camelCase")] | |
| 611 | + | pub struct AdminRemoveAliasArgs { | |
| 612 | + | pub alias: String, | |
| 613 | + | pub reason: String, | |
| 614 | + | pub staff: String, | |
| 615 | + | } | |
| 564 | 616 | ||
| 565 | 617 | /// `set_workspace_avatar`: owners only. `image` is the file's bytes in | |
| 566 | 618 | /// base64: PNG, JPEG, WebP or GIF, at most `MAX_AVATAR_BYTES`. Null removes |
| 38 | 38 | pub mod work; | |
| 39 | 39 | ||
| 40 | 40 | pub use ids::new_id; | |
| 41 | − | pub use names::{claimable_namespace, is_reserved_name, is_valid_namespace, is_valid_repo_name}; | |
| 41 | + | pub use names::{ | |
| 42 | + | aliasable_name, claimable_namespace, is_namespace_shaped, is_reserved_name, is_route_name, is_valid_namespace, | |
| 43 | + | is_valid_repo_name, | |
| 44 | + | }; | |
| 42 | 45 | pub use outcome::{Failure, FailureCode, Outcome}; | |
| 43 | 46 | ||
| 44 | 47 | use serde::{Deserialize, Serialize}; |
| 1 | − | /// Routes and reserved words that may not be registered as usernames. | |
| 2 | − | const RESERVED: &[&str] = &[ | |
| 1 | + | /// The site's own routes: first path segments that are never a workspace's, | |
| 2 | + | /// so nobody may register them, and no alias can be reached at them. | |
| 3 | + | const ROUTES: &[&str] = &[ | |
| 3 | 4 | "api", | |
| 4 | 5 | "mcp", | |
| 5 | 6 | "login", | |
| ⋯ | |||
| 19 | 20 | "avatars", | |
| 20 | 21 | "docs", | |
| 21 | 22 | "explore", | |
| 22 | − | // g1t itself, and the name its agent once went by: everything g1t | |
| 23 | − | // does is shown as `g1t`, so nobody else may be called either. | |
| 24 | − | "g1t", | |
| 25 | − | "g1t-agent", | |
| 26 | 23 | "about", | |
| 27 | 24 | "pricing", | |
| 28 | 25 | "terms", | |
| ⋯ | |||
| 50 | 47 | "notifications", | |
| 51 | 48 | ]; | |
| 52 | 49 | ||
| 50 | + | /// g1t itself, and the name its agent once went by: everything g1t does is | |
| 51 | + | /// shown as `g1t`, so nobody else may be called either. Not routes: staff | |
| 52 | + | /// may point one at a workspace as an alias (identity's `aliases.rs`). | |
| 53 | + | const OWN: &[&str] = &["g1t", "g1t-agent"]; | |
| 54 | + | ||
| 53 | 55 | /// Whether `value`, whatever its case, is a name nobody can register or | |
| 54 | 56 | /// rename a workspace to: a route, or g1t's own. | |
| 55 | 57 | pub fn is_reserved_name(value: &str) -> bool { | |
| 58 | + | is_route_name(value) || OWN.iter().any(|own| own.eq_ignore_ascii_case(value.trim())) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// Whether `value`, whatever its case, is one of the site's own routes. | |
| 62 | + | pub fn is_route_name(value: &str) -> bool { | |
| 56 | 63 | let value = value.trim(); | |
| 57 | − | RESERVED.iter().any(|reserved| reserved.eq_ignore_ascii_case(value)) | |
| 64 | + | ROUTES.iter().any(|route| route.eq_ignore_ascii_case(value)) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// Whether `value` has a namespace's shape: letters, digits and single | |
| 68 | + | /// hyphens, not starting or ending with a hyphen, at most 39 characters. | |
| 69 | + | /// Reserved names have it too; see [`is_valid_namespace`]. | |
| 70 | + | pub fn is_namespace_shaped(value: &str) -> bool { | |
| 71 | + | let bytes = value.as_bytes(); | |
| 72 | + | !bytes.is_empty() | |
| 73 | + | && bytes.len() <= 39 | |
| 74 | + | && bytes | |
| 75 | + | .iter() | |
| 76 | + | .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-') | |
| 77 | + | && !value.starts_with('-') | |
| 78 | + | && !value.ends_with('-') | |
| 79 | + | && !value.contains("--") | |
| 58 | 80 | } | |
| 59 | 81 | ||
| 82 | + | /// A workspace alias as staff typed it, trimmed and lowercased, if it can | |
| 83 | + | /// be one: shaped like a namespace and not one of the site's routes, which | |
| 84 | + | /// would always answer first. Reserved names such as `g1t` can be; whether | |
| 85 | + | /// a person or workspace already has it is identity's to check. | |
| 86 | + | pub fn aliasable_name(value: &str) -> Option<String> { | |
| 87 | + | let value = value.trim().to_lowercase(); | |
| 88 | + | (is_namespace_shaped(&value) && !is_route_name(&value)).then_some(value) | |
| 89 | + | } | |
| 90 | + | ||
| 60 | 91 | /// A username or workspace slug as someone typed it, trimmed and | |
| 61 | 92 | /// lowercased, if it can be registered: what signing up, signing up with | |
| 62 | 93 | /// GitHub and creating a workspace each take. None when it is malformed or | |
| ⋯ | |||
| 69 | 100 | /// Namespaces follow GitHub's rules: letters, digits and single hyphens, | |
| 70 | 101 | /// not starting or ending with a hyphen, at most 39 characters. | |
| 71 | 102 | pub fn is_valid_namespace(value: &str) -> bool { | |
| 72 | − | let bytes = value.as_bytes(); | |
| 73 | − | !bytes.is_empty() | |
| 74 | − | && bytes.len() <= 39 | |
| 75 | − | && bytes | |
| 76 | − | .iter() | |
| 77 | − | .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-') | |
| 78 | − | && !value.starts_with('-') | |
| 79 | − | && !value.ends_with('-') | |
| 80 | − | && !value.contains("--") | |
| 81 | − | && !is_reserved_name(value) | |
| 103 | + | is_namespace_shaped(value) && !is_reserved_name(value) | |
| 82 | 104 | } | |
| 83 | 105 | ||
| 84 | 106 | pub fn is_valid_repo_name(value: &str) -> bool { | |
| ⋯ | |||
| 114 | 136 | assert_eq!(claimable_namespace(" Ana ").as_deref(), Some("ana")); | |
| 115 | 137 | assert_eq!(claimable_namespace("an--a"), None); | |
| 116 | 138 | } | |
| 139 | + | ||
| 140 | + | #[test] | |
| 141 | + | fn g1t_can_be_an_alias_but_a_route_cannot() { | |
| 142 | + | assert_eq!(aliasable_name(" G1T ").as_deref(), Some("g1t")); | |
| 143 | + | assert_eq!(aliasable_name("acme-corp").as_deref(), Some("acme-corp")); | |
| 144 | + | for name in ["settings", "api", "login", "Explore", "-acme", "ac--me", "acme_inc", "", "a.b"] { | |
| 145 | + | assert_eq!(aliasable_name(name), None, "{name}"); | |
| 146 | + | } | |
| 147 | + | assert_eq!(aliasable_name(&"a".repeat(40)), None); | |
| 148 | + | // Still nobody's to register. | |
| 149 | + | assert!(is_reserved_name("g1t") && !is_route_name("g1t")); | |
| 150 | + | } | |
| 117 | 151 | } | |
| 1 | + | -- Workspace aliases: a name g1t's staff point at a workspace, so its | |
| 2 | + | -- addresses lead there under the workspace's own name. Staff-managed only, | |
| 3 | + | -- from sudo; not a feature workspaces can use. An alias is a reserved or | |
| 4 | + | -- unclaimed name, never a person's or a workspace's, and points at the | |
| 5 | + | -- workspace's id, so it follows the workspace through renames. See | |
| 6 | + | -- src/aliases.rs. | |
| 7 | + | CREATE TABLE workspace_aliases ( | |
| 8 | + | alias TEXT PRIMARY KEY, | |
| 9 | + | workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, | |
| 10 | + | created_by TEXT NOT NULL, | |
| 11 | + | created_at TEXT NOT NULL, | |
| 12 | + | note TEXT NOT NULL DEFAULT '' | |
| 13 | + | ); | |
| 14 | + | CREATE INDEX workspace_aliases_by_workspace ON workspace_aliases (workspace_id); | |
| 15 | + | ||
| 16 | + | -- g1t is the product Flagon, Inc. builds; flagon-io is the organization. | |
| 17 | + | -- Nothing is added where flagon-io does not exist (a fresh self-hosted | |
| 18 | + | -- install, a local database). | |
| 19 | + | INSERT OR IGNORE INTO workspace_aliases (alias, workspace_id, created_by, created_at, note) | |
| 20 | + | SELECT 'g1t', id, 'migration', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), 'The product''s name, for Flagon, Inc.' | |
| 21 | + | FROM workspaces WHERE slug = 'flagon-io' AND deleted_at IS NULL; |
| 1 | + | //! Workspace aliases: a name g1t's staff point at a workspace, so that its | |
| 2 | + | //! addresses lead to the workspace under its own name. `g1t` is the | |
| 3 | + | //! product Flagon, Inc. builds, and leads to `flagon-io`, the organization | |
| 4 | + | //! (migration 0029), so nobody is confused by the trading name. | |
| 5 | + | //! | |
| 6 | + | //! Staff set and remove them from sudo; there is no way for a workspace to | |
| 7 | + | //! make one. An alias is a reserved or unclaimed name, never a person's or | |
| 8 | + | //! a workspace's, and points at the workspace's id, so it follows the | |
| 9 | + | //! workspace through renames. While it exists nobody can register or | |
| 10 | + | //! rename a workspace to it. | |
| 11 | + | //! | |
| 12 | + | //! An alias is resolved wherever an old slug is (`resolve_slug`): the site | |
| 13 | + | //! and the API redirect or run again under the workspace's slug. Git over | |
| 14 | + | //! HTTPS resolves it in place (`resolve_alias`), as pushes do not follow | |
| 15 | + | //! redirects. | |
| 16 | + | ||
| 17 | + | use g1t_contracts::identity::*; | |
| 18 | + | use g1t_contracts::time::rfc3339; | |
| 19 | + | use g1t_contracts::{FailureCode, Outcome, aliasable_name}; | |
| 20 | + | use g1t_kit::now_ms; | |
| 21 | + | use serde::Deserialize; | |
| 22 | + | use worker::Result; | |
| 23 | + | ||
| 24 | + | use crate::Identity; | |
| 25 | + | ||
| 26 | + | /// The longest note or reason staff may give. | |
| 27 | + | pub const MAX_NOTE_LENGTH: usize = 500; | |
| 28 | + | ||
| 29 | + | /// Everything about a wanted alias that decides whether staff may set it, | |
| 30 | + | /// as read from the database. | |
| 31 | + | #[derive(Debug, Default)] | |
| 32 | + | pub struct Facts<'a> { | |
| 33 | + | /// The workspace it would lead to, if there is one by that slug: its id. | |
| 34 | + | pub target: Option<&'a str>, | |
| 35 | + | /// A person has the name as their username. | |
| 36 | + | pub username: bool, | |
| 37 | + | /// A workspace has it as its slug, deleted or not. | |
| 38 | + | pub workspace: bool, | |
| 39 | + | /// A renamed workspace's old slug still held: the workspace it is held for. | |
| 40 | + | pub held_for: Option<&'a str>, | |
| 41 | + | /// A purged workspace had it; it is never given to anyone else. | |
| 42 | + | pub purged: bool, | |
| 43 | + | /// It is already an alias: of which workspace's slug. | |
| 44 | + | pub alias_of: Option<&'a str>, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /// The alias and note to store, or why not, in words for staff. | |
| 48 | + | pub fn check(alias: &str, note: &str, facts: &Facts) -> std::result::Result<(String, String), (FailureCode, String)> { | |
| 49 | + | let refuse = |code, message: String| Err((code, message)); | |
| 50 | + | let Some(alias) = aliasable_name(alias) else { | |
| 51 | + | return refuse( | |
| 52 | + | FailureCode::Invalid, | |
| 53 | + | "An alias uses lowercase letters, digits and single hyphens, up to 39 characters, and cannot be one of the site's routes.".into(), | |
| 54 | + | ); | |
| 55 | + | }; | |
| 56 | + | let note = note.trim(); | |
| 57 | + | if note.is_empty() { | |
| 58 | + | return refuse(FailureCode::Invalid, "Say why the alias exists.".into()); | |
| 59 | + | } | |
| 60 | + | if note.chars().count() > MAX_NOTE_LENGTH { | |
| 61 | + | return refuse(FailureCode::Invalid, format!("Keep the note to {MAX_NOTE_LENGTH} characters.")); | |
| 62 | + | } | |
| 63 | + | let Some(target) = facts.target else { | |
| 64 | + | return refuse(FailureCode::NotFound, "There is no workspace with that slug.".into()); | |
| 65 | + | }; | |
| 66 | + | if let Some(slug) = facts.alias_of { | |
| 67 | + | return refuse(FailureCode::Conflict, format!("{alias} is already an alias of {slug}.")); | |
| 68 | + | } | |
| 69 | + | if facts.username { | |
| 70 | + | return refuse(FailureCode::Conflict, format!("{alias} is someone's username.")); | |
| 71 | + | } | |
| 72 | + | if facts.workspace { | |
| 73 | + | return refuse(FailureCode::Conflict, format!("{alias} is a workspace's slug.")); | |
| 74 | + | } | |
| 75 | + | if facts.purged { | |
| 76 | + | return refuse(FailureCode::Conflict, format!("{alias} belonged to a deleted workspace.")); | |
| 77 | + | } | |
| 78 | + | // A workspace's own old slug can become its alias for good. | |
| 79 | + | if facts.held_for.is_some_and(|holder| holder != target) { | |
| 80 | + | return refuse(FailureCode::Conflict, format!("{alias} is held for a renamed workspace.")); | |
| 81 | + | } | |
| 82 | + | Ok((alias, note.to_owned())) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// Where a first path segment leads, in the order `resolve_slug` asks: a | |
| 86 | + | /// workspace that has it leads nowhere else; then an alias, which is for | |
| 87 | + | /// good; then a renamed workspace's old slug, while it is held. | |
| 88 | + | pub fn resolve(in_use: bool, alias: Option<String>, renamed: impl FnOnce() -> Option<String>) -> Option<String> { | |
| 89 | + | if in_use { | |
| 90 | + | return None; | |
| 91 | + | } | |
| 92 | + | alias.or_else(renamed) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | #[derive(Deserialize)] | |
| 96 | + | struct AliasRow { | |
| 97 | + | alias: String, | |
| 98 | + | workspace_id: String, | |
| 99 | + | slug: String, | |
| 100 | + | name: String, | |
| 101 | + | note: String, | |
| 102 | + | created_by: String, | |
| 103 | + | created_at: String, | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | impl From<AliasRow> for WorkspaceAlias { | |
| 107 | + | fn from(row: AliasRow) -> Self { | |
| 108 | + | WorkspaceAlias { | |
| 109 | + | alias: row.alias, | |
| 110 | + | workspace_id: row.workspace_id, | |
| 111 | + | workspace: row.slug, | |
| 112 | + | workspace_name: row.name, | |
| 113 | + | note: row.note, | |
| 114 | + | created_by: row.created_by, | |
| 115 | + | created_at: row.created_at, | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | /// Aliases with their workspace as it is now. Never a deleted one's. | |
| 121 | + | const ALIAS_ROWS: &str = "SELECT a.alias, a.workspace_id, w.slug, w.name, a.note, a.created_by, a.created_at | |
| 122 | + | FROM workspace_aliases a JOIN workspaces w ON w.id = a.workspace_id AND w.deleted_at IS NULL"; | |
| 123 | + | ||
| 124 | + | #[derive(Deserialize)] | |
| 125 | + | struct Id { | |
| 126 | + | id: String, | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | impl Identity { | |
| 130 | + | async fn alias_row(&self, alias: &str) -> Result<Option<AliasRow>> { | |
| 131 | + | self.db | |
| 132 | + | .prepare(format!("{ALIAS_ROWS} WHERE a.alias = ?")) | |
| 133 | + | .bind(&[alias.into()])? | |
| 134 | + | .first::<AliasRow>(None) | |
| 135 | + | .await | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /// `resolve_alias`: the slug now of the workspace `slug` is an alias of. | |
| 139 | + | pub async fn resolve_alias(&self, a: SlugArgs) -> Result<Option<String>> { | |
| 140 | + | let slug = a.slug.trim().to_lowercase(); | |
| 141 | + | Ok(self.alias_row(&slug).await?.map(|row| row.slug)) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | /// Whether `slug` is an alias, of a workspace deleted or not, so nobody | |
| 145 | + | /// may register or rename a workspace to it. | |
| 146 | + | pub async fn is_alias(&self, slug: &str) -> Result<bool> { | |
| 147 | + | Ok(self | |
| 148 | + | .db | |
| 149 | + | .prepare("SELECT 1 AS held FROM workspace_aliases WHERE alias = ?") | |
| 150 | + | .bind(&[slug.trim().to_lowercase().into()])? | |
| 151 | + | .first::<serde_json::Value>(None) | |
| 152 | + | .await? | |
| 153 | + | .is_some()) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | /// `admin_aliases`: every alias, by name. Staff only. | |
| 157 | + | pub async fn admin_aliases(&self) -> Result<Vec<WorkspaceAlias>> { | |
| 158 | + | Ok(self | |
| 159 | + | .db | |
| 160 | + | .prepare(format!("{ALIAS_ROWS} ORDER BY a.alias")) | |
| 161 | + | .all() | |
| 162 | + | .await? | |
| 163 | + | .results::<AliasRow>()? | |
| 164 | + | .into_iter() | |
| 165 | + | .map(WorkspaceAlias::from) | |
| 166 | + | .collect()) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | /// `admin_set_alias`: staff only. Recorded in sudo's audit log. | |
| 170 | + | pub async fn admin_set_alias(&self, a: AdminSetAliasArgs) -> Result<Outcome<WorkspaceAlias>> { | |
| 171 | + | let staff = a.staff.trim(); | |
| 172 | + | if staff.is_empty() { | |
| 173 | + | return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is setting it.")); | |
| 174 | + | } | |
| 175 | + | let alias = a.alias.trim().to_lowercase(); | |
| 176 | + | let workspace = a.workspace.trim().to_lowercase(); | |
| 177 | + | let target = self | |
| 178 | + | .db | |
| 179 | + | .prepare("SELECT id FROM workspaces WHERE slug = ? AND deleted_at IS NULL") | |
| 180 | + | .bind(&[workspace.as_str().into()])? | |
| 181 | + | .first::<Id>(None) | |
| 182 | + | .await?; | |
| 183 | + | let username = self | |
| 184 | + | .db | |
| 185 | + | .prepare("SELECT 1 AS taken FROM users WHERE username = ?") | |
| 186 | + | .bind(&[alias.as_str().into()])? | |
| 187 | + | .first::<serde_json::Value>(None) | |
| 188 | + | .await? | |
| 189 | + | .is_some(); | |
| 190 | + | #[derive(Deserialize)] | |
| 191 | + | struct Held { | |
| 192 | + | workspace_id: String, | |
| 193 | + | created_at: String, | |
| 194 | + | } | |
| 195 | + | let held = self | |
| 196 | + | .db | |
| 197 | + | .prepare("SELECT workspace_id, created_at FROM workspace_redirects WHERE old_slug = ?") | |
| 198 | + | .bind(&[alias.as_str().into()])? | |
| 199 | + | .first::<Held>(None) | |
| 200 | + | .await? | |
| 201 | + | .filter(|row| row.created_at >= crate::rename::hold_cutoff(now_ms())); | |
| 202 | + | let existing = self.alias_row(&alias).await?; | |
| 203 | + | // An alias of a deleted workspace is not listed, but still holds. | |
| 204 | + | let alias_of = match &existing { | |
| 205 | + | Some(row) => Some(row.slug.clone()), | |
| 206 | + | None => self.is_alias(&alias).await?.then(|| "a deleted workspace".to_owned()), | |
| 207 | + | }; | |
| 208 | + | let facts = Facts { | |
| 209 | + | target: target.as_ref().map(|row| row.id.as_str()), | |
| 210 | + | username, | |
| 211 | + | workspace: self.slug_in_use(&alias).await?, | |
| 212 | + | held_for: held.as_ref().map(|row| row.workspace_id.as_str()), | |
| 213 | + | purged: self.slug_deleted(&alias).await?, | |
| 214 | + | alias_of: alias_of.as_deref(), | |
| 215 | + | }; | |
| 216 | + | let (alias, note) = match check(&alias, &a.note, &facts) { | |
| 217 | + | Ok(checked) => checked, | |
| 218 | + | Err((code, message)) => return Ok(Outcome::fail(code, message)), | |
| 219 | + | }; | |
| 220 | + | let Some(target) = target else { | |
| 221 | + | return Ok(Outcome::fail(FailureCode::NotFound, "There is no workspace with that slug.")); | |
| 222 | + | }; | |
| 223 | + | self.db | |
| 224 | + | .batch(vec![ | |
| 225 | + | // Its own old slug, made its alias: the redirect gives way. | |
| 226 | + | self.db | |
| 227 | + | .prepare("DELETE FROM workspace_redirects WHERE old_slug = ? AND workspace_id = ?") | |
| 228 | + | .bind(&[alias.as_str().into(), target.id.as_str().into()])?, | |
| 229 | + | self.db | |
| 230 | + | .prepare( | |
| 231 | + | "INSERT INTO workspace_aliases (alias, workspace_id, created_by, created_at, note) | |
| 232 | + | VALUES (?, ?, ?, ?, ?)", | |
| 233 | + | ) | |
| 234 | + | .bind(&[ | |
| 235 | + | alias.as_str().into(), | |
| 236 | + | target.id.as_str().into(), | |
| 237 | + | staff.into(), | |
| 238 | + | rfc3339(now_ms()).into(), | |
| 239 | + | note.as_str().into(), | |
| 240 | + | ])?, | |
| 241 | + | ]) | |
| 242 | + | .await?; | |
| 243 | + | self.record_for_staff(&workspace, "alias_added", &format!("Alias {alias} leads to {workspace}: {note}"), staff) | |
| 244 | + | .await; | |
| 245 | + | Ok(match self.alias_row(&alias).await? { | |
| 246 | + | Some(row) => Outcome::Ok(row.into()), | |
| 247 | + | None => Outcome::fail(FailureCode::NotFound, "There is no workspace with that slug."), | |
| 248 | + | }) | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | /// `admin_remove_alias`: staff only. Recorded in sudo's audit log. | |
| 252 | + | pub async fn admin_remove_alias(&self, a: AdminRemoveAliasArgs) -> Result<Outcome<bool>> { | |
| 253 | + | let staff = a.staff.trim(); | |
| 254 | + | if staff.is_empty() { | |
| 255 | + | return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is removing it.")); | |
| 256 | + | } | |
| 257 | + | let reason = a.reason.trim(); | |
| 258 | + | if reason.is_empty() { | |
| 259 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Say why the alias is being removed.")); | |
| 260 | + | } | |
| 261 | + | if reason.chars().count() > MAX_NOTE_LENGTH { | |
| 262 | + | return Ok(Outcome::fail( | |
| 263 | + | FailureCode::Invalid, | |
| 264 | + | format!("Keep the reason to {MAX_NOTE_LENGTH} characters."), | |
| 265 | + | )); | |
| 266 | + | } | |
| 267 | + | let alias = a.alias.trim().to_lowercase(); | |
| 268 | + | let Some(row) = self.alias_row(&alias).await? else { | |
| 269 | + | return Ok(Outcome::fail(FailureCode::NotFound, "There is no alias by that name.")); | |
| 270 | + | }; | |
| 271 | + | self.db | |
| 272 | + | .prepare("DELETE FROM workspace_aliases WHERE alias = ?") | |
| 273 | + | .bind(&[alias.as_str().into()])? | |
| 274 | + | .run() | |
| 275 | + | .await?; | |
| 276 | + | self.record_for_staff( | |
| 277 | + | &row.slug, | |
| 278 | + | "alias_removed", | |
| 279 | + | &format!("Alias {alias} no longer leads to {}: {reason}", row.slug), | |
| 280 | + | staff, | |
| 281 | + | ) | |
| 282 | + | .await; | |
| 283 | + | Ok(Outcome::Ok(true)) | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | #[cfg(test)] | |
| 288 | + | mod tests { | |
| 289 | + | use super::*; | |
| 290 | + | use std::collections::HashMap; | |
| 291 | + | ||
| 292 | + | fn facts<'a>() -> Facts<'a> { | |
| 293 | + | Facts { | |
| 294 | + | target: Some("wsp_flagon"), | |
| 295 | + | ..Facts::default() | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | fn refused(alias: &str, facts: &Facts) -> FailureCode { | |
| 300 | + | check(alias, "The product's name", facts).unwrap_err().0 | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | #[test] | |
| 304 | + | fn a_reserved_or_unclaimed_name_can_be_an_alias() { | |
| 305 | + | assert_eq!( | |
| 306 | + | check(" G1T ", " The product's name, for Flagon, Inc. ", &facts()).unwrap(), | |
| 307 | + | ("g1t".to_owned(), "The product's name, for Flagon, Inc.".to_owned()) | |
| 308 | + | ); | |
| 309 | + | assert!(check("flagon", "Short name", &facts()).is_ok()); | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | #[test] | |
| 313 | + | fn routes_and_malformed_names_are_never_aliases() { | |
| 314 | + | for bad in ["settings", "api", "login", "-g1t", "g1t-", "g--1t", "g1t_inc", "", "a.b"] { | |
| 315 | + | assert_eq!(refused(bad, &facts()), FailureCode::Invalid, "{bad}"); | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | #[test] | |
| 320 | + | fn a_reason_is_required_and_bounded() { | |
| 321 | + | assert_eq!(check("g1t", " ", &facts()).unwrap_err().0, FailureCode::Invalid); | |
| 322 | + | let long = "x".repeat(MAX_NOTE_LENGTH + 1); | |
| 323 | + | assert_eq!(check("g1t", &long, &facts()).unwrap_err().0, FailureCode::Invalid); | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | #[test] | |
| 327 | + | fn a_persons_or_workspaces_name_is_never_an_alias() { | |
| 328 | + | let missing = Facts { target: None, ..facts() }; | |
| 329 | + | assert_eq!(refused("g1t", &missing), FailureCode::NotFound); | |
| 330 | + | let person = Facts { username: true, ..facts() }; | |
| 331 | + | assert_eq!(refused("ana", &person), FailureCode::Conflict); | |
| 332 | + | let workspace = Facts { workspace: true, ..facts() }; | |
| 333 | + | assert_eq!(refused("acme", &workspace), FailureCode::Conflict); | |
| 334 | + | let purged = Facts { purged: true, ..facts() }; | |
| 335 | + | assert_eq!(refused("initech", &purged), FailureCode::Conflict); | |
| 336 | + | let aliased = Facts { | |
| 337 | + | alias_of: Some("globex"), | |
| 338 | + | ..facts() | |
| 339 | + | }; | |
| 340 | + | let (code, message) = check("g1t", "x", &aliased).unwrap_err(); | |
| 341 | + | assert_eq!(code, FailureCode::Conflict); | |
| 342 | + | assert_eq!(message, "g1t is already an alias of globex."); | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | #[test] | |
| 346 | + | fn only_its_own_old_slug_can_become_a_workspaces_alias() { | |
| 347 | + | let others = Facts { | |
| 348 | + | held_for: Some("wsp_other"), | |
| 349 | + | ..facts() | |
| 350 | + | }; | |
| 351 | + | assert_eq!(refused("acme", &others), FailureCode::Conflict); | |
| 352 | + | let own = Facts { | |
| 353 | + | held_for: Some("wsp_flagon"), | |
| 354 | + | ..facts() | |
| 355 | + | }; | |
| 356 | + | assert!(check("flagon", "Its old name, for good", &own).is_ok()); | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | #[test] | |
| 360 | + | fn a_workspace_in_use_is_never_resolved_elsewhere() { | |
| 361 | + | let alias = || Some("flagon-io".to_owned()); | |
| 362 | + | assert_eq!(resolve(true, alias(), || Some("x".into())), None); | |
| 363 | + | assert_eq!(resolve(false, alias(), || Some("x".into())).as_deref(), Some("flagon-io")); | |
| 364 | + | assert_eq!(resolve(false, None, || Some("acme-inc".into())).as_deref(), Some("acme-inc")); | |
| 365 | + | assert_eq!(resolve(false, None, || None), None); | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | /// Aliases point at ids, as the table does; renames change the slug. | |
| 369 | + | #[test] | |
| 370 | + | fn an_alias_follows_its_workspace_through_renames() { | |
| 371 | + | let mut slugs: HashMap<&str, &str> = HashMap::from([("wsp_flagon", "flagon-io")]); | |
| 372 | + | let aliases: HashMap<&str, &str> = HashMap::from([("g1t", "wsp_flagon")]); | |
| 373 | + | let lookup = |slugs: &HashMap<&str, &str>, alias: &str| { | |
| 374 | + | aliases.get(alias).and_then(|id| slugs.get(id)).map(|slug| (*slug).to_owned()) | |
| 375 | + | }; | |
| 376 | + | assert_eq!(lookup(&slugs, "g1t").as_deref(), Some("flagon-io")); | |
| 377 | + | slugs.insert("wsp_flagon", "flagon"); | |
| 378 | + | assert_eq!(lookup(&slugs, "g1t").as_deref(), Some("flagon")); | |
| 379 | + | slugs.insert("wsp_flagon", "flagon-inc"); | |
| 380 | + | assert_eq!(lookup(&slugs, "g1t").as_deref(), Some("flagon-inc")); | |
| 381 | + | assert_eq!(lookup(&slugs, "acme"), None); | |
| 382 | + | } | |
| 383 | + | } |
| 608 | 608 | self.db | |
| 609 | 609 | .prepare("DELETE FROM workspace_redirects WHERE workspace_id = ?") | |
| 610 | 610 | .bind(&[id.into()])?, | |
| 611 | + | // Aliases staff pointed at it lead nowhere now (aliases.rs). | |
| 612 | + | self.db | |
| 613 | + | .prepare("DELETE FROM workspace_aliases WHERE workspace_id = ?") | |
| 614 | + | .bind(&[id.into()])?, | |
| 611 | 615 | // Only while it is still deleted: a restore a moment ago wins. | |
| 612 | 616 | self.db | |
| 613 | 617 | .prepare("DELETE FROM workspaces WHERE id = ? AND deleted_at IS NOT NULL") | |
| ⋯ | |||
| 627 | 631 | } | |
| 628 | 632 | ||
| 629 | 633 | /// An entry in the workspace's audit log, which outlives it. | |
| 630 | − | async fn record_on_workspace( | |
| 634 | + | pub(crate) async fn record_on_workspace( | |
| 631 | 635 | &self, | |
| 632 | 636 | slug: &str, | |
| 633 | 637 | actor: AuditActor, | |
| ⋯ | |||
| 668 | 672 | ||
| 669 | 673 | /// A line in sudo's audit log (billing keeps it), naming the staff | |
| 670 | 674 | /// member. | |
| 671 | − | async fn record_for_staff(&self, slug: &str, action: &str, detail: &str, staff: &str) { | |
| 675 | + | pub(crate) async fn record_for_staff(&self, slug: &str, action: &str, detail: &str, staff: &str) { | |
| 672 | 676 | let Ok(billing) = self.env.service("BILLING") else { | |
| 673 | 677 | return; | |
| 674 | 678 | }; | |
| 5 | 5 | ||
| 6 | 6 | mod access; | |
| 7 | 7 | mod admin; | |
| 8 | + | mod aliases; | |
| 8 | 9 | mod avatars; | |
| 9 | 10 | mod crypto; | |
| 10 | 11 | mod deletion; | |
| ⋯ | |||
| 736 | 737 | "rename_workspace" => reply(&identity.rename_workspace(args(body)?).await?), | |
| 737 | 738 | "check_workspace_rename" => reply(&identity.check_workspace_rename(args(body)?).await?), | |
| 738 | 739 | "resolve_slug" => reply(&identity.resolve_slug(args(body)?).await?), | |
| 740 | + | "resolve_alias" => reply(&identity.resolve_alias(args(body)?).await?), | |
| 739 | 741 | "check_workspace_deletion" => reply(&identity.check_workspace_deletion(args(body)?).await?), | |
| 740 | 742 | "delete_workspace" => reply(&identity.delete_workspace(args(body)?).await?), | |
| 741 | 743 | "transfer_repo_scopes" => reply(&identity.transfer_repo_scopes(args(body)?).await?), | |
| ⋯ | |||
| 891 | 893 | "admin_deleted_workspaces" => reply(&identity.admin_deleted_workspaces().await?), | |
| 892 | 894 | "admin_restore_workspace" => reply(&identity.admin_restore_workspace(args(body)?).await?), | |
| 893 | 895 | "admin_purge_workspace" => reply(&identity.admin_purge_workspace(args(body)?).await?), | |
| 896 | + | // Workspace aliases, set by staff only; see aliases.rs. | |
| 897 | + | "admin_aliases" => reply(&identity.admin_aliases().await?), | |
| 898 | + | "admin_set_alias" => reply(&identity.admin_set_alias(args(body)?).await?), | |
| 899 | + | "admin_remove_alias" => reply(&identity.admin_remove_alias(args(body)?).await?), | |
| 894 | 900 | _ => Response::error("Unknown method", 404), | |
| 895 | 901 | }; | |
| 896 | 902 | served.finish(answered) | |
| 56 | 56 | pub last_renamed_at: Option<&'a str>, | |
| 57 | 57 | /// A deleted workspace had `wanted`; it is never given to another. | |
| 58 | 58 | pub deleted: bool, | |
| 59 | + | /// Staff made `wanted` an alias (aliases.rs); it stays theirs. | |
| 60 | + | pub aliased: bool, | |
| 59 | 61 | pub now_ms: u64, | |
| 60 | 62 | } | |
| 61 | 63 | ||
| ⋯ | |||
| 81 | 83 | ); | |
| 82 | 84 | } | |
| 83 | 85 | } | |
| 84 | − | if facts.someone_elses_username || facts.another_workspace || facts.deleted { | |
| 86 | + | if facts.someone_elses_username || facts.another_workspace || facts.deleted || facts.aliased { | |
| 85 | 87 | return refuse(FailureCode::Conflict, TAKEN); | |
| 86 | 88 | } | |
| 87 | 89 | if let Some((holder, created_at)) = facts.redirect | |
| ⋯ | |||
| 131 | 133 | } | |
| 132 | 134 | ||
| 133 | 135 | /// Whether `slug` is an old slug still reserved for the workspace that | |
| 134 | − | /// had it, so nobody else may register or create it. | |
| 136 | + | /// had it, or an alias staff set, so nobody else may register or create | |
| 137 | + | /// it. | |
| 135 | 138 | pub async fn slug_held(&self, slug: &str) -> Result<bool> { | |
| 136 | − | Ok(resolve(self.redirect(slug).await?, now_ms()).is_some()) | |
| 139 | + | Ok(resolve(self.redirect(slug).await?, now_ms()).is_some() || self.is_alias(slug).await?) | |
| 137 | 140 | } | |
| 138 | 141 | ||
| 139 | − | /// `resolve_slug`: the current slug for an old one still redirecting. | |
| 142 | + | /// `resolve_slug`: the current slug for an old one still redirecting, | |
| 143 | + | /// or for an alias (aliases.rs). | |
| 140 | 144 | pub async fn resolve_slug(&self, a: SlugArgs) -> Result<Option<String>> { | |
| 141 | 145 | let slug = a.slug.trim().to_lowercase(); | |
| 142 | − | if self.get_workspace(SlugArgs { slug: slug.clone() }).await?.is_some() { | |
| 146 | + | let in_use = self.get_workspace(SlugArgs { slug: slug.clone() }).await?.is_some(); | |
| 147 | + | if in_use { | |
| 143 | 148 | return Ok(None); | |
| 144 | 149 | } | |
| 145 | − | Ok(resolve(self.redirect(&slug).await?, now_ms())) | |
| 150 | + | let alias = self.resolve_alias(SlugArgs { slug: slug.clone() }).await?; | |
| 151 | + | let redirect = match alias { | |
| 152 | + | Some(_) => None, | |
| 153 | + | None => self.redirect(&slug).await?, | |
| 154 | + | }; | |
| 155 | + | Ok(crate::aliases::resolve(in_use, alias, || resolve(redirect, now_ms()))) | |
| 146 | 156 | } | |
| 147 | 157 | ||
| 148 | 158 | /// Checks a rename, returning the workspace's id when it is allowed. | |
| ⋯ | |||
| 203 | 213 | .map(|row| (row.workspace_id.as_str(), row.created_at.as_str())), | |
| 204 | 214 | last_renamed_at: last_renamed_at.as_deref(), | |
| 205 | 215 | deleted: self.slug_deleted(&wanted).await?, | |
| 216 | + | aliased: self.is_alias(&wanted).await?, | |
| 206 | 217 | now_ms: now_ms(), | |
| 207 | 218 | }; | |
| 208 | 219 | Ok(match check(&facts) { | |
| ⋯ | |||
| 364 | 375 | ..facts("acme", "initech") | |
| 365 | 376 | }; | |
| 366 | 377 | assert_eq!(refused(&deleted), FailureCode::Conflict); | |
| 378 | + | let aliased = Facts { | |
| 379 | + | aliased: true, | |
| 380 | + | ..facts("acme", "flagon") | |
| 381 | + | }; | |
| 382 | + | assert_eq!(refused(&aliased), FailureCode::Conflict); | |
| 367 | 383 | } | |
| 368 | 384 | ||
| 369 | 385 | #[test] | |