Skip to content

g1t/services/identity/src/aliases.rs

383 lines15,216 bytesCodeBlame
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
17use g1t_contracts::identity::*;
18use g1t_contracts::time::rfc3339;
19use g1t_contracts::{FailureCode, Outcome, aliasable_name};
20use g1t_kit::now_ms;
21use serde::Deserialize;
22use worker::Result;
23
24use crate::Identity;
25
26/// The longest note or reason staff may give.
27pub 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)]
32pub 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.
48pub 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.
88pub 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)]
96struct 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
106impl 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.
121const 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)]
125struct Id {
126 id: String,
127}
128
129impl 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)]
288mod 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}