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 each | 16 | //! Staff can instead delete those workspaces with the account |
| 17 | //! (`with_sole_workspaces`), say a retired test account that owns only its | |
| 18 | //! own personal workspace. Every one is checked first: if any is protected | |
| 19 | //! or its billing cannot settle, nothing is deleted and staff are told | |
| 20 | //! which ([`staff_steps`]). Then each is deleted exactly as its owner would | |
| 21 | //! delete it (deletion.rs, [`Identity::staff_delete_workspace`]), and the | |
| 22 | //! account last ([`run_steps`]): should a workspace fail on the way, the | |
| 23 | //! account is not deleted and the failure names it. The account's record | |
| 24 | //! lists the workspaces that went with it, so sudo can purge them at once | |
| 25 | //! too. They lose the account as a member when it is deleted, so to undo | |
| 26 | //! it all, staff restore the account first and then its workspaces. | |
| 27 | //! | |
| 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 managers | 30 | //! ([`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 each | 58 | use std::future::Future; |
| 59 | ||
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 60 | use g1t_contracts::FailureCode; |
| 61 | use g1t_contracts::Outcome; | |
| 62 | use g1t_contracts::User; | |
| 63 | use g1t_contracts::account_deletion::*; | |
| 64 | use g1t_contracts::events::{UserDeleted, UserDeleting, UserRestored}; | |
| 65 | use g1t_contracts::identity::{UserArgs, protected_names}; | |
| 66 | use g1t_contracts::time::rfc3339; | |
| 67 | use g1t_kit::now_ms; | |
| 68 | use serde::{Deserialize, Serialize}; | |
| 69 | use worker::Result; | |
| 70 | use worker::wasm_bindgen::JsValue; | |
| 71 | ||
| 72 | use crate::Identity; | |
| Merge sudo: delete an account with the workspaces it alone owns, purge each | 73 | use crate::deletion::staff_billing_actor; |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 74 | use crate::security::is_person; |
| 75 | ||
| 76 | type Refusal = (FailureCode, String); | |
| 77 | ||
| 78 | /// How many accounts one sweep purges. | |
| 79 | const PURGES_PER_SWEEP: u32 = 25; | |
| 80 | ||
| 81 | pub 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. | |
| 84 | pub 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). | |
| 90 | pub 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. | |
| 97 | pub 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. | |
| 105 | pub 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 each | 118 | /// What staff deleting an account does, in order. |
| 119 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 120 | pub 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. | |
| 133 | pub 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`. | |
| 159 | pub 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`]). | |
| 180 | pub async fn run_steps<W, WF, A, AF>(username: &str, steps: &[Step], mut workspace: W, account: A) -> Result<Outcome<Vec<WorkspaceDeletedWith>>> | |
| 181 | where | |
| 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)] | |
| 215 | pub(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`. |
| 225 | pub 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. | |
| 237 | pub 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. | |
| 251 | pub 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 managers | 253 | // 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 managers | 256 | // 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. | |
| 302 | pub 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)] | |
| 344 | struct 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)] | |
| 356 | struct 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)] | |
| 364 | struct 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)] | |
| 377 | struct 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 | ||
| 388 | fn 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)] | |
| 394 | struct 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 | ||
| 405 | impl 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. | |
| 420 | const 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)] | |
| 425 | struct Live { | |
| 426 | id: String, | |
| 427 | username: String, | |
| 428 | } | |
| 429 | ||
| 430 | impl 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 each | 460 | 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 each | 464 | #[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 each | 470 | "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 each | 482 | 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 each | 491 | /// 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 each | 520 | 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 each | 554 | 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 each | 565 | 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 each | 574 | 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 each | 578 | /// `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 each | 593 | // 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 each | 623 | &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 each | 637 | 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 each | 640 | 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 each | 721 | 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)] | |
| 903 | mod 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 each | 911 | 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 each | 1010 | 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 each | 1105 | 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.