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