Skip to content
359 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)1//! Deleting an account: 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 owns9//! 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
27use serde::{Deserialize, Serialize};
28
29use crate::User;
30use crate::accounts::Reauth;
31
32/// How long a deleted account is kept, for g1t's staff to restore, before
33/// it is purged.
34pub 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.
39pub 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.
43pub 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`).
50pub 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.
56pub 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)]
64pub 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 owns71 /// 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 owns74 /// 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 owns80impl 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.
93pub 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")]
106pub 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)]
116pub 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
136impl 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.
152pub 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`.
167fn 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.
177pub 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)]
188pub 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")]
200pub 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 owns212 /// 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")]
224pub 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 owns238/// 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")]
243pub 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 owns250 /// 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")]
268pub struct AdminDeletedAccountArgs {
269 pub user_id: String,
270 pub staff: String,
271 #[serde(default)]
272 pub confirm: String,
273}
274
275#[cfg(test)]
276mod 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 owns281 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.