Skip to content
1,198 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 (classic,
31//! fine-grained and 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::{UserDeleted, 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 fine-grained token's 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 (classic,
257 // fine-grained, 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 "workspace_members",
271 "team_members",
272 ] {
273 sql.push((format!("DELETE FROM {table} WHERE user_id = ?1"), 1));
274 }
275 sql.extend([
276 // Deploy keys it added to repositories.
277 ("DELETE FROM deploy_keys WHERE created_by = ?1".to_owned(), 1),
278 // g1t keeps no GitHub token for it any more.
279 ("UPDATE github_accounts SET tokens = NULL WHERE user_id = ?1".to_owned(), 1),
280 ("DELETE FROM repo_grants WHERE principal_kind = 'user' AND principal_id = ?1".to_owned(), 1),
281 (
282 "UPDATE repo_invitations SET revoked_at = ?2
283 WHERE invitee_id = ?1 AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL"
284 .to_owned(),
285 2,
286 ),
287 // Invites it made that nobody used.
288 (
289 "UPDATE invites SET revoked_at = ?2, sealed_code = NULL
290 WHERE inviter_id = ?1 AND redeemed_at IS NULL AND revoked_at IS NULL"
291 .to_owned(),
292 2,
293 ),
294 ]);
295 sql
296}
297
298/// What a purge runs, in one batch, each with how many of the account's
299/// id (`?1`), username (`?2`), when it was deleted (`?3`) and now (`?4`) it
300/// takes. Every statement acts only while the account is still deleted and
301/// awaiting its purge, so a restore a moment before wins whole.
302pub fn purge_statements() -> Vec<(String, usize)> {
303 const STILL: &str = "EXISTS (SELECT 1 FROM users WHERE id = ?1 AND deleted_at IS NOT NULL AND purge_after IS NOT NULL)";
304 let mut sql = vec![(
305 format!(
306 "INSERT OR REPLACE INTO deleted_users (username, user_id, deleted_at, purged_at)
307 SELECT ?2, ?1, ?3, ?4 WHERE {STILL}"
308 ),
309 4,
310 )];
311 // What names it as its maker or deleter names ghost: a workspace's
312 // creator must be an account.
313 for (table, column) in [("workspaces", "created_by"), ("workspaces", "deleted_by"), ("teams", "created_by")] {
314 sql.push((format!("UPDATE {table} SET {column} = '{GHOST_ID}' WHERE {column} = ?1 AND {STILL}"), 1));
315 }
316 // Its personal data. Most goes by cascade with the row; each is said
317 // outright so nothing depends on that.
318 for table in [
319 "user_emails",
320 "github_accounts",
321 "two_factor",
322 "two_factor_recovery",
323 "two_factor_challenges",
324 "security_events",
325 "sessions",
326 "access_tokens",
327 "oauth_grants",
328 "oauth_codes",
329 "device_codes",
330 "email_tokens",
331 "ssh_keys",
332 "workspace_members",
333 "team_members",
334 ] {
335 sql.push((format!("DELETE FROM {table} WHERE user_id = ?1 AND {STILL}"), 1));
336 }
337 sql.push(("DELETE FROM users WHERE id = ?1 AND deleted_at IS NOT NULL AND purge_after IS NOT NULL".to_owned(), 1));
338 sql
339}
340
341/// A membership the account left, as it was.
342#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
343struct Member {
344 workspace_id: String,
345 role: String,
346 #[serde(default)]
347 billing_manager: u8,
348 #[serde(default)]
349 security_manager: u8,
350 created_at: String,
351}
352
353/// A team the account left.
354#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
355struct TeamMember {
356 team_id: String,
357 role: String,
358 created_at: String,
359}
360
361/// A role on a repository the account had directly.
362#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
363struct Grant {
364 repo_id: String,
365 workspace_id: String,
366 repo_name: String,
367 role: String,
368 #[serde(default)]
369 granted_by: Option<String>,
370 created_at: String,
371 updated_at: String,
372}
373
374/// What `deleted_went` holds.
375#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
376struct Snapshot {
377 #[serde(default)]
378 went: AccountWent,
379 #[serde(default)]
380 memberships: Vec<Member>,
381 #[serde(default)]
382 teams: Vec<TeamMember>,
383 #[serde(default)]
384 grants: Vec<Grant>,
385}
386
387fn snapshot_of(stored: Option<&str>) -> Snapshot {
388 stored.and_then(|text| serde_json::from_str(text).ok()).unwrap_or_default()
389}
390
391/// A deleted account's row.
392#[derive(Deserialize)]
393struct DeletedRow {
394 id: String,
395 username: String,
396 deleted_at: String,
397 purge_after: String,
398 #[serde(default)]
399 deleted_went: Option<String>,
400 #[serde(default)]
401 avatar: Option<String>,
402}
403
404impl DeletedRow {
405 fn listed(&self, now: &str) -> DeletedAccount {
406 DeletedAccount {
407 user_id: self.id.clone(),
408 username: self.username.clone(),
409 deleted_at: self.deleted_at.clone(),
410 purge_after: self.purge_after.clone(),
411 went: snapshot_of(self.deleted_went.as_deref()).went,
412 restorable: restorable(&self.purge_after, now),
413 }
414 }
415}
416
417/// Deleted accounts awaiting their purge: never `ghost`, whose row has no
418/// purge time.
419const DELETED_COLUMNS: &str = "id, username, deleted_at, purge_after, deleted_went, avatar
420 FROM users WHERE deleted_at IS NOT NULL AND purge_after IS NOT NULL";
421
422/// A live account, as found by username.
423#[derive(Deserialize)]
424struct Live {
425 id: String,
426 username: String,
427}
428
429impl Identity {
430 /// `PROTECTED_ACCOUNTS`, with what is always protected.
431 fn protected_account_names(&self) -> Vec<String> {
432 let configured = self.env.var("PROTECTED_ACCOUNTS").ok().map(|v| v.to_string());
433 protected_names(configured.as_deref())
434 }
435
436 /// Whether `user_id` is an account that has not been deleted.
437 pub(crate) async fn account_live(&self, user_id: &str) -> Result<bool> {
438 Ok(self
439 .db
440 .prepare("SELECT 1 AS live FROM users WHERE id = ? AND deleted_at IS NULL")
441 .bind(&[user_id.into()])?
442 .first::<serde_json::Value>(None)
443 .await?
444 .is_some())
445 }
446
447 async fn live_account(&self, column: &str, value: &str) -> Result<Option<Live>> {
448 self.db
449 .prepare(format!("SELECT id, username FROM users WHERE {column} = ? AND deleted_at IS NULL"))
450 .bind(&[value.into()])?
451 .first::<Live>(None)
452 .await
453 }
454
455 /// The live workspaces `user_id` is the only owner of.
456 async fn sole_owned(&self, user_id: &str) -> Result<Vec<SoleOwnedWorkspace>> {
457 #[derive(Deserialize)]
458 struct Row {
459 id: String,
460 slug: String,
461 name: String,
462 members: u32,
463 #[serde(default)]
464 protected: u8,
465 }
466 let rows = self
467 .db
468 .prepare(
469 "SELECT w.id, w.slug, w.name, w.protected,
470 (SELECT count(*) FROM workspace_members a WHERE a.workspace_id = w.id) AS members
471 FROM workspace_members m JOIN workspaces w ON w.id = m.workspace_id
472 WHERE m.user_id = ?1 AND m.role = 'owner' AND w.deleted_at IS NULL
473 AND NOT EXISTS (SELECT 1 FROM workspace_members o
474 WHERE o.workspace_id = w.id AND o.role = 'owner' AND o.user_id <> ?1)
475 ORDER BY w.slug",
476 )
477 .bind(&[user_id.into()])?
478 .all()
479 .await?
480 .results::<Row>()?;
481 let mut sole = Vec::with_capacity(rows.len());
482 for row in rows {
483 let protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?;
484 sole.push(SoleOwnedWorkspace { slug: row.slug, name: row.name, members: row.members, billing: None, protected });
485 }
486 Ok(sole)
487 }
488
489 /// What deleting the account would take with it, and what stands in
490 /// the way. Asked (`ask`), billing says for each workspace it owns
491 /// alone what deleting that workspace would need: for the person, as
492 /// themselves; for staff, as the owner billing closes it for.
493 pub(crate) async fn account_deletion_facts(&self, user_id: &str, username: &str, ask: AskBilling<'_>) -> Result<AccountDeletion> {
494 #[derive(Deserialize)]
495 struct Counts {
496 workspaces: u32,
497 tokens: u32,
498 ssh_keys: u32,
499 applications: u32,
500 repositories: u32,
501 }
502 let counts = self
503 .db
504 .prepare(
505 "SELECT
506 (SELECT count(*) FROM workspace_members m JOIN workspaces w ON w.id = m.workspace_id
507 WHERE m.user_id = ?1 AND w.deleted_at IS NULL) AS workspaces,
508 (SELECT count(*) FROM access_tokens WHERE user_id = ?1 AND agent_scope IS NULL
509 AND (expires_at IS NULL OR listed = 1)) AS tokens,
510 (SELECT count(*) FROM ssh_keys WHERE user_id = ?1) AS ssh_keys,
511 (SELECT count(*) FROM oauth_grants WHERE user_id = ?1) AS applications,
512 (SELECT count(*) FROM repo_grants WHERE principal_kind = 'user' AND principal_id = ?1) AS repositories",
513 )
514 .bind(&[user_id.into()])?
515 .first::<Counts>(None)
516 .await?
517 .unwrap_or(Counts { workspaces: 0, tokens: 0, ssh_keys: 0, applications: 0, repositories: 0 });
518 let mut sole_owner_of = self.sole_owned(user_id).await?;
519 for workspace in &mut sole_owner_of {
520 let actor = match ask {
521 AskBilling::No => break,
522 AskBilling::Person(person) => person.clone(),
523 AskBilling::Staff => staff_billing_actor(user_id, "g1t staff", &workspace.slug),
524 };
525 workspace.billing = match self.billing_refusal(&actor, &workspace.slug).await {
526 Ok(refusal) => refusal,
527 // Staff are not let through on a billing that did not
528 // answer: deleting it would ask again and stop there.
529 Err(error) => matches!(ask, AskBilling::Staff).then(|| format!("Billing did not answer for {}: {error}", workspace.slug)),
530 };
531 }
532 Ok(AccountDeletion {
533 username: username.to_owned(),
534 workspaces: counts.workspaces,
535 tokens: counts.tokens,
536 ssh_keys: counts.ssh_keys,
537 applications: counts.applications,
538 repositories: counts.repositories,
539 sole_owner_of,
540 protected: is_protected_account(&self.protected_account_names(), user_id, username),
541 })
542 }
543
544 /// `check_account_deletion`: what deleting the person's own account
545 /// would take, and what is in the way, changing nothing.
546 pub async fn check_account_deletion(&self, a: UserArgs) -> Result<Outcome<AccountDeletion>> {
547 if !is_person(&a.user) {
548 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
549 }
550 let Some(live) = self.live_account("id", &a.user.id).await? else {
551 return Ok(Outcome::fail(FailureCode::NotFound, "Account not found."));
552 };
553 Ok(Outcome::Ok(self.account_deletion_facts(&live.id, &live.username, AskBilling::Person(&a.user)).await?))
554 }
555
556 /// `delete_account`: the person deletes their own account.
557 pub async fn delete_account(&self, a: DeleteAccountArgs) -> Result<Outcome<bool>> {
558 if !is_person(&a.user) {
559 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
560 }
561 let Some(live) = self.live_account("id", &a.user.id).await? else {
562 return Ok(Outcome::fail(FailureCode::NotFound, "Account not found."));
563 };
564 let deletion = self.account_deletion_facts(&live.id, &live.username, AskBilling::No).await?;
565 if let Err((code, message)) = may_delete_own(true, &deletion, &a.confirm) {
566 return Ok(Outcome::fail(code, message));
567 }
568 // Proof last: nothing else in the way, so a password typed now is
569 // the last thing asked.
570 if let Some(refusal) = self.proof(&live.id, &a.reauth).await?.refusal() {
571 return Ok(refusal);
572 }
573 self.soft_delete(&live, &deletion, Some(&live.id), None, Vec::new()).await?;
574 Ok(Outcome::Ok(true))
575 }
576
577 /// `admin_delete_account`: staff delete an account, with a reason, and
578 /// with `with_sole_workspaces` the workspaces it is the only owner of
579 /// first.
580 pub async fn admin_delete_account(&self, a: AdminDeleteAccountArgs) -> Result<Outcome<bool>> {
581 let staff = a.staff.trim();
582 let reason = a.reason.trim();
583 if staff.is_empty() {
584 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is deleting it."));
585 }
586 if reason.is_empty() {
587 return Ok(Outcome::fail(FailureCode::Invalid, "Say why the account is being deleted."));
588 }
589 let Some(live) = self.live_account("username", &a.username.trim().to_lowercase()).await? else {
590 return Ok(Outcome::fail(FailureCode::NotFound, "No such account, or it is already deleted."));
591 };
592 // Billing is asked only when it matters: the workspaces go too.
593 let ask = if a.with_sole_workspaces { AskBilling::Staff } else { AskBilling::No };
594 let deletion = self.account_deletion_facts(&live.id, &live.username, ask).await?;
595 let steps = match staff_steps(&deletion, a.with_sole_workspaces, &a.confirm) {
596 Ok(steps) => steps,
597 Err((code, message)) => return Ok(Outcome::fail(code, message)),
598 };
599 let (owner_id, owner, live_ref, deletion_ref) = (live.id.as_str(), live.username.as_str(), &live, &deletion);
600 let done = run_steps(
601 &live.username,
602 &steps,
603 move |slug| async move { self.staff_delete_workspace(&slug, owner_id, owner, staff, reason).await },
604 move |workspaces| async move {
605 self.soft_delete(live_ref, deletion_ref, None, Some((staff, reason)), workspaces).await
606 },
607 )
608 .await?;
609 let workspaces = match done {
610 Outcome::Ok(workspaces) => workspaces,
611 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
612 };
613 let with = if workspaces.is_empty() {
614 String::new()
615 } else {
616 let slugs: Vec<&str> = workspaces.iter().map(|workspace| workspace.slug.as_str()).collect();
617 format!(" and the workspaces it alone owned ({})", slugs.join(", "))
618 };
619 self.record_for_staff(
620 &live.username,
621 "account_deleted",
622 &format!("Deleted the account {}{with}: {reason}", live.username),
623 staff,
624 )
625 .await;
626 Ok(Outcome::Ok(true))
627 }
628
629 /// Deletes an account softly: see the module docs.
630 async fn soft_delete(
631 &self,
632 live: &Live,
633 deletion: &AccountDeletion,
634 deleted_by: Option<&str>,
635 staff: Option<(&str, &str)>,
636 workspaces: Vec<WorkspaceDeletedWith>,
637 ) -> Result<()> {
638 let id = live.id.as_str();
639 let mut snapshot = self.snapshot(id, deletion, staff).await?;
640 snapshot.went.deleted_workspaces = workspaces;
641 let now = now_ms();
642 let at = rfc3339(now);
643 let purge = purge_after(now);
644 let by_staff = staff.is_some();
645 // Recorded in each workspace it was in, while it still is.
646 let person = User { id: live.id.clone(), username: live.username.clone(), ..User::default() };
647 let action = if by_staff { "account.deleted_by_staff" } else { "account.deleted" };
648 self.audit_account(&person, action, &format!("Deleted the account {}", live.username)).await;
649 // The person hears of it while their addresses are still there.
650 for address in self.notice_recipients(id, false).await.unwrap_or_default() {
651 if let Err(error) = crate::email::send_account_deleted(&self.env, &address, &live.username, by_staff, ACCOUNT_RESTORE_DAYS).await {
652 worker::console_error!("account deleted notice failed: {error}");
653 }
654 }
655 let went = serde_json::to_string(&snapshot).unwrap_or_default();
656 let by: JsValue = deleted_by.map_or(JsValue::NULL, Into::into);
657 let mut statements = vec![
658 // Only while it is still live: two deletions at once delete once.
659 self.db
660 .prepare(
661 "UPDATE users SET deleted_at = ?, deleted_by = ?, purge_after = ?, deleted_went = ?
662 WHERE id = ? AND deleted_at IS NULL",
663 )
664 .bind(&[at.as_str().into(), by, purge.as_str().into(), went.into(), id.into()])?,
665 ];
666 let values = [id, at.as_str()];
667 for (sql, binds) in revoke_statements() {
668 let binds: Vec<JsValue> = values[..binds].iter().map(|value| JsValue::from(*value)).collect();
669 statements.push(self.db.prepare(sql).bind(&binds)?);
670 }
671 self.db.batch(statements).await?;
672 self.log_security(id, "account_deleted", None, staff).await;
673 self.announce(
674 "user.deleting",
675 deleted_by,
676 UserDeleting { user_id: live.id.clone(), username: live.username.clone(), by_staff, purge_after: purge },
677 )
678 .await;
679 Ok(())
680 }
681
682 /// What the account has now, kept so a restore can put it back.
683 async fn snapshot(&self, id: &str, deletion: &AccountDeletion, staff: Option<(&str, &str)>) -> Result<Snapshot> {
684 let memberships = self
685 .db
686 .prepare(
687 "SELECT workspace_id, role, billing_manager, security_manager, created_at
688 FROM workspace_members WHERE user_id = ?",
689 )
690 .bind(&[id.into()])?
691 .all()
692 .await?
693 .results::<Member>()?;
694 let teams = self
695 .db
696 .prepare("SELECT team_id, role, created_at FROM team_members WHERE user_id = ?")
697 .bind(&[id.into()])?
698 .all()
699 .await?
700 .results::<TeamMember>()?;
701 let grants = self
702 .db
703 .prepare(
704 "SELECT repo_id, workspace_id, repo_name, role, granted_by, created_at, updated_at
705 FROM repo_grants WHERE principal_kind = 'user' AND principal_id = ?",
706 )
707 .bind(&[id.into()])?
708 .all()
709 .await?
710 .results::<Grant>()?;
711 Ok(Snapshot {
712 went: AccountWent {
713 workspaces: deletion.workspaces,
714 teams: teams.len() as u32,
715 repositories: grants.len() as u32,
716 tokens: deletion.tokens,
717 ssh_keys: deletion.ssh_keys,
718 staff: staff.map(|(who, _)| who.to_owned()),
719 reason: staff.map(|(_, why)| why.to_owned()),
720 deleted_workspaces: Vec::new(),
721 },
722 memberships,
723 teams,
724 grants,
725 })
726 }
727
728 /// Deleted accounts not purged yet, newest first. Staff only.
729 pub async fn admin_deleted_accounts(&self) -> Result<Vec<DeletedAccount>> {
730 let rows = self
731 .db
732 .prepare(format!("SELECT {DELETED_COLUMNS} ORDER BY deleted_at DESC LIMIT 500"))
733 .all()
734 .await?
735 .results::<DeletedRow>()?;
736 let now = rfc3339(now_ms());
737 Ok(rows.iter().map(|row| row.listed(&now)).collect())
738 }
739
740 async fn deleted_account_row(&self, column: &str, value: &str) -> Result<Option<DeletedRow>> {
741 self.db
742 .prepare(format!("SELECT {DELETED_COLUMNS} AND {column} = ?"))
743 .bind(&[value.into()])?
744 .first::<DeletedRow>(None)
745 .await
746 }
747
748 /// The deletion of `user_id`, when it is deleted and not purged.
749 pub(crate) async fn deleted_account(&self, user_id: &str) -> Result<Option<DeletedAccount>> {
750 let now = rfc3339(now_ms());
751 Ok(self.deleted_account_row("id", user_id).await?.map(|row| row.listed(&now)))
752 }
753
754 /// Staff bring a deleted account back within its window, with the
755 /// memberships, teams and repository roles it left. Staff only.
756 pub async fn admin_restore_account(&self, a: AdminDeletedAccountArgs) -> Result<Outcome<bool>> {
757 let staff = a.staff.trim();
758 if staff.is_empty() {
759 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is restoring it."));
760 }
761 let Some(row) = self.deleted_account_row("id", &a.user_id).await? else {
762 return Ok(Outcome::fail(FailureCode::NotFound, "There is no deleted account with that id."));
763 };
764 if let Err((code, message)) = may_restore(&row.username, &row.purge_after, &rfc3339(now_ms())) {
765 return Ok(Outcome::fail(code, message));
766 }
767 let snapshot = snapshot_of(row.deleted_went.as_deref());
768 let id = row.id.as_str();
769 let mut statements = vec![
770 self.db
771 .prepare(
772 "UPDATE users SET deleted_at = NULL, deleted_by = NULL, purge_after = NULL, deleted_went = NULL
773 WHERE id = ? AND deleted_at IS NOT NULL",
774 )
775 .bind(&[id.into()])?,
776 ];
777 // Back where the workspace, team or repository's workspace is
778 // still there; never over what was given since.
779 for member in &snapshot.memberships {
780 statements.push(
781 self.db
782 .prepare(
783 "INSERT OR IGNORE INTO workspace_members
784 (workspace_id, user_id, role, created_at, billing_manager, security_manager)
785 SELECT ?1, ?2, ?3, ?4, ?5, ?6 WHERE EXISTS (SELECT 1 FROM workspaces WHERE id = ?1)",
786 )
787 .bind(&[
788 member.workspace_id.as_str().into(),
789 id.into(),
790 member.role.as_str().into(),
791 member.created_at.as_str().into(),
792 member.billing_manager.into(),
793 member.security_manager.into(),
794 ])?,
795 );
796 }
797 for team in &snapshot.teams {
798 statements.push(
799 self.db
800 .prepare(
801 "INSERT OR IGNORE INTO team_members (team_id, user_id, role, created_at)
802 SELECT ?1, ?2, ?3, ?4 WHERE EXISTS (SELECT 1 FROM teams WHERE id = ?1)",
803 )
804 .bind(&[team.team_id.as_str().into(), id.into(), team.role.as_str().into(), team.created_at.as_str().into()])?,
805 );
806 }
807 for grant in &snapshot.grants {
808 statements.push(
809 self.db
810 .prepare(
811 "INSERT OR IGNORE INTO repo_grants
812 (repo_id, principal_kind, principal_id, workspace_id, repo_name, role, granted_by, created_at, updated_at)
813 SELECT ?1, 'user', ?2, ?3, ?4, ?5, ?6, ?7, ?8
814 WHERE EXISTS (SELECT 1 FROM workspaces WHERE id = ?3)",
815 )
816 .bind(&[
817 grant.repo_id.as_str().into(),
818 id.into(),
819 grant.workspace_id.as_str().into(),
820 grant.repo_name.as_str().into(),
821 grant.role.as_str().into(),
822 grant.granted_by.as_deref().map_or(JsValue::NULL, Into::into),
823 grant.created_at.as_str().into(),
824 grant.updated_at.as_str().into(),
825 ])?,
826 );
827 }
828 self.db.batch(statements).await?;
829 self.log_security(id, "account_restored", None, Some((staff, "Restored by g1t's staff"))).await;
830 self.record_for_staff(&row.username, "account_restored", &format!("Restored the account {}", row.username), staff)
831 .await;
832 self.announce("user.restored", None, UserRestored { user_id: row.id.clone(), username: row.username.clone() })
833 .await;
834 Ok(Outcome::Ok(true))
835 }
836
837 /// Staff purge a deleted account now rather than at `purge_after`.
838 pub async fn admin_purge_account(&self, a: AdminDeletedAccountArgs) -> Result<Outcome<bool>> {
839 let staff = a.staff.trim();
840 if staff.is_empty() {
841 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is purging it."));
842 }
843 let Some(row) = self.deleted_account_row("id", &a.user_id).await? else {
844 return Ok(Outcome::fail(FailureCode::NotFound, "There is no deleted account with that id."));
845 };
846 let protected = is_protected_account(&self.protected_account_names(), &row.id, &row.username);
847 if let Err((code, message)) = may_purge(protected, &row.username, Some(&a.confirm)) {
848 return Ok(Outcome::fail(code, message));
849 }
850 self.purge_account(&row).await?;
851 self.record_for_staff(&row.username, "account_purged", &format!("Purged the account {} now", row.username), staff)
852 .await;
853 Ok(Outcome::Ok(true))
854 }
855
856 /// The sweep: purges deleted accounts whose restore window has passed.
857 pub async fn purge_due_accounts(&self) -> Result<u32> {
858 let due = self
859 .db
860 .prepare(format!(
861 "SELECT {DELETED_COLUMNS} AND purge_after <= ? ORDER BY purge_after LIMIT {PURGES_PER_SWEEP}"
862 ))
863 .bind(&[rfc3339(now_ms()).into()])?
864 .all()
865 .await?
866 .results::<DeletedRow>()?;
867 let names = self.protected_account_names();
868 let mut purged = 0;
869 for row in due {
870 if let Err((_, why)) = may_purge(is_protected_account(&names, &row.id, &row.username), &row.username, None) {
871 worker::console_error!("{} not purged: {why}", row.username);
872 continue;
873 }
874 match self.purge_account(&row).await {
875 Ok(()) => purged += 1,
876 Err(error) => worker::console_error!("{} not purged: {error}", row.username),
877 }
878 }
879 Ok(purged)
880 }
881
882 /// Removes a deleted account for good: see the module docs.
883 async fn purge_account(&self, row: &DeletedRow) -> Result<()> {
884 let now = rfc3339(now_ms());
885 let values = [row.id.as_str(), row.username.as_str(), row.deleted_at.as_str(), now.as_str()];
886 let mut batch = Vec::new();
887 for (sql, binds) in purge_statements() {
888 let binds: Vec<JsValue> = values[..binds].iter().map(|value| JsValue::from(*value)).collect();
889 batch.push(self.db.prepare(sql).bind(&binds)?);
890 }
891 self.db.batch(batch).await?;
892 if let Err(error) = self.forget_avatar(row.avatar.clone()).await {
893 worker::console_error!("avatar of {} not removed: {error}", row.username);
894 }
895 self.announce("user.deleted", None, UserDeleted { user_id: row.id.clone(), username: row.username.clone() })
896 .await;
897 Ok(())
898 }
899}
900
901#[cfg(test)]
902mod tests {
903 use super::*;
904
905 fn deletion(username: &str) -> AccountDeletion {
906 AccountDeletion { username: username.into(), ..AccountDeletion::default() }
907 }
908
909 fn sole(slug: &str) -> SoleOwnedWorkspace {
910 SoleOwnedWorkspace { slug: slug.into(), name: slug.into(), members: 2, billing: None, protected: false }
911 }
912
913 /// Polls a future to its end. What these tests run never waits.
914 fn block_on<F: Future>(future: F) -> F::Output {
915 use std::task::{Context, Poll, Waker};
916 let mut future = std::pin::pin!(future);
917 let mut cx = Context::from_waker(Waker::noop());
918 loop {
919 if let Poll::Ready(value) = future.as_mut().poll(&mut cx) {
920 return value;
921 }
922 }
923 }
924
925 fn gone(slug: &str) -> WorkspaceDeletedWith {
926 WorkspaceDeletedWith { workspace_id: format!("wsp_{slug}"), slug: slug.into() }
927 }
928
929 /// Runs `steps` against a record of what was done, with the workspaces
930 /// in `failing` refusing.
931 fn run(steps: &[Step], failing: &[&str]) -> (Outcome<Vec<WorkspaceDeletedWith>>, Vec<String>) {
932 let done = std::cell::RefCell::new(Vec::<String>::new());
933 let outcome = block_on(run_steps(
934 "ada",
935 steps,
936 |slug| {
937 let refused = failing.contains(&slug.as_str());
938 if !refused {
939 done.borrow_mut().push(format!("workspace {slug}"));
940 }
941 async move {
942 if refused {
943 Ok(Outcome::fail(FailureCode::PaymentRequired, format!("The card on file was declined for {slug}.")))
944 } else {
945 Ok(Outcome::Ok(gone(&slug)))
946 }
947 }
948 },
949 |workspaces| {
950 let slugs: Vec<String> = workspaces.iter().map(|workspace| workspace.slug.clone()).collect();
951 done.borrow_mut().push(format!("account with [{}]", slugs.join(", ")));
952 async { Ok(()) }
953 },
954 ))
955 .unwrap();
956 (outcome, done.into_inner())
957 }
958
959 #[test]
960 fn with_its_workspaces_staff_delete_each_then_the_account() {
961 let owner = AccountDeletion { sole_owner_of: vec![sole("ada"), sole("ada-labs")], ..deletion("ada") };
962 let steps = staff_steps(&owner, true, "ada").unwrap();
963 assert_eq!(steps, vec![Step::Workspace("ada".into()), Step::Workspace("ada-labs".into()), Step::Account]);
964 let (outcome, done) = run(&steps, &[]);
965 assert_eq!(done, vec!["workspace ada", "workspace ada-labs", "account with [ada, ada-labs]"]);
966 match outcome {
967 Outcome::Ok(workspaces) => assert_eq!(workspaces, vec![gone("ada"), gone("ada-labs")]),
968 Outcome::Fail(failure) => panic!("{}", failure.message),
969 }
970 // Without them, only the account, and only when it owns none alone.
971 assert_eq!(staff_steps(&deletion("ada"), false, "ada").unwrap(), vec![Step::Account]);
972 assert_eq!(staff_steps(&deletion("ada"), true, "ada").unwrap(), vec![Step::Account]);
973 }
974
975 #[test]
976 fn without_asking_for_its_workspaces_staff_are_told_it_owns_them() {
977 let owner = AccountDeletion { sole_owner_of: vec![sole("acme")], ..deletion("ada") };
978 assert_eq!(
979 staff_steps(&owner, false, "ada").unwrap_err(),
980 (
981 FailureCode::Conflict,
982 "ada is the only owner of acme. Make someone else an owner of it, or delete it, first.".to_owned()
983 )
984 );
985 }
986
987 #[test]
988 fn a_protected_workspace_refuses_the_whole_deletion_before_anything_goes() {
989 let flagon = SoleOwnedWorkspace { protected: true, ..sole("flagon-io") };
990 let owner = AccountDeletion { sole_owner_of: vec![sole("ada"), flagon], ..deletion("ada") };
991 let (code, message) = staff_steps(&owner, true, "ada").unwrap_err();
992 assert_eq!(code, FailureCode::Forbidden);
993 assert_eq!(message, "Nothing was deleted. flagon-io is protected and can never be deleted.");
994 // A protected account, whatever it owns.
995 let protected = AccountDeletion { protected: true, ..deletion("g1t") };
996 assert_eq!(staff_steps(&protected, true, "g1t").unwrap_err().0, FailureCode::Forbidden);
997 }
998
999 #[test]
1000 fn billing_that_cannot_settle_refuses_the_whole_deletion_before_anything_goes() {
1001 let owing = SoleOwnedWorkspace { billing: Some("ada-labs has an unpaid invoice. Pay it from the workspace's Billing page first.".into()), ..sole("ada-labs") };
1002 let owner = AccountDeletion { sole_owner_of: vec![sole("ada"), owing], ..deletion("ada") };
1003 let (code, message) = staff_steps(&owner, true, "ada").unwrap_err();
1004 assert_eq!(code, FailureCode::PaymentRequired);
1005 assert_eq!(message, "Nothing was deleted. ada-labs has an unpaid invoice. Pay it from the workspace's Billing page first.");
1006 }
1007
1008 #[test]
1009 fn staff_type_the_username_after_everything_else() {
1010 let owner = AccountDeletion { sole_owner_of: vec![sole("ada")], ..deletion("ada") };
1011 assert_eq!(staff_steps(&owner, true, "").unwrap_err(), (FailureCode::Invalid, "Type ada to confirm.".into()));
1012 assert_eq!(staff_steps(&owner, true, "grace").unwrap_err().0, FailureCode::Invalid);
1013 }
1014
1015 #[test]
1016 fn a_workspace_failing_on_the_way_stops_before_the_account() {
1017 let steps = vec![Step::Workspace("ada".into()), Step::Workspace("ada-labs".into()), Step::Workspace("ada-old".into()), Step::Account];
1018 let (outcome, done) = run(&steps, &["ada-labs"]);
1019 // ada went; ada-labs refused; ada-old and the account were not tried.
1020 assert_eq!(done, vec!["workspace ada"]);
1021 let Outcome::Fail(failure) = outcome else { panic!("the account must not be deleted") };
1022 assert_eq!(failure.code, FailureCode::PaymentRequired);
1023 assert_eq!(
1024 failure.message,
1025 "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."
1026 );
1027 // Failing first, nothing went at all.
1028 let (outcome, done) = run(&steps, &["ada"]);
1029 assert!(done.is_empty());
1030 let Outcome::Fail(failure) = outcome else { panic!("the account must not be deleted") };
1031 assert_eq!(failure.message, "ada could not be deleted: The card on file was declined for ada. Nothing was deleted.");
1032 }
1033
1034 #[test]
1035 fn what_stopped_it_names_what_went_before() {
1036 assert_eq!(
1037 stopped_at("ada", &[gone("a"), gone("b")], "c", "Declined. "),
1038 "c could not be deleted: Declined. a and b were deleted already (restore from Deleted workspaces if need be); ada was not deleted."
1039 );
1040 }
1041
1042 #[test]
1043 fn only_the_person_typing_their_username() {
1044 assert!(may_delete_own(true, &deletion("ada"), " ADA ").is_ok());
1045 assert_eq!(may_delete_own(false, &deletion("ada"), "ada").unwrap_err().0, FailureCode::Forbidden);
1046 assert_eq!(may_delete_own(true, &deletion("ada"), "").unwrap_err(), (FailureCode::Invalid, "Type ada to confirm.".into()));
1047 assert_eq!(may_delete_own(true, &deletion("ada"), "ada-l").unwrap_err().0, FailureCode::Invalid);
1048 }
1049
1050 #[test]
1051 fn the_only_owner_of_a_live_workspace_is_refused_with_its_name() {
1052 let owner = AccountDeletion { sole_owner_of: vec![sole("acme"), sole("globex")], ..deletion("ada") };
1053 let (code, message) = may_delete_own(true, &owner, "ada").unwrap_err();
1054 assert_eq!(code, FailureCode::Conflict);
1055 assert!(message.contains("acme and globex"), "{message}");
1056 // Staff are refused the same way.
1057 assert_eq!(may_delete(&owner, "ada").unwrap_err().0, FailureCode::Conflict);
1058 }
1059
1060 #[test]
1061 fn a_protected_account_is_refused_to_everyone_and_never_purged() {
1062 let names = protected_names(None);
1063 for username in ["g1t", "g1t-agent", "ghost"] {
1064 assert!(is_protected_account(&names, "usr_1", username));
1065 let protected = AccountDeletion { protected: true, ..deletion(username) };
1066 let (code, message) = may_delete_own(true, &protected, username).unwrap_err();
1067 assert_eq!(code, FailureCode::Forbidden);
1068 assert_eq!(message, format!("{username} is protected and can never be deleted."));
1069 assert_eq!(may_purge(true, username, None).unwrap_err().0, FailureCode::Forbidden);
1070 assert_eq!(may_purge(true, username, Some(username)).unwrap_err().0, FailureCode::Forbidden);
1071 }
1072 // Named in PROTECTED_ACCOUNTS, by username or id.
1073 let named = protected_names(Some("ada,usr_9"));
1074 assert!(is_protected_account(&named, "usr_2", "Ada"));
1075 assert!(is_protected_account(&named, "usr_9", "grace"));
1076 assert!(!is_protected_account(&named, "usr_3", "grace"));
1077 }
1078
1079 #[test]
1080 fn a_deleted_account_is_restorable_for_thirty_days_then_due() {
1081 let deleted = 1_790_000_000_000;
1082 let purge = purge_after(deleted);
1083 assert_eq!(purge, rfc3339(deleted + 30 * 86_400_000));
1084 assert!(restorable(&purge, &rfc3339(deleted + 29 * 86_400_000)));
1085 assert!(!restorable(&purge, &purge));
1086 assert!(may_restore("ada", &purge, &rfc3339(deleted + 86_400_000)).is_ok());
1087 assert_eq!(
1088 may_restore("ada", &purge, &rfc3339(deleted + 31 * 86_400_000)).unwrap_err(),
1089 (FailureCode::Conflict, "ada is being purged and can no longer be restored.".to_owned())
1090 );
1091 }
1092
1093 #[test]
1094 fn staff_purge_only_with_the_username_typed() {
1095 assert!(may_purge(false, "ada", None).is_ok());
1096 assert!(may_purge(false, "ada", Some(" Ada ")).is_ok());
1097 assert_eq!(may_purge(false, "ada", Some("")).unwrap_err().0, FailureCode::Invalid);
1098 assert_eq!(may_purge(false, "ada", Some("grace")).unwrap_err().0, FailureCode::Invalid);
1099 }
1100
1101 #[test]
1102 fn what_it_left_is_kept_for_a_restore_and_read_back() {
1103 let snapshot = Snapshot {
1104 went: AccountWent {
1105 workspaces: 2,
1106 teams: 1,
1107 repositories: 1,
1108 tokens: 3,
1109 ssh_keys: 1,
1110 staff: Some("s@g1t.sh".into()),
1111 reason: Some("asked".into()),
1112 deleted_workspaces: vec![gone("ada")],
1113 },
1114 memberships: vec![Member {
1115 workspace_id: "wsp_1".into(),
1116 role: "owner".into(),
1117 billing_manager: 0,
1118 security_manager: 1,
1119 created_at: "2026-01-01T00:00:00.000Z".into(),
1120 }],
1121 teams: vec![TeamMember { team_id: "tem_1".into(), role: "maintainer".into(), created_at: "x".into() }],
1122 grants: vec![Grant {
1123 repo_id: "rep_1".into(),
1124 workspace_id: "wsp_2".into(),
1125 repo_name: "api".into(),
1126 role: "write".into(),
1127 granted_by: None,
1128 created_at: "x".into(),
1129 updated_at: "y".into(),
1130 }],
1131 };
1132 let stored = serde_json::to_string(&snapshot).unwrap();
1133 assert_eq!(snapshot_of(Some(&stored)), snapshot);
1134 assert_eq!(snapshot_of(None), Snapshot::default());
1135 assert_eq!(snapshot_of(Some("not json")), Snapshot::default());
1136 }
1137
1138 /// The highest `?N` a statement names: D1 refuses a statement given
1139 /// more or fewer values than that.
1140 fn highest_bind(sql: &str) -> usize {
1141 (1..=9).filter(|n| sql.contains(&format!("?{n}"))).max().unwrap_or(0)
1142 }
1143
1144 #[test]
1145 fn every_statement_is_given_exactly_the_values_it_names() {
1146 for (sql, binds) in revoke_statements().into_iter().chain(purge_statements()) {
1147 assert_eq!(highest_bind(&sql), binds, "{sql}");
1148 }
1149 }
1150
1151 #[test]
1152 fn deleting_ends_everything_it_signs_in_with_and_every_membership() {
1153 let sql: Vec<String> = revoke_statements().into_iter().map(|(sql, _)| sql).collect();
1154 for table in [
1155 "sessions",
1156 "access_tokens",
1157 "oauth_grants",
1158 "device_codes",
1159 "ssh_keys",
1160 "two_factor_challenges",
1161 "email_tokens",
1162 "workspace_members",
1163 "team_members",
1164 ] {
1165 assert!(sql.iter().any(|s| s == &format!("DELETE FROM {table} WHERE user_id = ?1")), "{table}");
1166 }
1167 assert!(sql.iter().any(|s| s.starts_with("DELETE FROM deploy_keys WHERE created_by = ?1")));
1168 assert!(sql.iter().any(|s| s.starts_with("DELETE FROM repo_grants")));
1169 assert!(sql.iter().any(|s| s.starts_with("UPDATE github_accounts SET tokens = NULL")));
1170 // A token's repositories go before the token, which the subquery needs.
1171 let repositories = sql.iter().position(|s| s.contains("token_repositories")).unwrap();
1172 let tokens = sql.iter().position(|s| s == "DELETE FROM access_tokens WHERE user_id = ?1").unwrap();
1173 assert!(repositories < tokens);
1174 }
1175
1176 #[test]
1177 fn a_purge_keeps_the_username_hands_authorship_to_ghost_and_loses_to_a_restore() {
1178 let sql: Vec<String> = purge_statements().into_iter().map(|(sql, _)| sql).collect();
1179 assert!(sql[0].starts_with("INSERT OR REPLACE INTO deleted_users"));
1180 assert!(sql.iter().any(|s| s.starts_with("UPDATE workspaces SET created_by = 'usr_ghost' WHERE created_by = ?1")));
1181 for table in ["user_emails", "github_accounts", "two_factor", "two_factor_recovery", "security_events", "ssh_keys"] {
1182 assert!(sql.iter().any(|s| s.starts_with(&format!("DELETE FROM {table} WHERE user_id = ?1"))), "{table}");
1183 }
1184 // The row goes last, and everything before it only while the
1185 // account is still deleted, so a restore a moment before wins.
1186 assert!(sql.last().unwrap().starts_with("DELETE FROM users WHERE id = ?1 AND deleted_at IS NOT NULL"));
1187 for s in &sql[..sql.len() - 1] {
1188 assert!(s.contains("deleted_at IS NOT NULL AND purge_after IS NOT NULL"), "{s}");
1189 }
1190 }
1191
1192 #[test]
1193 fn the_deleted_list_never_shows_ghost() {
1194 // ghost's row is deleted with no purge time; the list and the
1195 // sweep both need one.
1196 assert!(DELETED_COLUMNS.contains("purge_after IS NOT NULL"));
1197 }
1198}