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: what it takes with it, what stands in its way, and |
| 2 | //! how long g1t keeps it. Identity's `account_deletion.rs` does it; these | |
| 3 | //! are its arguments and the rules every caller applies the same way. | |
| 4 | //! | |
| 5 | //! A person deletes their own account from their settings, signed in as | |
| 6 | //! themselves, typing their username and proving it is them | |
| 7 | //! ([`crate::accounts::Reauth`]); g1t's staff can delete one from sudo with | |
| 8 | //! a reason. Neither is possible while the account is the only owner of a | |
| Staff delete an account with the workspaces it alone owns | 9 | //! live workspace: its owner transfers it or deletes it first. Staff may |
| 10 | //! instead delete those workspaces with it, in the same request | |
| 11 | //! ([`AdminDeleteAccountArgs::with_sole_workspaces`]), unless one of them is | |
| 12 | //! protected or its billing cannot settle ([`staff_sole_owner_refusal`]). | |
| 13 | //! Accounts | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 14 | //! that run g1t, and the names g1t shows for itself, can never be deleted |
| 15 | //! ([`is_protected_account`]). | |
| 16 | //! | |
| 17 | //! Deleting is soft first. The account's sessions, tokens, applications, | |
| 18 | //! SSH keys, the deploy keys it added and its pending sign-ins end at once; | |
| 19 | //! it leaves every workspace, team and repository; its profile is not | |
| 20 | //! found and it cannot sign in. Its row is kept, holding its username, for | |
| 21 | //! [`ACCOUNT_RESTORE_DAYS`], when staff can restore it. Then it is purged: | |
| 22 | //! the row and its personal data go, the username is never given to anyone | |
| 23 | //! again, and what it wrote shows as [`GHOST_USERNAME`]. | |
| 24 | //! | |
| 25 | //! There is no API route for it: only the site and sudo delete accounts. | |
| 26 | ||
| 27 | use serde::{Deserialize, Serialize}; | |
| 28 | ||
| 29 | use crate::User; | |
| 30 | use crate::accounts::Reauth; | |
| 31 | ||
| 32 | /// How long a deleted account is kept, for g1t's staff to restore, before | |
| 33 | /// it is purged. | |
| 34 | pub const ACCOUNT_RESTORE_DAYS: u64 = 30; | |
| 35 | ||
| 36 | /// Who wrote what a purged account wrote: issues, pull requests, comments, | |
| 37 | /// reviews, and commits made with its noreply address. Reserved: nobody may | |
| 38 | /// register it. | |
| 39 | pub const GHOST_USERNAME: &str = "ghost"; | |
| 40 | ||
| 41 | /// The account row `ghost` has, so that what pointed at a purged account | |
| 42 | /// (a workspace's creator) points somewhere. It can never sign in. | |
| 43 | pub const GHOST_ID: &str = "usr_ghost"; | |
| 44 | ||
| 45 | /// Whether the account `id`, called `username`, can never be deleted: one | |
| 46 | /// of the names g1t shows for itself (`g1t`, `g1t-agent`, `ghost`; see | |
| 47 | /// [`crate::is_reserved_name`]), or named by id or username in `names` | |
| 48 | /// (from [`crate::identity::protected_names`] over identity's | |
| 49 | /// `PROTECTED_ACCOUNTS`). | |
| 50 | pub fn is_protected_account(names: &[String], id: &str, username: &str) -> bool { | |
| 51 | let named = |name: &str| names.iter().any(|protected| protected.eq_ignore_ascii_case(name.trim())); | |
| 52 | id == GHOST_ID || crate::is_reserved_name(username) || named(id) || named(username) | |
| 53 | } | |
| 54 | ||
| 55 | /// Why an account that is protected is not deleted or purged. | |
| 56 | pub fn protected_account_refusal(username: &str) -> String { | |
| 57 | format!("{username} is protected and can never be deleted.") | |
| 58 | } | |
| 59 | ||
| 60 | /// A live workspace the account is the only owner of. Each one stands in | |
| 61 | /// the way of deleting the account until it has another owner or is | |
| 62 | /// deleted. | |
| 63 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 64 | pub struct SoleOwnedWorkspace { | |
| 65 | pub slug: String, | |
| 66 | pub name: String, | |
| 67 | /// Everyone in it, the account included. | |
| 68 | pub members: u32, | |
| 69 | /// Why billing could not close it yet if it were deleted now, in words | |
| 70 | /// for its owner: what deleting it first would need. Null when nothing | |
| Staff delete an account with the workspaces it alone owns | 71 | /// is owed. |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 72 | #[serde(default)] |
| 73 | pub billing: Option<String>, | |
| Staff delete an account with the workspaces it alone owns | 74 | /// It can never be deleted, by anyone (`PROTECTED_WORKSPACES`, or its |
| 75 | /// row says so), so staff cannot delete it with the account either. | |
| 76 | #[serde(default)] | |
| 77 | pub protected: bool, | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 78 | } |
| 79 | ||
| Staff delete an account with the workspaces it alone owns | 80 | impl SoleOwnedWorkspace { |
| 81 | /// Why staff cannot delete it with the account, or `None`. | |
| 82 | pub fn refusal(&self) -> Option<String> { | |
| 83 | if self.protected { | |
| 84 | return Some(crate::identity::protected_refusal(&self.slug)); | |
| 85 | } | |
| 86 | self.billing.clone() | |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 90 | /// Why staff cannot delete an account together with the workspaces it is | |
| 91 | /// the only owner of: each one that is protected or whose billing cannot | |
| 92 | /// settle, said in turn. `None` when every one can go. | |
| 93 | pub fn staff_sole_owner_refusal(workspaces: &[SoleOwnedWorkspace]) -> Option<String> { | |
| 94 | let reasons: Vec<String> = workspaces.iter().filter_map(SoleOwnedWorkspace::refusal).collect(); | |
| 95 | if reasons.is_empty() { | |
| 96 | return None; | |
| 97 | } | |
| 98 | Some(format!("Nothing was deleted. {}", reasons.join(" "))) | |
| 99 | } | |
| 100 | ||
| 101 | /// A workspace staff deleted together with the account that alone owned | |
| 102 | /// it, kept in the account's [`AccountWent`] so sudo can show it and purge | |
| 103 | /// it. | |
| 104 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 105 | #[serde(rename_all = "camelCase")] | |
| 106 | pub struct WorkspaceDeletedWith { | |
| 107 | pub workspace_id: String, | |
| 108 | pub slug: String, | |
| 109 | } | |
| 110 | ||
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 111 | /// `check_account_deletion` (takes `UserArgs`, people only) returns |
| 112 | /// `Outcome<AccountDeletion>`: what deleting the account would take with | |
| 113 | /// it, and what stands in the way, changing nothing. Nothing does when | |
| 114 | /// `sole_owner_of` is empty and it is not `protected`. | |
| 115 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 116 | pub struct AccountDeletion { | |
| 117 | pub username: String, | |
| 118 | /// Live workspaces it is a member or owner of, which it leaves. | |
| 119 | pub workspaces: u32, | |
| 120 | /// Its personal access tokens, classic and fine-grained. | |
| 121 | pub tokens: u32, | |
| 122 | pub ssh_keys: u32, | |
| 123 | /// Applications signed in as it (OAuth). | |
| 124 | pub applications: u32, | |
| 125 | /// Repositories it has a role on directly, as an outside collaborator | |
| 126 | /// or a member given more. | |
| 127 | pub repositories: u32, | |
| 128 | /// Workspaces it is the only owner of. | |
| 129 | #[serde(default)] | |
| 130 | pub sole_owner_of: Vec<SoleOwnedWorkspace>, | |
| 131 | /// It can never be deleted, by anyone. | |
| 132 | #[serde(default)] | |
| 133 | pub protected: bool, | |
| 134 | } | |
| 135 | ||
| 136 | impl AccountDeletion { | |
| 137 | pub fn blocked(&self) -> bool { | |
| 138 | self.reason().is_some() | |
| 139 | } | |
| 140 | ||
| 141 | /// Why the account cannot be deleted, as one sentence, or `None`. | |
| 142 | pub fn reason(&self) -> Option<String> { | |
| 143 | if self.protected { | |
| 144 | return Some(protected_account_refusal(&self.username)); | |
| 145 | } | |
| 146 | sole_owner_refusal(&self.sole_owner_of) | |
| 147 | } | |
| 148 | } | |
| 149 | ||
| 150 | /// Why an account that is the only owner of `workspaces` cannot be | |
| 151 | /// deleted, naming them, or `None` when it owns none alone. | |
| 152 | pub fn sole_owner_refusal(workspaces: &[SoleOwnedWorkspace]) -> Option<String> { | |
| 153 | let slugs: Vec<&str> = workspaces.iter().map(|workspace| workspace.slug.as_str()).collect(); | |
| 154 | match slugs.as_slice() { | |
| 155 | [] => None, | |
| 156 | [one] => Some(format!( | |
| 157 | "You are the only owner of {one}. Make someone else an owner of it, or delete it, first." | |
| 158 | )), | |
| 159 | many => Some(format!( | |
| 160 | "You are the only owner of {}. Make someone else an owner of each, or delete them, first.", | |
| 161 | list(many) | |
| 162 | )), | |
| 163 | } | |
| 164 | } | |
| 165 | ||
| 166 | /// `a`, `a and b`, `a, b and c`. | |
| 167 | fn list(items: &[&str]) -> String { | |
| 168 | match items { | |
| 169 | [] => String::new(), | |
| 170 | [one] => (*one).to_owned(), | |
| 171 | [rest @ .., last] => format!("{} and {last}", rest.join(", ")), | |
| 172 | } | |
| 173 | } | |
| 174 | ||
| 175 | /// Whether what was typed confirms `username`: the username itself, in | |
| 176 | /// any case, without the spaces around it. | |
| 177 | pub fn confirms_username(username: &str, typed: &str) -> bool { | |
| 178 | let typed = typed.trim(); | |
| 179 | !typed.is_empty() && typed.eq_ignore_ascii_case(username.trim()) | |
| 180 | } | |
| 181 | ||
| 182 | /// `delete_account`: the person deletes their own account. `confirm` is | |
| 183 | /// their username typed out; `reauth` is proof it is them. Refused for | |
| 184 | /// anyone but the person themselves (never a token's or an agent's), for a | |
| 185 | /// protected account, and while they are the only owner of a live | |
| 186 | /// workspace. Publishes `user.deleting`. Returns `Outcome<bool>`. | |
| 187 | #[derive(Debug, Serialize, Deserialize)] | |
| 188 | pub struct DeleteAccountArgs { | |
| 189 | pub user: User, | |
| 190 | #[serde(default)] | |
| 191 | pub confirm: String, | |
| 192 | #[serde(default)] | |
| 193 | pub reauth: Reauth, | |
| 194 | } | |
| 195 | ||
| 196 | /// What went with a deleted account, counted when it was deleted, and who | |
| 197 | /// deleted it when it was staff. | |
| 198 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 199 | #[serde(rename_all = "camelCase")] | |
| 200 | pub struct AccountWent { | |
| 201 | pub workspaces: u32, | |
| 202 | pub teams: u32, | |
| 203 | pub repositories: u32, | |
| 204 | pub tokens: u32, | |
| 205 | pub ssh_keys: u32, | |
| 206 | /// The staff member who deleted it, by email; null when the person did. | |
| 207 | #[serde(default)] | |
| 208 | pub staff: Option<String>, | |
| 209 | /// Why staff deleted it. | |
| 210 | #[serde(default)] | |
| 211 | pub reason: Option<String>, | |
| Staff delete an account with the workspaces it alone owns | 212 | /// The workspaces it was the only owner of that staff deleted with it, |
| 213 | /// in the same request (`with_sole_workspaces`). Each is deleted the | |
| 214 | /// way an owner deletes one, and restored or purged on its own. | |
| 215 | #[serde(default)] | |
| 216 | pub deleted_workspaces: Vec<WorkspaceDeletedWith>, | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 217 | } |
| 218 | ||
| 219 | /// An account deleted and kept until `purge_after` for staff to restore. | |
| 220 | /// `admin_deleted_accounts` takes no arguments (`{}`) and returns | |
| 221 | /// `Vec<DeletedAccount>`, newest first. Staff only. | |
| 222 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 223 | #[serde(rename_all = "camelCase")] | |
| 224 | pub struct DeletedAccount { | |
| 225 | pub user_id: String, | |
| 226 | pub username: String, | |
| 227 | /// RFC 3339. | |
| 228 | pub deleted_at: String, | |
| 229 | /// RFC 3339: when it is purged unless restored first. | |
| 230 | pub purge_after: String, | |
| 231 | pub went: AccountWent, | |
| 232 | /// Whether staff can still restore it. | |
| 233 | pub restorable: bool, | |
| 234 | } | |
| 235 | ||
| 236 | /// `admin_delete_account`: staff delete an account, with a reason (kept in | |
| 237 | /// sudo's audit log and the account's record). `confirm` is the username | |
| Staff delete an account with the workspaces it alone owns | 238 | /// typed out. Refused for a protected account, and while it is the only |
| 239 | /// owner of a live workspace unless `with_sole_workspaces`. Returns | |
| 240 | /// `Outcome<bool>`. | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 241 | #[derive(Debug, Serialize, Deserialize)] |
| 242 | #[serde(rename_all = "camelCase")] | |
| 243 | pub struct AdminDeleteAccountArgs { | |
| 244 | pub username: String, | |
| 245 | pub reason: String, | |
| 246 | #[serde(default)] | |
| 247 | pub confirm: String, | |
| 248 | /// The staff member, by email. | |
| 249 | pub staff: String, | |
| Staff delete an account with the workspaces it alone owns | 250 | /// Delete the workspaces the account is the only owner of first, each |
| 251 | /// exactly as its owner would (billing closes it, `workspace.deleting`, | |
| 252 | /// its audit log), with the staff member as `deleted_by`, then the | |
| 253 | /// account. Refused whole, deleting nothing, while any of them is | |
| 254 | /// protected or its billing cannot settle | |
| 255 | /// ([`staff_sole_owner_refusal`]). Should one fail on the way (a card | |
| 256 | /// declined that moment), the account is not deleted, and the failure | |
| 257 | /// says which workspace, and which went before it. | |
| 258 | #[serde(default)] | |
| 259 | pub with_sole_workspaces: bool, | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 260 | } |
| 261 | ||
| 262 | /// `admin_restore_account` and `admin_purge_account`: staff restore a | |
| 263 | /// deleted account within [`ACCOUNT_RESTORE_DAYS`], or purge it now. | |
| 264 | /// Purging needs `confirm`, the username typed out. Restoring publishes | |
| 265 | /// `user.restored`; purging, `user.deleted`. Both return `Outcome<bool>`. | |
| 266 | #[derive(Debug, Serialize, Deserialize)] | |
| 267 | #[serde(rename_all = "camelCase")] | |
| 268 | pub struct AdminDeletedAccountArgs { | |
| 269 | pub user_id: String, | |
| 270 | pub staff: String, | |
| 271 | #[serde(default)] | |
| 272 | pub confirm: String, | |
| 273 | } | |
| 274 | ||
| 275 | #[cfg(test)] | |
| 276 | mod tests { | |
| 277 | use super::*; | |
| 278 | use crate::identity::protected_names; | |
| 279 | ||
| 280 | fn sole(slug: &str) -> SoleOwnedWorkspace { | |
| Staff delete an account with the workspaces it alone owns | 281 | SoleOwnedWorkspace { slug: slug.into(), name: slug.into(), members: 1, billing: None, protected: false } |
| 282 | } | |
| 283 | ||
| 284 | #[test] | |
| 285 | fn staff_are_told_each_workspace_that_cannot_go_with_the_account() { | |
| 286 | assert_eq!(staff_sole_owner_refusal(&[]), None); | |
| 287 | assert_eq!(staff_sole_owner_refusal(&[sole("acme"), sole("globex")]), None); | |
| 288 | let protected = SoleOwnedWorkspace { protected: true, ..sole("flagon-io") }; | |
| 289 | let owing = SoleOwnedWorkspace { billing: Some("globex has an unpaid invoice.".into()), ..sole("globex") }; | |
| 290 | assert_eq!( | |
| 291 | staff_sole_owner_refusal(&[sole("acme"), protected, owing]).unwrap(), | |
| 292 | "Nothing was deleted. flagon-io is protected and can never be deleted. globex has an unpaid invoice." | |
| 293 | ); | |
| 294 | } | |
| 295 | ||
| 296 | #[test] | |
| 297 | fn older_records_and_requests_read_without_the_new_fields() { | |
| 298 | let old: AccountWent = | |
| 299 | serde_json::from_str(r#"{"workspaces":1,"teams":0,"repositories":0,"tokens":0,"sshKeys":0}"#).unwrap(); | |
| 300 | assert!(old.deleted_workspaces.is_empty()); | |
| 301 | let args: AdminDeleteAccountArgs = | |
| 302 | serde_json::from_str(r#"{"username":"ada","reason":"r","staff":"s","withSoleWorkspaces":true}"#).unwrap(); | |
| 303 | assert!(args.with_sole_workspaces); | |
| 304 | let args: AdminDeleteAccountArgs = serde_json::from_str(r#"{"username":"ada","reason":"r","staff":"s"}"#).unwrap(); | |
| 305 | assert!(!args.with_sole_workspaces); | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 306 | } |
| 307 | ||
| 308 | #[test] | |
| 309 | fn g1t_ghost_and_named_accounts_are_protected() { | |
| 310 | let names = protected_names(Some("usr_keep, Ada")); | |
| 311 | for (id, username) in [ | |
| 312 | ("usr_1", "g1t"), | |
| 313 | ("usr_1", "G1T-Agent"), | |
| 314 | ("usr_1", "ghost"), | |
| 315 | (GHOST_ID, "anything"), | |
| 316 | ("usr_keep", "someone"), | |
| 317 | ("usr_2", "ada"), | |
| 318 | ] { | |
| 319 | assert!(is_protected_account(&names, id, username), "{id} {username}"); | |
| 320 | } | |
| 321 | assert!(!is_protected_account(&names, "usr_3", "grace")); | |
| 322 | assert!(!is_protected_account(&protected_names(None), "usr_3", "ghosts")); | |
| 323 | } | |
| 324 | ||
| 325 | #[test] | |
| 326 | fn the_only_owner_of_a_workspace_is_told_which() { | |
| 327 | assert_eq!(sole_owner_refusal(&[]), None); | |
| 328 | assert_eq!( | |
| 329 | sole_owner_refusal(&[sole("acme")]).unwrap(), | |
| 330 | "You are the only owner of acme. Make someone else an owner of it, or delete it, first." | |
| 331 | ); | |
| 332 | assert_eq!( | |
| 333 | sole_owner_refusal(&[sole("acme"), sole("globex"), sole("initech")]).unwrap(), | |
| 334 | "You are the only owner of acme, globex and initech. Make someone else an owner of each, or delete them, first." | |
| 335 | ); | |
| 336 | } | |
| 337 | ||
| 338 | #[test] | |
| 339 | fn protection_comes_before_ownership() { | |
| 340 | let deletion = AccountDeletion { | |
| 341 | username: "g1t".into(), | |
| 342 | sole_owner_of: vec![sole("acme")], | |
| 343 | protected: true, | |
| 344 | ..AccountDeletion::default() | |
| 345 | }; | |
| 346 | assert_eq!(deletion.reason().unwrap(), "g1t is protected and can never be deleted."); | |
| 347 | assert!(deletion.blocked()); | |
| 348 | let free = AccountDeletion { username: "ada".into(), ..AccountDeletion::default() }; | |
| 349 | assert!(!free.blocked()); | |
| 350 | } | |
| 351 | ||
| 352 | #[test] | |
| 353 | fn only_the_username_itself_confirms() { | |
| 354 | assert!(confirms_username("ada", " Ada ")); | |
| 355 | assert!(!confirms_username("ada", "")); | |
| 356 | assert!(!confirms_username("ada", "ada-l")); | |
| 357 | assert!(!confirms_username("ada", " ")); | |
| 358 | } | |
| 359 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.