g1t/services/identity/src/rename.rs

465 lines18,252 bytesCodeBlame

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.

Agents and memory, checks and conflicts, profiles, slug renames, custom domains1//! 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_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>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look57 /// A deleted workspace had `wanted`; it is never given to another.
58 pub deleted: bool,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains59 pub now_ms: u64,
60}
61
62/// Whether the rename `facts` describe is allowed: `Ok`, or why not, in
63/// words for the owner.
64pub fn check(facts: &Facts) -> std::result::Result<(), (FailureCode, String)> {
65 let refuse = |code, message: &str| Err((code, message.to_owned()));
66 if !is_valid_namespace(facts.wanted) {
67 return refuse(
68 FailureCode::Invalid,
69 "Workspace names use lowercase letters, digits and single hyphens, up to 39 characters, and cannot be a reserved word.",
70 );
71 }
72 if facts.wanted == facts.current {
73 return refuse(FailureCode::Invalid, "That is already this workspace's name.");
74 }
75 if let Some(last) = facts.last_renamed_at {
76 let cooldown_from = rfc3339(facts.now_ms.saturating_sub(RENAME_COOLDOWN_HOURS * HOUR_MS));
77 if last > cooldown_from.as_str() {
78 return refuse(
79 FailureCode::Conflict,
80 "A workspace can be renamed once a day. Try again tomorrow.",
81 );
82 }
83 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look84 if facts.someone_elses_username || facts.another_workspace || facts.deleted {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains85 return refuse(FailureCode::Conflict, TAKEN);
86 }
87 if let Some((holder, created_at)) = facts.redirect
88 && holder != facts.workspace_id
89 && created_at >= hold_cutoff(facts.now_ms).as_str()
90 {
91 return refuse(FailureCode::Conflict, TAKEN);
92 }
93 Ok(())
94}
95
96/// A redirect as read with the slug its workspace has now.
97#[derive(Debug, Deserialize)]
98pub struct RedirectRow {
99 pub workspace_id: String,
100 /// The workspace's slug now.
101 pub slug: String,
102 pub created_at: String,
103}
104
105/// Where an old slug leads: the workspace's current slug, while the
106/// redirect still holds.
107pub fn resolve(row: Option<RedirectRow>, now_ms: u64) -> Option<String> {
108 row.filter(|row| row.created_at >= hold_cutoff(now_ms))
109 .map(|row| row.slug)
110}
111
112#[derive(Deserialize)]
113struct Target {
114 id: String,
115}
116
117impl Identity {
118 /// The redirect holding `slug`, if any, with its workspace's slug now.
119 async fn redirect(&self, slug: &str) -> Result<Option<RedirectRow>> {
120 self.db
121 .prepare(
122 "SELECT workspace_redirects.workspace_id, workspaces.slug,
123 workspace_redirects.created_at
124 FROM workspace_redirects
125 JOIN workspaces ON workspaces.id = workspace_redirects.workspace_id
126 WHERE workspace_redirects.old_slug = ?",
127 )
128 .bind(&[slug.into()])?
129 .first::<RedirectRow>(None)
130 .await
131 }
132
133 /// Whether `slug` is an old slug still reserved for the workspace that
134 /// had it, so nobody else may register or create it.
135 pub async fn slug_held(&self, slug: &str) -> Result<bool> {
136 Ok(resolve(self.redirect(slug).await?, now_ms()).is_some())
137 }
138
139 /// `resolve_slug`: the current slug for an old one still redirecting.
140 pub async fn resolve_slug(&self, a: SlugArgs) -> Result<Option<String>> {
141 let slug = a.slug.trim().to_lowercase();
142 if self.get_workspace(SlugArgs { slug: slug.clone() }).await?.is_some() {
143 return Ok(None);
144 }
145 Ok(resolve(self.redirect(&slug).await?, now_ms()))
146 }
147
148 /// Checks a rename, returning the workspace's id when it is allowed.
149 async fn rename_allowed(&self, a: &RenameWorkspaceArgs) -> Result<Outcome<(String, String)>> {
150 let current = a.slug.trim().to_lowercase();
151 let wanted = a.new_slug.trim().to_lowercase();
152 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&current) != Some(Role::Owner) {
153 return Ok(Outcome::fail(
154 FailureCode::Forbidden,
155 "Only an owner can rename a workspace.",
156 ));
157 }
158 if !a.actor.verified {
159 return Ok(Outcome::fail(
160 FailureCode::Forbidden,
161 "Confirm your email address before renaming a workspace.",
162 ));
163 }
164 let Some(workspace) = self
165 .db
166 .prepare("SELECT id FROM workspaces WHERE slug = ?")
167 .bind(&[current.as_str().into()])?
168 .first::<Target>(None)
169 .await?
170 else {
171 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
172 };
173 let someone_elses_username = self
174 .db
175 .prepare("SELECT id FROM users WHERE username = ? AND id != ?")
176 .bind(&[wanted.as_str().into(), a.actor.id.as_str().into()])?
177 .first::<serde_json::Value>(None)
178 .await?
179 .is_some();
180 let another_workspace = self
181 .db
182 .prepare("SELECT id FROM workspaces WHERE slug = ? AND id != ?")
183 .bind(&[wanted.as_str().into(), workspace.id.as_str().into()])?
184 .first::<serde_json::Value>(None)
185 .await?
186 .is_some();
187 let redirect = self.redirect(&wanted).await?;
188 let last_renamed_at = self
189 .db
190 .prepare("SELECT max(created_at) AS at FROM workspace_redirects WHERE workspace_id = ?")
191 .bind(&[workspace.id.as_str().into()])?
192 .first::<Option<String>>(Some("at"))
193 .await?
194 .flatten();
195 let facts = Facts {
196 workspace_id: &workspace.id,
197 current: &current,
198 wanted: &wanted,
199 someone_elses_username,
200 another_workspace,
201 redirect: redirect
202 .as_ref()
203 .map(|row| (row.workspace_id.as_str(), row.created_at.as_str())),
204 last_renamed_at: last_renamed_at.as_deref(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look205 deleted: self.slug_deleted(&wanted).await?,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains206 now_ms: now_ms(),
207 };
208 Ok(match check(&facts) {
209 Ok(()) => Outcome::Ok((workspace.id, wanted)),
210 Err((code, message)) => Outcome::fail(code, message),
211 })
212 }
213
214 pub async fn check_workspace_rename(&self, a: RenameWorkspaceArgs) -> Result<Outcome<bool>> {
215 Ok(match self.rename_allowed(&a).await? {
216 Outcome::Ok(_) => Outcome::Ok(true),
217 Outcome::Fail(failure) => Outcome::Fail(failure),
218 })
219 }
220
221 pub async fn rename_workspace(&self, a: RenameWorkspaceArgs) -> Result<Outcome<Workspace>> {
222 let (workspace_id, wanted) = match self.rename_allowed(&a).await? {
223 Outcome::Ok(allowed) => allowed,
224 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
225 };
226 let from = a.slug.trim().to_lowercase();
227 let now = rfc3339(now_ms());
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member228 // A workspace protected by its slug stays protected under the new
229 // one: the protection goes on its row (deletion.rs).
230 let protected = self.is_protected(&workspace_id, &from, false).await?;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains231 self.db
232 .batch(vec![
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member233 self.db
234 .prepare("UPDATE workspaces SET protected = 1 WHERE id = ? AND ? = 1")
235 .bind(&[workspace_id.as_str().into(), u8::from(protected).into()])?,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains236 // A redirect the workspace is renaming back to, or one whose
237 // hold has ended, gives way to the slug in use.
238 self.db
239 .prepare("DELETE FROM workspace_redirects WHERE old_slug = ?")
240 .bind(&[wanted.as_str().into()])?,
241 self.db
242 .prepare("UPDATE workspaces SET slug = ? WHERE id = ? AND slug = ?")
243 .bind(&[
244 wanted.as_str().into(),
245 workspace_id.as_str().into(),
246 from.as_str().into(),
247 ])?,
248 self.db
249 .prepare(
250 "INSERT OR REPLACE INTO workspace_redirects (old_slug, workspace_id, created_at)
251 VALUES (?, ?, ?)",
252 )
253 .bind(&[
254 from.as_str().into(),
255 workspace_id.as_str().into(),
256 now.as_str().into(),
257 ])?,
258 // Agents at work keep their scope: it names the repository
259 // by its path.
260 self.db
261 .prepare(
262 "UPDATE access_tokens SET agent_scope = json_set(agent_scope, '$.repo.namespace', ?)
263 WHERE agent_scope IS NOT NULL
264 AND json_extract(agent_scope, '$.repo.namespace') = ?",
265 )
266 .bind(&[wanted.as_str().into(), from.as_str().into()])?,
267 ])
268 .await?;
269 self.publish_renamed(WorkspaceRenamed {
270 workspace_id,
271 from,
272 to: wanted.clone(),
273 }, &a.actor.id)
274 .await;
275 Ok(match self.get_workspace(SlugArgs { slug: wanted }).await? {
276 Some(workspace) => Outcome::Ok(workspace),
277 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
278 })
279 }
280
281 /// Tells every other service. The rename has happened by now, so a
282 /// failure is logged rather than undoing it.
283 async fn publish_renamed(&self, renamed: WorkspaceRenamed, actor: &str) {
284 let events = match self.env.service("EVENTS") {
285 Ok(events) => events,
286 Err(error) => {
287 worker::console_error!("workspace.renamed not published: {error}");
288 return;
289 }
290 };
291 let publish = Publish {
292 events: vec![NewEvent {
293 kind: "workspace.renamed",
294 source: SOURCE,
295 repo_id: None,
296 actor: Some(actor.to_owned()),
297 data: renamed,
298 }],
299 };
300 for attempt in 1..=PUBLISH_ATTEMPTS {
301 match g1t_kit::call::<_, serde_json::Value>(&events, "publish", &publish).await {
302 Ok(_) => return,
303 Err(error) => worker::console_error!(
304 "workspace.renamed publish attempt {attempt} failed: {error}"
305 ),
306 }
307 }
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use std::collections::HashMap;
315
316 const NOW: u64 = 1_790_918_179_123;
317
318 fn facts<'a>(current: &'a str, wanted: &'a str) -> Facts<'a> {
319 Facts {
320 workspace_id: "wsp_a",
321 current,
322 wanted,
323 now_ms: NOW,
324 ..Facts::default()
325 }
326 }
327
328 fn refused(facts: &Facts) -> FailureCode {
329 check(facts).unwrap_err().0
330 }
331
332 #[test]
333 fn validates_like_creation() {
334 assert!(check(&facts("acme", "acme-inc")).is_ok());
335 for bad in ["", "-acme", "acme-", "ac--me", "Acme", "acme_inc", "api", "settings", "pulls"] {
336 assert_eq!(refused(&facts("acme", bad)), FailureCode::Invalid, "{bad}");
337 }
338 assert_eq!(refused(&facts("acme", &"a".repeat(40))), FailureCode::Invalid);
339 assert_eq!(refused(&facts("acme", "acme")), FailureCode::Invalid);
340 }
341
342 #[test]
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent343 fn no_workspace_is_renamed_to_g1ts_names() {
344 for name in ["g1t", "g1t-agent", "G1T", "G1T-Agent"] {
345 assert_eq!(refused(&facts("acme", name)), FailureCode::Invalid, "{name}");
346 }
347 assert!(check(&facts("acme", "g1t-fans")).is_ok());
348 }
349
350 #[test]
Agents and memory, checks and conflicts, profiles, slug renames, custom domains351 fn refuses_names_in_use() {
352 let taken = Facts {
353 someone_elses_username: true,
354 ..facts("acme", "bob")
355 };
356 assert_eq!(refused(&taken), FailureCode::Conflict);
357 let taken = Facts {
358 another_workspace: true,
359 ..facts("acme", "globex")
360 };
361 assert_eq!(refused(&taken), FailureCode::Conflict);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look362 let deleted = Facts {
363 deleted: true,
364 ..facts("acme", "initech")
365 };
366 assert_eq!(refused(&deleted), FailureCode::Conflict);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains367 }
368
369 #[test]
370 fn an_old_slug_is_held_for_its_workspace_until_the_hold_ends() {
371 let recently = rfc3339(NOW - 10 * DAY_MS);
372 let long_ago = rfc3339(NOW - (SLUG_HOLD_DAYS + 1) * DAY_MS);
373 let held_by_other = Facts {
374 redirect: Some(("wsp_b", recently.as_str())),
375 ..facts("acme", "globex")
376 };
377 assert_eq!(refused(&held_by_other), FailureCode::Conflict);
378 let held_by_self = Facts {
379 redirect: Some(("wsp_a", recently.as_str())),
380 last_renamed_at: Some(recently.as_str()),
381 ..facts("acme-inc", "acme")
382 };
383 assert!(check(&held_by_self).is_ok(), "a workspace can rename back");
384 let expired = Facts {
385 redirect: Some(("wsp_b", long_ago.as_str())),
386 ..facts("acme", "globex")
387 };
388 assert!(check(&expired).is_ok(), "after the hold anyone can take it");
389 }
390
391 #[test]
392 fn renames_are_limited_to_one_a_day() {
393 let an_hour_ago = rfc3339(NOW - HOUR_MS);
394 let two_days_ago = rfc3339(NOW - 2 * DAY_MS);
395 let soon = Facts {
396 last_renamed_at: Some(an_hour_ago.as_str()),
397 ..facts("acme", "acme-inc")
398 };
399 assert_eq!(refused(&soon), FailureCode::Conflict);
400 let later = Facts {
401 last_renamed_at: Some(two_days_ago.as_str()),
402 ..facts("acme", "acme-inc")
403 };
404 assert!(check(&later).is_ok());
405 }
406
407 #[test]
408 fn hold_ends_after_the_hold_period() {
409 assert_eq!(hold_cutoff(NOW), rfc3339(NOW - SLUG_HOLD_DAYS * DAY_MS));
410 }
411
412 /// The tables, as `rename_workspace` changes them: slug by workspace
413 /// id, and old slug → (workspace id, when).
414 #[derive(Default)]
415 struct Tables {
416 workspaces: HashMap<&'static str, String>,
417 redirects: HashMap<String, (&'static str, String)>,
418 }
419
420 impl Tables {
421 /// The same steps, in the same order, as the batch.
422 fn rename(&mut self, id: &'static str, to: &str, at: u64) {
423 self.redirects.remove(to);
424 let from = self.workspaces.insert(id, to.to_owned()).unwrap();
425 self.redirects.insert(from, (id, rfc3339(at)));
426 }
427
428 /// As `redirect` + `resolve`.
429 fn resolve(&self, slug: &str, now: u64) -> Option<String> {
430 let row = self.redirects.get(slug).map(|(id, created_at)| RedirectRow {
431 workspace_id: (*id).to_owned(),
432 slug: self.workspaces[id].clone(),
433 created_at: created_at.clone(),
434 });
435 resolve(row, now)
436 }
437 }
438
439 #[test]
440 fn renames_chain_to_the_current_slug() {
441 let mut tables = Tables::default();
442 tables.workspaces.insert("wsp_a", "acme".into());
443 tables.rename("wsp_a", "acme-inc", NOW);
444 tables.rename("wsp_a", "acme-corp", NOW + 2 * DAY_MS);
445 let later = NOW + 3 * DAY_MS;
446 assert_eq!(tables.resolve("acme", later).as_deref(), Some("acme-corp"));
447 assert_eq!(tables.resolve("acme-inc", later).as_deref(), Some("acme-corp"));
448 assert_eq!(tables.resolve("unknown", later), None);
449 // Past the hold, the first old slug stops redirecting.
450 let much_later = NOW + (SLUG_HOLD_DAYS + 1) * DAY_MS;
451 assert_eq!(tables.resolve("acme", much_later), None);
452 assert_eq!(tables.resolve("acme-inc", much_later).as_deref(), Some("acme-corp"));
453 }
454
455 #[test]
456 fn renaming_back_drops_the_redirect_for_the_slug_in_use() {
457 let mut tables = Tables::default();
458 tables.workspaces.insert("wsp_a", "acme".into());
459 tables.rename("wsp_a", "acme-inc", NOW);
460 tables.rename("wsp_a", "acme", NOW + 2 * DAY_MS);
461 assert!(!tables.redirects.contains_key("acme"));
462 let later = NOW + 3 * DAY_MS;
463 assert_eq!(tables.resolve("acme-inc", later).as_deref(), Some("acme"));
464 }
465}