Skip to content
280 linesCodeBlameRaw
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
23use serde::{Deserialize, Serialize};
24
25use crate::User;
26use crate::accounts::Reauth;
27
28/// How long a deleted account is kept, for g1t's staff to restore, before
29/// it is purged.
30pub 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.
35pub 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.
39pub 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`).
46pub 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.
52pub 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)]
60pub 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)]
77pub 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
97impl 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.
113pub 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`.
128fn 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.
138pub 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)]
149pub 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")]
161pub 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")]
180pub 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")]
198pub 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")]
213pub struct AdminDeletedAccountArgs {
214 pub user_id: String,
215 pub staff: String,
216 #[serde(default)]
217 pub confirm: String,
218}
219
220#[cfg(test)]
221mod 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}