Skip to content
913 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//!
16//! Deleting is soft first, as for a workspace. At once, in one batch: the
17//! row gets `deleted_at`, `deleted_by` and `purge_after`
18//! ([`ACCOUNT_RESTORE_DAYS`] on); its sessions, access tokens (classic,
19//! fine-grained and agents'), OAuth grants and codes, device sign-ins, SSH
20//! keys, the deploy keys it added, two-factor sign-ins in progress, emailed
21//! links and GitHub sign-ins in progress go; it leaves every workspace,
22//! team and repository, its pending repository invitations are revoked and
23//! the invites it made and nobody used are revoked. Every read that
24//! resolves a person leaves it out from then on: it cannot sign in (the
25//! answer is the one any wrong password gets), its profile is not found,
26//! nobody can add it to anything, and nothing is emailed to it. Its
27//! username stays held by its row. `user.deleting` tells services to stop
28//! what they do for it. The memberships, teams and repository roles it
29//! left are kept in `deleted_went`, so a restore puts them back.
30//!
31//! Until `purge_after`, staff can restore it from sudo: the columns are
32//! cleared, its memberships come back where their workspace is still
33//! there, and `user.restored` tells services. Its old sessions, tokens and
34//! keys stay ended; the person signs in again with their password.
35//!
36//! The purge, by the scheduled sweep or by staff, removes the row, and with
37//! it (by cascade and here) its addresses, keys, two-factor secret, GitHub
38//! link, security log and profile. Its username goes into `deleted_users`,
39//! so it is never given to another account or workspace. A workspace it
40//! made names `ghost` as its creator instead. `user.deleted` tells services
41//! to drop what they keep for it and show what it wrote as `ghost`.
42//! Billing's ledgers and invoices and the audit logs keep its username.
43//!
44//! There is no API route for any of this: only the site and sudo call it.
45
46use g1t_contracts::FailureCode;
47use g1t_contracts::Outcome;
48use g1t_contracts::User;
49use g1t_contracts::account_deletion::*;
50use g1t_contracts::billing::CloseWorkspaceArgs;
51use g1t_contracts::events::{UserDeleted, UserDeleting, UserRestored};
52use g1t_contracts::identity::{UserArgs, protected_names};
53use g1t_contracts::time::rfc3339;
54use g1t_kit::now_ms;
55use serde::{Deserialize, Serialize};
56use worker::Result;
57use worker::wasm_bindgen::JsValue;
58
59use crate::Identity;
60use crate::security::is_person;
61
62type Refusal = (FailureCode, String);
63
64/// How many accounts one sweep purges.
65const PURGES_PER_SWEEP: u32 = 25;
66
67pub const PEOPLE_ONLY: &str = "Only you can delete your account, signed in as yourself; never with a token or as an agent.";
68
69/// When an account deleted at `now_ms` is purged.
70pub fn purge_after(now_ms: u64) -> String {
71 rfc3339(now_ms + ACCOUNT_RESTORE_DAYS * 86_400_000)
72}
73
74/// Whether an account to be purged at `purge_after` can still be restored
75/// at `now` (both RFC 3339, which compare as text).
76pub fn restorable(purge_after: &str, now: &str) -> bool {
77 now < purge_after
78}
79
80/// Whether the person may delete their account, from what is in the way
81/// and what they typed. Protection first, then the workspaces they own
82/// alone, then the typed username.
83pub fn may_delete_own(person: bool, deletion: &AccountDeletion, confirm: &str) -> std::result::Result<(), Refusal> {
84 if !person {
85 return Err((FailureCode::Forbidden, PEOPLE_ONLY.to_owned()));
86 }
87 may_delete(deletion, confirm)
88}
89
90/// Whether an account may be deleted, for the person or staff alike.
91pub fn may_delete(deletion: &AccountDeletion, confirm: &str) -> std::result::Result<(), Refusal> {
92 if deletion.protected {
93 return Err((FailureCode::Forbidden, protected_account_refusal(&deletion.username)));
94 }
95 if let Some(reason) = sole_owner_refusal(&deletion.sole_owner_of) {
96 return Err((FailureCode::Conflict, reason));
97 }
98 if !confirms_username(&deletion.username, confirm) {
99 return Err((FailureCode::Invalid, format!("Type {} to confirm.", deletion.username)));
100 }
101 Ok(())
102}
103
104/// Whether staff may restore a deleted account, now `now`.
105pub fn may_restore(username: &str, purge_after: &str, now: &str) -> std::result::Result<(), Refusal> {
106 if !restorable(purge_after, now) {
107 return Err((
108 FailureCode::Conflict,
109 format!("{username} is being purged and can no longer be restored."),
110 ));
111 }
112 Ok(())
113}
114
115/// Whether a deleted account may be purged, by staff (`confirm` is what
116/// they typed) or by the sweep (`None`). Never a protected one.
117pub fn may_purge(protected: bool, username: &str, confirm: Option<&str>) -> std::result::Result<(), Refusal> {
118 if protected {
119 return Err((FailureCode::Forbidden, protected_account_refusal(username)));
120 }
121 if let Some(typed) = confirm
122 && !confirms_username(username, typed)
123 {
124 return Err((FailureCode::Invalid, format!("Type {username} to confirm.")));
125 }
126 Ok(())
127}
128
129/// What a deletion ends at once, in the batch that marks the row, each
130/// with how many of the account's id (`?1`) and now (`?2`) it takes.
131pub fn revoke_statements() -> Vec<(String, usize)> {
132 let mut sql: Vec<(String, usize)> = vec![
133 // A fine-grained token's repositories go with it, before it.
134 ("DELETE FROM token_repositories WHERE token_id IN (SELECT id FROM access_tokens WHERE user_id = ?1)".to_owned(), 1),
135 ];
136 // Everything it signs in or acts with: sessions, tokens (classic,
137 // fine-grained, agents'), applications, device sign-ins, SSH keys,
138 // two-factor sign-ins in progress, emailed links, GitHub sign-ins in
139 // progress. Then its place in workspaces and teams.
140 for table in [
141 "sessions",
142 "access_tokens",
143 "oauth_grants",
144 "oauth_codes",
145 "device_codes",
146 "ssh_keys",
147 "two_factor_challenges",
148 "email_tokens",
149 "github_states",
150 "workspace_members",
151 "team_members",
152 ] {
153 sql.push((format!("DELETE FROM {table} WHERE user_id = ?1"), 1));
154 }
155 sql.extend([
156 // Deploy keys it added to repositories.
157 ("DELETE FROM deploy_keys WHERE created_by = ?1".to_owned(), 1),
158 // g1t keeps no GitHub token for it any more.
159 ("UPDATE github_accounts SET tokens = NULL WHERE user_id = ?1".to_owned(), 1),
160 ("DELETE FROM repo_grants WHERE principal_kind = 'user' AND principal_id = ?1".to_owned(), 1),
161 (
162 "UPDATE repo_invitations SET revoked_at = ?2
163 WHERE invitee_id = ?1 AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL"
164 .to_owned(),
165 2,
166 ),
167 // Invites it made that nobody used.
168 (
169 "UPDATE invites SET revoked_at = ?2, sealed_code = NULL
170 WHERE inviter_id = ?1 AND redeemed_at IS NULL AND revoked_at IS NULL"
171 .to_owned(),
172 2,
173 ),
174 ]);
175 sql
176}
177
178/// What a purge runs, in one batch, each with how many of the account's
179/// id (`?1`), username (`?2`), when it was deleted (`?3`) and now (`?4`) it
180/// takes. Every statement acts only while the account is still deleted and
181/// awaiting its purge, so a restore a moment before wins whole.
182pub fn purge_statements() -> Vec<(String, usize)> {
183 const STILL: &str = "EXISTS (SELECT 1 FROM users WHERE id = ?1 AND deleted_at IS NOT NULL AND purge_after IS NOT NULL)";
184 let mut sql = vec![(
185 format!(
186 "INSERT OR REPLACE INTO deleted_users (username, user_id, deleted_at, purged_at)
187 SELECT ?2, ?1, ?3, ?4 WHERE {STILL}"
188 ),
189 4,
190 )];
191 // What names it as its maker or deleter names ghost: a workspace's
192 // creator must be an account.
193 for (table, column) in [("workspaces", "created_by"), ("workspaces", "deleted_by"), ("teams", "created_by")] {
194 sql.push((format!("UPDATE {table} SET {column} = '{GHOST_ID}' WHERE {column} = ?1 AND {STILL}"), 1));
195 }
196 // Its personal data. Most goes by cascade with the row; each is said
197 // outright so nothing depends on that.
198 for table in [
199 "user_emails",
200 "github_accounts",
201 "two_factor",
202 "two_factor_recovery",
203 "two_factor_challenges",
204 "security_events",
205 "sessions",
206 "access_tokens",
207 "oauth_grants",
208 "oauth_codes",
209 "device_codes",
210 "email_tokens",
211 "ssh_keys",
212 "workspace_members",
213 "team_members",
214 ] {
215 sql.push((format!("DELETE FROM {table} WHERE user_id = ?1 AND {STILL}"), 1));
216 }
217 sql.push(("DELETE FROM users WHERE id = ?1 AND deleted_at IS NOT NULL AND purge_after IS NOT NULL".to_owned(), 1));
218 sql
219}
220
221/// A membership the account left, as it was.
222#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
223struct Member {
224 workspace_id: String,
225 role: String,
226 #[serde(default)]
227 billing_manager: u8,
228 #[serde(default)]
229 security_manager: u8,
230 created_at: String,
231}
232
233/// A team the account left.
234#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
235struct TeamMember {
236 team_id: String,
237 role: String,
238 created_at: String,
239}
240
241/// A role on a repository the account had directly.
242#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
243struct Grant {
244 repo_id: String,
245 workspace_id: String,
246 repo_name: String,
247 role: String,
248 #[serde(default)]
249 granted_by: Option<String>,
250 created_at: String,
251 updated_at: String,
252}
253
254/// What `deleted_went` holds.
255#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
256struct Snapshot {
257 #[serde(default)]
258 went: AccountWent,
259 #[serde(default)]
260 memberships: Vec<Member>,
261 #[serde(default)]
262 teams: Vec<TeamMember>,
263 #[serde(default)]
264 grants: Vec<Grant>,
265}
266
267fn snapshot_of(stored: Option<&str>) -> Snapshot {
268 stored.and_then(|text| serde_json::from_str(text).ok()).unwrap_or_default()
269}
270
271/// A deleted account's row.
272#[derive(Deserialize)]
273struct DeletedRow {
274 id: String,
275 username: String,
276 deleted_at: String,
277 purge_after: String,
278 #[serde(default)]
279 deleted_went: Option<String>,
280 #[serde(default)]
281 avatar: Option<String>,
282}
283
284impl DeletedRow {
285 fn listed(&self, now: &str) -> DeletedAccount {
286 DeletedAccount {
287 user_id: self.id.clone(),
288 username: self.username.clone(),
289 deleted_at: self.deleted_at.clone(),
290 purge_after: self.purge_after.clone(),
291 went: snapshot_of(self.deleted_went.as_deref()).went,
292 restorable: restorable(&self.purge_after, now),
293 }
294 }
295}
296
297/// Deleted accounts awaiting their purge: never `ghost`, whose row has no
298/// purge time.
299const DELETED_COLUMNS: &str = "id, username, deleted_at, purge_after, deleted_went, avatar
300 FROM users WHERE deleted_at IS NOT NULL AND purge_after IS NOT NULL";
301
302/// A live account, as found by username.
303#[derive(Deserialize)]
304struct Live {
305 id: String,
306 username: String,
307}
308
309impl Identity {
310 /// `PROTECTED_ACCOUNTS`, with what is always protected.
311 fn protected_account_names(&self) -> Vec<String> {
312 let configured = self.env.var("PROTECTED_ACCOUNTS").ok().map(|v| v.to_string());
313 protected_names(configured.as_deref())
314 }
315
316 /// Whether `user_id` is an account that has not been deleted.
317 pub(crate) async fn account_live(&self, user_id: &str) -> Result<bool> {
318 Ok(self
319 .db
320 .prepare("SELECT 1 AS live FROM users WHERE id = ? AND deleted_at IS NULL")
321 .bind(&[user_id.into()])?
322 .first::<serde_json::Value>(None)
323 .await?
324 .is_some())
325 }
326
327 async fn live_account(&self, column: &str, value: &str) -> Result<Option<Live>> {
328 self.db
329 .prepare(format!("SELECT id, username FROM users WHERE {column} = ? AND deleted_at IS NULL"))
330 .bind(&[value.into()])?
331 .first::<Live>(None)
332 .await
333 }
334
335 /// The live workspaces `user_id` is the only owner of.
336 async fn sole_owned(&self, user_id: &str) -> Result<Vec<SoleOwnedWorkspace>> {
337 #[derive(Deserialize)]
338 struct Row {
339 slug: String,
340 name: String,
341 members: u32,
342 }
343 let rows = self
344 .db
345 .prepare(
346 "SELECT w.slug, w.name,
347 (SELECT count(*) FROM workspace_members a WHERE a.workspace_id = w.id) AS members
348 FROM workspace_members m JOIN workspaces w ON w.id = m.workspace_id
349 WHERE m.user_id = ?1 AND m.role = 'owner' AND w.deleted_at IS NULL
350 AND NOT EXISTS (SELECT 1 FROM workspace_members o
351 WHERE o.workspace_id = w.id AND o.role = 'owner' AND o.user_id <> ?1)
352 ORDER BY w.slug",
353 )
354 .bind(&[user_id.into()])?
355 .all()
356 .await?
357 .results::<Row>()?;
358 Ok(rows
359 .into_iter()
360 .map(|row| SoleOwnedWorkspace { slug: row.slug, name: row.name, members: row.members, billing: None })
361 .collect())
362 }
363
364 /// What deleting the account would take with it, and what stands in
365 /// the way. With `actor` (the person), billing says for each workspace
366 /// they own alone what deleting it first would need.
367 pub(crate) async fn account_deletion_facts(&self, user_id: &str, username: &str, actor: Option<&User>) -> Result<AccountDeletion> {
368 #[derive(Deserialize)]
369 struct Counts {
370 workspaces: u32,
371 tokens: u32,
372 ssh_keys: u32,
373 applications: u32,
374 repositories: u32,
375 }
376 let counts = self
377 .db
378 .prepare(
379 "SELECT
380 (SELECT count(*) FROM workspace_members m JOIN workspaces w ON w.id = m.workspace_id
381 WHERE m.user_id = ?1 AND w.deleted_at IS NULL) AS workspaces,
382 (SELECT count(*) FROM access_tokens WHERE user_id = ?1 AND agent_scope IS NULL
383 AND (expires_at IS NULL OR listed = 1)) AS tokens,
384 (SELECT count(*) FROM ssh_keys WHERE user_id = ?1) AS ssh_keys,
385 (SELECT count(*) FROM oauth_grants WHERE user_id = ?1) AS applications,
386 (SELECT count(*) FROM repo_grants WHERE principal_kind = 'user' AND principal_id = ?1) AS repositories",
387 )
388 .bind(&[user_id.into()])?
389 .first::<Counts>(None)
390 .await?
391 .unwrap_or(Counts { workspaces: 0, tokens: 0, ssh_keys: 0, applications: 0, repositories: 0 });
392 let mut sole_owner_of = self.sole_owned(user_id).await?;
393 if let Some(actor) = actor
394 && !sole_owner_of.is_empty()
395 {
396 let billing = self.env.service("BILLING")?;
397 for workspace in &mut sole_owner_of {
398 let closing: Result<Outcome<bool>> = g1t_kit::call(
399 &billing,
400 "close_workspace",
401 &CloseWorkspaceArgs { actor: actor.clone(), workspace: workspace.slug.clone(), dry_run: true },
402 )
403 .await;
404 workspace.billing = match closing {
405 Ok(Outcome::Fail(failure)) => Some(failure.message),
406 _ => None,
407 };
408 }
409 }
410 Ok(AccountDeletion {
411 username: username.to_owned(),
412 workspaces: counts.workspaces,
413 tokens: counts.tokens,
414 ssh_keys: counts.ssh_keys,
415 applications: counts.applications,
416 repositories: counts.repositories,
417 sole_owner_of,
418 protected: is_protected_account(&self.protected_account_names(), user_id, username),
419 })
420 }
421
422 /// `check_account_deletion`: what deleting the person's own account
423 /// would take, and what is in the way, changing nothing.
424 pub async fn check_account_deletion(&self, a: UserArgs) -> Result<Outcome<AccountDeletion>> {
425 if !is_person(&a.user) {
426 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
427 }
428 let Some(live) = self.live_account("id", &a.user.id).await? else {
429 return Ok(Outcome::fail(FailureCode::NotFound, "Account not found."));
430 };
431 Ok(Outcome::Ok(self.account_deletion_facts(&live.id, &live.username, Some(&a.user)).await?))
432 }
433
434 /// `delete_account`: the person deletes their own account.
435 pub async fn delete_account(&self, a: DeleteAccountArgs) -> Result<Outcome<bool>> {
436 if !is_person(&a.user) {
437 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
438 }
439 let Some(live) = self.live_account("id", &a.user.id).await? else {
440 return Ok(Outcome::fail(FailureCode::NotFound, "Account not found."));
441 };
442 let deletion = self.account_deletion_facts(&live.id, &live.username, None).await?;
443 if let Err((code, message)) = may_delete_own(true, &deletion, &a.confirm) {
444 return Ok(Outcome::fail(code, message));
445 }
446 // Proof last: nothing else in the way, so a password typed now is
447 // the last thing asked.
448 if let Some(refusal) = self.proof(&live.id, &a.reauth).await?.refusal() {
449 return Ok(refusal);
450 }
451 self.soft_delete(&live, &deletion, Some(&live.id), None).await?;
452 Ok(Outcome::Ok(true))
453 }
454
455 /// `admin_delete_account`: staff delete an account, with a reason.
456 pub async fn admin_delete_account(&self, a: AdminDeleteAccountArgs) -> Result<Outcome<bool>> {
457 let staff = a.staff.trim();
458 let reason = a.reason.trim();
459 if staff.is_empty() {
460 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is deleting it."));
461 }
462 if reason.is_empty() {
463 return Ok(Outcome::fail(FailureCode::Invalid, "Say why the account is being deleted."));
464 }
465 let Some(live) = self.live_account("username", &a.username.trim().to_lowercase()).await? else {
466 return Ok(Outcome::fail(FailureCode::NotFound, "No such account, or it is already deleted."));
467 };
468 let deletion = self.account_deletion_facts(&live.id, &live.username, None).await?;
469 if let Err((code, message)) = may_delete(&deletion, &a.confirm) {
470 // Staff read "you" as the account's.
471 let message = message.replacen("You are the only owner", &format!("{} is the only owner", live.username), 1);
472 return Ok(Outcome::fail(code, message));
473 }
474 self.soft_delete(&live, &deletion, None, Some((staff, reason))).await?;
475 self.record_for_staff(
476 &live.username,
477 "account_deleted",
478 &format!("Deleted the account {}: {reason}", live.username),
479 staff,
480 )
481 .await;
482 Ok(Outcome::Ok(true))
483 }
484
485 /// Deletes an account softly: see the module docs.
486 async fn soft_delete(
487 &self,
488 live: &Live,
489 deletion: &AccountDeletion,
490 deleted_by: Option<&str>,
491 staff: Option<(&str, &str)>,
492 ) -> Result<()> {
493 let id = live.id.as_str();
494 let snapshot = self.snapshot(id, deletion, staff).await?;
495 let now = now_ms();
496 let at = rfc3339(now);
497 let purge = purge_after(now);
498 let by_staff = staff.is_some();
499 // Recorded in each workspace it was in, while it still is.
500 let person = User { id: live.id.clone(), username: live.username.clone(), ..User::default() };
501 let action = if by_staff { "account.deleted_by_staff" } else { "account.deleted" };
502 self.audit_account(&person, action, &format!("Deleted the account {}", live.username)).await;
503 // The person hears of it while their addresses are still there.
504 for address in self.notice_recipients(id, false).await.unwrap_or_default() {
505 if let Err(error) = crate::email::send_account_deleted(&self.env, &address, &live.username, by_staff, ACCOUNT_RESTORE_DAYS).await {
506 worker::console_error!("account deleted notice failed: {error}");
507 }
508 }
509 let went = serde_json::to_string(&snapshot).unwrap_or_default();
510 let by: JsValue = deleted_by.map_or(JsValue::NULL, Into::into);
511 let mut statements = vec![
512 // Only while it is still live: two deletions at once delete once.
513 self.db
514 .prepare(
515 "UPDATE users SET deleted_at = ?, deleted_by = ?, purge_after = ?, deleted_went = ?
516 WHERE id = ? AND deleted_at IS NULL",
517 )
518 .bind(&[at.as_str().into(), by, purge.as_str().into(), went.into(), id.into()])?,
519 ];
520 let values = [id, at.as_str()];
521 for (sql, binds) in revoke_statements() {
522 let binds: Vec<JsValue> = values[..binds].iter().map(|value| JsValue::from(*value)).collect();
523 statements.push(self.db.prepare(sql).bind(&binds)?);
524 }
525 self.db.batch(statements).await?;
526 self.log_security(id, "account_deleted", None, staff).await;
527 self.announce(
528 "user.deleting",
529 deleted_by,
530 UserDeleting { user_id: live.id.clone(), username: live.username.clone(), by_staff, purge_after: purge },
531 )
532 .await;
533 Ok(())
534 }
535
536 /// What the account has now, kept so a restore can put it back.
537 async fn snapshot(&self, id: &str, deletion: &AccountDeletion, staff: Option<(&str, &str)>) -> Result<Snapshot> {
538 let memberships = self
539 .db
540 .prepare(
541 "SELECT workspace_id, role, billing_manager, security_manager, created_at
542 FROM workspace_members WHERE user_id = ?",
543 )
544 .bind(&[id.into()])?
545 .all()
546 .await?
547 .results::<Member>()?;
548 let teams = self
549 .db
550 .prepare("SELECT team_id, role, created_at FROM team_members WHERE user_id = ?")
551 .bind(&[id.into()])?
552 .all()
553 .await?
554 .results::<TeamMember>()?;
555 let grants = self
556 .db
557 .prepare(
558 "SELECT repo_id, workspace_id, repo_name, role, granted_by, created_at, updated_at
559 FROM repo_grants WHERE principal_kind = 'user' AND principal_id = ?",
560 )
561 .bind(&[id.into()])?
562 .all()
563 .await?
564 .results::<Grant>()?;
565 Ok(Snapshot {
566 went: AccountWent {
567 workspaces: deletion.workspaces,
568 teams: teams.len() as u32,
569 repositories: grants.len() as u32,
570 tokens: deletion.tokens,
571 ssh_keys: deletion.ssh_keys,
572 staff: staff.map(|(who, _)| who.to_owned()),
573 reason: staff.map(|(_, why)| why.to_owned()),
574 },
575 memberships,
576 teams,
577 grants,
578 })
579 }
580
581 /// Deleted accounts not purged yet, newest first. Staff only.
582 pub async fn admin_deleted_accounts(&self) -> Result<Vec<DeletedAccount>> {
583 let rows = self
584 .db
585 .prepare(format!("SELECT {DELETED_COLUMNS} ORDER BY deleted_at DESC LIMIT 500"))
586 .all()
587 .await?
588 .results::<DeletedRow>()?;
589 let now = rfc3339(now_ms());
590 Ok(rows.iter().map(|row| row.listed(&now)).collect())
591 }
592
593 async fn deleted_account_row(&self, column: &str, value: &str) -> Result<Option<DeletedRow>> {
594 self.db
595 .prepare(format!("SELECT {DELETED_COLUMNS} AND {column} = ?"))
596 .bind(&[value.into()])?
597 .first::<DeletedRow>(None)
598 .await
599 }
600
601 /// The deletion of `user_id`, when it is deleted and not purged.
602 pub(crate) async fn deleted_account(&self, user_id: &str) -> Result<Option<DeletedAccount>> {
603 let now = rfc3339(now_ms());
604 Ok(self.deleted_account_row("id", user_id).await?.map(|row| row.listed(&now)))
605 }
606
607 /// Staff bring a deleted account back within its window, with the
608 /// memberships, teams and repository roles it left. Staff only.
609 pub async fn admin_restore_account(&self, a: AdminDeletedAccountArgs) -> Result<Outcome<bool>> {
610 let staff = a.staff.trim();
611 if staff.is_empty() {
612 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is restoring it."));
613 }
614 let Some(row) = self.deleted_account_row("id", &a.user_id).await? else {
615 return Ok(Outcome::fail(FailureCode::NotFound, "There is no deleted account with that id."));
616 };
617 if let Err((code, message)) = may_restore(&row.username, &row.purge_after, &rfc3339(now_ms())) {
618 return Ok(Outcome::fail(code, message));
619 }
620 let snapshot = snapshot_of(row.deleted_went.as_deref());
621 let id = row.id.as_str();
622 let mut statements = vec![
623 self.db
624 .prepare(
625 "UPDATE users SET deleted_at = NULL, deleted_by = NULL, purge_after = NULL, deleted_went = NULL
626 WHERE id = ? AND deleted_at IS NOT NULL",
627 )
628 .bind(&[id.into()])?,
629 ];
630 // Back where the workspace, team or repository's workspace is
631 // still there; never over what was given since.
632 for member in &snapshot.memberships {
633 statements.push(
634 self.db
635 .prepare(
636 "INSERT OR IGNORE INTO workspace_members
637 (workspace_id, user_id, role, created_at, billing_manager, security_manager)
638 SELECT ?1, ?2, ?3, ?4, ?5, ?6 WHERE EXISTS (SELECT 1 FROM workspaces WHERE id = ?1)",
639 )
640 .bind(&[
641 member.workspace_id.as_str().into(),
642 id.into(),
643 member.role.as_str().into(),
644 member.created_at.as_str().into(),
645 member.billing_manager.into(),
646 member.security_manager.into(),
647 ])?,
648 );
649 }
650 for team in &snapshot.teams {
651 statements.push(
652 self.db
653 .prepare(
654 "INSERT OR IGNORE INTO team_members (team_id, user_id, role, created_at)
655 SELECT ?1, ?2, ?3, ?4 WHERE EXISTS (SELECT 1 FROM teams WHERE id = ?1)",
656 )
657 .bind(&[team.team_id.as_str().into(), id.into(), team.role.as_str().into(), team.created_at.as_str().into()])?,
658 );
659 }
660 for grant in &snapshot.grants {
661 statements.push(
662 self.db
663 .prepare(
664 "INSERT OR IGNORE INTO repo_grants
665 (repo_id, principal_kind, principal_id, workspace_id, repo_name, role, granted_by, created_at, updated_at)
666 SELECT ?1, 'user', ?2, ?3, ?4, ?5, ?6, ?7, ?8
667 WHERE EXISTS (SELECT 1 FROM workspaces WHERE id = ?3)",
668 )
669 .bind(&[
670 grant.repo_id.as_str().into(),
671 id.into(),
672 grant.workspace_id.as_str().into(),
673 grant.repo_name.as_str().into(),
674 grant.role.as_str().into(),
675 grant.granted_by.as_deref().map_or(JsValue::NULL, Into::into),
676 grant.created_at.as_str().into(),
677 grant.updated_at.as_str().into(),
678 ])?,
679 );
680 }
681 self.db.batch(statements).await?;
682 self.log_security(id, "account_restored", None, Some((staff, "Restored by g1t's staff"))).await;
683 self.record_for_staff(&row.username, "account_restored", &format!("Restored the account {}", row.username), staff)
684 .await;
685 self.announce("user.restored", None, UserRestored { user_id: row.id.clone(), username: row.username.clone() })
686 .await;
687 Ok(Outcome::Ok(true))
688 }
689
690 /// Staff purge a deleted account now rather than at `purge_after`.
691 pub async fn admin_purge_account(&self, a: AdminDeletedAccountArgs) -> Result<Outcome<bool>> {
692 let staff = a.staff.trim();
693 if staff.is_empty() {
694 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is purging it."));
695 }
696 let Some(row) = self.deleted_account_row("id", &a.user_id).await? else {
697 return Ok(Outcome::fail(FailureCode::NotFound, "There is no deleted account with that id."));
698 };
699 let protected = is_protected_account(&self.protected_account_names(), &row.id, &row.username);
700 if let Err((code, message)) = may_purge(protected, &row.username, Some(&a.confirm)) {
701 return Ok(Outcome::fail(code, message));
702 }
703 self.purge_account(&row).await?;
704 self.record_for_staff(&row.username, "account_purged", &format!("Purged the account {} now", row.username), staff)
705 .await;
706 Ok(Outcome::Ok(true))
707 }
708
709 /// The sweep: purges deleted accounts whose restore window has passed.
710 pub async fn purge_due_accounts(&self) -> Result<u32> {
711 let due = self
712 .db
713 .prepare(format!(
714 "SELECT {DELETED_COLUMNS} AND purge_after <= ? ORDER BY purge_after LIMIT {PURGES_PER_SWEEP}"
715 ))
716 .bind(&[rfc3339(now_ms()).into()])?
717 .all()
718 .await?
719 .results::<DeletedRow>()?;
720 let names = self.protected_account_names();
721 let mut purged = 0;
722 for row in due {
723 if let Err((_, why)) = may_purge(is_protected_account(&names, &row.id, &row.username), &row.username, None) {
724 worker::console_error!("{} not purged: {why}", row.username);
725 continue;
726 }
727 match self.purge_account(&row).await {
728 Ok(()) => purged += 1,
729 Err(error) => worker::console_error!("{} not purged: {error}", row.username),
730 }
731 }
732 Ok(purged)
733 }
734
735 /// Removes a deleted account for good: see the module docs.
736 async fn purge_account(&self, row: &DeletedRow) -> Result<()> {
737 let now = rfc3339(now_ms());
738 let values = [row.id.as_str(), row.username.as_str(), row.deleted_at.as_str(), now.as_str()];
739 let mut batch = Vec::new();
740 for (sql, binds) in purge_statements() {
741 let binds: Vec<JsValue> = values[..binds].iter().map(|value| JsValue::from(*value)).collect();
742 batch.push(self.db.prepare(sql).bind(&binds)?);
743 }
744 self.db.batch(batch).await?;
745 if let Err(error) = self.forget_avatar(row.avatar.clone()).await {
746 worker::console_error!("avatar of {} not removed: {error}", row.username);
747 }
748 self.announce("user.deleted", None, UserDeleted { user_id: row.id.clone(), username: row.username.clone() })
749 .await;
750 Ok(())
751 }
752}
753
754#[cfg(test)]
755mod tests {
756 use super::*;
757
758 fn deletion(username: &str) -> AccountDeletion {
759 AccountDeletion { username: username.into(), ..AccountDeletion::default() }
760 }
761
762 fn sole(slug: &str) -> SoleOwnedWorkspace {
763 SoleOwnedWorkspace { slug: slug.into(), name: slug.into(), members: 2, billing: None }
764 }
765
766 #[test]
767 fn only_the_person_typing_their_username() {
768 assert!(may_delete_own(true, &deletion("ada"), " ADA ").is_ok());
769 assert_eq!(may_delete_own(false, &deletion("ada"), "ada").unwrap_err().0, FailureCode::Forbidden);
770 assert_eq!(may_delete_own(true, &deletion("ada"), "").unwrap_err(), (FailureCode::Invalid, "Type ada to confirm.".into()));
771 assert_eq!(may_delete_own(true, &deletion("ada"), "ada-l").unwrap_err().0, FailureCode::Invalid);
772 }
773
774 #[test]
775 fn the_only_owner_of_a_live_workspace_is_refused_with_its_name() {
776 let owner = AccountDeletion { sole_owner_of: vec![sole("acme"), sole("globex")], ..deletion("ada") };
777 let (code, message) = may_delete_own(true, &owner, "ada").unwrap_err();
778 assert_eq!(code, FailureCode::Conflict);
779 assert!(message.contains("acme and globex"), "{message}");
780 // Staff are refused the same way.
781 assert_eq!(may_delete(&owner, "ada").unwrap_err().0, FailureCode::Conflict);
782 }
783
784 #[test]
785 fn a_protected_account_is_refused_to_everyone_and_never_purged() {
786 let names = protected_names(None);
787 for username in ["g1t", "g1t-agent", "ghost"] {
788 assert!(is_protected_account(&names, "usr_1", username));
789 let protected = AccountDeletion { protected: true, ..deletion(username) };
790 let (code, message) = may_delete_own(true, &protected, username).unwrap_err();
791 assert_eq!(code, FailureCode::Forbidden);
792 assert_eq!(message, format!("{username} is protected and can never be deleted."));
793 assert_eq!(may_purge(true, username, None).unwrap_err().0, FailureCode::Forbidden);
794 assert_eq!(may_purge(true, username, Some(username)).unwrap_err().0, FailureCode::Forbidden);
795 }
796 // Named in PROTECTED_ACCOUNTS, by username or id.
797 let named = protected_names(Some("ada,usr_9"));
798 assert!(is_protected_account(&named, "usr_2", "Ada"));
799 assert!(is_protected_account(&named, "usr_9", "grace"));
800 assert!(!is_protected_account(&named, "usr_3", "grace"));
801 }
802
803 #[test]
804 fn a_deleted_account_is_restorable_for_thirty_days_then_due() {
805 let deleted = 1_790_000_000_000;
806 let purge = purge_after(deleted);
807 assert_eq!(purge, rfc3339(deleted + 30 * 86_400_000));
808 assert!(restorable(&purge, &rfc3339(deleted + 29 * 86_400_000)));
809 assert!(!restorable(&purge, &purge));
810 assert!(may_restore("ada", &purge, &rfc3339(deleted + 86_400_000)).is_ok());
811 assert_eq!(
812 may_restore("ada", &purge, &rfc3339(deleted + 31 * 86_400_000)).unwrap_err(),
813 (FailureCode::Conflict, "ada is being purged and can no longer be restored.".to_owned())
814 );
815 }
816
817 #[test]
818 fn staff_purge_only_with_the_username_typed() {
819 assert!(may_purge(false, "ada", None).is_ok());
820 assert!(may_purge(false, "ada", Some(" Ada ")).is_ok());
821 assert_eq!(may_purge(false, "ada", Some("")).unwrap_err().0, FailureCode::Invalid);
822 assert_eq!(may_purge(false, "ada", Some("grace")).unwrap_err().0, FailureCode::Invalid);
823 }
824
825 #[test]
826 fn what_it_left_is_kept_for_a_restore_and_read_back() {
827 let snapshot = Snapshot {
828 went: AccountWent { workspaces: 2, teams: 1, repositories: 1, tokens: 3, ssh_keys: 1, staff: Some("s@g1t.sh".into()), reason: Some("asked".into()) },
829 memberships: vec![Member {
830 workspace_id: "wsp_1".into(),
831 role: "owner".into(),
832 billing_manager: 0,
833 security_manager: 1,
834 created_at: "2026-01-01T00:00:00.000Z".into(),
835 }],
836 teams: vec![TeamMember { team_id: "tem_1".into(), role: "maintainer".into(), created_at: "x".into() }],
837 grants: vec![Grant {
838 repo_id: "rep_1".into(),
839 workspace_id: "wsp_2".into(),
840 repo_name: "api".into(),
841 role: "write".into(),
842 granted_by: None,
843 created_at: "x".into(),
844 updated_at: "y".into(),
845 }],
846 };
847 let stored = serde_json::to_string(&snapshot).unwrap();
848 assert_eq!(snapshot_of(Some(&stored)), snapshot);
849 assert_eq!(snapshot_of(None), Snapshot::default());
850 assert_eq!(snapshot_of(Some("not json")), Snapshot::default());
851 }
852
853 /// The highest `?N` a statement names: D1 refuses a statement given
854 /// more or fewer values than that.
855 fn highest_bind(sql: &str) -> usize {
856 (1..=9).filter(|n| sql.contains(&format!("?{n}"))).max().unwrap_or(0)
857 }
858
859 #[test]
860 fn every_statement_is_given_exactly_the_values_it_names() {
861 for (sql, binds) in revoke_statements().into_iter().chain(purge_statements()) {
862 assert_eq!(highest_bind(&sql), binds, "{sql}");
863 }
864 }
865
866 #[test]
867 fn deleting_ends_everything_it_signs_in_with_and_every_membership() {
868 let sql: Vec<String> = revoke_statements().into_iter().map(|(sql, _)| sql).collect();
869 for table in [
870 "sessions",
871 "access_tokens",
872 "oauth_grants",
873 "device_codes",
874 "ssh_keys",
875 "two_factor_challenges",
876 "email_tokens",
877 "workspace_members",
878 "team_members",
879 ] {
880 assert!(sql.iter().any(|s| s == &format!("DELETE FROM {table} WHERE user_id = ?1")), "{table}");
881 }
882 assert!(sql.iter().any(|s| s.starts_with("DELETE FROM deploy_keys WHERE created_by = ?1")));
883 assert!(sql.iter().any(|s| s.starts_with("DELETE FROM repo_grants")));
884 assert!(sql.iter().any(|s| s.starts_with("UPDATE github_accounts SET tokens = NULL")));
885 // A token's repositories go before the token, which the subquery needs.
886 let repositories = sql.iter().position(|s| s.contains("token_repositories")).unwrap();
887 let tokens = sql.iter().position(|s| s == "DELETE FROM access_tokens WHERE user_id = ?1").unwrap();
888 assert!(repositories < tokens);
889 }
890
891 #[test]
892 fn a_purge_keeps_the_username_hands_authorship_to_ghost_and_loses_to_a_restore() {
893 let sql: Vec<String> = purge_statements().into_iter().map(|(sql, _)| sql).collect();
894 assert!(sql[0].starts_with("INSERT OR REPLACE INTO deleted_users"));
895 assert!(sql.iter().any(|s| s.starts_with("UPDATE workspaces SET created_by = 'usr_ghost' WHERE created_by = ?1")));
896 for table in ["user_emails", "github_accounts", "two_factor", "two_factor_recovery", "security_events", "ssh_keys"] {
897 assert!(sql.iter().any(|s| s.starts_with(&format!("DELETE FROM {table} WHERE user_id = ?1"))), "{table}");
898 }
899 // The row goes last, and everything before it only while the
900 // account is still deleted, so a restore a moment before wins.
901 assert!(sql.last().unwrap().starts_with("DELETE FROM users WHERE id = ?1 AND deleted_at IS NOT NULL"));
902 for s in &sql[..sql.len() - 1] {
903 assert!(s.contains("deleted_at IS NOT NULL AND purge_after IS NOT NULL"), "{s}");
904 }
905 }
906
907 #[test]
908 fn the_deleted_list_never_shows_ghost() {
909 // ghost's row is deleted with no purge time; the list and the
910 // sweep both need one.
911 assert!(DELETED_COLUMNS.contains("purge_after IS NOT NULL"));
912 }
913}

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