Skip to content
1,199 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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

This file's history is long; its oldest lines are credited to the oldest commit read.