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