Skip to content
829 linesCodeBlameRaw

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.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1//! Deleting a workspace.
2//!
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member3//! Only an owner can, only a person, typing the slug to confirm, and only
4//! once billing can settle it (`close_workspace`: nothing owed that cannot
5//! be charged now, no failed invoice, no prepaid credit left). Some
6//! workspaces can never be deleted, by anyone: those in
7//! `PROTECTED_WORKSPACES`, Flagon's whatever that says
8//! (`g1t_contracts::identity::protected_names`), and any whose row is
9//! marked protected, which a rename of a protected workspace sets so the
10//! protection follows it.
11//!
12//! Everything in it goes in that one step, softly. The row gets
13//! `deleted_at`, `deleted_by` and `purge_after` ([`WORKSPACE_RESTORE_DAYS`]
14//! on), and every read that resolves a workspace leaves it out: nobody's
15//! memberships list it, its tokens are refused, its pages are not found.
16//! Its members and tokens are kept as they were, and its slug stays held by
17//! its row. `workspace.deleting` tells every service to put away what it
18//! keeps for it: repos deletes its repositories softly, marked as gone with
19//! it, deployments pauses its apps, projects and search hide it.
20//!
21//! Until `purge_after`, g1t's staff can restore it from sudo: the columns
22//! are cleared, `workspace.restored` undoes exactly what the deletion did,
23//! and it is back with its members and tokens. A malicious or mistaken
24//! deletion is undone this way, through support.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look25//!
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member26//! The purge, by the scheduled sweep once `purge_after` passes or by staff
27//! from sudo, is what deleting used to do at once: the row, memberships,
28//! tokens and old-slug redirects go, the slug is kept in
29//! `deleted_workspaces` so it is never given to another workspace or
30//! account, and `workspace.deleted` tells services to drop what they keep.
31//! Billing's ledger and invoices, and the audit log, keep its history under
32//! its slug. The one exception to the slug is the person whose username it
33//! is: usernames and workspaces share one namespace, so the name is theirs
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look34//! anyway, and they may make a workspace of it again (it starts empty).
35//!
36//! A person keeps their account whatever workspaces they lose: an account
37//! with no workspace, or with only other people's, works as any other.
38
39use g1t_contracts::audit::{
40 AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface,
41};
42use g1t_contracts::billing::CloseWorkspaceArgs;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member43use g1t_contracts::events::{WorkspaceDeleted, WorkspaceDeleting, WorkspaceRestored};
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look44use g1t_contracts::identity::*;
45use g1t_contracts::repos::NamespaceCountArgs;
46use g1t_contracts::time::rfc3339;
47use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, new_id};
48use g1t_kit::now_ms;
49use serde::Deserialize;
50use serde_json::json;
51use worker::Result;
52
53use crate::Identity;
54
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member55type Refusal = (FailureCode, String);
56
57/// How many workspaces one sweep purges.
58const PURGES_PER_SWEEP: u32 = 25;
59
60/// Who may delete, decided from the request and whether the workspace is
61/// protected: `Ok`, or why not. A protected workspace is refused to
62/// everyone, owners included.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look63pub fn may_delete(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member64 protected: bool,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look65 person: bool,
66 verified: bool,
67 role: Option<Role>,
68 slug: &str,
69 confirm: Option<&str>,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member70) -> std::result::Result<(), Refusal> {
71 if protected {
72 return Err((FailureCode::Forbidden, protected_refusal(slug)));
73 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look74 if !person || role != Some(Role::Owner) {
75 return Err((
76 FailureCode::Forbidden,
77 "Only an owner can delete a workspace.".into(),
78 ));
79 }
80 if !verified {
81 return Err((
82 FailureCode::Forbidden,
83 "Confirm your email address before deleting a workspace.".into(),
84 ));
85 }
86 if let Some(typed) = confirm
87 && typed.trim().to_lowercase() != slug
88 {
89 return Err((
90 FailureCode::Invalid,
91 format!("Type {slug} to confirm."),
92 ));
93 }
94 Ok(())
95}
96
97/// Whether `slug`, once a deleted workspace's, may be taken by the person
98/// whose username is `username`: only when it is that very name.
99pub fn may_reclaim(slug: &str, username: &str) -> bool {
100 slug.eq_ignore_ascii_case(username)
101}
102
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member103/// When a workspace deleted at `now_ms` is purged.
104pub fn purge_after(now_ms: u64) -> String {
105 rfc3339(now_ms + WORKSPACE_RESTORE_DAYS * 86_400_000)
106}
107
108/// Whether a workspace to be purged at `purge_after` can still be restored
109/// at `now` (both RFC 3339, which compare as text). Once it cannot, the
110/// next sweep purges it.
111pub fn restorable(purge_after: &str, now: &str) -> bool {
112 now < purge_after
113}
114
115/// Whether the workspace `id`, now at `slug`, is protected: its row says
116/// so (`flagged`), or `names` (from [`protected_names`]) holds its id, its
117/// slug or a slug it was renamed from (`old_slugs`).
118pub fn is_protected(names: &[String], id: &str, slug: &str, flagged: bool, old_slugs: &[String]) -> bool {
119 let named = |name: &str| names.iter().any(|protected| protected.eq_ignore_ascii_case(name));
120 flagged || named(id) || named(slug) || old_slugs.iter().any(|old| named(old))
121}
122
123/// Whether staff may restore a deleted workspace, now `now`.
124pub fn may_restore(slug: &str, purge_after: &str, now: &str) -> std::result::Result<(), Refusal> {
125 if !restorable(purge_after, now) {
126 return Err((
127 FailureCode::Conflict,
128 format!("{slug} is being purged and can no longer be restored."),
129 ));
130 }
131 Ok(())
132}
133
134/// Whether a deleted workspace may be purged, by staff (`confirm` is what
135/// they typed) or by the sweep (`None`). Never a protected one.
136pub fn may_purge(protected: bool, slug: &str, confirm: Option<&str>) -> std::result::Result<(), Refusal> {
137 if protected {
138 return Err((FailureCode::Forbidden, protected_refusal(slug)));
139 }
140 if let Some(typed) = confirm
141 && typed.trim().to_lowercase() != slug
142 {
143 return Err((FailureCode::Invalid, format!("Type {slug} to confirm.")));
144 }
145 Ok(())
146}
147
148/// What `deleted_went` holds: what went with the workspace.
149fn went_json(went: &WorkspaceDeletion) -> String {
150 json!({
151 "repositories": went.repositories,
152 "projects": went.projects,
153 "members": went.members,
154 })
155 .to_string()
156}
157
158fn went_of(stored: Option<&str>) -> WorkspaceDeletion {
159 stored
160 .and_then(|text| serde_json::from_str::<WorkspaceDeletion>(text).ok())
161 .unwrap_or_default()
162}
163
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look164#[derive(Deserialize)]
165struct Target {
166 id: String,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member167 #[serde(default)]
168 protected: u8,
169}
170
171/// A deleted workspace's row.
172#[derive(Deserialize)]
173struct DeletedRow {
174 id: String,
175 slug: String,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look176 name: String,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member177 deleted_at: String,
178 #[serde(default)]
179 deleted_by: Option<String>,
180 purge_after: String,
181 #[serde(default)]
182 deleted_went: Option<String>,
183 #[serde(default)]
184 protected: u8,
185 /// The deleter's username, when their account is still there.
186 #[serde(default)]
187 deleter: Option<String>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look188}
189
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member190const DELETED_COLUMNS: &str = "w.id, w.slug, w.name, w.deleted_at, w.deleted_by, w.purge_after,
191 w.deleted_went, w.protected, u.username AS deleter
192 FROM workspaces w LEFT JOIN users u ON u.id = w.deleted_by
193 WHERE w.deleted_at IS NOT NULL";
194
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look195impl Identity {
Account deletion: from Settings and sudo, soft for 30 days, then purged to ghost (identity 0037)196 /// Whether `slug` belonged to a workspace that was deleted and purged,
197 /// or is the username of an account that was (account_deletion.rs):
198 /// neither is ever given to anyone again.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look199 pub async fn slug_deleted(&self, slug: &str) -> Result<bool> {
200 Ok(self
201 .db
Account deletion: from Settings and sudo, soft for 30 days, then purged to ghost (identity 0037)202 .prepare(
203 "SELECT 1 AS held FROM deleted_workspaces WHERE slug = ?1
204 UNION ALL SELECT 1 FROM deleted_users WHERE username = ?1",
205 )
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look206 .bind(&[slug.to_lowercase().into()])?
207 .first::<serde_json::Value>(None)
208 .await?
209 .is_some())
210 }
211
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member212 /// Whether a workspace holds `slug`, deleted or not: one deleted and
213 /// not yet purged keeps it for a restore.
214 pub async fn slug_in_use(&self, slug: &str) -> Result<bool> {
215 Ok(self
216 .db
217 .prepare("SELECT 1 AS held FROM workspaces WHERE slug = ?")
218 .bind(&[slug.to_lowercase().into()])?
219 .first::<serde_json::Value>(None)
220 .await?
221 .is_some())
222 }
223
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look224 /// A workspace made again under a deleted slug is a workspace again.
225 pub async fn forget_deleted(&self, slug: &str) -> Result<()> {
226 self.db
227 .prepare("DELETE FROM deleted_workspaces WHERE slug = ?")
228 .bind(&[slug.into()])?
229 .run()
230 .await?;
231 Ok(())
232 }
233
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member234 /// `PROTECTED_WORKSPACES`, with Flagon's whatever it says.
235 fn protected_names(&self) -> Vec<String> {
236 let configured = self.env.var("PROTECTED_WORKSPACES").ok().map(|v| v.to_string());
237 protected_names(configured.as_deref())
238 }
239
240 /// Whether the workspace `id`, now at `slug`, can never be deleted.
241 /// Asked each time, of its row, the variable and the slugs it was
242 /// renamed from, so a rename cannot take the protection away.
243 pub(crate) async fn is_protected(&self, id: &str, slug: &str, flagged: bool) -> Result<bool> {
244 let names = self.protected_names();
245 if is_protected(&names, id, slug, flagged, &[]) {
246 return Ok(true);
247 }
248 #[derive(Deserialize)]
249 struct Old {
250 old_slug: String,
251 }
252 let old: Vec<String> = self
253 .db
254 .prepare("SELECT old_slug FROM workspace_redirects WHERE workspace_id = ?")
255 .bind(&[id.into()])?
256 .all()
257 .await?
258 .results::<Old>()?
259 .into_iter()
260 .map(|row| row.old_slug)
261 .collect();
262 Ok(is_protected(&names, id, slug, flagged, &old))
263 }
264
265 /// What would go with the workspace, and whether billing can close it.
266 async fn deletion_facts(&self, a: &DeleteWorkspaceArgs, slug: &str, target: &Target) -> Result<WorkspaceDeletion> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look267 let repositories: u32 = g1t_kit::call(
268 &self.env.service("REPOS")?,
269 "namespace_count",
270 &NamespaceCountArgs {
271 namespace: slug.to_owned(),
272 },
273 )
274 .await?;
275 let projects: u32 = g1t_kit::call(
276 &self.env.service("PROJECTS")?,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member277 "count",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look278 &json!({ "workspace": slug }),
279 )
280 .await?;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member281 #[derive(Deserialize)]
282 struct Count {
283 n: u32,
284 }
285 let members = self
286 .db
287 .prepare("SELECT count(*) AS n FROM workspace_members WHERE workspace_id = ?")
288 .bind(&[target.id.as_str().into()])?
289 .first::<Count>(None)
290 .await?
291 .map_or(0, |count| count.n);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look292 let closing: Outcome<bool> = g1t_kit::call(
293 &self.env.service("BILLING")?,
294 "close_workspace",
295 &CloseWorkspaceArgs {
296 actor: a.actor.clone(),
297 workspace: slug.to_owned(),
298 dry_run: true,
299 },
300 )
301 .await?;
302 Ok(WorkspaceDeletion {
303 repositories,
304 projects,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member305 members,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look306 billing: match closing {
307 Outcome::Ok(_) => None,
308 Outcome::Fail(failure) => Some(failure.message),
309 },
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member310 protected: self.is_protected(&target.id, slug, target.protected != 0).await?,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look311 })
312 }
313
314 /// The workspace, if the actor may delete it.
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member315 async fn deletable(&self, a: &DeleteWorkspaceArgs, confirm: bool) -> Result<Outcome<(Target, bool)>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look316 let slug = a.slug.trim().to_lowercase();
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member317 let Some(target) = self
318 .db
319 .prepare("SELECT id, protected FROM workspaces WHERE slug = ? AND deleted_at IS NULL")
320 .bind(&[slug.as_str().into()])?
321 .first::<Target>(None)
322 .await?
323 else {
324 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
325 };
326 let protected = self.is_protected(&target.id, &slug, target.protected != 0).await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look327 if let Err((code, message)) = may_delete(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member328 // Only the actual deletion is refused for it; the check says
329 // so in `protected`, for the page to show.
330 protected && confirm,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look331 a.actor.kind == PrincipalKind::User,
332 a.actor.verified,
333 a.actor.role_in(&slug),
334 &slug,
335 confirm.then_some(a.confirm.as_str()),
336 ) {
337 return Ok(Outcome::fail(code, message));
338 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member339 Ok(Outcome::Ok((target, protected)))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look340 }
341
342 pub async fn check_workspace_deletion(&self, a: DeleteWorkspaceArgs) -> Result<Outcome<WorkspaceDeletion>> {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member343 let target = match self.deletable(&a, false).await? {
344 Outcome::Ok((target, _)) => target,
345 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
346 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look347 let slug = a.slug.trim().to_lowercase();
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member348 Ok(Outcome::Ok(self.deletion_facts(&a, &slug, &target).await?))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look349 }
350
351 pub async fn delete_workspace(&self, a: DeleteWorkspaceArgs) -> Result<Outcome<bool>> {
352 let workspace = match self.deletable(&a, true).await? {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member353 Outcome::Ok((workspace, _)) => workspace,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look354 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
355 };
356 let slug = a.slug.trim().to_lowercase();
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member357 let went = self.deletion_facts(&a, &slug, &workspace).await?;
358 if let Some(reason) = went.reason(&slug) {
359 let code = if went.protected { FailureCode::Forbidden } else { FailureCode::PaymentRequired };
360 return Ok(Outcome::fail(code, reason));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look361 }
362 // Money first: if billing cannot settle it after all (a card
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member363 // declined a moment ago), nothing is deleted. Its plan ends now, so
364 // nothing more is charged while it waits to be purged.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look365 let closed: Outcome<bool> = g1t_kit::call(
366 &self.env.service("BILLING")?,
367 "close_workspace",
368 &CloseWorkspaceArgs {
369 actor: a.actor.clone(),
370 workspace: slug.clone(),
371 dry_run: false,
372 },
373 )
374 .await?;
375 if let Outcome::Fail(failure) = closed {
376 return Ok(Outcome::Fail(failure));
377 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member378 let now = now_ms();
379 let purge = purge_after(now);
380 self.db
381 .prepare(
382 "UPDATE workspaces SET deleted_at = ?, deleted_by = ?, purge_after = ?, deleted_went = ?
383 WHERE id = ? AND deleted_at IS NULL",
384 )
385 .bind(&[
386 rfc3339(now).into(),
387 a.actor.id.as_str().into(),
388 purge.as_str().into(),
389 went_json(&went).into(),
390 workspace.id.as_str().into(),
391 ])?
392 .run()
393 .await?;
394 self.record_on_workspace(
395 &slug,
396 AuditActor::of(&a.actor),
397 a.surface.unwrap_or(Surface::Web),
398 "workspace.deleted",
399 "owner",
400 format!("Deleted {slug}; restorable by g1t's staff until {purge}"),
401 )
402 .await;
403 self.announce(
404 "workspace.deleting",
405 Some(&a.actor.id),
406 WorkspaceDeleting {
407 workspace_id: workspace.id,
408 slug,
409 by: a.actor.username.clone(),
410 purge_after: purge,
411 },
412 )
413 .await;
414 Ok(Outcome::Ok(true))
415 }
416
417 /// Deleted workspaces not purged yet, newest first. Staff only.
418 pub async fn admin_deleted_workspaces(&self) -> Result<Vec<DeletedWorkspace>> {
419 let rows = self
420 .db
421 .prepare(format!("SELECT {DELETED_COLUMNS} ORDER BY w.deleted_at DESC LIMIT 500"))
422 .all()
423 .await?
424 .results::<DeletedRow>()?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look425 let now = rfc3339(now_ms());
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member426 let mut listed = Vec::with_capacity(rows.len());
427 for row in rows {
428 let mut went = went_of(row.deleted_went.as_deref());
429 went.protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?;
430 listed.push(DeletedWorkspace {
431 restorable: restorable(&row.purge_after, &now),
432 workspace_id: row.id,
433 slug: row.slug,
434 name: row.name,
435 deleted_at: row.deleted_at,
436 deleted_by: row.deleter.or(row.deleted_by).unwrap_or_default(),
437 purge_after: row.purge_after,
438 went,
439 });
440 }
441 Ok(listed)
442 }
443
444 async fn deleted_row(&self, id: &str) -> Result<Option<DeletedRow>> {
445 self.db
446 .prepare(format!("SELECT {DELETED_COLUMNS} AND w.id = ?"))
447 .bind(&[id.into()])?
448 .first::<DeletedRow>(None)
449 .await
450 }
451
452 /// Staff bring a deleted workspace back within its window, with its
453 /// members and tokens; `workspace.restored` brings back what went with
454 /// it. Staff only.
455 pub async fn admin_restore_workspace(&self, a: AdminDeletedWorkspaceArgs) -> Result<Outcome<bool>> {
456 let staff = a.staff.trim();
457 if staff.is_empty() {
458 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is restoring it."));
459 }
460 let Some(row) = self.deleted_row(&a.workspace_id).await? else {
461 return Ok(Outcome::fail(FailureCode::NotFound, "There is no deleted workspace with that id."));
462 };
463 if let Err((code, message)) = may_restore(&row.slug, &row.purge_after, &rfc3339(now_ms())) {
464 return Ok(Outcome::fail(code, message));
465 }
466 self.db
467 .prepare(
468 "UPDATE workspaces SET deleted_at = NULL, deleted_by = NULL, purge_after = NULL, deleted_went = NULL
469 WHERE id = ? AND deleted_at IS NOT NULL",
470 )
471 .bind(&[row.id.as_str().into()])?
472 .run()
473 .await?;
474 let message = format!(
475 "Restored by g1t's staff; deleted by {} at {}",
476 row.deleter.as_deref().or(row.deleted_by.as_deref()).unwrap_or("an owner"),
477 row.deleted_at
478 );
479 self.record_on_workspace(&row.slug, AuditActor::system(), Surface::Web, "workspace.restored", "staff", message)
480 .await;
481 self.record_for_staff(&row.slug, "workspace_restored", &format!("Restored {}", row.slug), staff)
482 .await;
483 self.announce(
484 "workspace.restored",
485 None,
486 WorkspaceRestored {
487 workspace_id: row.id,
488 slug: row.slug,
489 },
490 )
491 .await;
492 Ok(Outcome::Ok(true))
493 }
494
495 /// Staff purge a deleted workspace now rather than at `purge_after`.
496 /// Staff only.
497 pub async fn admin_purge_workspace(&self, a: AdminDeletedWorkspaceArgs) -> Result<Outcome<bool>> {
498 let staff = a.staff.trim();
499 if staff.is_empty() {
500 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is purging it."));
501 }
502 let Some(row) = self.deleted_row(&a.workspace_id).await? else {
503 return Ok(Outcome::fail(FailureCode::NotFound, "There is no deleted workspace with that id."));
504 };
505 let protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?;
506 if let Err((code, message)) = may_purge(protected, &row.slug, Some(&a.confirm)) {
507 return Ok(Outcome::fail(code, message));
508 }
509 self.purge(&row).await?;
510 self.record_on_workspace(
511 &row.slug,
512 AuditActor::system(),
513 Surface::Web,
514 "workspace.purged",
515 "staff",
516 "Purged by g1t's staff before its restore window ended".to_owned(),
517 )
518 .await;
519 self.record_for_staff(&row.slug, "workspace_purged", &format!("Purged {} now", row.slug), staff)
520 .await;
521 Ok(Outcome::Ok(true))
522 }
523
524 /// The sweep: purges deleted workspaces whose restore window has
525 /// passed. A protected one is never purged, however it came to be
526 /// deleted.
527 pub async fn purge_due_workspaces(&self) -> Result<u32> {
528 let due = self
529 .db
530 .prepare(format!(
531 "SELECT {DELETED_COLUMNS} AND w.purge_after <= ? ORDER BY w.purge_after LIMIT {PURGES_PER_SWEEP}"
532 ))
533 .bind(&[rfc3339(now_ms()).into()])?
534 .all()
535 .await?
536 .results::<DeletedRow>()?;
537 let mut purged = 0;
538 for row in due {
539 let protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?;
540 if let Err((_, why)) = may_purge(protected, &row.slug, None) {
541 worker::console_error!("{} not purged: {why}", row.slug);
542 continue;
543 }
544 match self.purge(&row).await {
545 Ok(()) => {
546 purged += 1;
547 self.record_on_workspace(
548 &row.slug,
549 AuditActor::system(),
550 Surface::Web,
551 "workspace.purged",
552 "schedule",
553 format!("Purged {WORKSPACE_RESTORE_DAYS} days after it was deleted"),
554 )
555 .await;
556 }
557 Err(error) => worker::console_error!("{} not purged: {error}", row.slug),
558 }
559 }
560 Ok(purged)
561 }
562
563 /// Removes a deleted workspace for good, as deleting one always did:
564 /// its row, memberships, tokens and redirects go, its slugs are kept in
565 /// `deleted_workspaces`, and `workspace.deleted` tells services to drop
566 /// what they keep for it.
567 async fn purge(&self, row: &DeletedRow) -> Result<()> {
568 let id = row.id.as_str();
569 let by = row.deleted_by.as_deref().unwrap_or_default();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look570 self.db
571 .batch(vec![
572 self.db
573 .prepare(
574 "INSERT OR REPLACE INTO deleted_workspaces (slug, workspace_id, name, deleted_by, deleted_at)
575 VALUES (?, ?, ?, ?, ?)",
576 )
577 .bind(&[
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member578 row.slug.as_str().into(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look579 id.into(),
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member580 row.name.as_str().into(),
581 by.into(),
582 row.deleted_at.as_str().into(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look583 ])?,
584 // Slugs it was renamed from, still redirecting, are kept
585 // the same way.
586 self.db
587 .prepare(
588 "INSERT OR IGNORE INTO deleted_workspaces (slug, workspace_id, name, deleted_by, deleted_at)
589 SELECT old_slug, workspace_id, ?, ?, ? FROM workspace_redirects WHERE workspace_id = ?",
590 )
591 .bind(&[
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member592 row.name.as_str().into(),
593 by.into(),
594 row.deleted_at.as_str().into(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look595 id.into(),
596 ])?,
597 self.db
598 .prepare("DELETE FROM access_tokens WHERE workspace_id = ?")
599 .bind(&[id.into()])?,
600 self.db
601 .prepare("DELETE FROM workspace_members WHERE workspace_id = ?")
602 .bind(&[id.into()])?,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar603 // Its teams, their people and the roles they gave (teams.rs).
604 self.db
605 .prepare("DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE workspace_id = ?)")
606 .bind(&[id.into()])?,
607 self.db
608 .prepare("DELETE FROM repo_grants WHERE principal_kind = 'team' AND principal_id IN (SELECT id FROM teams WHERE workspace_id = ?)")
609 .bind(&[id.into()])?,
610 self.db
611 .prepare("DELETE FROM teams WHERE workspace_id = ?")
612 .bind(&[id.into()])?,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look613 self.db
614 .prepare("DELETE FROM workspace_redirects WHERE workspace_id = ?")
615 .bind(&[id.into()])?,
Merge branch 'worktree-agent-a8385d293d42c913a'616 // Aliases staff pointed at it lead nowhere now (aliases.rs).
617 self.db
618 .prepare("DELETE FROM workspace_aliases WHERE workspace_id = ?")
619 .bind(&[id.into()])?,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member620 // Only while it is still deleted: a restore a moment ago wins.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look621 self.db
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member622 .prepare("DELETE FROM workspaces WHERE id = ? AND deleted_at IS NOT NULL")
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look623 .bind(&[id.into()])?,
624 ])
625 .await?;
626 self.announce(
627 "workspace.deleted",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member628 None,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look629 WorkspaceDeleted {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member630 workspace_id: row.id.clone(),
631 slug: row.slug.clone(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look632 },
633 )
634 .await;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member635 Ok(())
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look636 }
637
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member638 /// An entry in the workspace's audit log, which outlives it.
Merge branch 'worktree-agent-a8385d293d42c913a'639 pub(crate) async fn record_on_workspace(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member640 &self,
641 slug: &str,
642 actor: AuditActor,
643 surface: Surface,
644 action: &str,
645 rule: &str,
646 message: String,
647 ) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look648 let Ok(events) = self.env.service("EVENTS") else {
649 return;
650 };
651 let entry = NewAuditEntry {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member652 actor,
653 action: action.to_owned(),
654 surface,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look655 target: AuditTarget {
656 workspace: slug.to_owned(),
657 ..AuditTarget::default()
658 },
659 outcome: AuditOutcome::Allowed,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member660 rule: rule.to_owned(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look661 result: Some("ok".to_owned()),
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member662 message: Some(message),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look663 request_id: new_id("req", now_ms()),
664 };
665 let recorded: Result<u32> = g1t_kit::call(
666 &events,
667 "audit_record",
668 &RecordAuditArgs {
669 entries: vec![entry],
670 },
671 )
672 .await;
673 if let Err(error) = recorded {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member674 worker::console_error!("{action} of {slug} not recorded: {error}");
675 }
676 }
677
678 /// A line in sudo's audit log (billing keeps it), naming the staff
679 /// member.
Merge branch 'worktree-agent-a8385d293d42c913a'680 pub(crate) async fn record_for_staff(&self, slug: &str, action: &str, detail: &str, staff: &str) {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member681 let Ok(billing) = self.env.service("BILLING") else {
682 return;
683 };
684 let recorded: Result<bool> = g1t_kit::call(
685 &billing,
686 "admin_log",
687 &json!({ "workspace": slug, "action": action, "detail": detail, "by": staff }),
688 )
689 .await;
690 if let Err(error) = recorded {
691 worker::console_error!("{action} of {slug} by {staff} not recorded for sudo: {error}");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look692 }
693 }
694
695 /// `transfer_repo_scopes`: agents at work on a transferred repository
696 /// keep their scope, which names it by path.
697 pub async fn transfer_repo_scopes(&self, a: TransferRepoScopesArgs) -> Result<bool> {
698 self.db
699 .prepare(
700 "UPDATE access_tokens
701 SET agent_scope = json_set(agent_scope, '$.repo.namespace', ?1, '$.repo.name', ?2)
702 WHERE agent_scope IS NOT NULL
703 AND json_extract(agent_scope, '$.repo.namespace') = ?3
704 AND json_extract(agent_scope, '$.repo.name') = ?4",
705 )
706 .bind(&[
707 a.to.namespace.as_str().into(),
708 a.to.name.as_str().into(),
709 a.from.namespace.as_str().into(),
710 a.from.name.as_str().into(),
711 ])?
712 .run()
713 .await?;
714 // Who has access to it follows it too (access.rs).
715 self.move_repo_access(&a.from, &a.to).await?;
716 Ok(true)
717 }
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723
724 #[test]
725 fn only_a_verified_owner_who_types_the_name() {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member726 assert!(may_delete(false, true, true, Some(Role::Owner), "acme", Some(" Acme ")).is_ok());
727 assert!(may_delete(false, true, true, Some(Role::Owner), "acme", None).is_ok());
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look728 assert_eq!(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member729 may_delete(false, true, true, Some(Role::Member), "acme", Some("acme")).unwrap_err().0,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look730 FailureCode::Forbidden
731 );
732 assert_eq!(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member733 may_delete(false, false, true, Some(Role::Owner), "acme", Some("acme")).unwrap_err().0,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look734 FailureCode::Forbidden
735 );
736 assert_eq!(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member737 may_delete(false, true, false, Some(Role::Owner), "acme", Some("acme")).unwrap_err().0,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look738 FailureCode::Forbidden
739 );
740 assert_eq!(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member741 may_delete(false, true, true, Some(Role::Owner), "acme", Some("acme-inc")).unwrap_err().0,
742 FailureCode::Invalid
743 );
744 // Nothing typed is no confirmation.
745 assert_eq!(
746 may_delete(false, true, true, Some(Role::Owner), "acme", Some("")).unwrap_err().0,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look747 FailureCode::Invalid
748 );
749 }
750
751 #[test]
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member752 fn a_protected_workspace_is_refused_to_every_caller() {
753 let refused = |person: bool, verified: bool, role: Option<Role>| {
754 may_delete(true, person, verified, role, "flagon-io", Some("flagon-io")).unwrap_err()
755 };
756 // An owner who types the name, a workspace's token, a member, anyone.
757 for (person, verified, role) in [
758 (true, true, Some(Role::Owner)),
759 (false, true, Some(Role::Owner)),
760 (true, true, Some(Role::Member)),
761 (true, false, None),
762 ] {
763 let (code, message) = refused(person, verified, role);
764 assert_eq!(code, FailureCode::Forbidden);
765 assert_eq!(message, "flagon-io is protected and can never be deleted.");
766 }
767 // Neither the sweep nor staff purge it, typed or not.
768 assert_eq!(may_purge(true, "flagon-io", None).unwrap_err().0, FailureCode::Forbidden);
769 assert_eq!(may_purge(true, "flagon-io", Some("flagon-io")).unwrap_err().0, FailureCode::Forbidden);
770 }
771
772 #[test]
773 fn protection_follows_the_workspace_through_a_rename() {
774 let names = protected_names(Some(""));
775 assert!(is_protected(&names, "wsp_1", "flagon-io", false, &[]));
776 assert!(is_protected(&names, "wsp_1", "FLAGON-IO", false, &[]));
777 // Renamed away: its old slug, or the mark on its row, still holds.
778 assert!(is_protected(&names, "wsp_1", "flagon", false, &["flagon-io".into()]));
779 assert!(is_protected(&names, "wsp_1", "flagon", true, &[]));
780 // Named by id in the variable.
781 assert!(is_protected(&protected_names(Some("wsp_9")), "wsp_9", "anything", false, &[]));
782 assert!(!is_protected(&names, "wsp_2", "acme", false, &["acme-old".into()]));
783 }
784
785 #[test]
786 fn staff_purge_only_with_the_name_typed() {
787 assert!(may_purge(false, "acme", None).is_ok());
788 assert!(may_purge(false, "acme", Some(" ACME ")).is_ok());
789 assert_eq!(may_purge(false, "acme", Some("")).unwrap_err().0, FailureCode::Invalid);
790 assert_eq!(may_purge(false, "acme", Some("acme-inc")).unwrap_err().0, FailureCode::Invalid);
791 }
792
793 #[test]
794 fn a_deleted_workspace_is_restorable_for_thirty_days_then_due() {
795 let deleted = 1_790_000_000_000;
796 let purge = purge_after(deleted);
797 assert_eq!(purge, rfc3339(deleted + 30 * 86_400_000));
798 assert!(restorable(&purge, &rfc3339(deleted)));
799 assert!(restorable(&purge, &rfc3339(deleted + 29 * 86_400_000)));
800 assert!(!restorable(&purge, &purge));
801 assert!(!restorable(&purge, &rfc3339(deleted + 31 * 86_400_000)));
802 assert!(may_restore("acme", &purge, &rfc3339(deleted + 86_400_000)).is_ok());
803 assert_eq!(
804 may_restore("acme", &purge, &rfc3339(deleted + 30 * 86_400_000)).unwrap_err(),
805 (FailureCode::Conflict, "acme is being purged and can no longer be restored.".to_owned())
806 );
807 }
808
809 #[test]
810 fn what_went_is_kept_and_read_back() {
811 let went = WorkspaceDeletion {
812 repositories: 4,
813 projects: 2,
814 members: 3,
815 billing: None,
816 protected: false,
817 };
818 assert_eq!(went_of(Some(&went_json(&went))), went);
819 assert_eq!(went_of(None), WorkspaceDeletion::default());
820 assert_eq!(went_of(Some("not json")), WorkspaceDeletion::default());
821 }
822
823 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look824 fn a_deleted_slug_is_reclaimed_only_by_its_namesake() {
825 assert!(may_reclaim("syntaqx", "syntaqx"));
826 assert!(may_reclaim("syntaqx", "Syntaqx"));
827 assert!(!may_reclaim("flagon-io", "syntaqx"));
828 }
829}

This file's history is long; its oldest lines are credited to the oldest commit read.