Skip to content
495 linesCodeBlameRaw
1//! Renaming a workspace: changing its slug, the first segment of its URLs,
2//! the way GitHub renames an organization.
3//!
4//! The workspace keeps its id, members, tokens and display name. Its old
5//! slug is recorded in `workspace_redirects`, pointing at the workspace's
6//! id, so that old addresses resolve to whatever the slug is now: renaming
7//! twice chains, because every old slug points at the same id. An old slug
8//! stays reserved for the workspace that had it for [`SLUG_HOLD_DAYS`], so
9//! nobody else can take it while links to it still redirect; the workspace
10//! itself can rename back to it. Renames are limited to one per
11//! [`RENAME_COOLDOWN_HOURS`] to stop churn.
12//!
13//! The rename publishes `workspace.renamed`; every other service moves the
14//! rows it keeps under the slug when it hears it.
15
16use g1t_contracts::events::{NewEvent, Publish, WorkspaceRenamed};
17use g1t_contracts::identity::*;
18use g1t_contracts::time::rfc3339;
19use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, is_action_owner_name, is_valid_namespace};
20use g1t_kit::now_ms;
21use serde::Deserialize;
22use worker::Result;
23
24use crate::Identity;
25
26const HOUR_MS: u64 = 60 * 60 * 1000;
27const DAY_MS: u64 = 24 * HOUR_MS;
28const SOURCE: &str = "identity";
29const TAKEN: &str = "That workspace name is taken.";
30/// How many times publishing the event is tried before giving up.
31const PUBLISH_ATTEMPTS: u32 = 3;
32
33/// The earliest `created_at` of a redirect that still holds its slug.
34pub fn hold_cutoff(now_ms: u64) -> String {
35 rfc3339(now_ms.saturating_sub(SLUG_HOLD_DAYS * DAY_MS))
36}
37
38/// Everything about a wanted slug that decides whether a workspace may
39/// take it, as read from the database.
40#[derive(Debug, Default)]
41pub struct Facts<'a> {
42 /// The workspace's id and its slug now.
43 pub workspace_id: &'a str,
44 pub current: &'a str,
45 /// The slug asked for, lowercased and trimmed.
46 pub wanted: &'a str,
47 /// Another person's username is `wanted`. The actor's own is theirs
48 /// to use, as when creating a workspace.
49 pub someone_elses_username: bool,
50 /// Another workspace's slug is `wanted`.
51 pub another_workspace: bool,
52 /// A redirect holds `wanted`: the workspace it points at, and when it
53 /// was made.
54 pub redirect: Option<(&'a str, &'a str)>,
55 /// When the workspace was last renamed, if ever.
56 pub last_renamed_at: Option<&'a str>,
57 /// A deleted workspace had `wanted`; it is never given to another.
58 pub deleted: bool,
59 /// Staff made `wanted` an alias (aliases.rs); it stays theirs.
60 pub aliased: bool,
61 pub now_ms: u64,
62}
63
64/// Whether the rename `facts` describe is allowed: `Ok`, or why not, in
65/// words for the owner.
66pub fn check(facts: &Facts) -> std::result::Result<(), (FailureCode, String)> {
67 let refuse = |code, message: &str| Err((code, message.to_owned()));
68 if !is_valid_namespace(facts.wanted) {
69 return refuse(
70 FailureCode::Invalid,
71 "Workspace names use lowercase letters, digits and single hyphens, up to 39 characters, and cannot be a reserved word.",
72 );
73 }
74 if is_action_owner_name(facts.wanted) {
75 return refuse(
76 FailureCode::Invalid,
77 "That name is a GitHub account whose actions workflows use, so it is kept for that account. If it is yours, contact support to prove it.",
78 );
79 }
80 if facts.wanted == facts.current {
81 return refuse(FailureCode::Invalid, "That is already this workspace's name.");
82 }
83 if let Some(last) = facts.last_renamed_at {
84 let cooldown_from = rfc3339(facts.now_ms.saturating_sub(RENAME_COOLDOWN_HOURS * HOUR_MS));
85 if last > cooldown_from.as_str() {
86 return refuse(
87 FailureCode::Conflict,
88 "A workspace can be renamed once a day. Try again tomorrow.",
89 );
90 }
91 }
92 if facts.someone_elses_username || facts.another_workspace || facts.deleted || facts.aliased {
93 return refuse(FailureCode::Conflict, TAKEN);
94 }
95 if let Some((holder, created_at)) = facts.redirect
96 && holder != facts.workspace_id
97 && created_at >= hold_cutoff(facts.now_ms).as_str()
98 {
99 return refuse(FailureCode::Conflict, TAKEN);
100 }
101 Ok(())
102}
103
104/// A redirect as read with the slug its workspace has now.
105#[derive(Debug, Deserialize)]
106pub struct RedirectRow {
107 pub workspace_id: String,
108 /// The workspace's slug now.
109 pub slug: String,
110 pub created_at: String,
111}
112
113/// Where an old slug leads: the workspace's current slug, while the
114/// redirect still holds.
115pub fn resolve(row: Option<RedirectRow>, now_ms: u64) -> Option<String> {
116 row.filter(|row| row.created_at >= hold_cutoff(now_ms))
117 .map(|row| row.slug)
118}
119
120#[derive(Deserialize)]
121struct Target {
122 id: String,
123}
124
125impl Identity {
126 /// The redirect holding `slug`, if any, with its workspace's slug now.
127 async fn redirect(&self, slug: &str) -> Result<Option<RedirectRow>> {
128 self.db
129 .prepare(
130 "SELECT workspace_redirects.workspace_id, workspaces.slug,
131 workspace_redirects.created_at
132 FROM workspace_redirects
133 JOIN workspaces ON workspaces.id = workspace_redirects.workspace_id
134 WHERE workspace_redirects.old_slug = ?",
135 )
136 .bind(&[slug.into()])?
137 .first::<RedirectRow>(None)
138 .await
139 }
140
141 /// Whether `slug` is an old slug still reserved for the workspace that
142 /// had it, or an alias staff set, so nobody else may register or create
143 /// it.
144 pub async fn slug_held(&self, slug: &str) -> Result<bool> {
145 Ok(resolve(self.redirect(slug).await?, now_ms()).is_some() || self.is_alias(slug).await?)
146 }
147
148 /// `resolve_slug`: the current slug for an old one still redirecting,
149 /// or for an alias (aliases.rs).
150 pub async fn resolve_slug(&self, a: SlugArgs) -> Result<Option<String>> {
151 let slug = a.slug.trim().to_lowercase();
152 let in_use = self.get_workspace(SlugArgs { slug: slug.clone() }).await?.is_some();
153 if in_use {
154 return Ok(None);
155 }
156 let alias = self.resolve_alias(SlugArgs { slug: slug.clone() }).await?;
157 let redirect = match alias {
158 Some(_) => None,
159 None => self.redirect(&slug).await?,
160 };
161 Ok(crate::aliases::resolve(in_use, alias, || resolve(redirect, now_ms())))
162 }
163
164 /// Checks a rename, returning the workspace's id when it is allowed.
165 async fn rename_allowed(&self, a: &RenameWorkspaceArgs) -> Result<Outcome<(String, String)>> {
166 let current = a.slug.trim().to_lowercase();
167 let wanted = a.new_slug.trim().to_lowercase();
168 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&current) != Some(Role::Owner) {
169 return Ok(Outcome::fail(
170 FailureCode::Forbidden,
171 "Only an owner can rename a workspace.",
172 ));
173 }
174 if !a.actor.verified {
175 return Ok(Outcome::fail(
176 FailureCode::Forbidden,
177 "Confirm your email address before renaming a workspace.",
178 ));
179 }
180 let Some(workspace) = self
181 .db
182 .prepare("SELECT id FROM workspaces WHERE slug = ?")
183 .bind(&[current.as_str().into()])?
184 .first::<Target>(None)
185 .await?
186 else {
187 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
188 };
189 let someone_elses_username = self
190 .db
191 .prepare("SELECT id FROM users WHERE username = ? AND id != ?")
192 .bind(&[wanted.as_str().into(), a.actor.id.as_str().into()])?
193 .first::<serde_json::Value>(None)
194 .await?
195 .is_some();
196 let another_workspace = self
197 .db
198 .prepare("SELECT id FROM workspaces WHERE slug = ? AND id != ?")
199 .bind(&[wanted.as_str().into(), workspace.id.as_str().into()])?
200 .first::<serde_json::Value>(None)
201 .await?
202 .is_some();
203 let redirect = self.redirect(&wanted).await?;
204 let last_renamed_at = self
205 .db
206 .prepare("SELECT max(created_at) AS at FROM workspace_redirects WHERE workspace_id = ?")
207 .bind(&[workspace.id.as_str().into()])?
208 .first::<Option<String>>(Some("at"))
209 .await?
210 .flatten();
211 let facts = Facts {
212 workspace_id: &workspace.id,
213 current: &current,
214 wanted: &wanted,
215 someone_elses_username,
216 another_workspace,
217 redirect: redirect
218 .as_ref()
219 .map(|row| (row.workspace_id.as_str(), row.created_at.as_str())),
220 last_renamed_at: last_renamed_at.as_deref(),
221 deleted: self.slug_deleted(&wanted).await?,
222 aliased: self.is_alias(&wanted).await?,
223 now_ms: now_ms(),
224 };
225 Ok(match check(&facts) {
226 Ok(()) => Outcome::Ok((workspace.id, wanted)),
227 Err((code, message)) => Outcome::fail(code, message),
228 })
229 }
230
231 pub async fn check_workspace_rename(&self, a: RenameWorkspaceArgs) -> Result<Outcome<bool>> {
232 Ok(match self.rename_allowed(&a).await? {
233 Outcome::Ok(_) => Outcome::Ok(true),
234 Outcome::Fail(failure) => Outcome::Fail(failure),
235 })
236 }
237
238 pub async fn rename_workspace(&self, a: RenameWorkspaceArgs) -> Result<Outcome<Workspace>> {
239 let (workspace_id, wanted) = match self.rename_allowed(&a).await? {
240 Outcome::Ok(allowed) => allowed,
241 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
242 };
243 let from = a.slug.trim().to_lowercase();
244 let now = rfc3339(now_ms());
245 // A workspace protected by its slug stays protected under the new
246 // one: the protection goes on its row (deletion.rs).
247 let protected = self.is_protected(&workspace_id, &from, false).await?;
248 self.db
249 .batch(vec![
250 self.db
251 .prepare("UPDATE workspaces SET protected = 1 WHERE id = ? AND ? = 1")
252 .bind(&[workspace_id.as_str().into(), u8::from(protected).into()])?,
253 // A redirect the workspace is renaming back to, or one whose
254 // hold has ended, gives way to the slug in use.
255 self.db
256 .prepare("DELETE FROM workspace_redirects WHERE old_slug = ?")
257 .bind(&[wanted.as_str().into()])?,
258 self.db
259 .prepare("UPDATE workspaces SET slug = ? WHERE id = ? AND slug = ?")
260 .bind(&[
261 wanted.as_str().into(),
262 workspace_id.as_str().into(),
263 from.as_str().into(),
264 ])?,
265 self.db
266 .prepare(
267 "INSERT OR REPLACE INTO workspace_redirects (old_slug, workspace_id, created_at)
268 VALUES (?, ?, ?)",
269 )
270 .bind(&[
271 from.as_str().into(),
272 workspace_id.as_str().into(),
273 now.as_str().into(),
274 ])?,
275 // Agents at work keep their scope: it names the repository
276 // by its path.
277 self.db
278 .prepare(
279 "UPDATE access_tokens SET agent_scope = json_set(agent_scope, '$.repo.namespace', ?)
280 WHERE agent_scope IS NOT NULL
281 AND json_extract(agent_scope, '$.repo.namespace') = ?",
282 )
283 .bind(&[wanted.as_str().into(), from.as_str().into()])?,
284 ])
285 .await?;
286 self.publish_renamed(WorkspaceRenamed {
287 workspace_id,
288 from,
289 to: wanted.clone(),
290 }, &a.actor.id)
291 .await;
292 Ok(match self.get_workspace(SlugArgs { slug: wanted }).await? {
293 Some(workspace) => Outcome::Ok(workspace),
294 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
295 })
296 }
297
298 /// Tells every other service. The rename has happened by now, so a
299 /// failure is logged rather than undoing it.
300 async fn publish_renamed(&self, renamed: WorkspaceRenamed, actor: &str) {
301 let events = match self.env.service("EVENTS") {
302 Ok(events) => events,
303 Err(error) => {
304 worker::console_error!("workspace.renamed not published: {error}");
305 return;
306 }
307 };
308 let publish = Publish {
309 events: vec![NewEvent {
310 kind: "workspace.renamed",
311 source: SOURCE,
312 repo_id: None,
313 actor: Some(actor.to_owned()),
314 data: renamed,
315 }],
316 };
317 for attempt in 1..=PUBLISH_ATTEMPTS {
318 match g1t_kit::call::<_, serde_json::Value>(&events, "publish", &publish).await {
319 Ok(_) => return,
320 Err(error) => worker::console_error!(
321 "workspace.renamed publish attempt {attempt} failed: {error}"
322 ),
323 }
324 }
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use std::collections::HashMap;
332
333 const NOW: u64 = 1_790_918_179_123;
334
335 fn facts<'a>(current: &'a str, wanted: &'a str) -> Facts<'a> {
336 Facts {
337 workspace_id: "wsp_a",
338 current,
339 wanted,
340 now_ms: NOW,
341 ..Facts::default()
342 }
343 }
344
345 fn refused(facts: &Facts) -> FailureCode {
346 check(facts).unwrap_err().0
347 }
348
349 #[test]
350 fn validates_like_creation() {
351 assert!(check(&facts("acme", "acme-inc")).is_ok());
352 for bad in ["", "-acme", "acme-", "ac--me", "Acme", "acme_inc", "api", "settings", "pulls"] {
353 assert_eq!(refused(&facts("acme", bad)), FailureCode::Invalid, "{bad}");
354 }
355 assert_eq!(refused(&facts("acme", &"a".repeat(40))), FailureCode::Invalid);
356 assert_eq!(refused(&facts("acme", "acme")), FailureCode::Invalid);
357 }
358
359 #[test]
360 fn no_workspace_is_renamed_to_g1ts_names() {
361 for name in ["g1t", "g1t-agent", "G1T", "G1T-Agent"] {
362 assert_eq!(refused(&facts("acme", name)), FailureCode::Invalid, "{name}");
363 }
364 assert!(check(&facts("acme", "g1t-fans")).is_ok());
365 }
366
367 #[test]
368 fn no_workspace_is_renamed_to_a_github_action_owner() {
369 for name in ["actions", "docker", "aws-actions"] {
370 assert_eq!(refused(&facts("acme", name)), FailureCode::Invalid, "{name}");
371 }
372 assert!(check(&facts("acme", "actions-team")).is_ok());
373 }
374
375 #[test]
376 fn refuses_names_in_use() {
377 let taken = Facts {
378 someone_elses_username: true,
379 ..facts("acme", "bob")
380 };
381 assert_eq!(refused(&taken), FailureCode::Conflict);
382 let taken = Facts {
383 another_workspace: true,
384 ..facts("acme", "globex")
385 };
386 assert_eq!(refused(&taken), FailureCode::Conflict);
387 let deleted = Facts {
388 deleted: true,
389 ..facts("acme", "initech")
390 };
391 assert_eq!(refused(&deleted), FailureCode::Conflict);
392 let aliased = Facts {
393 aliased: true,
394 ..facts("acme", "flagon")
395 };
396 assert_eq!(refused(&aliased), FailureCode::Conflict);
397 }
398
399 #[test]
400 fn an_old_slug_is_held_for_its_workspace_until_the_hold_ends() {
401 let recently = rfc3339(NOW - 10 * DAY_MS);
402 let long_ago = rfc3339(NOW - (SLUG_HOLD_DAYS + 1) * DAY_MS);
403 let held_by_other = Facts {
404 redirect: Some(("wsp_b", recently.as_str())),
405 ..facts("acme", "globex")
406 };
407 assert_eq!(refused(&held_by_other), FailureCode::Conflict);
408 let held_by_self = Facts {
409 redirect: Some(("wsp_a", recently.as_str())),
410 last_renamed_at: Some(recently.as_str()),
411 ..facts("acme-inc", "acme")
412 };
413 assert!(check(&held_by_self).is_ok(), "a workspace can rename back");
414 let expired = Facts {
415 redirect: Some(("wsp_b", long_ago.as_str())),
416 ..facts("acme", "globex")
417 };
418 assert!(check(&expired).is_ok(), "after the hold anyone can take it");
419 }
420
421 #[test]
422 fn renames_are_limited_to_one_a_day() {
423 let an_hour_ago = rfc3339(NOW - HOUR_MS);
424 let two_days_ago = rfc3339(NOW - 2 * DAY_MS);
425 let soon = Facts {
426 last_renamed_at: Some(an_hour_ago.as_str()),
427 ..facts("acme", "acme-inc")
428 };
429 assert_eq!(refused(&soon), FailureCode::Conflict);
430 let later = Facts {
431 last_renamed_at: Some(two_days_ago.as_str()),
432 ..facts("acme", "acme-inc")
433 };
434 assert!(check(&later).is_ok());
435 }
436
437 #[test]
438 fn hold_ends_after_the_hold_period() {
439 assert_eq!(hold_cutoff(NOW), rfc3339(NOW - SLUG_HOLD_DAYS * DAY_MS));
440 }
441
442 /// The tables, as `rename_workspace` changes them: slug by workspace
443 /// id, and old slug → (workspace id, when).
444 #[derive(Default)]
445 struct Tables {
446 workspaces: HashMap<&'static str, String>,
447 redirects: HashMap<String, (&'static str, String)>,
448 }
449
450 impl Tables {
451 /// The same steps, in the same order, as the batch.
452 fn rename(&mut self, id: &'static str, to: &str, at: u64) {
453 self.redirects.remove(to);
454 let from = self.workspaces.insert(id, to.to_owned()).unwrap();
455 self.redirects.insert(from, (id, rfc3339(at)));
456 }
457
458 /// As `redirect` + `resolve`.
459 fn resolve(&self, slug: &str, now: u64) -> Option<String> {
460 let row = self.redirects.get(slug).map(|(id, created_at)| RedirectRow {
461 workspace_id: (*id).to_owned(),
462 slug: self.workspaces[id].clone(),
463 created_at: created_at.clone(),
464 });
465 resolve(row, now)
466 }
467 }
468
469 #[test]
470 fn renames_chain_to_the_current_slug() {
471 let mut tables = Tables::default();
472 tables.workspaces.insert("wsp_a", "acme".into());
473 tables.rename("wsp_a", "acme-inc", NOW);
474 tables.rename("wsp_a", "acme-corp", NOW + 2 * DAY_MS);
475 let later = NOW + 3 * DAY_MS;
476 assert_eq!(tables.resolve("acme", later).as_deref(), Some("acme-corp"));
477 assert_eq!(tables.resolve("acme-inc", later).as_deref(), Some("acme-corp"));
478 assert_eq!(tables.resolve("unknown", later), None);
479 // Past the hold, the first old slug stops redirecting.
480 let much_later = NOW + (SLUG_HOLD_DAYS + 1) * DAY_MS;
481 assert_eq!(tables.resolve("acme", much_later), None);
482 assert_eq!(tables.resolve("acme-inc", much_later).as_deref(), Some("acme-corp"));
483 }
484
485 #[test]
486 fn renaming_back_drops_the_redirect_for_the_slug_in_use() {
487 let mut tables = Tables::default();
488 tables.workspaces.insert("wsp_a", "acme".into());
489 tables.rename("wsp_a", "acme-inc", NOW);
490 tables.rename("wsp_a", "acme", NOW + 2 * DAY_MS);
491 assert!(!tables.redirects.contains_key("acme"));
492 let later = NOW + 3 * DAY_MS;
493 assert_eq!(tables.resolve("acme-inc", later).as_deref(), Some("acme"));
494 }
495}