Skip to content
957 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.
Merge sudo: delete an account with the workspaces it alone owns, purge each38//!
39//! g1t's staff delete a workspace only together with the account that is
40//! its only owner (account_deletion.rs, `with_sole_workspaces`), through
41//! [`Identity::staff_delete_workspace`]: the same steps, the same refusals
42//! (protected, billing that cannot settle), with the staff member as
43//! `deleted_by` and a line in sudo's audit log with the reason.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look44
45use g1t_contracts::audit::{
46 AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface,
47};
48use g1t_contracts::billing::CloseWorkspaceArgs;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member49use g1t_contracts::events::{WorkspaceDeleted, WorkspaceDeleting, WorkspaceRestored};
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look50use g1t_contracts::identity::*;
51use g1t_contracts::repos::NamespaceCountArgs;
52use g1t_contracts::time::rfc3339;
Merge sudo: delete an account with the workspaces it alone owns, purge each53use g1t_contracts::account_deletion::WorkspaceDeletedWith;
54use g1t_contracts::{FailureCode, Membership, Outcome, PrincipalKind, Role, User, new_id};
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look55use g1t_kit::now_ms;
56use serde::Deserialize;
57use serde_json::json;
58use worker::Result;
59
60use crate::Identity;
61
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member62type Refusal = (FailureCode, String);
63
64/// How many workspaces one sweep purges.
65const PURGES_PER_SWEEP: u32 = 25;
66
67/// Who may delete, decided from the request and whether the workspace is
68/// protected: `Ok`, or why not. A protected workspace is refused to
69/// everyone, owners included.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look70pub fn may_delete(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member71 protected: bool,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look72 person: bool,
73 verified: bool,
74 role: Option<Role>,
75 slug: &str,
76 confirm: Option<&str>,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member77) -> std::result::Result<(), Refusal> {
78 if protected {
79 return Err((FailureCode::Forbidden, protected_refusal(slug)));
80 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look81 if !person || role != Some(Role::Owner) {
82 return Err((
83 FailureCode::Forbidden,
84 "Only an owner can delete a workspace.".into(),
85 ));
86 }
87 if !verified {
88 return Err((
89 FailureCode::Forbidden,
90 "Confirm your email address before deleting a workspace.".into(),
91 ));
92 }
93 if let Some(typed) = confirm
94 && typed.trim().to_lowercase() != slug
95 {
96 return Err((
97 FailureCode::Invalid,
98 format!("Type {slug} to confirm."),
99 ));
100 }
101 Ok(())
102}
103
104/// Whether `slug`, once a deleted workspace's, may be taken by the person
105/// whose username is `username`: only when it is that very name.
106pub fn may_reclaim(slug: &str, username: &str) -> bool {
107 slug.eq_ignore_ascii_case(username)
108}
109
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member110/// When a workspace deleted at `now_ms` is purged.
111pub fn purge_after(now_ms: u64) -> String {
112 rfc3339(now_ms + WORKSPACE_RESTORE_DAYS * 86_400_000)
113}
114
115/// Whether a workspace to be purged at `purge_after` can still be restored
116/// at `now` (both RFC 3339, which compare as text). Once it cannot, the
117/// next sweep purges it.
118pub fn restorable(purge_after: &str, now: &str) -> bool {
119 now < purge_after
120}
121
122/// Whether the workspace `id`, now at `slug`, is protected: its row says
123/// so (`flagged`), or `names` (from [`protected_names`]) holds its id, its
124/// slug or a slug it was renamed from (`old_slugs`).
125pub fn is_protected(names: &[String], id: &str, slug: &str, flagged: bool, old_slugs: &[String]) -> bool {
126 let named = |name: &str| names.iter().any(|protected| protected.eq_ignore_ascii_case(name));
127 flagged || named(id) || named(slug) || old_slugs.iter().any(|old| named(old))
128}
129
130/// Whether staff may restore a deleted workspace, now `now`.
131pub fn may_restore(slug: &str, purge_after: &str, now: &str) -> std::result::Result<(), Refusal> {
132 if !restorable(purge_after, now) {
133 return Err((
134 FailureCode::Conflict,
135 format!("{slug} is being purged and can no longer be restored."),
136 ));
137 }
138 Ok(())
139}
140
141/// Whether a deleted workspace may be purged, by staff (`confirm` is what
142/// they typed) or by the sweep (`None`). Never a protected one.
143pub fn may_purge(protected: bool, slug: &str, confirm: Option<&str>) -> std::result::Result<(), Refusal> {
144 if protected {
145 return Err((FailureCode::Forbidden, protected_refusal(slug)));
146 }
147 if let Some(typed) = confirm
148 && typed.trim().to_lowercase() != slug
149 {
150 return Err((FailureCode::Invalid, format!("Type {slug} to confirm.")));
151 }
152 Ok(())
153}
154
Merge sudo: delete an account with the workspaces it alone owns, purge each155/// Who billing's `close_workspace` sees when staff delete a workspace with
156/// the account that is its only owner: the account, as owner of `slug`
157/// (billing closes only for an owner), named by the staff member so
158/// billing's record says who closed it.
159pub fn staff_billing_actor(owner_id: &str, staff: &str, slug: &str) -> User {
160 User {
161 id: owner_id.to_owned(),
162 username: staff.to_owned(),
163 kind: PrincipalKind::User,
164 verified: true,
165 workspaces: vec![Membership { role: Role::Owner, ..Membership::member(slug) }],
166 ..User::default()
167 }
168}
169
170/// Who deletes a workspace, for its row, its audit log and the event.
171struct Deleter<'a> {
172 /// Who billing closes it for: an owner.
173 billing: &'a User,
174 /// `deleted_by` on its row: the owner's id, or the staff member.
175 deleted_by: &'a str,
176 /// `by` on `workspace.deleting`: the owner's username, or the staff
177 /// member.
178 by: &'a str,
179 audit: AuditActor,
180 surface: Surface,
181 /// The audit log's rule: `owner` or `staff`.
182 rule: &'static str,
183 /// What the audit log says, given when it can be restored until.
184 message: Box<dyn Fn(&str) -> String + 'a>,
185 /// The event's actor.
186 actor_id: Option<&'a str>,
187}
188
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member189/// What `deleted_went` holds: what went with the workspace.
190fn went_json(went: &WorkspaceDeletion) -> String {
191 json!({
192 "repositories": went.repositories,
193 "projects": went.projects,
194 "members": went.members,
195 })
196 .to_string()
197}
198
199fn went_of(stored: Option<&str>) -> WorkspaceDeletion {
200 stored
201 .and_then(|text| serde_json::from_str::<WorkspaceDeletion>(text).ok())
202 .unwrap_or_default()
203}
204
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look205#[derive(Deserialize)]
206struct Target {
207 id: String,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member208 #[serde(default)]
209 protected: u8,
210}
211
212/// A deleted workspace's row.
213#[derive(Deserialize)]
214struct DeletedRow {
215 id: String,
216 slug: String,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look217 name: String,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member218 deleted_at: String,
219 #[serde(default)]
220 deleted_by: Option<String>,
221 purge_after: String,
222 #[serde(default)]
223 deleted_went: Option<String>,
224 #[serde(default)]
225 protected: u8,
226 /// The deleter's username, when their account is still there.
227 #[serde(default)]
228 deleter: Option<String>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look229}
230
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member231const DELETED_COLUMNS: &str = "w.id, w.slug, w.name, w.deleted_at, w.deleted_by, w.purge_after,
232 w.deleted_went, w.protected, u.username AS deleter
233 FROM workspaces w LEFT JOIN users u ON u.id = w.deleted_by
234 WHERE w.deleted_at IS NOT NULL";
235
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look236impl Identity {
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)237 /// Whether `slug` belonged to a workspace that was deleted and purged,
238 /// or is the username of an account that was (account_deletion.rs):
239 /// neither is ever given to anyone again.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look240 pub async fn slug_deleted(&self, slug: &str) -> Result<bool> {
241 Ok(self
242 .db
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)243 .prepare(
244 "SELECT 1 AS held FROM deleted_workspaces WHERE slug = ?1
245 UNION ALL SELECT 1 FROM deleted_users WHERE username = ?1",
246 )
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look247 .bind(&[slug.to_lowercase().into()])?
248 .first::<serde_json::Value>(None)
249 .await?
250 .is_some())
251 }
252
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member253 /// Whether a workspace holds `slug`, deleted or not: one deleted and
254 /// not yet purged keeps it for a restore.
255 pub async fn slug_in_use(&self, slug: &str) -> Result<bool> {
256 Ok(self
257 .db
258 .prepare("SELECT 1 AS held FROM workspaces WHERE slug = ?")
259 .bind(&[slug.to_lowercase().into()])?
260 .first::<serde_json::Value>(None)
261 .await?
262 .is_some())
263 }
264
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look265 /// A workspace made again under a deleted slug is a workspace again.
266 pub async fn forget_deleted(&self, slug: &str) -> Result<()> {
267 self.db
268 .prepare("DELETE FROM deleted_workspaces WHERE slug = ?")
269 .bind(&[slug.into()])?
270 .run()
271 .await?;
272 Ok(())
273 }
274
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member275 /// `PROTECTED_WORKSPACES`, with Flagon's whatever it says.
276 fn protected_names(&self) -> Vec<String> {
277 let configured = self.env.var("PROTECTED_WORKSPACES").ok().map(|v| v.to_string());
278 protected_names(configured.as_deref())
279 }
280
281 /// Whether the workspace `id`, now at `slug`, can never be deleted.
282 /// Asked each time, of its row, the variable and the slugs it was
283 /// renamed from, so a rename cannot take the protection away.
284 pub(crate) async fn is_protected(&self, id: &str, slug: &str, flagged: bool) -> Result<bool> {
285 let names = self.protected_names();
286 if is_protected(&names, id, slug, flagged, &[]) {
287 return Ok(true);
288 }
289 #[derive(Deserialize)]
290 struct Old {
291 old_slug: String,
292 }
293 let old: Vec<String> = self
294 .db
295 .prepare("SELECT old_slug FROM workspace_redirects WHERE workspace_id = ?")
296 .bind(&[id.into()])?
297 .all()
298 .await?
299 .results::<Old>()?
300 .into_iter()
301 .map(|row| row.old_slug)
302 .collect();
303 Ok(is_protected(&names, id, slug, flagged, &old))
304 }
305
Merge sudo: delete an account with the workspaces it alone owns, purge each306 /// Why billing could not close `slug` now for `actor` (an owner), or
307 /// `None` when it could. Changes nothing.
308 pub(crate) async fn billing_refusal(&self, actor: &User, slug: &str) -> Result<Option<String>> {
309 let closing: Outcome<bool> = g1t_kit::call(
310 &self.env.service("BILLING")?,
311 "close_workspace",
312 &CloseWorkspaceArgs {
313 actor: actor.clone(),
314 workspace: slug.to_owned(),
315 dry_run: true,
316 },
317 )
318 .await?;
319 Ok(match closing {
320 Outcome::Ok(_) => None,
321 Outcome::Fail(failure) => Some(failure.message),
322 })
323 }
324
325 /// What would go with the workspace, and whether billing can close it
326 /// for `actor`.
327 async fn deletion_facts(&self, actor: &User, slug: &str, target: &Target) -> Result<WorkspaceDeletion> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look328 let repositories: u32 = g1t_kit::call(
329 &self.env.service("REPOS")?,
330 "namespace_count",
331 &NamespaceCountArgs {
332 namespace: slug.to_owned(),
333 },
334 )
335 .await?;
336 let projects: u32 = g1t_kit::call(
337 &self.env.service("PROJECTS")?,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member338 "count",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look339 &json!({ "workspace": slug }),
340 )
341 .await?;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member342 #[derive(Deserialize)]
343 struct Count {
344 n: u32,
345 }
346 let members = self
347 .db
348 .prepare("SELECT count(*) AS n FROM workspace_members WHERE workspace_id = ?")
349 .bind(&[target.id.as_str().into()])?
350 .first::<Count>(None)
351 .await?
352 .map_or(0, |count| count.n);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look353 Ok(WorkspaceDeletion {
354 repositories,
355 projects,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member356 members,
Merge sudo: delete an account with the workspaces it alone owns, purge each357 billing: self.billing_refusal(actor, slug).await?,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member358 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 look359 })
360 }
361
362 /// 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 member363 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 look364 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 member365 let Some(target) = self
366 .db
367 .prepare("SELECT id, protected FROM workspaces WHERE slug = ? AND deleted_at IS NULL")
368 .bind(&[slug.as_str().into()])?
369 .first::<Target>(None)
370 .await?
371 else {
372 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
373 };
374 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 look375 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 member376 // Only the actual deletion is refused for it; the check says
377 // so in `protected`, for the page to show.
378 protected && confirm,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look379 a.actor.kind == PrincipalKind::User,
380 a.actor.verified,
381 a.actor.role_in(&slug),
382 &slug,
383 confirm.then_some(a.confirm.as_str()),
384 ) {
385 return Ok(Outcome::fail(code, message));
386 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member387 Ok(Outcome::Ok((target, protected)))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look388 }
389
390 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 member391 let target = match self.deletable(&a, false).await? {
392 Outcome::Ok((target, _)) => target,
393 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
394 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look395 let slug = a.slug.trim().to_lowercase();
Merge sudo: delete an account with the workspaces it alone owns, purge each396 Ok(Outcome::Ok(self.deletion_facts(&a.actor, &slug, &target).await?))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look397 }
398
399 pub async fn delete_workspace(&self, a: DeleteWorkspaceArgs) -> Result<Outcome<bool>> {
400 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 member401 Outcome::Ok((workspace, _)) => workspace,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look402 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
403 };
404 let slug = a.slug.trim().to_lowercase();
Merge sudo: delete an account with the workspaces it alone owns, purge each405 let deleter = Deleter {
406 billing: &a.actor,
407 deleted_by: &a.actor.id,
408 by: &a.actor.username,
409 audit: AuditActor::of(&a.actor),
410 surface: a.surface.unwrap_or(Surface::Web),
411 rule: "owner",
412 message: Box::new(|purge| format!("Deleted {slug}; restorable by g1t's staff until {purge}")),
413 actor_id: Some(&a.actor.id),
414 };
415 self.soft_delete_workspace(&workspace, &slug, &deleter).await
416 }
417
418 /// g1t's staff delete a live workspace that `owner_id` (`owner`) is the
419 /// only owner of, as part of deleting that account (account_deletion.rs):
420 /// exactly as its owner would, refused the same way, with the staff
421 /// member as `deleted_by` and in sudo's audit log with `reason`.
422 pub(crate) async fn staff_delete_workspace(
423 &self,
424 slug: &str,
425 owner_id: &str,
426 owner: &str,
427 staff: &str,
428 reason: &str,
429 ) -> Result<Outcome<WorkspaceDeletedWith>> {
430 let slug = slug.trim().to_lowercase();
431 let Some(workspace) = self
432 .db
433 .prepare("SELECT id, protected FROM workspaces WHERE slug = ? AND deleted_at IS NULL")
434 .bind(&[slug.as_str().into()])?
435 .first::<Target>(None)
436 .await?
437 else {
438 return Ok(Outcome::fail(FailureCode::NotFound, format!("{slug} is not a live workspace any more.")));
439 };
440 let billing = staff_billing_actor(owner_id, staff, &slug);
441 let deleter = Deleter {
442 billing: &billing,
443 deleted_by: staff,
444 by: staff,
445 // The workspace's members read its audit log: it says g1t's
446 // staff did it, and sudo's log says who and why.
447 audit: AuditActor::system(),
448 surface: Surface::Web,
449 rule: "staff",
450 message: Box::new(|purge| {
451 format!("Deleted {slug} by g1t's staff, with the account {owner} that was its only owner; restorable by g1t's staff until {purge}")
452 }),
453 actor_id: None,
454 };
455 let id = workspace.id.clone();
456 match self.soft_delete_workspace(&workspace, &slug, &deleter).await? {
457 Outcome::Ok(_) => {}
458 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
459 }
460 self.record_for_staff(
461 &slug,
462 "workspace_deleted",
463 &format!("Deleted {slug} with the account {owner}, its only owner: {reason}"),
464 staff,
465 )
466 .await;
467 Ok(Outcome::Ok(WorkspaceDeletedWith { workspace_id: id, slug: slug.clone() }))
468 }
469
470 /// Deletes a live workspace softly, as every deletion does: refused if
471 /// it is protected or billing cannot settle it; billing closes it for
472 /// real first; then its row is marked, its audit log says so and
473 /// `workspace.deleting` tells every service.
474 async fn soft_delete_workspace(&self, workspace: &Target, slug: &str, deleter: &Deleter<'_>) -> Result<Outcome<bool>> {
475 let went = self.deletion_facts(deleter.billing, slug, workspace).await?;
476 if let Some(reason) = went.reason(slug) {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member477 let code = if went.protected { FailureCode::Forbidden } else { FailureCode::PaymentRequired };
478 return Ok(Outcome::fail(code, reason));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look479 }
480 // 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 member481 // declined a moment ago), nothing is deleted. Its plan ends now, so
482 // 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 look483 let closed: Outcome<bool> = g1t_kit::call(
484 &self.env.service("BILLING")?,
485 "close_workspace",
486 &CloseWorkspaceArgs {
Merge sudo: delete an account with the workspaces it alone owns, purge each487 actor: deleter.billing.clone(),
488 workspace: slug.to_owned(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look489 dry_run: false,
490 },
491 )
492 .await?;
493 if let Outcome::Fail(failure) = closed {
494 return Ok(Outcome::Fail(failure));
495 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member496 let now = now_ms();
497 let purge = purge_after(now);
498 self.db
499 .prepare(
500 "UPDATE workspaces SET deleted_at = ?, deleted_by = ?, purge_after = ?, deleted_went = ?
501 WHERE id = ? AND deleted_at IS NULL",
502 )
503 .bind(&[
504 rfc3339(now).into(),
Merge sudo: delete an account with the workspaces it alone owns, purge each505 deleter.deleted_by.into(),
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member506 purge.as_str().into(),
507 went_json(&went).into(),
508 workspace.id.as_str().into(),
509 ])?
510 .run()
511 .await?;
512 self.record_on_workspace(
Merge sudo: delete an account with the workspaces it alone owns, purge each513 slug,
514 deleter.audit.clone(),
515 deleter.surface,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member516 "workspace.deleted",
Merge sudo: delete an account with the workspaces it alone owns, purge each517 deleter.rule,
518 (deleter.message)(&purge),
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member519 )
520 .await;
521 self.announce(
522 "workspace.deleting",
Merge sudo: delete an account with the workspaces it alone owns, purge each523 deleter.actor_id,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member524 WorkspaceDeleting {
Merge sudo: delete an account with the workspaces it alone owns, purge each525 workspace_id: workspace.id.clone(),
526 slug: slug.to_owned(),
527 by: deleter.by.to_owned(),
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member528 purge_after: purge,
529 },
530 )
531 .await;
532 Ok(Outcome::Ok(true))
533 }
534
535 /// Deleted workspaces not purged yet, newest first. Staff only.
536 pub async fn admin_deleted_workspaces(&self) -> Result<Vec<DeletedWorkspace>> {
537 let rows = self
538 .db
539 .prepare(format!("SELECT {DELETED_COLUMNS} ORDER BY w.deleted_at DESC LIMIT 500"))
540 .all()
541 .await?
542 .results::<DeletedRow>()?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look543 let now = rfc3339(now_ms());
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member544 let mut listed = Vec::with_capacity(rows.len());
545 for row in rows {
546 let mut went = went_of(row.deleted_went.as_deref());
547 went.protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?;
548 listed.push(DeletedWorkspace {
549 restorable: restorable(&row.purge_after, &now),
550 workspace_id: row.id,
551 slug: row.slug,
552 name: row.name,
553 deleted_at: row.deleted_at,
554 deleted_by: row.deleter.or(row.deleted_by).unwrap_or_default(),
555 purge_after: row.purge_after,
556 went,
557 });
558 }
559 Ok(listed)
560 }
561
562 async fn deleted_row(&self, id: &str) -> Result<Option<DeletedRow>> {
563 self.db
564 .prepare(format!("SELECT {DELETED_COLUMNS} AND w.id = ?"))
565 .bind(&[id.into()])?
566 .first::<DeletedRow>(None)
567 .await
568 }
569
570 /// Staff bring a deleted workspace back within its window, with its
571 /// members and tokens; `workspace.restored` brings back what went with
572 /// it. Staff only.
573 pub async fn admin_restore_workspace(&self, a: AdminDeletedWorkspaceArgs) -> Result<Outcome<bool>> {
574 let staff = a.staff.trim();
575 if staff.is_empty() {
576 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is restoring it."));
577 }
578 let Some(row) = self.deleted_row(&a.workspace_id).await? else {
579 return Ok(Outcome::fail(FailureCode::NotFound, "There is no deleted workspace with that id."));
580 };
581 if let Err((code, message)) = may_restore(&row.slug, &row.purge_after, &rfc3339(now_ms())) {
582 return Ok(Outcome::fail(code, message));
583 }
584 self.db
585 .prepare(
586 "UPDATE workspaces SET deleted_at = NULL, deleted_by = NULL, purge_after = NULL, deleted_went = NULL
587 WHERE id = ? AND deleted_at IS NOT NULL",
588 )
589 .bind(&[row.id.as_str().into()])?
590 .run()
591 .await?;
592 let message = format!(
593 "Restored by g1t's staff; deleted by {} at {}",
594 row.deleter.as_deref().or(row.deleted_by.as_deref()).unwrap_or("an owner"),
595 row.deleted_at
596 );
597 self.record_on_workspace(&row.slug, AuditActor::system(), Surface::Web, "workspace.restored", "staff", message)
598 .await;
599 self.record_for_staff(&row.slug, "workspace_restored", &format!("Restored {}", row.slug), staff)
600 .await;
601 self.announce(
602 "workspace.restored",
603 None,
604 WorkspaceRestored {
605 workspace_id: row.id,
606 slug: row.slug,
607 },
608 )
609 .await;
610 Ok(Outcome::Ok(true))
611 }
612
613 /// Staff purge a deleted workspace now rather than at `purge_after`.
614 /// Staff only.
615 pub async fn admin_purge_workspace(&self, a: AdminDeletedWorkspaceArgs) -> Result<Outcome<bool>> {
616 let staff = a.staff.trim();
617 if staff.is_empty() {
618 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is purging it."));
619 }
620 let Some(row) = self.deleted_row(&a.workspace_id).await? else {
621 return Ok(Outcome::fail(FailureCode::NotFound, "There is no deleted workspace with that id."));
622 };
623 let protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?;
624 if let Err((code, message)) = may_purge(protected, &row.slug, Some(&a.confirm)) {
625 return Ok(Outcome::fail(code, message));
626 }
627 self.purge(&row).await?;
628 self.record_on_workspace(
629 &row.slug,
630 AuditActor::system(),
631 Surface::Web,
632 "workspace.purged",
633 "staff",
634 "Purged by g1t's staff before its restore window ended".to_owned(),
635 )
636 .await;
637 self.record_for_staff(&row.slug, "workspace_purged", &format!("Purged {} now", row.slug), staff)
638 .await;
639 Ok(Outcome::Ok(true))
640 }
641
642 /// The sweep: purges deleted workspaces whose restore window has
643 /// passed. A protected one is never purged, however it came to be
644 /// deleted.
645 pub async fn purge_due_workspaces(&self) -> Result<u32> {
646 let due = self
647 .db
648 .prepare(format!(
649 "SELECT {DELETED_COLUMNS} AND w.purge_after <= ? ORDER BY w.purge_after LIMIT {PURGES_PER_SWEEP}"
650 ))
651 .bind(&[rfc3339(now_ms()).into()])?
652 .all()
653 .await?
654 .results::<DeletedRow>()?;
655 let mut purged = 0;
656 for row in due {
657 let protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?;
658 if let Err((_, why)) = may_purge(protected, &row.slug, None) {
659 worker::console_error!("{} not purged: {why}", row.slug);
660 continue;
661 }
662 match self.purge(&row).await {
663 Ok(()) => {
664 purged += 1;
665 self.record_on_workspace(
666 &row.slug,
667 AuditActor::system(),
668 Surface::Web,
669 "workspace.purged",
670 "schedule",
671 format!("Purged {WORKSPACE_RESTORE_DAYS} days after it was deleted"),
672 )
673 .await;
674 }
675 Err(error) => worker::console_error!("{} not purged: {error}", row.slug),
676 }
677 }
678 Ok(purged)
679 }
680
681 /// Removes a deleted workspace for good, as deleting one always did:
682 /// its row, memberships, tokens and redirects go, its slugs are kept in
683 /// `deleted_workspaces`, and `workspace.deleted` tells services to drop
684 /// what they keep for it.
685 async fn purge(&self, row: &DeletedRow) -> Result<()> {
686 let id = row.id.as_str();
687 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 look688 self.db
689 .batch(vec![
690 self.db
691 .prepare(
692 "INSERT OR REPLACE INTO deleted_workspaces (slug, workspace_id, name, deleted_by, deleted_at)
693 VALUES (?, ?, ?, ?, ?)",
694 )
695 .bind(&[
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member696 row.slug.as_str().into(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look697 id.into(),
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member698 row.name.as_str().into(),
699 by.into(),
700 row.deleted_at.as_str().into(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look701 ])?,
702 // Slugs it was renamed from, still redirecting, are kept
703 // the same way.
704 self.db
705 .prepare(
706 "INSERT OR IGNORE INTO deleted_workspaces (slug, workspace_id, name, deleted_by, deleted_at)
707 SELECT old_slug, workspace_id, ?, ?, ? FROM workspace_redirects WHERE workspace_id = ?",
708 )
709 .bind(&[
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member710 row.name.as_str().into(),
711 by.into(),
712 row.deleted_at.as_str().into(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look713 id.into(),
714 ])?,
715 self.db
716 .prepare("DELETE FROM access_tokens WHERE workspace_id = ?")
717 .bind(&[id.into()])?,
718 self.db
719 .prepare("DELETE FROM workspace_members WHERE workspace_id = ?")
720 .bind(&[id.into()])?,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar721 // Its teams, their people and the roles they gave (teams.rs).
722 self.db
723 .prepare("DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE workspace_id = ?)")
724 .bind(&[id.into()])?,
725 self.db
726 .prepare("DELETE FROM repo_grants WHERE principal_kind = 'team' AND principal_id IN (SELECT id FROM teams WHERE workspace_id = ?)")
727 .bind(&[id.into()])?,
728 self.db
729 .prepare("DELETE FROM teams WHERE workspace_id = ?")
730 .bind(&[id.into()])?,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look731 self.db
732 .prepare("DELETE FROM workspace_redirects WHERE workspace_id = ?")
733 .bind(&[id.into()])?,
Merge branch 'worktree-agent-a8385d293d42c913a'734 // Aliases staff pointed at it lead nowhere now (aliases.rs).
735 self.db
736 .prepare("DELETE FROM workspace_aliases WHERE workspace_id = ?")
737 .bind(&[id.into()])?,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member738 // 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 look739 self.db
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member740 .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 look741 .bind(&[id.into()])?,
742 ])
743 .await?;
744 self.announce(
745 "workspace.deleted",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member746 None,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look747 WorkspaceDeleted {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member748 workspace_id: row.id.clone(),
749 slug: row.slug.clone(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look750 },
751 )
752 .await;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member753 Ok(())
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look754 }
755
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member756 /// An entry in the workspace's audit log, which outlives it.
Merge branch 'worktree-agent-a8385d293d42c913a'757 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 member758 &self,
759 slug: &str,
760 actor: AuditActor,
761 surface: Surface,
762 action: &str,
763 rule: &str,
764 message: String,
765 ) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look766 let Ok(events) = self.env.service("EVENTS") else {
767 return;
768 };
769 let entry = NewAuditEntry {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member770 actor,
771 action: action.to_owned(),
772 surface,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look773 target: AuditTarget {
774 workspace: slug.to_owned(),
775 ..AuditTarget::default()
776 },
777 outcome: AuditOutcome::Allowed,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member778 rule: rule.to_owned(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look779 result: Some("ok".to_owned()),
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member780 message: Some(message),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look781 request_id: new_id("req", now_ms()),
782 };
783 let recorded: Result<u32> = g1t_kit::call(
784 &events,
785 "audit_record",
786 &RecordAuditArgs {
787 entries: vec![entry],
788 },
789 )
790 .await;
791 if let Err(error) = recorded {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member792 worker::console_error!("{action} of {slug} not recorded: {error}");
793 }
794 }
795
796 /// A line in sudo's audit log (billing keeps it), naming the staff
797 /// member.
Merge branch 'worktree-agent-a8385d293d42c913a'798 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 member799 let Ok(billing) = self.env.service("BILLING") else {
800 return;
801 };
802 let recorded: Result<bool> = g1t_kit::call(
803 &billing,
804 "admin_log",
805 &json!({ "workspace": slug, "action": action, "detail": detail, "by": staff }),
806 )
807 .await;
808 if let Err(error) = recorded {
809 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 look810 }
811 }
812
813 /// `transfer_repo_scopes`: agents at work on a transferred repository
814 /// keep their scope, which names it by path.
815 pub async fn transfer_repo_scopes(&self, a: TransferRepoScopesArgs) -> Result<bool> {
816 self.db
817 .prepare(
818 "UPDATE access_tokens
819 SET agent_scope = json_set(agent_scope, '$.repo.namespace', ?1, '$.repo.name', ?2)
820 WHERE agent_scope IS NOT NULL
821 AND json_extract(agent_scope, '$.repo.namespace') = ?3
822 AND json_extract(agent_scope, '$.repo.name') = ?4",
823 )
824 .bind(&[
825 a.to.namespace.as_str().into(),
826 a.to.name.as_str().into(),
827 a.from.namespace.as_str().into(),
828 a.from.name.as_str().into(),
829 ])?
830 .run()
831 .await?;
832 // Who has access to it follows it too (access.rs).
833 self.move_repo_access(&a.from, &a.to).await?;
834 Ok(true)
835 }
836}
837
838#[cfg(test)]
839mod tests {
840 use super::*;
841
842 #[test]
843 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 member844 assert!(may_delete(false, true, true, Some(Role::Owner), "acme", Some(" Acme ")).is_ok());
845 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 look846 assert_eq!(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member847 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 look848 FailureCode::Forbidden
849 );
850 assert_eq!(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member851 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 look852 FailureCode::Forbidden
853 );
854 assert_eq!(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member855 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 look856 FailureCode::Forbidden
857 );
858 assert_eq!(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member859 may_delete(false, true, true, Some(Role::Owner), "acme", Some("acme-inc")).unwrap_err().0,
860 FailureCode::Invalid
861 );
862 // Nothing typed is no confirmation.
863 assert_eq!(
864 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 look865 FailureCode::Invalid
866 );
867 }
868
869 #[test]
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member870 fn a_protected_workspace_is_refused_to_every_caller() {
871 let refused = |person: bool, verified: bool, role: Option<Role>| {
872 may_delete(true, person, verified, role, "flagon-io", Some("flagon-io")).unwrap_err()
873 };
874 // An owner who types the name, a workspace's token, a member, anyone.
875 for (person, verified, role) in [
876 (true, true, Some(Role::Owner)),
877 (false, true, Some(Role::Owner)),
878 (true, true, Some(Role::Member)),
879 (true, false, None),
880 ] {
881 let (code, message) = refused(person, verified, role);
882 assert_eq!(code, FailureCode::Forbidden);
883 assert_eq!(message, "flagon-io is protected and can never be deleted.");
884 }
885 // Neither the sweep nor staff purge it, typed or not.
886 assert_eq!(may_purge(true, "flagon-io", None).unwrap_err().0, FailureCode::Forbidden);
887 assert_eq!(may_purge(true, "flagon-io", Some("flagon-io")).unwrap_err().0, FailureCode::Forbidden);
888 }
889
890 #[test]
891 fn protection_follows_the_workspace_through_a_rename() {
892 let names = protected_names(Some(""));
893 assert!(is_protected(&names, "wsp_1", "flagon-io", false, &[]));
894 assert!(is_protected(&names, "wsp_1", "FLAGON-IO", false, &[]));
895 // Renamed away: its old slug, or the mark on its row, still holds.
896 assert!(is_protected(&names, "wsp_1", "flagon", false, &["flagon-io".into()]));
897 assert!(is_protected(&names, "wsp_1", "flagon", true, &[]));
898 // Named by id in the variable.
899 assert!(is_protected(&protected_names(Some("wsp_9")), "wsp_9", "anything", false, &[]));
900 assert!(!is_protected(&names, "wsp_2", "acme", false, &["acme-old".into()]));
901 }
902
903 #[test]
904 fn staff_purge_only_with_the_name_typed() {
905 assert!(may_purge(false, "acme", None).is_ok());
906 assert!(may_purge(false, "acme", Some(" ACME ")).is_ok());
907 assert_eq!(may_purge(false, "acme", Some("")).unwrap_err().0, FailureCode::Invalid);
908 assert_eq!(may_purge(false, "acme", Some("acme-inc")).unwrap_err().0, FailureCode::Invalid);
909 }
910
911 #[test]
912 fn a_deleted_workspace_is_restorable_for_thirty_days_then_due() {
913 let deleted = 1_790_000_000_000;
914 let purge = purge_after(deleted);
915 assert_eq!(purge, rfc3339(deleted + 30 * 86_400_000));
916 assert!(restorable(&purge, &rfc3339(deleted)));
917 assert!(restorable(&purge, &rfc3339(deleted + 29 * 86_400_000)));
918 assert!(!restorable(&purge, &purge));
919 assert!(!restorable(&purge, &rfc3339(deleted + 31 * 86_400_000)));
920 assert!(may_restore("acme", &purge, &rfc3339(deleted + 86_400_000)).is_ok());
921 assert_eq!(
922 may_restore("acme", &purge, &rfc3339(deleted + 30 * 86_400_000)).unwrap_err(),
923 (FailureCode::Conflict, "acme is being purged and can no longer be restored.".to_owned())
924 );
925 }
926
927 #[test]
928 fn what_went_is_kept_and_read_back() {
929 let went = WorkspaceDeletion {
930 repositories: 4,
931 projects: 2,
932 members: 3,
933 billing: None,
934 protected: false,
935 };
936 assert_eq!(went_of(Some(&went_json(&went))), went);
937 assert_eq!(went_of(None), WorkspaceDeletion::default());
938 assert_eq!(went_of(Some("not json")), WorkspaceDeletion::default());
939 }
940
941 #[test]
Merge sudo: delete an account with the workspaces it alone owns, purge each942 fn billing_sees_staff_as_the_owner_closing_it_named_by_the_staff_member() {
943 let actor = staff_billing_actor("usr_ada", "staff@g1t.sh", "ada");
944 assert_eq!(actor.role_in("ada"), Some(Role::Owner));
945 assert_eq!(actor.role_in("globex"), None);
946 assert_eq!(actor.username, "staff@g1t.sh");
947 assert_eq!(actor.id, "usr_ada");
948 assert_eq!(actor.kind, PrincipalKind::User);
949 }
950
951 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look952 fn a_deleted_slug_is_reclaimed_only_by_its_namesake() {
953 assert!(may_reclaim("syntaqx", "syntaqx"));
954 assert!(may_reclaim("syntaqx", "Syntaqx"));
955 assert!(!may_reclaim("flagon-io", "syntaqx"));
956 }
957}

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