Skip to content

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

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