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 | ||
| 46 | use g1t_contracts::FailureCode; | |
| 47 | use g1t_contracts::Outcome; | |
| 48 | use g1t_contracts::User; | |
| 49 | use g1t_contracts::account_deletion::*; | |
| 50 | use g1t_contracts::billing::CloseWorkspaceArgs; | |
| 51 | use g1t_contracts::events::{UserDeleted, UserDeleting, UserRestored}; | |
| 52 | use g1t_contracts::identity::{UserArgs, protected_names}; | |
| 53 | use g1t_contracts::time::rfc3339; | |
| 54 | use g1t_kit::now_ms; | |
| 55 | use serde::{Deserialize, Serialize}; | |
| 56 | use worker::Result; | |
| 57 | use worker::wasm_bindgen::JsValue; | |
| 58 | ||
| 59 | use crate::Identity; | |
| 60 | use crate::security::is_person; | |
| 61 | ||
| 62 | type Refusal = (FailureCode, String); | |
| 63 | ||
| 64 | /// How many accounts one sweep purges. | |
| 65 | const PURGES_PER_SWEEP: u32 = 25; | |
| 66 | ||
| 67 | pub 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. | |
| 70 | pub 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). | |
| 76 | pub 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. | |
| 83 | pub 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. | |
| 91 | pub 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`. | |
| 105 | pub 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. | |
| 117 | pub 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. | |
| 131 | pub 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. | |
| 182 | pub 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)] | |
| 223 | struct 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)] | |
| 235 | struct 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)] | |
| 243 | struct 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)] | |
| 256 | struct 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 | ||
| 267 | fn 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)] | |
| 273 | struct 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 | ||
| 284 | impl 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. | |
| 299 | const 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)] | |
| 304 | struct Live { | |
| 305 | id: String, | |
| 306 | username: String, | |
| 307 | } | |
| 308 | ||
| 309 | impl 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)] | |
| 755 | mod 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.