g1t/services/identity/src/deletion.rs

810 lines31,564 bytesCodeBlame
1//! Deleting a workspace.
2//!
3//! 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.
25//!
26//! 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
34//! 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;
43use g1t_contracts::events::{WorkspaceDeleted, WorkspaceDeleting, WorkspaceRestored};
44use 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
55type 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.
63pub fn may_delete(
64 protected: bool,
65 person: bool,
66 verified: bool,
67 role: Option<Role>,
68 slug: &str,
69 confirm: Option<&str>,
70) -> std::result::Result<(), Refusal> {
71 if protected {
72 return Err((FailureCode::Forbidden, protected_refusal(slug)));
73 }
74 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
103/// 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
164#[derive(Deserialize)]
165struct Target {
166 id: String,
167 #[serde(default)]
168 protected: u8,
169}
170
171/// A deleted workspace's row.
172#[derive(Deserialize)]
173struct DeletedRow {
174 id: String,
175 slug: String,
176 name: String,
177 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>,
188}
189
190const 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
195impl Identity {
196 /// Whether `slug` belonged to a workspace that was deleted and purged.
197 pub async fn slug_deleted(&self, slug: &str) -> Result<bool> {
198 Ok(self
199 .db
200 .prepare("SELECT 1 AS held FROM deleted_workspaces WHERE slug = ?")
201 .bind(&[slug.to_lowercase().into()])?
202 .first::<serde_json::Value>(None)
203 .await?
204 .is_some())
205 }
206
207 /// Whether a workspace holds `slug`, deleted or not: one deleted and
208 /// not yet purged keeps it for a restore.
209 pub async fn slug_in_use(&self, slug: &str) -> Result<bool> {
210 Ok(self
211 .db
212 .prepare("SELECT 1 AS held FROM workspaces WHERE slug = ?")
213 .bind(&[slug.to_lowercase().into()])?
214 .first::<serde_json::Value>(None)
215 .await?
216 .is_some())
217 }
218
219 /// A workspace made again under a deleted slug is a workspace again.
220 pub async fn forget_deleted(&self, slug: &str) -> Result<()> {
221 self.db
222 .prepare("DELETE FROM deleted_workspaces WHERE slug = ?")
223 .bind(&[slug.into()])?
224 .run()
225 .await?;
226 Ok(())
227 }
228
229 /// `PROTECTED_WORKSPACES`, with Flagon's whatever it says.
230 fn protected_names(&self) -> Vec<String> {
231 let configured = self.env.var("PROTECTED_WORKSPACES").ok().map(|v| v.to_string());
232 protected_names(configured.as_deref())
233 }
234
235 /// Whether the workspace `id`, now at `slug`, can never be deleted.
236 /// Asked each time, of its row, the variable and the slugs it was
237 /// renamed from, so a rename cannot take the protection away.
238 pub(crate) async fn is_protected(&self, id: &str, slug: &str, flagged: bool) -> Result<bool> {
239 let names = self.protected_names();
240 if is_protected(&names, id, slug, flagged, &[]) {
241 return Ok(true);
242 }
243 #[derive(Deserialize)]
244 struct Old {
245 old_slug: String,
246 }
247 let old: Vec<String> = self
248 .db
249 .prepare("SELECT old_slug FROM workspace_redirects WHERE workspace_id = ?")
250 .bind(&[id.into()])?
251 .all()
252 .await?
253 .results::<Old>()?
254 .into_iter()
255 .map(|row| row.old_slug)
256 .collect();
257 Ok(is_protected(&names, id, slug, flagged, &old))
258 }
259
260 /// What would go with the workspace, and whether billing can close it.
261 async fn deletion_facts(&self, a: &DeleteWorkspaceArgs, slug: &str, target: &Target) -> Result<WorkspaceDeletion> {
262 let repositories: u32 = g1t_kit::call(
263 &self.env.service("REPOS")?,
264 "namespace_count",
265 &NamespaceCountArgs {
266 namespace: slug.to_owned(),
267 },
268 )
269 .await?;
270 let projects: u32 = g1t_kit::call(
271 &self.env.service("PROJECTS")?,
272 "count",
273 &json!({ "workspace": slug }),
274 )
275 .await?;
276 #[derive(Deserialize)]
277 struct Count {
278 n: u32,
279 }
280 let members = self
281 .db
282 .prepare("SELECT count(*) AS n FROM workspace_members WHERE workspace_id = ?")
283 .bind(&[target.id.as_str().into()])?
284 .first::<Count>(None)
285 .await?
286 .map_or(0, |count| count.n);
287 let closing: Outcome<bool> = g1t_kit::call(
288 &self.env.service("BILLING")?,
289 "close_workspace",
290 &CloseWorkspaceArgs {
291 actor: a.actor.clone(),
292 workspace: slug.to_owned(),
293 dry_run: true,
294 },
295 )
296 .await?;
297 Ok(WorkspaceDeletion {
298 repositories,
299 projects,
300 members,
301 billing: match closing {
302 Outcome::Ok(_) => None,
303 Outcome::Fail(failure) => Some(failure.message),
304 },
305 protected: self.is_protected(&target.id, slug, target.protected != 0).await?,
306 })
307 }
308
309 /// The workspace, if the actor may delete it.
310 async fn deletable(&self, a: &DeleteWorkspaceArgs, confirm: bool) -> Result<Outcome<(Target, bool)>> {
311 let slug = a.slug.trim().to_lowercase();
312 let Some(target) = self
313 .db
314 .prepare("SELECT id, protected FROM workspaces WHERE slug = ? AND deleted_at IS NULL")
315 .bind(&[slug.as_str().into()])?
316 .first::<Target>(None)
317 .await?
318 else {
319 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
320 };
321 let protected = self.is_protected(&target.id, &slug, target.protected != 0).await?;
322 if let Err((code, message)) = may_delete(
323 // Only the actual deletion is refused for it; the check says
324 // so in `protected`, for the page to show.
325 protected && confirm,
326 a.actor.kind == PrincipalKind::User,
327 a.actor.verified,
328 a.actor.role_in(&slug),
329 &slug,
330 confirm.then_some(a.confirm.as_str()),
331 ) {
332 return Ok(Outcome::fail(code, message));
333 }
334 Ok(Outcome::Ok((target, protected)))
335 }
336
337 pub async fn check_workspace_deletion(&self, a: DeleteWorkspaceArgs) -> Result<Outcome<WorkspaceDeletion>> {
338 let target = match self.deletable(&a, false).await? {
339 Outcome::Ok((target, _)) => target,
340 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
341 };
342 let slug = a.slug.trim().to_lowercase();
343 Ok(Outcome::Ok(self.deletion_facts(&a, &slug, &target).await?))
344 }
345
346 pub async fn delete_workspace(&self, a: DeleteWorkspaceArgs) -> Result<Outcome<bool>> {
347 let workspace = match self.deletable(&a, true).await? {
348 Outcome::Ok((workspace, _)) => workspace,
349 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
350 };
351 let slug = a.slug.trim().to_lowercase();
352 let went = self.deletion_facts(&a, &slug, &workspace).await?;
353 if let Some(reason) = went.reason(&slug) {
354 let code = if went.protected { FailureCode::Forbidden } else { FailureCode::PaymentRequired };
355 return Ok(Outcome::fail(code, reason));
356 }
357 // Money first: if billing cannot settle it after all (a card
358 // declined a moment ago), nothing is deleted. Its plan ends now, so
359 // nothing more is charged while it waits to be purged.
360 let closed: Outcome<bool> = g1t_kit::call(
361 &self.env.service("BILLING")?,
362 "close_workspace",
363 &CloseWorkspaceArgs {
364 actor: a.actor.clone(),
365 workspace: slug.clone(),
366 dry_run: false,
367 },
368 )
369 .await?;
370 if let Outcome::Fail(failure) = closed {
371 return Ok(Outcome::Fail(failure));
372 }
373 let now = now_ms();
374 let purge = purge_after(now);
375 self.db
376 .prepare(
377 "UPDATE workspaces SET deleted_at = ?, deleted_by = ?, purge_after = ?, deleted_went = ?
378 WHERE id = ? AND deleted_at IS NULL",
379 )
380 .bind(&[
381 rfc3339(now).into(),
382 a.actor.id.as_str().into(),
383 purge.as_str().into(),
384 went_json(&went).into(),
385 workspace.id.as_str().into(),
386 ])?
387 .run()
388 .await?;
389 self.record_on_workspace(
390 &slug,
391 AuditActor::of(&a.actor),
392 a.surface.unwrap_or(Surface::Web),
393 "workspace.deleted",
394 "owner",
395 format!("Deleted {slug}; restorable by g1t's staff until {purge}"),
396 )
397 .await;
398 self.announce(
399 "workspace.deleting",
400 Some(&a.actor.id),
401 WorkspaceDeleting {
402 workspace_id: workspace.id,
403 slug,
404 by: a.actor.username.clone(),
405 purge_after: purge,
406 },
407 )
408 .await;
409 Ok(Outcome::Ok(true))
410 }
411
412 /// Deleted workspaces not purged yet, newest first. Staff only.
413 pub async fn admin_deleted_workspaces(&self) -> Result<Vec<DeletedWorkspace>> {
414 let rows = self
415 .db
416 .prepare(format!("SELECT {DELETED_COLUMNS} ORDER BY w.deleted_at DESC LIMIT 500"))
417 .all()
418 .await?
419 .results::<DeletedRow>()?;
420 let now = rfc3339(now_ms());
421 let mut listed = Vec::with_capacity(rows.len());
422 for row in rows {
423 let mut went = went_of(row.deleted_went.as_deref());
424 went.protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?;
425 listed.push(DeletedWorkspace {
426 restorable: restorable(&row.purge_after, &now),
427 workspace_id: row.id,
428 slug: row.slug,
429 name: row.name,
430 deleted_at: row.deleted_at,
431 deleted_by: row.deleter.or(row.deleted_by).unwrap_or_default(),
432 purge_after: row.purge_after,
433 went,
434 });
435 }
436 Ok(listed)
437 }
438
439 async fn deleted_row(&self, id: &str) -> Result<Option<DeletedRow>> {
440 self.db
441 .prepare(format!("SELECT {DELETED_COLUMNS} AND w.id = ?"))
442 .bind(&[id.into()])?
443 .first::<DeletedRow>(None)
444 .await
445 }
446
447 /// Staff bring a deleted workspace back within its window, with its
448 /// members and tokens; `workspace.restored` brings back what went with
449 /// it. Staff only.
450 pub async fn admin_restore_workspace(&self, a: AdminDeletedWorkspaceArgs) -> Result<Outcome<bool>> {
451 let staff = a.staff.trim();
452 if staff.is_empty() {
453 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is restoring it."));
454 }
455 let Some(row) = self.deleted_row(&a.workspace_id).await? else {
456 return Ok(Outcome::fail(FailureCode::NotFound, "There is no deleted workspace with that id."));
457 };
458 if let Err((code, message)) = may_restore(&row.slug, &row.purge_after, &rfc3339(now_ms())) {
459 return Ok(Outcome::fail(code, message));
460 }
461 self.db
462 .prepare(
463 "UPDATE workspaces SET deleted_at = NULL, deleted_by = NULL, purge_after = NULL, deleted_went = NULL
464 WHERE id = ? AND deleted_at IS NOT NULL",
465 )
466 .bind(&[row.id.as_str().into()])?
467 .run()
468 .await?;
469 let message = format!(
470 "Restored by g1t's staff; deleted by {} at {}",
471 row.deleter.as_deref().or(row.deleted_by.as_deref()).unwrap_or("an owner"),
472 row.deleted_at
473 );
474 self.record_on_workspace(&row.slug, AuditActor::system(), Surface::Web, "workspace.restored", "staff", message)
475 .await;
476 self.record_for_staff(&row.slug, "workspace_restored", &format!("Restored {}", row.slug), staff)
477 .await;
478 self.announce(
479 "workspace.restored",
480 None,
481 WorkspaceRestored {
482 workspace_id: row.id,
483 slug: row.slug,
484 },
485 )
486 .await;
487 Ok(Outcome::Ok(true))
488 }
489
490 /// Staff purge a deleted workspace now rather than at `purge_after`.
491 /// Staff only.
492 pub async fn admin_purge_workspace(&self, a: AdminDeletedWorkspaceArgs) -> Result<Outcome<bool>> {
493 let staff = a.staff.trim();
494 if staff.is_empty() {
495 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is purging it."));
496 }
497 let Some(row) = self.deleted_row(&a.workspace_id).await? else {
498 return Ok(Outcome::fail(FailureCode::NotFound, "There is no deleted workspace with that id."));
499 };
500 let protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?;
501 if let Err((code, message)) = may_purge(protected, &row.slug, Some(&a.confirm)) {
502 return Ok(Outcome::fail(code, message));
503 }
504 self.purge(&row).await?;
505 self.record_on_workspace(
506 &row.slug,
507 AuditActor::system(),
508 Surface::Web,
509 "workspace.purged",
510 "staff",
511 "Purged by g1t's staff before its restore window ended".to_owned(),
512 )
513 .await;
514 self.record_for_staff(&row.slug, "workspace_purged", &format!("Purged {} now", row.slug), staff)
515 .await;
516 Ok(Outcome::Ok(true))
517 }
518
519 /// The sweep: purges deleted workspaces whose restore window has
520 /// passed. A protected one is never purged, however it came to be
521 /// deleted.
522 pub async fn purge_due_workspaces(&self) -> Result<u32> {
523 let due = self
524 .db
525 .prepare(format!(
526 "SELECT {DELETED_COLUMNS} AND w.purge_after <= ? ORDER BY w.purge_after LIMIT {PURGES_PER_SWEEP}"
527 ))
528 .bind(&[rfc3339(now_ms()).into()])?
529 .all()
530 .await?
531 .results::<DeletedRow>()?;
532 let mut purged = 0;
533 for row in due {
534 let protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?;
535 if let Err((_, why)) = may_purge(protected, &row.slug, None) {
536 worker::console_error!("{} not purged: {why}", row.slug);
537 continue;
538 }
539 match self.purge(&row).await {
540 Ok(()) => {
541 purged += 1;
542 self.record_on_workspace(
543 &row.slug,
544 AuditActor::system(),
545 Surface::Web,
546 "workspace.purged",
547 "schedule",
548 format!("Purged {WORKSPACE_RESTORE_DAYS} days after it was deleted"),
549 )
550 .await;
551 }
552 Err(error) => worker::console_error!("{} not purged: {error}", row.slug),
553 }
554 }
555 Ok(purged)
556 }
557
558 /// Removes a deleted workspace for good, as deleting one always did:
559 /// its row, memberships, tokens and redirects go, its slugs are kept in
560 /// `deleted_workspaces`, and `workspace.deleted` tells services to drop
561 /// what they keep for it.
562 async fn purge(&self, row: &DeletedRow) -> Result<()> {
563 let id = row.id.as_str();
564 let by = row.deleted_by.as_deref().unwrap_or_default();
565 self.db
566 .batch(vec![
567 self.db
568 .prepare(
569 "INSERT OR REPLACE INTO deleted_workspaces (slug, workspace_id, name, deleted_by, deleted_at)
570 VALUES (?, ?, ?, ?, ?)",
571 )
572 .bind(&[
573 row.slug.as_str().into(),
574 id.into(),
575 row.name.as_str().into(),
576 by.into(),
577 row.deleted_at.as_str().into(),
578 ])?,
579 // Slugs it was renamed from, still redirecting, are kept
580 // the same way.
581 self.db
582 .prepare(
583 "INSERT OR IGNORE INTO deleted_workspaces (slug, workspace_id, name, deleted_by, deleted_at)
584 SELECT old_slug, workspace_id, ?, ?, ? FROM workspace_redirects WHERE workspace_id = ?",
585 )
586 .bind(&[
587 row.name.as_str().into(),
588 by.into(),
589 row.deleted_at.as_str().into(),
590 id.into(),
591 ])?,
592 self.db
593 .prepare("DELETE FROM access_tokens WHERE workspace_id = ?")
594 .bind(&[id.into()])?,
595 self.db
596 .prepare("DELETE FROM workspace_members WHERE workspace_id = ?")
597 .bind(&[id.into()])?,
598 self.db
599 .prepare("DELETE FROM workspace_redirects WHERE workspace_id = ?")
600 .bind(&[id.into()])?,
601 // Only while it is still deleted: a restore a moment ago wins.
602 self.db
603 .prepare("DELETE FROM workspaces WHERE id = ? AND deleted_at IS NOT NULL")
604 .bind(&[id.into()])?,
605 ])
606 .await?;
607 self.announce(
608 "workspace.deleted",
609 None,
610 WorkspaceDeleted {
611 workspace_id: row.id.clone(),
612 slug: row.slug.clone(),
613 },
614 )
615 .await;
616 Ok(())
617 }
618
619 /// An entry in the workspace's audit log, which outlives it.
620 async fn record_on_workspace(
621 &self,
622 slug: &str,
623 actor: AuditActor,
624 surface: Surface,
625 action: &str,
626 rule: &str,
627 message: String,
628 ) {
629 let Ok(events) = self.env.service("EVENTS") else {
630 return;
631 };
632 let entry = NewAuditEntry {
633 actor,
634 action: action.to_owned(),
635 surface,
636 target: AuditTarget {
637 workspace: slug.to_owned(),
638 ..AuditTarget::default()
639 },
640 outcome: AuditOutcome::Allowed,
641 rule: rule.to_owned(),
642 result: Some("ok".to_owned()),
643 message: Some(message),
644 request_id: new_id("req", now_ms()),
645 };
646 let recorded: Result<u32> = g1t_kit::call(
647 &events,
648 "audit_record",
649 &RecordAuditArgs {
650 entries: vec![entry],
651 },
652 )
653 .await;
654 if let Err(error) = recorded {
655 worker::console_error!("{action} of {slug} not recorded: {error}");
656 }
657 }
658
659 /// A line in sudo's audit log (billing keeps it), naming the staff
660 /// member.
661 async fn record_for_staff(&self, slug: &str, action: &str, detail: &str, staff: &str) {
662 let Ok(billing) = self.env.service("BILLING") else {
663 return;
664 };
665 let recorded: Result<bool> = g1t_kit::call(
666 &billing,
667 "admin_log",
668 &json!({ "workspace": slug, "action": action, "detail": detail, "by": staff }),
669 )
670 .await;
671 if let Err(error) = recorded {
672 worker::console_error!("{action} of {slug} by {staff} not recorded for sudo: {error}");
673 }
674 }
675
676 /// `transfer_repo_scopes`: agents at work on a transferred repository
677 /// keep their scope, which names it by path.
678 pub async fn transfer_repo_scopes(&self, a: TransferRepoScopesArgs) -> Result<bool> {
679 self.db
680 .prepare(
681 "UPDATE access_tokens
682 SET agent_scope = json_set(agent_scope, '$.repo.namespace', ?1, '$.repo.name', ?2)
683 WHERE agent_scope IS NOT NULL
684 AND json_extract(agent_scope, '$.repo.namespace') = ?3
685 AND json_extract(agent_scope, '$.repo.name') = ?4",
686 )
687 .bind(&[
688 a.to.namespace.as_str().into(),
689 a.to.name.as_str().into(),
690 a.from.namespace.as_str().into(),
691 a.from.name.as_str().into(),
692 ])?
693 .run()
694 .await?;
695 // Who has access to it follows it too (access.rs).
696 self.move_repo_access(&a.from, &a.to).await?;
697 Ok(true)
698 }
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704
705 #[test]
706 fn only_a_verified_owner_who_types_the_name() {
707 assert!(may_delete(false, true, true, Some(Role::Owner), "acme", Some(" Acme ")).is_ok());
708 assert!(may_delete(false, true, true, Some(Role::Owner), "acme", None).is_ok());
709 assert_eq!(
710 may_delete(false, true, true, Some(Role::Member), "acme", Some("acme")).unwrap_err().0,
711 FailureCode::Forbidden
712 );
713 assert_eq!(
714 may_delete(false, false, true, Some(Role::Owner), "acme", Some("acme")).unwrap_err().0,
715 FailureCode::Forbidden
716 );
717 assert_eq!(
718 may_delete(false, true, false, Some(Role::Owner), "acme", Some("acme")).unwrap_err().0,
719 FailureCode::Forbidden
720 );
721 assert_eq!(
722 may_delete(false, true, true, Some(Role::Owner), "acme", Some("acme-inc")).unwrap_err().0,
723 FailureCode::Invalid
724 );
725 // Nothing typed is no confirmation.
726 assert_eq!(
727 may_delete(false, true, true, Some(Role::Owner), "acme", Some("")).unwrap_err().0,
728 FailureCode::Invalid
729 );
730 }
731
732 #[test]
733 fn a_protected_workspace_is_refused_to_every_caller() {
734 let refused = |person: bool, verified: bool, role: Option<Role>| {
735 may_delete(true, person, verified, role, "flagon-io", Some("flagon-io")).unwrap_err()
736 };
737 // An owner who types the name, a workspace's token, a member, anyone.
738 for (person, verified, role) in [
739 (true, true, Some(Role::Owner)),
740 (false, true, Some(Role::Owner)),
741 (true, true, Some(Role::Member)),
742 (true, false, None),
743 ] {
744 let (code, message) = refused(person, verified, role);
745 assert_eq!(code, FailureCode::Forbidden);
746 assert_eq!(message, "flagon-io is protected and can never be deleted.");
747 }
748 // Neither the sweep nor staff purge it, typed or not.
749 assert_eq!(may_purge(true, "flagon-io", None).unwrap_err().0, FailureCode::Forbidden);
750 assert_eq!(may_purge(true, "flagon-io", Some("flagon-io")).unwrap_err().0, FailureCode::Forbidden);
751 }
752
753 #[test]
754 fn protection_follows_the_workspace_through_a_rename() {
755 let names = protected_names(Some(""));
756 assert!(is_protected(&names, "wsp_1", "flagon-io", false, &[]));
757 assert!(is_protected(&names, "wsp_1", "FLAGON-IO", false, &[]));
758 // Renamed away: its old slug, or the mark on its row, still holds.
759 assert!(is_protected(&names, "wsp_1", "flagon", false, &["flagon-io".into()]));
760 assert!(is_protected(&names, "wsp_1", "flagon", true, &[]));
761 // Named by id in the variable.
762 assert!(is_protected(&protected_names(Some("wsp_9")), "wsp_9", "anything", false, &[]));
763 assert!(!is_protected(&names, "wsp_2", "acme", false, &["acme-old".into()]));
764 }
765
766 #[test]
767 fn staff_purge_only_with_the_name_typed() {
768 assert!(may_purge(false, "acme", None).is_ok());
769 assert!(may_purge(false, "acme", Some(" ACME ")).is_ok());
770 assert_eq!(may_purge(false, "acme", Some("")).unwrap_err().0, FailureCode::Invalid);
771 assert_eq!(may_purge(false, "acme", Some("acme-inc")).unwrap_err().0, FailureCode::Invalid);
772 }
773
774 #[test]
775 fn a_deleted_workspace_is_restorable_for_thirty_days_then_due() {
776 let deleted = 1_790_000_000_000;
777 let purge = purge_after(deleted);
778 assert_eq!(purge, rfc3339(deleted + 30 * 86_400_000));
779 assert!(restorable(&purge, &rfc3339(deleted)));
780 assert!(restorable(&purge, &rfc3339(deleted + 29 * 86_400_000)));
781 assert!(!restorable(&purge, &purge));
782 assert!(!restorable(&purge, &rfc3339(deleted + 31 * 86_400_000)));
783 assert!(may_restore("acme", &purge, &rfc3339(deleted + 86_400_000)).is_ok());
784 assert_eq!(
785 may_restore("acme", &purge, &rfc3339(deleted + 30 * 86_400_000)).unwrap_err(),
786 (FailureCode::Conflict, "acme is being purged and can no longer be restored.".to_owned())
787 );
788 }
789
790 #[test]
791 fn what_went_is_kept_and_read_back() {
792 let went = WorkspaceDeletion {
793 repositories: 4,
794 projects: 2,
795 members: 3,
796 billing: None,
797 protected: false,
798 };
799 assert_eq!(went_of(Some(&went_json(&went))), went);
800 assert_eq!(went_of(None), WorkspaceDeletion::default());
801 assert_eq!(went_of(Some("not json")), WorkspaceDeletion::default());
802 }
803
804 #[test]
805 fn a_deleted_slug_is_reclaimed_only_by_its_namesake() {
806 assert!(may_reclaim("syntaqx", "syntaqx"));
807 assert!(may_reclaim("syntaqx", "Syntaqx"));
808 assert!(!may_reclaim("flagon-io", "syntaqx"));
809 }
810}