Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

1,212 linesCodeBlameRaw
1//! Deleting an account.
2//!
3//! A person deletes their own account from their settings: signed in as
4//! themselves (never with a token or as an agent), typing their username,
5//! and proving it is them ([`Identity::proof`], security.rs). g1t's staff
6//! can delete one from sudo, with a reason. Both are refused for a
7//! protected account (`g1t`, `g1t-agent`, `ghost`, and whatever
8//! `PROTECTED_ACCOUNTS` names, through
9//! `g1t_contracts::identity::protected_names`), and while the account is
10//! the only owner of any live workspace: its owner makes someone else an
11//! owner, or deletes the workspace (deletion.rs, which settles its billing
12//! with `close_workspace`), first. Billing is per workspace, so a workspace
13//! the account co-owns is someone else's to pay for, and one it owns alone
14//! is in the way already.
15//!
16//! Staff can instead delete those workspaces with the account
17//! (`with_sole_workspaces`), say a retired test account that owns only its
18//! own personal workspace. Every one is checked first: if any is protected
19//! or its billing cannot settle, nothing is deleted and staff are told
20//! which ([`staff_steps`]). Then each is deleted exactly as its owner would
21//! delete it (deletion.rs, [`Identity::staff_delete_workspace`]), and the
22//! account last ([`run_steps`]): should a workspace fail on the way, the
23//! account is not deleted and the failure names it. The account's record
24//! lists the workspaces that went with it, so sudo can purge them at once
25//! too. They lose the account as a member when it is deleted, so to undo
26//! it all, staff restore the account first and then its workspaces.
27//!
28//! Deleting is soft first, as for a workspace. At once, in one batch: the
29//! row gets `deleted_at`, `deleted_by` and `purge_after`
30//! ([`ACCOUNT_RESTORE_DAYS`] on); its sessions, access tokens (its own and
31//! agents'), OAuth grants and codes, device sign-ins, SSH
32//! keys, the deploy keys it added, two-factor sign-ins in progress, emailed
33//! links and GitHub sign-ins in progress go; it leaves every workspace,
34//! team and repository, its pending repository invitations are revoked and
35//! the invites it made and nobody used are revoked. Every read that
36//! resolves a person leaves it out from then on: it cannot sign in (the
37//! answer is the one any wrong password gets), its profile is not found,
38//! nobody can add it to anything, and nothing is emailed to it. Its
39//! username stays held by its row. `user.deleting` tells services to stop
40//! what they do for it. The memberships, teams and repository roles it
41//! left are kept in `deleted_went`, so a restore puts them back.
42//!
43//! Until `purge_after`, staff can restore it from sudo: the columns are
44//! cleared, its memberships come back where their workspace is still
45//! there, and `user.restored` tells services. Its old sessions, tokens and
46//! keys stay ended; the person signs in again with their password.
47//!
48//! The purge, by the scheduled sweep or by staff, removes the row, and with
49//! it (by cascade and here) its addresses, keys, two-factor secret, GitHub
50//! link, security log and profile. Its username goes into `deleted_users`,
51//! so it is never given to another account or workspace. A workspace it
52//! made names `ghost` as its creator instead. `user.deleted` tells services
53//! to drop what they keep for it and show what it wrote as `ghost`.
54//! Billing's ledgers and invoices and the audit logs keep its username.
55//!
56//! There is no API route for any of this: only the site and sudo call it.
57
58use std::future::Future;
59
60use g1t_contracts::FailureCode;
61use g1t_contracts::Outcome;
62use g1t_contracts::User;
63use g1t_contracts::account_deletion::*;
64use g1t_contracts::events::{UserDeleting, UserRestored};
65use g1t_contracts::identity::{UserArgs, protected_names};
66use g1t_contracts::time::rfc3339;
67use g1t_kit::now_ms;
68use serde::{Deserialize, Serialize};
69use worker::Result;
70use worker::wasm_bindgen::JsValue;
71
72use crate::Identity;
73use crate::deletion::staff_billing_actor;
74use crate::security::is_person;
75
76type Refusal = (FailureCode, String);
77
78/// How many accounts one sweep purges.
79const PURGES_PER_SWEEP: u32 = 25;
80
81pub const PEOPLE_ONLY: &str = "Only you can delete your account, signed in as yourself; never with a token or as an agent.";
82
83/// When an account deleted at `now_ms` is purged.
84pub fn purge_after(now_ms: u64) -> String {
85 rfc3339(now_ms + ACCOUNT_RESTORE_DAYS * 86_400_000)
86}
87
88/// Whether an account to be purged at `purge_after` can still be restored
89/// at `now` (both RFC 3339, which compare as text).
90pub fn restorable(purge_after: &str, now: &str) -> bool {
91 now < purge_after
92}
93
94/// Whether the person may delete their account, from what is in the way
95/// and what they typed. Protection first, then the workspaces they own
96/// alone, then the typed username.
97pub fn may_delete_own(person: bool, deletion: &AccountDeletion, confirm: &str) -> std::result::Result<(), Refusal> {
98 if !person {
99 return Err((FailureCode::Forbidden, PEOPLE_ONLY.to_owned()));
100 }
101 may_delete(deletion, confirm)
102}
103
104/// Whether an account may be deleted, for the person or staff alike.
105pub fn may_delete(deletion: &AccountDeletion, confirm: &str) -> std::result::Result<(), Refusal> {
106 if deletion.protected {
107 return Err((FailureCode::Forbidden, protected_account_refusal(&deletion.username)));
108 }
109 if let Some(reason) = sole_owner_refusal(&deletion.sole_owner_of) {
110 return Err((FailureCode::Conflict, reason));
111 }
112 if !confirms_username(&deletion.username, confirm) {
113 return Err((FailureCode::Invalid, format!("Type {} to confirm.", deletion.username)));
114 }
115 Ok(())
116}
117
118/// What staff deleting an account does, in order.
119#[derive(Clone, Debug, PartialEq, Eq)]
120pub enum Step {
121 /// Delete this workspace, which the account is the only owner of.
122 Workspace(String),
123 /// Then the account.
124 Account,
125}
126
127/// Whether staff may delete an account, and what that takes, in order:
128/// with `with_sole_workspaces`, every workspace it is the only owner of,
129/// and the account last. Refused whole, before anything is deleted, for a
130/// protected account; while it owns workspaces alone and staff did not ask
131/// to delete them; while any of those is protected or its billing cannot
132/// settle; and until the username is typed.
133pub fn staff_steps(deletion: &AccountDeletion, with_sole_workspaces: bool, confirm: &str) -> std::result::Result<Vec<Step>, Refusal> {
134 if deletion.protected {
135 return Err((FailureCode::Forbidden, protected_account_refusal(&deletion.username)));
136 }
137 let sole = &deletion.sole_owner_of;
138 if !sole.is_empty() {
139 if !with_sole_workspaces {
140 let reason = sole_owner_refusal(sole).unwrap_or_default();
141 // Staff read "you" as the account's.
142 let reason = reason.replacen("You are the only owner", &format!("{} is the only owner", deletion.username), 1);
143 return Err((FailureCode::Conflict, reason));
144 }
145 if let Some(reason) = staff_sole_owner_refusal(sole) {
146 let code = if sole.iter().any(|workspace| workspace.protected) { FailureCode::Forbidden } else { FailureCode::PaymentRequired };
147 return Err((code, reason));
148 }
149 }
150 if !confirms_username(&deletion.username, confirm) {
151 return Err((FailureCode::Invalid, format!("Type {} to confirm.", deletion.username)));
152 }
153 let mut steps: Vec<Step> = if with_sole_workspaces { sole.iter().map(|workspace| Step::Workspace(workspace.slug.clone())).collect() } else { Vec::new() };
154 steps.push(Step::Account);
155 Ok(steps)
156}
157
158/// Why a staff deletion stopped at `failed`, after deleting `deleted`.
159pub fn stopped_at(username: &str, deleted: &[WorkspaceDeletedWith], failed: &str, why: &str) -> String {
160 let why = why.trim();
161 if deleted.is_empty() {
162 return format!("{failed} could not be deleted: {why} Nothing was deleted.");
163 }
164 let slugs: Vec<&str> = deleted.iter().map(|workspace| workspace.slug.as_str()).collect();
165 let went = match slugs.as_slice() {
166 [one] => (*one).to_owned(),
167 [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
168 [] => String::new(),
169 };
170 format!(
171 "{failed} could not be deleted: {why} {went} {} deleted already (restore from Deleted workspaces if need be); {username} was not deleted.",
172 if slugs.len() == 1 { "was" } else { "were" }
173 )
174}
175
176/// Runs `steps` in order: each workspace with `workspace`, then the
177/// account with `account`, given the workspaces that went. The first
178/// workspace that fails stops it, before the account is deleted, saying
179/// which ([`stopped_at`]).
180pub async fn run_steps<W, WF, A, AF>(username: &str, steps: &[Step], mut workspace: W, account: A) -> Result<Outcome<Vec<WorkspaceDeletedWith>>>
181where
182 W: FnMut(String) -> WF,
183 WF: Future<Output = Result<Outcome<WorkspaceDeletedWith>>>,
184 A: FnOnce(Vec<WorkspaceDeletedWith>) -> AF,
185 AF: Future<Output = Result<()>>,
186{
187 let mut deleted: Vec<WorkspaceDeletedWith> = Vec::new();
188 let mut account = Some(account);
189 for step in steps {
190 match step {
191 Step::Workspace(slug) => {
192 let (code, why) = match workspace(slug.clone()).await {
193 Ok(Outcome::Ok(gone)) => {
194 deleted.push(gone);
195 continue;
196 }
197 Ok(Outcome::Fail(failure)) => (failure.code, failure.message),
198 Err(error) => (FailureCode::Conflict, error.to_string()),
199 };
200 return Ok(Outcome::fail(code, stopped_at(username, &deleted, slug, &why)));
201 }
202 Step::Account => {
203 if let Some(account) = account.take() {
204 account(deleted.clone()).await?;
205 }
206 }
207 }
208 }
209 Ok(Outcome::Ok(deleted))
210}
211
212/// Whose billing `account_deletion_facts` asks about, for each workspace
213/// the account owns alone.
214#[derive(Clone, Copy)]
215pub(crate) enum AskBilling<'a> {
216 /// Nobody: what stands in the way is enough.
217 No,
218 /// The person, for their own deletion page.
219 Person(&'a User),
220 /// g1t's staff, who may delete the workspaces with the account.
221 Staff,
222}
223
224/// Whether staff may restore a deleted account, now `now`.
225pub fn may_restore(username: &str, purge_after: &str, now: &str) -> std::result::Result<(), Refusal> {
226 if !restorable(purge_after, now) {
227 return Err((
228 FailureCode::Conflict,
229 format!("{username} is being purged and can no longer be restored."),
230 ));
231 }
232 Ok(())
233}
234
235/// Whether a deleted account may be purged, by staff (`confirm` is what
236/// they typed) or by the sweep (`None`). Never a protected one.
237pub fn may_purge(protected: bool, username: &str, confirm: Option<&str>) -> std::result::Result<(), Refusal> {
238 if protected {
239 return Err((FailureCode::Forbidden, protected_account_refusal(username)));
240 }
241 if let Some(typed) = confirm
242 && !confirms_username(username, typed)
243 {
244 return Err((FailureCode::Invalid, format!("Type {username} to confirm.")));
245 }
246 Ok(())
247}
248
249/// What a deletion ends at once, in the batch that marks the row, each
250/// with how many of the account's id (`?1`) and now (`?2`) it takes.
251pub fn revoke_statements() -> Vec<(String, usize)> {
252 let mut sql: Vec<(String, usize)> = vec![
253 // A token's selected repositories go with it, before it.
254 ("DELETE FROM token_repositories WHERE token_id IN (SELECT id FROM access_tokens WHERE user_id = ?1)".to_owned(), 1),
255 ];
256 // Everything it signs in or acts with: sessions, tokens (its own and
257 // agents'), applications, device sign-ins, SSH keys,
258 // two-factor sign-ins in progress, emailed links, GitHub sign-ins in
259 // progress. Then its place in workspaces and teams.
260 for table in [
261 "sessions",
262 "access_tokens",
263 "oauth_grants",
264 "oauth_codes",
265 "device_codes",
266 "ssh_keys",
267 "two_factor_challenges",
268 "email_tokens",
269 "github_states",
270 "sso_identities",
271 "sso_states",
272 "workspace_members",
273 "team_members",
274 ] {
275 sql.push((format!("DELETE FROM {table} WHERE user_id = ?1"), 1));
276 }
277 sql.extend([
278 // Deploy keys it added to repositories.
279 ("DELETE FROM deploy_keys WHERE created_by = ?1".to_owned(), 1),
280 // Its SCIM assignments link to nobody; the provider still has them.
281 ("UPDATE scim_users SET user_id = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE user_id = ?1".to_owned(), 1),
282 // g1t keeps no GitHub token for it any more.
283 ("UPDATE github_accounts SET tokens = NULL WHERE user_id = ?1".to_owned(), 1),
284 ("DELETE FROM repo_grants WHERE principal_kind = 'user' AND principal_id = ?1".to_owned(), 1),
285 (
286 "UPDATE repo_invitations SET revoked_at = ?2
287 WHERE invitee_id = ?1 AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL"
288 .to_owned(),
289 2,
290 ),
291 // Invites it made that nobody used.
292 (
293 "UPDATE invites SET revoked_at = ?2, sealed_code = NULL
294 WHERE inviter_id = ?1 AND redeemed_at IS NULL AND revoked_at IS NULL"
295 .to_owned(),
296 2,
297 ),
298 ]);
299 sql
300}
301
302/// What a purge runs, in one batch, each with how many of the account's
303/// id (`?1`), username (`?2`), when it was deleted (`?3`) and now (`?4`) it
304/// takes. Every statement acts only while the account is still deleted and
305/// awaiting its purge, so a restore a moment before wins whole.
306pub fn purge_statements() -> Vec<(String, usize)> {
307 const STILL: &str = "EXISTS (SELECT 1 FROM users WHERE id = ?1 AND deleted_at IS NOT NULL AND purge_after IS NOT NULL)";
308 let mut sql = vec![(
309 format!(
310 "INSERT OR REPLACE INTO deleted_users (username, user_id, deleted_at, purged_at)
311 SELECT ?2, ?1, ?3, ?4 WHERE {STILL}"
312 ),
313 4,
314 )];
315 // What names it as its maker or deleter names ghost: a workspace's
316 // creator must be an account.
317 for (table, column) in [("workspaces", "created_by"), ("workspaces", "deleted_by"), ("teams", "created_by")] {
318 sql.push((format!("UPDATE {table} SET {column} = '{GHOST_ID}' WHERE {column} = ?1 AND {STILL}"), 1));
319 }
320 // Its personal data. Most goes by cascade with the row; each is said
321 // outright so nothing depends on that.
322 for table in [
323 "user_emails",
324 "github_accounts",
325 "two_factor",
326 "two_factor_recovery",
327 "two_factor_challenges",
328 "security_events",
329 "sessions",
330 "access_tokens",
331 "oauth_grants",
332 "oauth_codes",
333 "device_codes",
334 "email_tokens",
335 "ssh_keys",
336 "workspace_members",
337 "team_members",
338 "dock_pins",
339 "onboarding",
340 "sound_settings",
341 ] {
342 sql.push((format!("DELETE FROM {table} WHERE user_id = ?1 AND {STILL}"), 1));
343 }
344 sql.push(("DELETE FROM users WHERE id = ?1 AND deleted_at IS NOT NULL AND purge_after IS NOT NULL".to_owned(), 1));
345 sql
346}
347
348/// A membership the account left, as it was.
349#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
350struct Member {
351 workspace_id: String,
352 role: String,
353 #[serde(default)]
354 billing_manager: u8,
355 #[serde(default)]
356 security_manager: u8,
357 created_at: String,
358}
359
360/// A team the account left.
361#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
362struct TeamMember {
363 team_id: String,
364 role: String,
365 created_at: String,
366}
367
368/// A role on a repository the account had directly.
369#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
370struct Grant {
371 repo_id: String,
372 workspace_id: String,
373 repo_name: String,
374 role: String,
375 #[serde(default)]
376 granted_by: Option<String>,
377 created_at: String,
378 updated_at: String,
379}
380
381/// What `deleted_went` holds.
382#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
383struct Snapshot {
384 #[serde(default)]
385 went: AccountWent,
386 #[serde(default)]
387 memberships: Vec<Member>,
388 #[serde(default)]
389 teams: Vec<TeamMember>,
390 #[serde(default)]
391 grants: Vec<Grant>,
392}
393
394fn snapshot_of(stored: Option<&str>) -> Snapshot {
395 stored.and_then(|text| serde_json::from_str(text).ok()).unwrap_or_default()
396}
397
398/// A deleted account's row.
399#[derive(Deserialize)]
400struct DeletedRow {
401 id: String,
402 username: String,
403 deleted_at: String,
404 purge_after: String,
405 #[serde(default)]
406 deleted_went: Option<String>,
407 #[serde(default)]
408 avatar: Option<String>,
409}
410
411impl DeletedRow {
412 fn listed(&self, now: &str) -> DeletedAccount {
413 DeletedAccount {
414 user_id: self.id.clone(),
415 username: self.username.clone(),
416 deleted_at: self.deleted_at.clone(),
417 purge_after: self.purge_after.clone(),
418 went: snapshot_of(self.deleted_went.as_deref()).went,
419 restorable: restorable(&self.purge_after, now),
420 }
421 }
422}
423
424/// Deleted accounts awaiting their purge: never `ghost`, whose row has no
425/// purge time.
426const DELETED_COLUMNS: &str = "id, username, deleted_at, purge_after, deleted_went, avatar
427 FROM users WHERE deleted_at IS NOT NULL AND purge_after IS NOT NULL";
428
429/// A live account, as found by username.
430#[derive(Deserialize)]
431struct Live {
432 id: String,
433 username: String,
434}
435
436impl Identity {
437 /// `PROTECTED_ACCOUNTS`, with what is always protected.
438 fn protected_account_names(&self) -> Vec<String> {
439 let configured = self.env.var("PROTECTED_ACCOUNTS").ok().map(|v| v.to_string());
440 protected_names(configured.as_deref())
441 }
442
443 /// Whether `user_id` is an account that has not been deleted.
444 pub(crate) async fn account_live(&self, user_id: &str) -> Result<bool> {
445 Ok(self
446 .db
447 .prepare("SELECT 1 AS live FROM users WHERE id = ? AND deleted_at IS NULL")
448 .bind(&[user_id.into()])?
449 .first::<serde_json::Value>(None)
450 .await?
451 .is_some())
452 }
453
454 async fn live_account(&self, column: &str, value: &str) -> Result<Option<Live>> {
455 self.db
456 .prepare(format!("SELECT id, username FROM users WHERE {column} = ? AND deleted_at IS NULL"))
457 .bind(&[value.into()])?
458 .first::<Live>(None)
459 .await
460 }
461
462 /// The live workspaces `user_id` is the only owner of.
463 async fn sole_owned(&self, user_id: &str) -> Result<Vec<SoleOwnedWorkspace>> {
464 #[derive(Deserialize)]
465 struct Row {
466 id: String,
467 slug: String,
468 name: String,
469 members: u32,
470 #[serde(default)]
471 protected: u8,
472 }
473 let rows = self
474 .db
475 .prepare(
476 "SELECT w.id, w.slug, w.name, w.protected,
477 (SELECT count(*) FROM workspace_members a WHERE a.workspace_id = w.id) AS members
478 FROM workspace_members m JOIN workspaces w ON w.id = m.workspace_id
479 WHERE m.user_id = ?1 AND m.role = 'owner' AND w.deleted_at IS NULL
480 AND NOT EXISTS (SELECT 1 FROM workspace_members o
481 WHERE o.workspace_id = w.id AND o.role = 'owner' AND o.user_id <> ?1)
482 ORDER BY w.slug",
483 )
484 .bind(&[user_id.into()])?
485 .all()
486 .await?
487 .results::<Row>()?;
488 let mut sole = Vec::with_capacity(rows.len());
489 for row in rows {
490 let protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?;
491 sole.push(SoleOwnedWorkspace { slug: row.slug, name: row.name, members: row.members, billing: None, protected });
492 }
493 Ok(sole)
494 }
495
496 /// What deleting the account would take with it, and what stands in
497 /// the way. Asked (`ask`), billing says for each workspace it owns
498 /// alone what deleting that workspace would need: for the person, as
499 /// themselves; for staff, as the owner billing closes it for.
500 pub(crate) async fn account_deletion_facts(&self, user_id: &str, username: &str, ask: AskBilling<'_>) -> Result<AccountDeletion> {
501 #[derive(Deserialize)]
502 struct Counts {
503 workspaces: u32,
504 tokens: u32,
505 ssh_keys: u32,
506 applications: u32,
507 repositories: u32,
508 }
509 let counts = self
510 .db
511 .prepare(
512 "SELECT
513 (SELECT count(*) FROM workspace_members m JOIN workspaces w ON w.id = m.workspace_id
514 WHERE m.user_id = ?1 AND w.deleted_at IS NULL) AS workspaces,
515 (SELECT count(*) FROM access_tokens WHERE user_id = ?1 AND agent_scope IS NULL
516 AND (expires_at IS NULL OR listed = 1)) AS tokens,
517 (SELECT count(*) FROM ssh_keys WHERE user_id = ?1) AS ssh_keys,
518 (SELECT count(*) FROM oauth_grants WHERE user_id = ?1) AS applications,
519 (SELECT count(*) FROM repo_grants WHERE principal_kind = 'user' AND principal_id = ?1) AS repositories",
520 )
521 .bind(&[user_id.into()])?
522 .first::<Counts>(None)
523 .await?
524 .unwrap_or(Counts { workspaces: 0, tokens: 0, ssh_keys: 0, applications: 0, repositories: 0 });
525 let mut sole_owner_of = self.sole_owned(user_id).await?;
526 for workspace in &mut sole_owner_of {
527 let actor = match ask {
528 AskBilling::No => break,
529 AskBilling::Person(person) => person.clone(),
530 AskBilling::Staff => staff_billing_actor(user_id, "g1t staff", &workspace.slug),
531 };
532 workspace.billing = match self.billing_refusal(&actor, &workspace.slug).await {
533 Ok(refusal) => refusal,
534 // Staff are not let through on a billing that did not
535 // answer: deleting it would ask again and stop there.
536 Err(error) => matches!(ask, AskBilling::Staff).then(|| format!("Billing did not answer for {}: {error}", workspace.slug)),
537 };
538 }
539 Ok(AccountDeletion {
540 username: username.to_owned(),
541 workspaces: counts.workspaces,
542 tokens: counts.tokens,
543 ssh_keys: counts.ssh_keys,
544 applications: counts.applications,
545 repositories: counts.repositories,
546 sole_owner_of,
547 protected: is_protected_account(&self.protected_account_names(), user_id, username),
548 })
549 }
550
551 /// `check_account_deletion`: what deleting the person's own account
552 /// would take, and what is in the way, changing nothing.
553 pub async fn check_account_deletion(&self, a: UserArgs) -> Result<Outcome<AccountDeletion>> {
554 if !is_person(&a.user) {
555 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
556 }
557 let Some(live) = self.live_account("id", &a.user.id).await? else {
558 return Ok(Outcome::fail(FailureCode::NotFound, "Account not found."));
559 };
560 Ok(Outcome::Ok(self.account_deletion_facts(&live.id, &live.username, AskBilling::Person(&a.user)).await?))
561 }
562
563 /// `delete_account`: the person deletes their own account.
564 pub async fn delete_account(&self, a: DeleteAccountArgs) -> Result<Outcome<bool>> {
565 if !is_person(&a.user) {
566 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
567 }
568 let Some(live) = self.live_account("id", &a.user.id).await? else {
569 return Ok(Outcome::fail(FailureCode::NotFound, "Account not found."));
570 };
571 let deletion = self.account_deletion_facts(&live.id, &live.username, AskBilling::No).await?;
572 if let Err((code, message)) = may_delete_own(true, &deletion, &a.confirm) {
573 return Ok(Outcome::fail(code, message));
574 }
575 // Proof last: nothing else in the way, so a password typed now is
576 // the last thing asked.
577 if let Some(refusal) = self.proof(&live.id, &a.reauth).await?.refusal() {
578 return Ok(refusal);
579 }
580 self.soft_delete(&live, &deletion, Some(&live.id), None, Vec::new()).await?;
581 Ok(Outcome::Ok(true))
582 }
583
584 /// `admin_delete_account`: staff delete an account, with a reason, and
585 /// with `with_sole_workspaces` the workspaces it is the only owner of
586 /// first.
587 pub async fn admin_delete_account(&self, a: AdminDeleteAccountArgs) -> Result<Outcome<bool>> {
588 let staff = a.staff.trim();
589 let reason = a.reason.trim();
590 if staff.is_empty() {
591 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is deleting it."));
592 }
593 if reason.is_empty() {
594 return Ok(Outcome::fail(FailureCode::Invalid, "Say why the account is being deleted."));
595 }
596 let Some(live) = self.live_account("username", &a.username.trim().to_lowercase()).await? else {
597 return Ok(Outcome::fail(FailureCode::NotFound, "No such account, or it is already deleted."));
598 };
599 // Billing is asked only when it matters: the workspaces go too.
600 let ask = if a.with_sole_workspaces { AskBilling::Staff } else { AskBilling::No };
601 let deletion = self.account_deletion_facts(&live.id, &live.username, ask).await?;
602 let steps = match staff_steps(&deletion, a.with_sole_workspaces, &a.confirm) {
603 Ok(steps) => steps,
604 Err((code, message)) => return Ok(Outcome::fail(code, message)),
605 };
606 let (owner_id, owner, live_ref, deletion_ref) = (live.id.as_str(), live.username.as_str(), &live, &deletion);
607 let done = run_steps(
608 &live.username,
609 &steps,
610 move |slug| async move { self.staff_delete_workspace(&slug, owner_id, owner, staff, reason).await },
611 move |workspaces| async move {
612 self.soft_delete(live_ref, deletion_ref, None, Some((staff, reason)), workspaces).await
613 },
614 )
615 .await?;
616 let workspaces = match done {
617 Outcome::Ok(workspaces) => workspaces,
618 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
619 };
620 let with = if workspaces.is_empty() {
621 String::new()
622 } else {
623 let slugs: Vec<&str> = workspaces.iter().map(|workspace| workspace.slug.as_str()).collect();
624 format!(" and the workspaces it alone owned ({})", slugs.join(", "))
625 };
626 self.record_for_staff(
627 &live.username,
628 "account_deleted",
629 &format!("Deleted the account {}{with}: {reason}", live.username),
630 staff,
631 )
632 .await;
633 Ok(Outcome::Ok(true))
634 }
635
636 /// Deletes an account softly: see the module docs.
637 async fn soft_delete(
638 &self,
639 live: &Live,
640 deletion: &AccountDeletion,
641 deleted_by: Option<&str>,
642 staff: Option<(&str, &str)>,
643 workspaces: Vec<WorkspaceDeletedWith>,
644 ) -> Result<()> {
645 let id = live.id.as_str();
646 let mut snapshot = self.snapshot(id, deletion, staff).await?;
647 snapshot.went.deleted_workspaces = workspaces;
648 let now = now_ms();
649 let at = rfc3339(now);
650 let purge = purge_after(now);
651 let by_staff = staff.is_some();
652 // Recorded in each workspace it was in, while it still is.
653 let person = User { id: live.id.clone(), username: live.username.clone(), ..User::default() };
654 let action = if by_staff { "account.deleted_by_staff" } else { "account.deleted" };
655 self.audit_account(&person, action, &format!("Deleted the account {}", live.username)).await;
656 // The person hears of it while their addresses are still there.
657 for address in self.notice_recipients(id, false).await.unwrap_or_default() {
658 if let Err(error) = crate::email::send_account_deleted(&self.env, &address, &live.username, by_staff, ACCOUNT_RESTORE_DAYS).await {
659 worker::console_error!("account deleted notice failed: {error}");
660 }
661 }
662 let went = serde_json::to_string(&snapshot).unwrap_or_default();
663 let by: JsValue = deleted_by.map_or(JsValue::NULL, Into::into);
664 let mut statements = vec![
665 // Only while it is still live: two deletions at once delete once.
666 self.db
667 .prepare(
668 "UPDATE users SET deleted_at = ?, deleted_by = ?, purge_after = ?, deleted_went = ?
669 WHERE id = ? AND deleted_at IS NULL",
670 )
671 .bind(&[at.as_str().into(), by, purge.as_str().into(), went.into(), id.into()])?,
672 ];
673 let values = [id, at.as_str()];
674 for (sql, binds) in revoke_statements() {
675 let binds: Vec<JsValue> = values[..binds].iter().map(|value| JsValue::from(*value)).collect();
676 statements.push(self.db.prepare(sql).bind(&binds)?);
677 }
678 self.db.batch(statements).await?;
679 self.log_security(id, "account_deleted", None, staff).await;
680 self.announce(
681 "user.deleting",
682 deleted_by,
683 UserDeleting { user_id: live.id.clone(), username: live.username.clone(), by_staff, purge_after: purge },
684 )
685 .await;
686 Ok(())
687 }
688
689 /// What the account has now, kept so a restore can put it back.
690 async fn snapshot(&self, id: &str, deletion: &AccountDeletion, staff: Option<(&str, &str)>) -> Result<Snapshot> {
691 let memberships = self
692 .db
693 .prepare(
694 "SELECT workspace_id, role, billing_manager, security_manager, created_at
695 FROM workspace_members WHERE user_id = ?",
696 )
697 .bind(&[id.into()])?
698 .all()
699 .await?
700 .results::<Member>()?;
701 let teams = self
702 .db
703 .prepare("SELECT team_id, role, created_at FROM team_members WHERE user_id = ?")
704 .bind(&[id.into()])?
705 .all()
706 .await?
707 .results::<TeamMember>()?;
708 let grants = self
709 .db
710 .prepare(
711 "SELECT repo_id, workspace_id, repo_name, role, granted_by, created_at, updated_at
712 FROM repo_grants WHERE principal_kind = 'user' AND principal_id = ?",
713 )
714 .bind(&[id.into()])?
715 .all()
716 .await?
717 .results::<Grant>()?;
718 Ok(Snapshot {
719 went: AccountWent {
720 workspaces: deletion.workspaces,
721 teams: teams.len() as u32,
722 repositories: grants.len() as u32,
723 tokens: deletion.tokens,
724 ssh_keys: deletion.ssh_keys,
725 staff: staff.map(|(who, _)| who.to_owned()),
726 reason: staff.map(|(_, why)| why.to_owned()),
727 deleted_workspaces: Vec::new(),
728 },
729 memberships,
730 teams,
731 grants,
732 })
733 }
734
735 /// Deleted accounts not purged yet, newest first. Staff only.
736 pub async fn admin_deleted_accounts(&self) -> Result<Vec<DeletedAccount>> {
737 let rows = self
738 .db
739 .prepare(format!("SELECT {DELETED_COLUMNS} ORDER BY deleted_at DESC LIMIT 500"))
740 .all()
741 .await?
742 .results::<DeletedRow>()?;
743 let now = rfc3339(now_ms());
744 Ok(rows.iter().map(|row| row.listed(&now)).collect())
745 }
746
747 async fn deleted_account_row(&self, column: &str, value: &str) -> Result<Option<DeletedRow>> {
748 self.db
749 .prepare(format!("SELECT {DELETED_COLUMNS} AND {column} = ?"))
750 .bind(&[value.into()])?
751 .first::<DeletedRow>(None)
752 .await
753 }
754
755 /// The deletion of `user_id`, when it is deleted and not purged.
756 pub(crate) async fn deleted_account(&self, user_id: &str) -> Result<Option<DeletedAccount>> {
757 let now = rfc3339(now_ms());
758 Ok(self.deleted_account_row("id", user_id).await?.map(|row| row.listed(&now)))
759 }
760
761 /// Staff bring a deleted account back within its window, with the
762 /// memberships, teams and repository roles it left. Staff only.
763 pub async fn admin_restore_account(&self, a: AdminDeletedAccountArgs) -> Result<Outcome<bool>> {
764 let staff = a.staff.trim();
765 if staff.is_empty() {
766 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is restoring it."));
767 }
768 let Some(row) = self.deleted_account_row("id", &a.user_id).await? else {
769 return Ok(Outcome::fail(FailureCode::NotFound, "There is no deleted account with that id."));
770 };
771 if let Err((code, message)) = may_restore(&row.username, &row.purge_after, &rfc3339(now_ms())) {
772 return Ok(Outcome::fail(code, message));
773 }
774 let snapshot = snapshot_of(row.deleted_went.as_deref());
775 let id = row.id.as_str();
776 let mut statements = vec![
777 self.db
778 .prepare(
779 "UPDATE users SET deleted_at = NULL, deleted_by = NULL, purge_after = NULL, deleted_went = NULL
780 WHERE id = ? AND deleted_at IS NOT NULL",
781 )
782 .bind(&[id.into()])?,
783 ];
784 // Back where the workspace, team or repository's workspace is
785 // still there; never over what was given since.
786 for member in &snapshot.memberships {
787 statements.push(
788 self.db
789 .prepare(
790 "INSERT OR IGNORE INTO workspace_members
791 (workspace_id, user_id, role, created_at, billing_manager, security_manager)
792 SELECT ?1, ?2, ?3, ?4, ?5, ?6 WHERE EXISTS (SELECT 1 FROM workspaces WHERE id = ?1)",
793 )
794 .bind(&[
795 member.workspace_id.as_str().into(),
796 id.into(),
797 member.role.as_str().into(),
798 member.created_at.as_str().into(),
799 member.billing_manager.into(),
800 member.security_manager.into(),
801 ])?,
802 );
803 }
804 for team in &snapshot.teams {
805 statements.push(
806 self.db
807 .prepare(
808 "INSERT OR IGNORE INTO team_members (team_id, user_id, role, created_at)
809 SELECT ?1, ?2, ?3, ?4 WHERE EXISTS (SELECT 1 FROM teams WHERE id = ?1)",
810 )
811 .bind(&[team.team_id.as_str().into(), id.into(), team.role.as_str().into(), team.created_at.as_str().into()])?,
812 );
813 }
814 for grant in &snapshot.grants {
815 statements.push(
816 self.db
817 .prepare(
818 "INSERT OR IGNORE INTO repo_grants
819 (repo_id, principal_kind, principal_id, workspace_id, repo_name, role, granted_by, created_at, updated_at)
820 SELECT ?1, 'user', ?2, ?3, ?4, ?5, ?6, ?7, ?8
821 WHERE EXISTS (SELECT 1 FROM workspaces WHERE id = ?3)",
822 )
823 .bind(&[
824 grant.repo_id.as_str().into(),
825 id.into(),
826 grant.workspace_id.as_str().into(),
827 grant.repo_name.as_str().into(),
828 grant.role.as_str().into(),
829 grant.granted_by.as_deref().map_or(JsValue::NULL, Into::into),
830 grant.created_at.as_str().into(),
831 grant.updated_at.as_str().into(),
832 ])?,
833 );
834 }
835 self.db.batch(statements).await?;
836 self.log_security(id, "account_restored", None, Some((staff, "Restored by g1t's staff"))).await;
837 self.record_for_staff(&row.username, "account_restored", &format!("Restored the account {}", row.username), staff)
838 .await;
839 self.announce("user.restored", None, UserRestored { user_id: row.id.clone(), username: row.username.clone() })
840 .await;
841 Ok(Outcome::Ok(true))
842 }
843
844 /// Staff purge a deleted account now rather than at `purge_after`.
845 pub async fn admin_purge_account(&self, a: AdminDeletedAccountArgs) -> Result<Outcome<bool>> {
846 let staff = a.staff.trim();
847 if staff.is_empty() {
848 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is purging it."));
849 }
850 let Some(row) = self.deleted_account_row("id", &a.user_id).await? else {
851 return Ok(Outcome::fail(FailureCode::NotFound, "There is no deleted account with that id."));
852 };
853 let protected = is_protected_account(&self.protected_account_names(), &row.id, &row.username);
854 if let Err((code, message)) = may_purge(protected, &row.username, Some(&a.confirm)) {
855 return Ok(Outcome::fail(code, message));
856 }
857 self.purge_account(&row).await?;
858 self.record_for_staff(&row.username, "account_purged", &format!("Purged the account {} now", row.username), staff)
859 .await;
860 Ok(Outcome::Ok(true))
861 }
862
863 /// The sweep: purges deleted accounts whose restore window has passed.
864 pub async fn purge_due_accounts(&self) -> Result<u32> {
865 let due = self
866 .db
867 .prepare(format!(
868 "SELECT {DELETED_COLUMNS} AND purge_after <= ? ORDER BY purge_after LIMIT {PURGES_PER_SWEEP}"
869 ))
870 .bind(&[rfc3339(now_ms()).into()])?
871 .all()
872 .await?
873 .results::<DeletedRow>()?;
874 let names = self.protected_account_names();
875 let mut purged = 0;
876 for row in due {
877 if let Err((_, why)) = may_purge(is_protected_account(&names, &row.id, &row.username), &row.username, None) {
878 worker::console_error!("{} not purged: {why}", row.username);
879 continue;
880 }
881 match self.purge_account(&row).await {
882 Ok(()) => purged += 1,
883 Err(error) => worker::console_error!("{} not purged: {error}", row.username),
884 }
885 }
886 Ok(purged)
887 }
888
889 /// Removes a deleted account for good: see the module docs.
890 async fn purge_account(&self, row: &DeletedRow) -> Result<()> {
891 let now = rfc3339(now_ms());
892 let values = [
893 row.id.as_str(),
894 row.username.as_str(),
895 row.deleted_at.as_str(),
896 now.as_str(),
897 ];
898 let mut batch = self.purge_job_statements(
899 "user",
900 &row.id,
901 serde_json::json!({"username": row.username, "avatar": row.avatar}),
902 )?;
903 for (sql, binds) in purge_statements() {
904 let binds: Vec<JsValue> = values[..binds]
905 .iter()
906 .map(|value| JsValue::from(*value))
907 .collect();
908 batch.push(self.db.prepare(sql).bind(&binds)?);
909 }
910 self.db.batch(batch).await?;
911 Ok(())
912 }
913}
914
915#[cfg(test)]
916mod tests {
917 use super::*;
918
919 fn deletion(username: &str) -> AccountDeletion {
920 AccountDeletion { username: username.into(), ..AccountDeletion::default() }
921 }
922
923 fn sole(slug: &str) -> SoleOwnedWorkspace {
924 SoleOwnedWorkspace { slug: slug.into(), name: slug.into(), members: 2, billing: None, protected: false }
925 }
926
927 /// Polls a future to its end. What these tests run never waits.
928 fn block_on<F: Future>(future: F) -> F::Output {
929 use std::task::{Context, Poll, Waker};
930 let mut future = std::pin::pin!(future);
931 let mut cx = Context::from_waker(Waker::noop());
932 loop {
933 if let Poll::Ready(value) = future.as_mut().poll(&mut cx) {
934 return value;
935 }
936 }
937 }
938
939 fn gone(slug: &str) -> WorkspaceDeletedWith {
940 WorkspaceDeletedWith { workspace_id: format!("wsp_{slug}"), slug: slug.into() }
941 }
942
943 /// Runs `steps` against a record of what was done, with the workspaces
944 /// in `failing` refusing.
945 fn run(steps: &[Step], failing: &[&str]) -> (Outcome<Vec<WorkspaceDeletedWith>>, Vec<String>) {
946 let done = std::cell::RefCell::new(Vec::<String>::new());
947 let outcome = block_on(run_steps(
948 "ada",
949 steps,
950 |slug| {
951 let refused = failing.contains(&slug.as_str());
952 if !refused {
953 done.borrow_mut().push(format!("workspace {slug}"));
954 }
955 async move {
956 if refused {
957 Ok(Outcome::fail(FailureCode::PaymentRequired, format!("The card on file was declined for {slug}.")))
958 } else {
959 Ok(Outcome::Ok(gone(&slug)))
960 }
961 }
962 },
963 |workspaces| {
964 let slugs: Vec<String> = workspaces.iter().map(|workspace| workspace.slug.clone()).collect();
965 done.borrow_mut().push(format!("account with [{}]", slugs.join(", ")));
966 async { Ok(()) }
967 },
968 ))
969 .unwrap();
970 (outcome, done.into_inner())
971 }
972
973 #[test]
974 fn with_its_workspaces_staff_delete_each_then_the_account() {
975 let owner = AccountDeletion { sole_owner_of: vec![sole("ada"), sole("ada-labs")], ..deletion("ada") };
976 let steps = staff_steps(&owner, true, "ada").unwrap();
977 assert_eq!(steps, vec![Step::Workspace("ada".into()), Step::Workspace("ada-labs".into()), Step::Account]);
978 let (outcome, done) = run(&steps, &[]);
979 assert_eq!(done, vec!["workspace ada", "workspace ada-labs", "account with [ada, ada-labs]"]);
980 match outcome {
981 Outcome::Ok(workspaces) => assert_eq!(workspaces, vec![gone("ada"), gone("ada-labs")]),
982 Outcome::Fail(failure) => panic!("{}", failure.message),
983 }
984 // Without them, only the account, and only when it owns none alone.
985 assert_eq!(staff_steps(&deletion("ada"), false, "ada").unwrap(), vec![Step::Account]);
986 assert_eq!(staff_steps(&deletion("ada"), true, "ada").unwrap(), vec![Step::Account]);
987 }
988
989 #[test]
990 fn without_asking_for_its_workspaces_staff_are_told_it_owns_them() {
991 let owner = AccountDeletion { sole_owner_of: vec![sole("acme")], ..deletion("ada") };
992 assert_eq!(
993 staff_steps(&owner, false, "ada").unwrap_err(),
994 (
995 FailureCode::Conflict,
996 "ada is the only owner of acme. Make someone else an owner of it, or delete it, first.".to_owned()
997 )
998 );
999 }
1000
1001 #[test]
1002 fn a_protected_workspace_refuses_the_whole_deletion_before_anything_goes() {
1003 let flagon = SoleOwnedWorkspace { protected: true, ..sole("flagon-io") };
1004 let owner = AccountDeletion { sole_owner_of: vec![sole("ada"), flagon], ..deletion("ada") };
1005 let (code, message) = staff_steps(&owner, true, "ada").unwrap_err();
1006 assert_eq!(code, FailureCode::Forbidden);
1007 assert_eq!(message, "Nothing was deleted. flagon-io is protected and can never be deleted.");
1008 // A protected account, whatever it owns.
1009 let protected = AccountDeletion { protected: true, ..deletion("g1t") };
1010 assert_eq!(staff_steps(&protected, true, "g1t").unwrap_err().0, FailureCode::Forbidden);
1011 }
1012
1013 #[test]
1014 fn billing_that_cannot_settle_refuses_the_whole_deletion_before_anything_goes() {
1015 let owing = SoleOwnedWorkspace { billing: Some("ada-labs has an unpaid invoice. Pay it from the workspace's Billing page first.".into()), ..sole("ada-labs") };
1016 let owner = AccountDeletion { sole_owner_of: vec![sole("ada"), owing], ..deletion("ada") };
1017 let (code, message) = staff_steps(&owner, true, "ada").unwrap_err();
1018 assert_eq!(code, FailureCode::PaymentRequired);
1019 assert_eq!(message, "Nothing was deleted. ada-labs has an unpaid invoice. Pay it from the workspace's Billing page first.");
1020 }
1021
1022 #[test]
1023 fn staff_type_the_username_after_everything_else() {
1024 let owner = AccountDeletion { sole_owner_of: vec![sole("ada")], ..deletion("ada") };
1025 assert_eq!(staff_steps(&owner, true, "").unwrap_err(), (FailureCode::Invalid, "Type ada to confirm.".into()));
1026 assert_eq!(staff_steps(&owner, true, "grace").unwrap_err().0, FailureCode::Invalid);
1027 }
1028
1029 #[test]
1030 fn a_workspace_failing_on_the_way_stops_before_the_account() {
1031 let steps = vec![Step::Workspace("ada".into()), Step::Workspace("ada-labs".into()), Step::Workspace("ada-old".into()), Step::Account];
1032 let (outcome, done) = run(&steps, &["ada-labs"]);
1033 // ada went; ada-labs refused; ada-old and the account were not tried.
1034 assert_eq!(done, vec!["workspace ada"]);
1035 let Outcome::Fail(failure) = outcome else { panic!("the account must not be deleted") };
1036 assert_eq!(failure.code, FailureCode::PaymentRequired);
1037 assert_eq!(
1038 failure.message,
1039 "ada-labs could not be deleted: The card on file was declined for ada-labs. ada was deleted already (restore from Deleted workspaces if need be); ada was not deleted."
1040 );
1041 // Failing first, nothing went at all.
1042 let (outcome, done) = run(&steps, &["ada"]);
1043 assert!(done.is_empty());
1044 let Outcome::Fail(failure) = outcome else { panic!("the account must not be deleted") };
1045 assert_eq!(failure.message, "ada could not be deleted: The card on file was declined for ada. Nothing was deleted.");
1046 }
1047
1048 #[test]
1049 fn what_stopped_it_names_what_went_before() {
1050 assert_eq!(
1051 stopped_at("ada", &[gone("a"), gone("b")], "c", "Declined. "),
1052 "c could not be deleted: Declined. a and b were deleted already (restore from Deleted workspaces if need be); ada was not deleted."
1053 );
1054 }
1055
1056 #[test]
1057 fn only_the_person_typing_their_username() {
1058 assert!(may_delete_own(true, &deletion("ada"), " ADA ").is_ok());
1059 assert_eq!(may_delete_own(false, &deletion("ada"), "ada").unwrap_err().0, FailureCode::Forbidden);
1060 assert_eq!(may_delete_own(true, &deletion("ada"), "").unwrap_err(), (FailureCode::Invalid, "Type ada to confirm.".into()));
1061 assert_eq!(may_delete_own(true, &deletion("ada"), "ada-l").unwrap_err().0, FailureCode::Invalid);
1062 }
1063
1064 #[test]
1065 fn the_only_owner_of_a_live_workspace_is_refused_with_its_name() {
1066 let owner = AccountDeletion { sole_owner_of: vec![sole("acme"), sole("globex")], ..deletion("ada") };
1067 let (code, message) = may_delete_own(true, &owner, "ada").unwrap_err();
1068 assert_eq!(code, FailureCode::Conflict);
1069 assert!(message.contains("acme and globex"), "{message}");
1070 // Staff are refused the same way.
1071 assert_eq!(may_delete(&owner, "ada").unwrap_err().0, FailureCode::Conflict);
1072 }
1073
1074 #[test]
1075 fn a_protected_account_is_refused_to_everyone_and_never_purged() {
1076 let names = protected_names(None);
1077 for username in ["g1t", "g1t-agent", "ghost"] {
1078 assert!(is_protected_account(&names, "usr_1", username));
1079 let protected = AccountDeletion { protected: true, ..deletion(username) };
1080 let (code, message) = may_delete_own(true, &protected, username).unwrap_err();
1081 assert_eq!(code, FailureCode::Forbidden);
1082 assert_eq!(message, format!("{username} is protected and can never be deleted."));
1083 assert_eq!(may_purge(true, username, None).unwrap_err().0, FailureCode::Forbidden);
1084 assert_eq!(may_purge(true, username, Some(username)).unwrap_err().0, FailureCode::Forbidden);
1085 }
1086 // Named in PROTECTED_ACCOUNTS, by username or id.
1087 let named = protected_names(Some("ada,usr_9"));
1088 assert!(is_protected_account(&named, "usr_2", "Ada"));
1089 assert!(is_protected_account(&named, "usr_9", "grace"));
1090 assert!(!is_protected_account(&named, "usr_3", "grace"));
1091 }
1092
1093 #[test]
1094 fn a_deleted_account_is_restorable_for_thirty_days_then_due() {
1095 let deleted = 1_790_000_000_000;
1096 let purge = purge_after(deleted);
1097 assert_eq!(purge, rfc3339(deleted + 30 * 86_400_000));
1098 assert!(restorable(&purge, &rfc3339(deleted + 29 * 86_400_000)));
1099 assert!(!restorable(&purge, &purge));
1100 assert!(may_restore("ada", &purge, &rfc3339(deleted + 86_400_000)).is_ok());
1101 assert_eq!(
1102 may_restore("ada", &purge, &rfc3339(deleted + 31 * 86_400_000)).unwrap_err(),
1103 (FailureCode::Conflict, "ada is being purged and can no longer be restored.".to_owned())
1104 );
1105 }
1106
1107 #[test]
1108 fn staff_purge_only_with_the_username_typed() {
1109 assert!(may_purge(false, "ada", None).is_ok());
1110 assert!(may_purge(false, "ada", Some(" Ada ")).is_ok());
1111 assert_eq!(may_purge(false, "ada", Some("")).unwrap_err().0, FailureCode::Invalid);
1112 assert_eq!(may_purge(false, "ada", Some("grace")).unwrap_err().0, FailureCode::Invalid);
1113 }
1114
1115 #[test]
1116 fn what_it_left_is_kept_for_a_restore_and_read_back() {
1117 let snapshot = Snapshot {
1118 went: AccountWent {
1119 workspaces: 2,
1120 teams: 1,
1121 repositories: 1,
1122 tokens: 3,
1123 ssh_keys: 1,
1124 staff: Some("s@g1t.sh".into()),
1125 reason: Some("asked".into()),
1126 deleted_workspaces: vec![gone("ada")],
1127 },
1128 memberships: vec![Member {
1129 workspace_id: "wsp_1".into(),
1130 role: "owner".into(),
1131 billing_manager: 0,
1132 security_manager: 1,
1133 created_at: "2026-01-01T00:00:00.000Z".into(),
1134 }],
1135 teams: vec![TeamMember { team_id: "tem_1".into(), role: "maintainer".into(), created_at: "x".into() }],
1136 grants: vec![Grant {
1137 repo_id: "rep_1".into(),
1138 workspace_id: "wsp_2".into(),
1139 repo_name: "api".into(),
1140 role: "write".into(),
1141 granted_by: None,
1142 created_at: "x".into(),
1143 updated_at: "y".into(),
1144 }],
1145 };
1146 let stored = serde_json::to_string(&snapshot).unwrap();
1147 assert_eq!(snapshot_of(Some(&stored)), snapshot);
1148 assert_eq!(snapshot_of(None), Snapshot::default());
1149 assert_eq!(snapshot_of(Some("not json")), Snapshot::default());
1150 }
1151
1152 /// The highest `?N` a statement names: D1 refuses a statement given
1153 /// more or fewer values than that.
1154 fn highest_bind(sql: &str) -> usize {
1155 (1..=9).filter(|n| sql.contains(&format!("?{n}"))).max().unwrap_or(0)
1156 }
1157
1158 #[test]
1159 fn every_statement_is_given_exactly_the_values_it_names() {
1160 for (sql, binds) in revoke_statements().into_iter().chain(purge_statements()) {
1161 assert_eq!(highest_bind(&sql), binds, "{sql}");
1162 }
1163 }
1164
1165 #[test]
1166 fn deleting_ends_everything_it_signs_in_with_and_every_membership() {
1167 let sql: Vec<String> = revoke_statements().into_iter().map(|(sql, _)| sql).collect();
1168 for table in [
1169 "sessions",
1170 "access_tokens",
1171 "oauth_grants",
1172 "device_codes",
1173 "ssh_keys",
1174 "two_factor_challenges",
1175 "email_tokens",
1176 "workspace_members",
1177 "team_members",
1178 ] {
1179 assert!(sql.iter().any(|s| s == &format!("DELETE FROM {table} WHERE user_id = ?1")), "{table}");
1180 }
1181 assert!(sql.iter().any(|s| s.starts_with("DELETE FROM deploy_keys WHERE created_by = ?1")));
1182 assert!(sql.iter().any(|s| s.starts_with("DELETE FROM repo_grants")));
1183 assert!(sql.iter().any(|s| s.starts_with("UPDATE github_accounts SET tokens = NULL")));
1184 // A token's repositories go before the token, which the subquery needs.
1185 let repositories = sql.iter().position(|s| s.contains("token_repositories")).unwrap();
1186 let tokens = sql.iter().position(|s| s == "DELETE FROM access_tokens WHERE user_id = ?1").unwrap();
1187 assert!(repositories < tokens);
1188 }
1189
1190 #[test]
1191 fn a_purge_keeps_the_username_hands_authorship_to_ghost_and_loses_to_a_restore() {
1192 let sql: Vec<String> = purge_statements().into_iter().map(|(sql, _)| sql).collect();
1193 assert!(sql[0].starts_with("INSERT OR REPLACE INTO deleted_users"));
1194 assert!(sql.iter().any(|s| s.starts_with("UPDATE workspaces SET created_by = 'usr_ghost' WHERE created_by = ?1")));
1195 for table in ["user_emails", "github_accounts", "two_factor", "two_factor_recovery", "security_events", "ssh_keys", "dock_pins", "onboarding", "sound_settings"] {
1196 assert!(sql.iter().any(|s| s.starts_with(&format!("DELETE FROM {table} WHERE user_id = ?1"))), "{table}");
1197 }
1198 // The row goes last, and everything before it only while the
1199 // account is still deleted, so a restore a moment before wins.
1200 assert!(sql.last().unwrap().starts_with("DELETE FROM users WHERE id = ?1 AND deleted_at IS NOT NULL"));
1201 for s in &sql[..sql.len() - 1] {
1202 assert!(s.contains("deleted_at IS NOT NULL AND purge_after IS NOT NULL"), "{s}");
1203 }
1204 }
1205
1206 #[test]
1207 fn the_deleted_list_never_shows_ghost() {
1208 // ghost's row is deleted with no purge time; the list and the
1209 // sweep both need one.
1210 assert!(DELETED_COLUMNS.contains("purge_after IS NOT NULL"));
1211 }
1212}