Skip to content

Commit

Staff delete an account with the workspaces it alone owns

sudo's Delete account no longer only refuses an account that is the only owner of workspaces: it lists them and offers Delete account and the workspaces it alone owns (reason, username typed, a box ticked naming them). Identity's admin_delete_account gains with_sole_workspaces: every such workspace is checked first, and one that is protected or whose billing cannot settle refuses the whole request, deleting nothing, with each reason shown beside it in sudo. Then each workspace is soft-deleted exactly as an owner's deletion does (billing closes it, workspace.deleting, its audit log as g1t with rule staff, deleted_by the staff member, a workspace_deleted line in sudo's log with the reason), and the account last; a workspace failing on the way stops before the account and names it and any that went before. The account's record keeps the workspaces deleted with it, and its sudo page lists them with their own Purge now (admin_purge_workspace, only while still deleted, never protected), so staff can remove everything at once. Contracts, sudo README and the account guide say how staff handle sole owners.

syntaqxcommitted Parentb395f99Browse files
15 files+1026−1520/15 viewed
+1−1
5959 | `package.linked`, `package.unlinked` | A package was linked to a repository (from its settings, or by an image's source label), or unlinked. |
6060 | `workspace.residency_changed` | An owner changed where the workspace's new repositories are stored. See [data residency](/guides/workspaces/#data-residency). |
6161 | `account.deleted`, `account.deleted_by_staff` | A member [deleted their account](/guides/authentication/#deleting-your-account), or g1t's staff deleted it, and so left the workspace. Recorded in each of their workspaces. |
62−| `workspace.deleted`, `workspace.restored`, `workspace.purged` | An owner deleted the workspace, g1t's support restored it, or it was removed for good. See [deleting a workspace](/guides/workspaces/#delete-a-workspace). |
62+| `workspace.deleted`, `workspace.restored`, `workspace.purged` | An owner deleted the workspace (or g1t's staff did, with the [deleted account](/guides/authentication/#what-stands-in-the-way) that was its only owner), g1t's support restored it, or it was removed for good. See [deleting a workspace](/guides/workspaces/#delete-a-workspace). |
6363
6464 Through the API and the MCP server, the call itself is recorded under its
6565 operation's name too, such as `delete_repo`. See
+11−0
994994 Billing belongs to workspaces, not to accounts, so once no workspace
995995 depends on you alone there is nothing for billing to settle.
996996
997+When g1t's staff delete an account, on its owner's request or for abuse,
998+the workspaces it alone owns are not left without an owner. Staff either
999+wait for another owner to be made, or delete those workspaces together
1000+with the account, each exactly as its owner would: its billing is settled
1001+first, everything in it goes with it, and it is kept 30 days for a
1002+restore like any deleted workspace. Its audit log records the deletion as
1003+g1t's staff. Staff never do this for a workspace whose billing cannot be
1004+settled yet (an unpaid invoice, prepaid credit, usage still being metered),
1005+or for one of the workspaces g1t protects; if any of them stands in the
1006+way, nothing is deleted.
1007+
9971008 ### What happens
9981009
9991010 At once, when you delete it:
+30−7
7979 account from Settings does: signs them out everywhere, ends their tokens,
8080 SSH keys, deploy keys they added and applications, takes them out of
8181 every workspace, team and repository, and emails their addresses that
82− staff deleted it. It is refused while the account is the **only owner of
83− a live workspace**: the page lists those workspaces instead of the form,
84− each linking to its page. Each needs another owner first (an owner makes
85− one under People), or to be deleted by its owner, which settles its
86− billing; staff never delete a customer's workspace to get an account
87− out. Accounts that can never be deleted (`g1t`, `g1t-agent`, `ghost`,
82+ staff deleted it. While the account is the **only owner of a live
83+ workspace**, the page lists those workspaces, each linking to its page,
84+ and the form becomes **Delete account and the workspaces it alone
85+ owns**: the reason, the username typed, and a box ticked to say the
86+ named workspaces go too (`admin_delete_account` with
87+ `withSoleWorkspaces`). Use it for an account g1t no longer needs, such
88+ as a retired test account and its personal workspace; for a customer,
89+ prefer another owner first (an owner makes one under People). Identity
90+ checks every one of those workspaces before anything is deleted: one
91+ that is protected, or whose billing cannot settle (`close_workspace`:
92+ an unpaid invoice, prepaid credit, usage still metering, an enterprise
93+ account), refuses the whole deletion, and the page says which and why
94+ beside each, instead of the form. Then it deletes each workspace exactly
95+ as its owner would (billing closes it, `workspace.deleting`, its
96+ repositories and apps go with it, kept 30 days), with the staff member
97+ as who deleted it, and the account last. Each workspace is recorded in
98+ its own audit log as g1t (rule `staff`) and in sudo's
99+ (`workspace_deleted`, with the reason); the account in sudo's
100+ (`account_deleted`, naming the workspaces). Should one fail on the way
101+ (a card declined that moment), the account is not deleted and the
102+ error names the workspace and any that went before; restore those from
103+ Deleted workspaces, or try again. Accounts that can never be deleted (`g1t`, `g1t-agent`, `ghost`,
88104 and whatever identity's `PROTECTED_ACCOUNTS` names, by username or id)
89105 are marked **Protected** and offer no form. A deleted account's page
90106 says so, with who deleted it, why, when it is purged, and **Restore** and
91− **Purge now**, as on Deleted accounts.
107+ **Purge now**, as on Deleted accounts; both work at once, with no wait.
108+ When workspaces were deleted with it, it lists them too, each with its
109+ own **Purge now** (`admin_purge_workspace`, the slug typed; identity
110+ purges only a workspace that is still deleted, never a protected one),
111+ so staff can remove everything straight away. Purge the workspaces
112+ first: once the account is purged its page is gone (they stay on
113+ Deleted workspaces). To undo it all, restore the account first, then
114+ each workspace, so it comes back with its owner.
92115 - **Deleted accounts** (`/users/deleted`, linked from Workspaces):
93116 accounts deleted by the person or by staff, newest first
94117 (`admin_deleted_accounts`), each with who deleted it (the person, or the
+73−11
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { accountWentSummary, confirmsUsername, staffDeletionRefusal } from "./deleted-accounts.ts";
4+import {
5+ accountWentSummary,
6+ confirmsUsername,
7+ deletedWithAccount,
8+ soleOwnerNote,
9+ soleWorkspacesRefusal,
10+ staffDeleteProblem,
11+ staffDeletionRefusal,
12+} from "./deleted-accounts.ts";
513
614 const deletion = {
715 username: "ada",
1422 protected: false,
1523 };
1624
25+const sole = (slug: string) => ({ slug, name: slug, members: 1, billing: null, protected: false });
26+
27+const went = { workspaces: 2, teams: 1, repositories: 3, tokens: 1, sshKeys: 2, staff: null, reason: null, deletedWorkspaces: [] };
28+
1729 test("what went reads as one line", () => {
18− assert.equal(
19− accountWentSummary({ workspaces: 2, teams: 1, repositories: 3, tokens: 1, sshKeys: 2, staff: null, reason: null }),
20− "2 workspaces, 1 team, 3 repositories, 1 token, 2 SSH keys",
21− );
30+ assert.equal(accountWentSummary(went), "2 workspaces, 1 team, 3 repositories, 1 token, 2 SSH keys");
2231 });
2332
24−test("an account that owns workspaces alone, or is protected, is refused", () => {
33+test("only a protected account is refused outright", () => {
2534 assert.equal(staffDeletionRefusal(deletion), null);
26− const owner = { ...deletion, sole_owner_of: [{ slug: "acme", name: "Acme", members: 3, billing: null }] };
35+ const owner = { ...deletion, sole_owner_of: [sole("acme")] };
36+ assert.equal(staffDeletionRefusal(owner), null);
2737 assert.equal(
28− staffDeletionRefusal(owner),
29− "ada is the only owner of 1 workspace. Each needs another owner, or to be deleted by its owner, first.",
38+ staffDeletionRefusal({ ...owner, username: "g1t", protected: true }),
39+ "g1t is protected and can never be deleted.",
3040 );
41+});
42+
43+test("workspaces it owns alone go with it, said up front", () => {
44+ assert.equal(soleOwnerNote(deletion), null);
45+ assert.match(soleOwnerNote({ ...deletion, sole_owner_of: [sole("ada")] })!, /^ada is the only owner of 1 workspace\. Deleting the account deletes it first/);
46+ assert.match(soleOwnerNote({ ...deletion, sole_owner_of: [sole("a"), sole("b")] })!, /of 2 workspaces\. Deleting the account deletes them first/);
47+ assert.equal(soleWorkspacesRefusal({ ...deletion, sole_owner_of: [sole("ada")] }), null);
48+});
49+
50+test("a protected workspace or billing that cannot settle stops it, naming which", () => {
51+ const owner = {
52+ ...deletion,
53+ sole_owner_of: [
54+ sole("ada"),
55+ { ...sole("flagon-io"), protected: true },
56+ { ...sole("ada-labs"), billing: "ada-labs has an unpaid invoice. Pay it from the workspace's Billing page first." },
57+ ],
58+ };
3159 assert.equal(
32− staffDeletionRefusal({ ...owner, username: "g1t", protected: true }),
33− "g1t is protected and can never be deleted.",
60+ soleWorkspacesRefusal(owner),
61+ "ada cannot be deleted with its workspaces yet. flagon-io is protected and can never be deleted. ada-labs has an unpaid invoice. Pay it from the workspace's Billing page first.",
3462 );
3563 });
3664
3967 assert.ok(!confirmsUsername("ada", ""));
4068 assert.ok(!confirmsUsername("ada", "grace"));
4169 });
70+
71+test("the form needs a reason, the username, and the box ticked when workspaces go too", () => {
72+ const form = { username: "ada", reason: "Retired test account", confirm: "ada", withWorkspaces: false, acknowledged: false };
73+ assert.equal(staffDeleteProblem(form), null);
74+ assert.equal(staffDeleteProblem({ ...form, reason: "" }), "Say why the account is being deleted.");
75+ assert.equal(staffDeleteProblem({ ...form, confirm: "grace" }), "Type ada to confirm.");
76+ assert.equal(
77+ staffDeleteProblem({ ...form, withWorkspaces: true }),
78+ "Tick the box to say the workspaces it alone owns are deleted too.",
79+ );
80+ assert.equal(staffDeleteProblem({ ...form, withWorkspaces: true, acknowledged: true }), null);
81+});
82+
83+test("workspaces deleted with the account are matched to their deletions", () => {
84+ const waiting = {
85+ workspaceId: "wsp_1",
86+ slug: "ada",
87+ name: "Ada",
88+ deletedAt: "2026-10-08T00:00:00.000Z",
89+ deletedBy: "staff@g1t.sh",
90+ purgeAfter: "2026-11-07T00:00:00.000Z",
91+ went: { repositories: 1, projects: 0, members: 1, billing: null, protected: false },
92+ restorable: true,
93+ };
94+ const gone = deletedWithAccount(
95+ { ...went, deletedWorkspaces: [{ workspaceId: "wsp_1", slug: "ada" }, { workspaceId: "wsp_2", slug: "ada-labs" }] },
96+ [waiting],
97+ );
98+ assert.deepEqual(gone, [
99+ { workspaceId: "wsp_1", slug: "ada", deleted: waiting },
100+ { workspaceId: "wsp_2", slug: "ada-labs", deleted: null },
101+ ]);
102+ assert.deepEqual(deletedWithAccount(went, [waiting]), []);
103+});
+61−5
44 * and what staff type to confirm. Identity checks all of it again. No
55 * Workers imports, so it can be tested under Node.
66 */
7−import type { AccountDeletion, AccountWent } from "@g1t/contracts";
7+import type { AccountDeletion, AccountWent, DeletedWorkspace, SoleOwnedWorkspace, WorkspaceDeletedWith } from "@g1t/contracts";
88
99 const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
1010
1919 ].join(", ");
2020 }
2121
22−/** Why staff cannot delete the account, in a sentence about it, or null. */
22+/**
23+ * Why staff cannot delete the account at all, in a sentence about it, or
24+ * null. Owning workspaces alone is not one: staff delete them with it.
25+ */
2326 export function staffDeletionRefusal(deletion: AccountDeletion): string | null {
2427 if (deletion.protected) return `${deletion.username} is protected and can never be deleted.`;
25− const slugs = deletion.sole_owner_of.map((workspace) => workspace.slug);
26− if (slugs.length === 0) return null;
27− return `${deletion.username} is the only owner of ${slugs.length === 1 ? "1 workspace" : `${slugs.length} workspaces`}. Each needs another owner, or to be deleted by its owner, first.`;
28+ return null;
29+}
30+
31+/** What the workspaces the account owns alone mean for deleting it, in a sentence, or null when it owns none. */
32+export function soleOwnerNote(deletion: AccountDeletion): string | null {
33+ const count = deletion.sole_owner_of.length;
34+ if (count === 0) return null;
35+ return `${deletion.username} is the only owner of ${count === 1 ? "1 workspace" : `${count} workspaces`}. Deleting the account deletes ${count === 1 ? "it" : "them"} first, each as its owner would: billing closes it, and it is kept for a restore like any deleted workspace.`;
36+}
37+
38+/** Why staff cannot delete this workspace with the account, or null. */
39+export function soleWorkspaceRefusal(workspace: SoleOwnedWorkspace): string | null {
40+ if (workspace.protected) return `${workspace.slug} is protected and can never be deleted.`;
41+ return workspace.billing;
42+}
43+
44+/**
45+ * Why staff cannot delete the account together with the workspaces it owns
46+ * alone, naming each that stands in the way, or null when every one can go.
47+ * Identity says the same, and deletes nothing, when asked anyway.
48+ */
49+export function soleWorkspacesRefusal(deletion: AccountDeletion): string | null {
50+ const reasons = deletion.sole_owner_of.map(soleWorkspaceRefusal).filter((reason): reason is string => reason !== null);
51+ if (reasons.length === 0) return null;
52+ return `${deletion.username} cannot be deleted with its workspaces yet. ${reasons.join(" ")}`;
2853 }
2954
3055 /** Whether what staff typed is the username. Identity checks it again. */
3257 const value = typed.trim();
3358 return value !== "" && value.toLowerCase() === username.toLowerCase();
3459 }
60+
61+/** What staff sent to delete an account. */
62+export type StaffDeleteForm = {
63+ username: string;
64+ reason: string;
65+ confirm: string;
66+ /** The form that deletes the workspaces it owns alone too. */
67+ withWorkspaces: boolean;
68+ /** The box saying those workspaces go too, ticked. */
69+ acknowledged: boolean;
70+};
71+
72+/** What is wrong with the form to delete an account, or null. Identity checks it all again. */
73+export function staffDeleteProblem(form: StaffDeleteForm): string | null {
74+ if (!form.reason) return "Say why the account is being deleted.";
75+ if (!confirmsUsername(form.username, form.confirm)) return `Type ${form.username} to confirm.`;
76+ if (form.withWorkspaces && !form.acknowledged) return "Tick the box to say the workspaces it alone owns are deleted too.";
77+ return null;
78+}
79+
80+/** A workspace deleted with the account, as its page shows it: still waiting to be purged, or gone. */
81+export type DeletedWithAccount = WorkspaceDeletedWith & {
82+ /** Its deletion, while it waits to be purged; null once it is purged or restored. */
83+ deleted: DeletedWorkspace | null;
84+};
85+
86+/** The workspaces deleted with an account, each matched to its deletion by id. */
87+export function deletedWithAccount(went: AccountWent, deleted: DeletedWorkspace[]): DeletedWithAccount[] {
88+ const byId = new Map(deleted.map((workspace) => [workspace.workspaceId, workspace]));
89+ return (went.deletedWorkspaces ?? []).map((workspace) => ({ ...workspace, deleted: byId.get(workspace.workspaceId) ?? null }));
90+}
+242−40
11 import { ArrowLeft } from "lucide-react";
22 import { Form, Link, data, redirect } from "react-router";
33
4−import { ACCOUNT_RESTORE_DAYS, type AdminUser, securityEventLabel } from "@g1t/contracts";
4+import { ACCOUNT_RESTORE_DAYS, type AdminUser, WORKSPACE_RESTORE_DAYS, securityEventLabel } from "@g1t/contracts";
55
66 import type { Route } from "./+types/user";
77 import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, When } from "~/components/ui";
8−import { accountWentSummary, confirmsUsername, staffDeletionRefusal } from "~/lib/deleted-accounts";
9−import { daysLeft } from "~/lib/deleted-workspaces";
8+import {
9+ type DeletedWithAccount,
10+ accountWentSummary,
11+ confirmsUsername,
12+ deletedWithAccount,
13+ soleOwnerNote,
14+ soleWorkspaceRefusal,
15+ soleWorkspacesRefusal,
16+ staffDeleteProblem,
17+ staffDeletionRefusal,
18+} from "~/lib/deleted-accounts";
19+import { confirmsPurge, daysLeft } from "~/lib/deleted-workspaces";
1020 import { text } from "~/lib/forms";
11−import { accountsAdmin } from "~/lib/services.server";
21+import { accountsAdmin, identity } from "~/lib/services.server";
1222 import { settle } from "~/lib/settle";
1323 import { requireStaff } from "~/lib/staff";
1424
2333 if (result.ok && !result.value) throw data("No such account.", { status: 404 });
2434 const url = new URL(request.url);
2535 const done = url.searchParams.get("done");
36+ const slug = url.searchParams.get("slug") ?? "";
37+ // The workspaces staff deleted with it, each with its deletion while it
38+ // waits to be purged.
39+ const user = result.ok ? result.value : null;
40+ const went = user?.deleted?.went;
41+ let workspaces: DeletedWithAccount[] = [];
42+ let workspacesError: string | null = null;
43+ if (went && (went.deletedWorkspaces ?? []).length > 0) {
44+ const deleted = await settle(identity.deletedWorkspaces());
45+ workspaces = deletedWithAccount(went, deleted.ok ? deleted.value : []);
46+ workspacesError = deleted.ok ? null : deleted.error;
47+ }
48+ const messages: Record<string, string> = {
49+ deleted: "Deleted the account.",
50+ "deleted-with-workspaces": "Deleted the account and the workspaces it alone owned.",
51+ restored: "Restored the account.",
52+ "workspace-purged": `Purged ${slug}.`,
53+ };
2654 return {
27− user: result.ok ? result.value : null,
55+ user,
2856 error: result.ok ? null : result.error,
2957 removed: url.searchParams.get("removed"),
30− done: done === "deleted" ? "Deleted the account." : done === "restored" ? "Restored the account." : null,
58+ done: done ? (messages[done] ?? null) : null,
59+ workspaces,
60+ workspacesError,
3161 now: Date.now(),
3262 };
3363 }
3464
3565 /**
3666 * Removes an address (the reason is required, recorded and shown to the
37− * person), or deletes, restores or purges the account. Identity checks
38− * each again: protection, the workspaces it owns alone, the typed
39− * username, the restore window.
67+ * person), or deletes, restores or purges the account, or purges a
68+ * workspace deleted with it. Identity checks each again: protection, the
69+ * workspaces it owns alone and whether they can go, the typed username or
70+ * slug, the restore window.
4071 */
4172 export async function action({ params, request, context }: Route.ActionArgs) {
4273 const staff = requireStaff(context);
4677 if (intent === "delete-account") {
4778 const reason = text(form, "reason");
4879 const confirm = text(form, "confirm");
49− if (!reason) return data({ error: "Say why the account is being deleted.", account: true }, { status: 422 });
50− if (!confirmsUsername(params.username, confirm)) {
51− return data({ error: `Type ${params.username} to confirm.`, account: true }, { status: 422 });
52− }
53− const result = await accountsAdmin.deleteAccount(params.username, reason, confirm, staff.email);
80+ const withWorkspaces = text(form, "with-workspaces") === "1";
81+ const problem = staffDeleteProblem({
82+ username: params.username,
83+ reason,
84+ confirm,
85+ withWorkspaces,
86+ acknowledged: text(form, "acknowledge") === "on",
87+ });
88+ if (problem) return data({ error: problem, account: true }, { status: 422 });
89+ const result = await accountsAdmin.deleteAccount(params.username, reason, confirm, staff.email, withWorkspaces);
5490 if (!result.ok) return data({ error: result.error.message, account: true }, { status: 422 });
55− throw back("deleted");
91+ throw back(withWorkspaces ? "deleted-with-workspaces" : "deleted");
5692 }
93+ if (intent === "purge-workspace") {
94+ const id = text(form, "id");
95+ const slug = text(form, "slug");
96+ const confirm = text(form, "confirm");
97+ if (!confirmsPurge(slug, confirm)) return data({ error: `Type ${slug} to confirm.`, workspace: id }, { status: 422 });
98+ const result = await identity.purgeWorkspace(id, staff.email, confirm);
99+ if (!result.ok) return data({ error: result.error.message, workspace: id }, { status: 422 });
100+ throw redirect(`/users/${encodeURIComponent(params.username)}?done=workspace-purged&slug=${encodeURIComponent(slug)}`);
101+ }
57102 if (intent === "restore-account") {
58103 const result = await accountsAdmin.restoreAccount(text(form, "id"), staff.email);
59104 if (!result.ok) return data({ error: result.error.message, account: true }, { status: 422 });
77122 }
78123
79124 export default function User({ loaderData, actionData }: Route.ComponentProps) {
80− const { user, error, removed, done, now } = loaderData;
125+ const { user, error, removed, done, workspaces, workspacesError, now } = loaderData;
81126 const accountError = actionData && "account" in actionData ? actionData.error : null;
127+ const workspaceError = actionData && "workspace" in actionData ? { id: actionData.workspace, error: actionData.error } : null;
82128 if (!user) {
83129 return (
84130 <main className="mx-auto max-w-4xl px-4 py-8 sm:py-10">
183229 )}
184230 </Section>
185231
186− <AccountSection user={user} error={accountError} now={now} />
232+ <AccountSection
233+ user={user}
234+ error={accountError}
235+ workspaces={workspaces}
236+ workspacesError={workspacesError}
237+ workspaceError={workspaceError}
238+ now={now}
239+ />
187240 </main>
188241 );
189242 }
190243
191244 /**
192− * Deleting the account, or, once it is deleted, restoring or purging it.
193− * Every form is plain HTML: sudo ships no JavaScript.
245+ * Deleting the account, or, once it is deleted, restoring or purging it
246+ * and the workspaces deleted with it. Every form is plain HTML: sudo ships
247+ * no JavaScript.
194248 */
195−function AccountSection({ user, error, now }: { user: AdminUser; error: string | null; now: number }) {
249+function AccountSection({
250+ user,
251+ error,
252+ workspaces,
253+ workspacesError,
254+ workspaceError,
255+ now,
256+}: {
257+ user: AdminUser;
258+ error: string | null;
259+ workspaces: DeletedWithAccount[];
260+ workspacesError: string | null;
261+ workspaceError: { id: string; error: string } | null;
262+ now: number;
263+}) {
196264 const deleted = user.deleted;
197265 if (deleted) {
198266 const left = daysLeft(deleted.purgeAfter, now);
200268 <Section
201269 id="account"
202270 title="Deleted account"
203− description={`Deleted ${deleted.went.staff ? `by ${deleted.went.staff}` : "by the person"}. Kept ${ACCOUNT_RESTORE_DAYS} days for a restore, then purged.`}
271+ description={`Deleted ${deleted.went.staff ? `by ${deleted.went.staff}` : "by the person"}. Kept ${ACCOUNT_RESTORE_DAYS} days for a restore, then purged. Staff can purge it now.`}
204272 className="mt-6"
205273 >
206274 <div className="space-y-3 text-sm">
218286 </p>
219287 {deleted.went.reason && <p className="text-muted">Reason: {deleted.went.reason}</p>}
220288 <p className="text-muted">Left: {accountWentSummary(deleted.went)}</p>
289+ {workspaces.length > 0 && (
290+ <DeletedWorkspaces workspaces={workspaces} error={workspacesError} workspaceError={workspaceError} now={now} />
291+ )}
221292 {error && <Notice tone="error">{error}</Notice>}
222293 <div className="flex flex-col gap-3 border-t border-line pt-4 sm:flex-row sm:items-end sm:justify-between">
223294 <form method="post">
250321 );
251322 }
252323 const refusal = staffDeletionRefusal(user.deletion);
324+ const sole = user.deletion.sole_owner_of;
325+ const blocked = soleWorkspacesRefusal(user.deletion);
253326 return (
254327 <Section
255328 id="account"
256329 title="Delete account"
257− description={`Signs it out everywhere, ends its tokens and keys, and takes it out of every workspace. Kept ${ACCOUNT_RESTORE_DAYS} days for a restore, then purged; its username is never given out again. Only when the person asks, or for abuse, with a reason.`}
330+ description={`Signs it out everywhere, ends its tokens and keys, and takes it out of every workspace. Kept ${ACCOUNT_RESTORE_DAYS} days for a restore, then purged; its username is never given out again. Only when the person asks, for abuse, or for an account g1t no longer uses, with a reason.`}
258331 className="mt-6"
259332 >
260333 {refusal ? (
261− <div className="space-y-3 text-sm">
262− <Notice tone="warn">{refusal}</Notice>
263− {user.deletion.sole_owner_of.length > 0 && (
264− <ul className="divide-y divide-line rounded-md border border-line">
265− {user.deletion.sole_owner_of.map((workspace) => (
266− <li key={workspace.slug} className="flex flex-wrap items-center justify-between gap-2 px-4 py-2">
267− <Link to={`/workspaces/${workspace.slug}`} className="text-fg hover:underline">
268− {workspace.name} <span className="font-mono text-xs text-muted">{workspace.slug}</span>
269− </Link>
270− <span className="text-xs text-faint">
271− {workspace.members} member{workspace.members === 1 ? "" : "s"}
272− </span>
273− </li>
274− ))}
275− </ul>
276− )}
277− </div>
278− ) : (
334+ <Notice tone="warn">{refusal}</Notice>
335+ ) : sole.length === 0 ? (
279336 <details className="rounded-md border border-danger/30 px-3 py-2" open={Boolean(error)}>
280337 <summary className="cursor-pointer text-sm text-danger">Delete this account</summary>
281338 <form method="post" className="mt-3 grid gap-3 sm:max-w-md">
294351 </div>
295352 </form>
296353 </details>
354+ ) : (
355+ <div className="space-y-3 text-sm">
356+ <Notice tone="warn">{soleOwnerNote(user.deletion)}</Notice>
357+ <ul className="divide-y divide-line rounded-md border border-line">
358+ {sole.map((workspace) => {
359+ const why = soleWorkspaceRefusal(workspace);
360+ return (
361+ <li key={workspace.slug} className="space-y-1 px-4 py-2">
362+ <div className="flex flex-wrap items-center justify-between gap-2">
363+ <Link to={`/workspaces/${workspace.slug}`} className="text-fg hover:underline">
364+ {workspace.name} <span className="font-mono text-xs text-muted">{workspace.slug}</span>
365+ </Link>
366+ <span className="flex flex-wrap items-center gap-1.5 text-xs text-faint">
367+ {workspace.protected && <Badge tone="info">Protected</Badge>}
368+ {!workspace.protected && workspace.billing && <Badge tone="danger">Billing</Badge>}
369+ {workspace.members} member{workspace.members === 1 ? "" : "s"}
370+ </span>
371+ </div>
372+ {why && <p className="text-xs text-danger">{why}</p>}
373+ </li>
374+ );
375+ })}
376+ </ul>
377+ {blocked ? (
378+ <>
379+ <Notice tone="error">{blocked}</Notice>
380+ {error && <Notice tone="error">{error}</Notice>}
381+ </>
382+ ) : (
383+ <details className="rounded-md border border-danger/30 px-3 py-2" open={Boolean(error)}>
384+ <summary className="cursor-pointer text-sm text-danger">Delete account and the workspaces it alone owns</summary>
385+ <form method="post" className="mt-3 grid gap-3 sm:max-w-md">
386+ <input type="hidden" name="intent" value="delete-account" />
387+ <input type="hidden" name="with-workspaces" value="1" />
388+ <p className="text-muted">
389+ Deletes {sole.map((workspace) => workspace.slug).join(", ")} first, each as its owner would (billing closes it, its
390+ repositories go with it, kept {WORKSPACE_RESTORE_DAYS} days for a restore), then the account. If a workspace cannot go,
391+ the account is not deleted.
392+ </p>
393+ <Field label="Reason (kept in sudo's audit log)">
394+ <Input name="reason" required maxLength={200} placeholder="Retired test account" />
395+ </Field>
396+ <Field label={`Type ${user.username} to confirm`}>
397+ <Input name="confirm" required autoComplete="off" spellCheck={false} className="font-mono" />
398+ </Field>
399+ <label className="flex items-start gap-2 text-sm">
400+ <input type="checkbox" name="acknowledge" required className="mt-0.5 accent-[var(--g1t-accent)]" />
401+ <span>
402+ {sole.length === 1 ? (
403+ <>
404+ The workspace <span className="font-mono">{sole[0].slug}</span> is deleted too, with everything in it.
405+ </>
406+ ) : (
407+ <>
408+ The {sole.length} workspaces <span className="font-mono">{sole.map((workspace) => workspace.slug).join(", ")}</span> are
409+ deleted too, with everything in them.
410+ </>
411+ )}
412+ </span>
413+ </label>
414+ {error && <Notice tone="error">{error}</Notice>}
415+ <div>
416+ <Button type="submit" variant="danger">
417+ Delete account and {sole.length === 1 ? "its workspace" : `its ${sole.length} workspaces`}
418+ </Button>
419+ </div>
420+ </form>
421+ </details>
422+ )}
423+ </div>
297424 )}
298425 </Section>
299426 );
300427 }
428+
429+/**
430+ * The workspaces staff deleted with the account: each waiting to be purged
431+ * can be purged now (identity purges only a workspace still deleted), or
432+ * restored from Deleted workspaces.
433+ */
434+function DeletedWorkspaces({
435+ workspaces,
436+ error,
437+ workspaceError,
438+ now,
439+}: {
440+ workspaces: DeletedWithAccount[];
441+ error: string | null;
442+ workspaceError: { id: string; error: string } | null;
443+ now: number;
444+}) {
445+ return (
446+ <div className="space-y-2 border-t border-line pt-4">
447+ <p className="text-fg">Workspaces deleted with it</p>
448+ <p className="text-muted">
449+ Purge them before the account if they should go now: once the account is purged, this page is gone (they stay on{" "}
450+ <Link to="/workspaces/deleted" className="text-fg hover:underline">
451+ Deleted workspaces
452+ </Link>
453+ ). To undo it all, restore the account first, then each workspace, so it comes back with its owner.
454+ </p>
455+ {error && <Notice tone="error">Could not load deleted workspaces: {error}</Notice>}
456+ <ul className="divide-y divide-line rounded-md border border-line">
457+ {workspaces.map((workspace) => {
458+ const waiting = workspace.deleted;
459+ const left = waiting ? daysLeft(waiting.purgeAfter, now) : 0;
460+ return (
461+ <li key={workspace.workspaceId} className="space-y-2 px-4 py-3">
462+ <div className="flex flex-wrap items-center justify-between gap-2">
463+ <span className="font-mono">{workspace.slug}</span>
464+ {waiting ? (
465+ waiting.restorable ? (
466+ <Badge tone="warn">
467+ {left} day{left === 1 ? "" : "s"} left
468+ </Badge>
469+ ) : (
470+ <Badge tone="danger">Being purged</Badge>
471+ )
472+ ) : (
473+ <Badge>Purged or restored</Badge>
474+ )}
475+ </div>
476+ {waiting &&
477+ (waiting.went.protected ? (
478+ <p className="text-xs text-muted">Protected: it can never be purged.</p>
479+ ) : (
480+ <form method="post" className="flex flex-col gap-2 sm:flex-row sm:items-end">
481+ <input type="hidden" name="intent" value="purge-workspace" />
482+ <input type="hidden" name="id" value={workspace.workspaceId} />
483+ <input type="hidden" name="slug" value={workspace.slug} />
484+ <label className="grid gap-1 text-xs text-muted">
485+ <span>
486+ Type <span className="font-mono text-fg">{workspace.slug}</span> to purge it now
487+ </span>
488+ <Input name="confirm" autoComplete="off" spellCheck={false} className="font-mono" />
489+ </label>
490+ <Button type="submit" variant="danger">
491+ Purge now
492+ </Button>
493+ </form>
494+ ))}
495+ {workspaceError && workspaceError.id === workspace.workspaceId && <Notice tone="error">{workspaceError.error}</Notice>}
496+ </li>
497+ );
498+ })}
499+ </ul>
500+ </div>
501+ );
502+}
+1−1
1414 protected: false,
1515 };
1616
17−const sole = (slug: string) => ({ slug, name: slug, members: 3, billing: null });
17+const sole = (slug: string) => ({ slug, name: slug, members: 3, billing: null, protected: false });
1818
1919 test("the dialog lists only what the account has", () => {
2020 assert.deepEqual(whatAccountDeletionTakes(nothing), []);
+84−5
66 //! themselves, typing their username and proving it is them
77 //! ([`crate::accounts::Reauth`]); g1t's staff can delete one from sudo with
88 //! 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
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
1014 //! that run g1t, and the names g1t shows for itself, can never be deleted
1115 //! ([`is_protected_account`]).
1216 //!
6468 pub members: u32,
6569 /// Why billing could not close it yet if it were deleted now, in words
6670 /// for its owner: what deleting it first would need. Null when nothing
67− /// is owed, and always for staff.
71+ /// is owed.
6872 #[serde(default)]
6973 pub billing: Option<String>,
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,
7078 }
7179
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+
72111 /// `check_account_deletion` (takes `UserArgs`, people only) returns
73112 /// `Outcome<AccountDeletion>`: what deleting the account would take with
74113 /// it, and what stands in the way, changing nothing. Nothing does when
170209 /// Why staff deleted it.
171210 #[serde(default)]
172211 pub reason: Option<String>,
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>,
173217 }
174218
175219 /// An account deleted and kept until `purge_after` for staff to restore.
191235
192236 /// `admin_delete_account`: staff delete an account, with a reason (kept in
193237 /// 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>`.
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>`.
196241 #[derive(Debug, Serialize, Deserialize)]
197242 #[serde(rename_all = "camelCase")]
198243 pub struct AdminDeleteAccountArgs {
202247 pub confirm: String,
203248 /// The staff member, by email.
204249 pub staff: String,
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,
205260 }
206261
207262 /// `admin_restore_account` and `admin_purge_account`: staff restore a
223278 use crate::identity::protected_names;
224279
225280 fn sole(slug: &str) -> SoleOwnedWorkspace {
226− SoleOwnedWorkspace { slug: slug.into(), name: slug.into(), members: 1, billing: None }
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);
227306 }
228307
229308 #[test]
+2−1
601601 pub name: String,
602602 /// RFC 3339.
603603 pub deleted_at: String,
604− /// The username of the owner who deleted it.
604+ /// The username of the owner who deleted it, or the staff member (by
605+ /// email) who deleted it with the account that was its only owner.
605606 pub deleted_by: String,
606607 /// RFC 3339: when it is purged unless restored first.
607608 pub purge_after: String,
+14−2
55 * A person deletes their own account from Settings, typing their username
66 * and proving it is them; g1t's staff can delete one from sudo with a
77 * reason. Neither works while the account is the only owner of a live
8− * workspace, or for a protected account. It is soft first: everything it
8+ * workspace, or for a protected account; staff may instead delete those
9+ * workspaces with it (`withSoleWorkspaces`), unless one is protected or its
10+ * billing cannot settle. It is soft first: everything it
911 * could sign in with ends at once and it leaves every workspace, and it is
1012 * kept for `ACCOUNT_RESTORE_DAYS` for staff to restore. Then it is purged,
1113 * its username is never given to anyone again, and what it wrote shows as
2729 name: string;
2830 /** Everyone in it, the account included. */
2931 members: number;
30− /** What billing needs before the workspace itself can be deleted; null when nothing. Never asked for staff. */
32+ /** What billing needs before the workspace itself can be deleted; null when nothing. */
3133 billing: string | null;
34+ /** It can never be deleted, by anyone, so staff cannot delete it with the account either. */
35+ protected: boolean;
3236 };
3337
38+/** A workspace staff deleted together with the account that alone owned it. */
39+export type WorkspaceDeletedWith = {
40+ workspaceId: string;
41+ slug: string;
42+};
43+
3444 /** What deleting an account takes with it, and what stands in the way. */
3545 export type AccountDeletion = {
3646 username: string;
5868 /** The staff member who deleted it; null when the person did. */
5969 staff: string | null;
6070 reason: string | null;
71+ /** The workspaces it alone owned that staff deleted with it; each is restored or purged on its own. */
72+ deletedWorkspaces: WorkspaceDeletedWith[];
6173 };
6274
6375 /** An account deleted and kept until `purgeAfter` for staff to restore. */
+15−4
197197 removeEmail(username: string, email: string, reason: string, staff: string): Promise<Result<AdminUser>>;
198198 /**
199199 * Deletes an account, with the reason and the username typed out. Refused
200− * for a protected account and while it is the only owner of a live
201− * workspace. Recorded in sudo's audit log (`account_deleted`).
200+ * for a protected account, and while it is the only owner of a live
201+ * workspace unless `withSoleWorkspaces`: then each of those is deleted
202+ * first, as its owner would, and the account last. Refused whole while any
203+ * of them is protected or its billing cannot settle; a workspace failing
204+ * on the way stops it before the account. Recorded in sudo's audit log
205+ * (`workspace_deleted` for each, `account_deleted`).
202206 */
203− deleteAccount(username: string, reason: string, confirm: string, staff: string): Promise<Result<boolean>>;
207+ deleteAccount(
208+ username: string,
209+ reason: string,
210+ confirm: string,
211+ staff: string,
212+ withSoleWorkspaces?: boolean,
213+ ): Promise<Result<boolean>>;
204214 /** Deleted accounts not purged yet, newest first. */
205215 deletedAccounts(): Promise<DeletedAccount[]>;
206216 /** Brings a deleted account back within its window, with the memberships it left. Publishes `user.restored`. */
245255 return {
246256 user: (username) => call(identity, "admin_user", { username }),
247257 removeEmail: (username, email, reason, staff) => call(identity, "admin_remove_email", { username, email, reason, staff }),
248− deleteAccount: (username, reason, confirm, staff) => call(identity, "admin_delete_account", { username, reason, confirm, staff }),
258+ deleteAccount: (username, reason, confirm, staff, withSoleWorkspaces) =>
259+ call(identity, "admin_delete_account", { username, reason, confirm, staff, withSoleWorkspaces: withSoleWorkspaces ?? false }),
249260 deletedAccounts: () => call(identity, "admin_deleted_accounts", {}),
250261 restoreAccount: (userId, staff) => call(identity, "admin_restore_account", { userId, staff }),
251262 purgeAccount: (userId, staff, confirm) => call(identity, "admin_purge_account", { userId, staff, confirm }),
+1−1
591591 name: string;
592592 /** RFC 3339. */
593593 deletedAt: string;
594− /** The username of the owner who deleted it. */
594+ /** The username of the owner who deleted it, or the staff member who deleted it with the account that alone owned it. */
595595 deletedBy: string;
596596 /** RFC 3339: when it is purged unless restored first. */
597597 purgeAfter: string;
+325−40
1313 //! the account co-owns is someone else's to pay for, and one it owns alone
1414 //! is in the way already.
1515 //!
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+//!
1628 //! Deleting is soft first, as for a workspace. At once, in one batch: the
1729 //! row gets `deleted_at`, `deleted_by` and `purge_after`
1830 //! ([`ACCOUNT_RESTORE_DAYS`] on); its sessions, access tokens (classic,
4355 //!
4456 //! There is no API route for any of this: only the site and sudo call it.
4557
58+use std::future::Future;
59+
4660 use g1t_contracts::FailureCode;
4761 use g1t_contracts::Outcome;
4862 use g1t_contracts::User;
4963 use g1t_contracts::account_deletion::*;
50−use g1t_contracts::billing::CloseWorkspaceArgs;
5164 use g1t_contracts::events::{UserDeleted, UserDeleting, UserRestored};
5265 use g1t_contracts::identity::{UserArgs, protected_names};
5366 use g1t_contracts::time::rfc3339;
5770 use worker::wasm_bindgen::JsValue;
5871
5972 use crate::Identity;
73+use crate::deletion::staff_billing_actor;
6074 use crate::security::is_person;
6175
6276 type Refusal = (FailureCode, String);
101115 Ok(())
102116 }
103117
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+
104224 /// Whether staff may restore a deleted account, now `now`.
105225 pub fn may_restore(username: &str, purge_after: &str, now: &str) -> std::result::Result<(), Refusal> {
106226 if !restorable(purge_after, now) {
336456 async fn sole_owned(&self, user_id: &str) -> Result<Vec<SoleOwnedWorkspace>> {
337457 #[derive(Deserialize)]
338458 struct Row {
459+ id: String,
339460 slug: String,
340461 name: String,
341462 members: u32,
463+ #[serde(default)]
464+ protected: u8,
342465 }
343466 let rows = self
344467 .db
345468 .prepare(
346− "SELECT w.slug, w.name,
469+ "SELECT w.id, w.slug, w.name, w.protected,
347470 (SELECT count(*) FROM workspace_members a WHERE a.workspace_id = w.id) AS members
348471 FROM workspace_members m JOIN workspaces w ON w.id = m.workspace_id
349472 WHERE m.user_id = ?1 AND m.role = 'owner' AND w.deleted_at IS NULL
355478 .all()
356479 .await?
357480 .results::<Row>()?;
358− Ok(rows
359− .into_iter()
360− .map(|row| SoleOwnedWorkspace { slug: row.slug, name: row.name, members: row.members, billing: None })
361− .collect())
481+ let mut sole = Vec::with_capacity(rows.len());
482+ for row in rows {
483+ let protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?;
484+ sole.push(SoleOwnedWorkspace { slug: row.slug, name: row.name, members: row.members, billing: None, protected });
485+ }
486+ Ok(sole)
362487 }
363488
364489 /// What deleting the account would take with it, and what stands in
365− /// the way. With `actor` (the person), billing says for each workspace
366− /// they own alone what deleting it first would need.
367− pub(crate) async fn account_deletion_facts(&self, user_id: &str, username: &str, actor: Option<&User>) -> Result<AccountDeletion> {
490+ /// the way. Asked (`ask`), billing says for each workspace it owns
491+ /// alone what deleting that workspace would need: for the person, as
492+ /// themselves; for staff, as the owner billing closes it for.
493+ pub(crate) async fn account_deletion_facts(&self, user_id: &str, username: &str, ask: AskBilling<'_>) -> Result<AccountDeletion> {
368494 #[derive(Deserialize)]
369495 struct Counts {
370496 workspaces: u32,
390516 .await?
391517 .unwrap_or(Counts { workspaces: 0, tokens: 0, ssh_keys: 0, applications: 0, repositories: 0 });
392518 let mut sole_owner_of = self.sole_owned(user_id).await?;
393− if let Some(actor) = actor
394− && !sole_owner_of.is_empty()
395− {
396− let billing = self.env.service("BILLING")?;
397− for workspace in &mut sole_owner_of {
398− let closing: Result<Outcome<bool>> = g1t_kit::call(
399− &billing,
400− "close_workspace",
401− &CloseWorkspaceArgs { actor: actor.clone(), workspace: workspace.slug.clone(), dry_run: true },
402− )
403− .await;
404− workspace.billing = match closing {
405− Ok(Outcome::Fail(failure)) => Some(failure.message),
406− _ => None,
407− };
408− }
519+ for workspace in &mut sole_owner_of {
520+ let actor = match ask {
521+ AskBilling::No => break,
522+ AskBilling::Person(person) => person.clone(),
523+ AskBilling::Staff => staff_billing_actor(user_id, "g1t staff", &workspace.slug),
524+ };
525+ workspace.billing = match self.billing_refusal(&actor, &workspace.slug).await {
526+ Ok(refusal) => refusal,
527+ // Staff are not let through on a billing that did not
528+ // answer: deleting it would ask again and stop there.
529+ Err(error) => matches!(ask, AskBilling::Staff).then(|| format!("Billing did not answer for {}: {error}", workspace.slug)),
530+ };
409531 }
410532 Ok(AccountDeletion {
411533 username: username.to_owned(),
428550 let Some(live) = self.live_account("id", &a.user.id).await? else {
429551 return Ok(Outcome::fail(FailureCode::NotFound, "Account not found."));
430552 };
431− Ok(Outcome::Ok(self.account_deletion_facts(&live.id, &live.username, Some(&a.user)).await?))
553+ Ok(Outcome::Ok(self.account_deletion_facts(&live.id, &live.username, AskBilling::Person(&a.user)).await?))
432554 }
433555
434556 /// `delete_account`: the person deletes their own account.
439561 let Some(live) = self.live_account("id", &a.user.id).await? else {
440562 return Ok(Outcome::fail(FailureCode::NotFound, "Account not found."));
441563 };
442− let deletion = self.account_deletion_facts(&live.id, &live.username, None).await?;
564+ let deletion = self.account_deletion_facts(&live.id, &live.username, AskBilling::No).await?;
443565 if let Err((code, message)) = may_delete_own(true, &deletion, &a.confirm) {
444566 return Ok(Outcome::fail(code, message));
445567 }
448570 if let Some(refusal) = self.proof(&live.id, &a.reauth).await?.refusal() {
449571 return Ok(refusal);
450572 }
451− self.soft_delete(&live, &deletion, Some(&live.id), None).await?;
573+ self.soft_delete(&live, &deletion, Some(&live.id), None, Vec::new()).await?;
452574 Ok(Outcome::Ok(true))
453575 }
454576
455− /// `admin_delete_account`: staff delete an account, with a reason.
577+ /// `admin_delete_account`: staff delete an account, with a reason, and
578+ /// with `with_sole_workspaces` the workspaces it is the only owner of
579+ /// first.
456580 pub async fn admin_delete_account(&self, a: AdminDeleteAccountArgs) -> Result<Outcome<bool>> {
457581 let staff = a.staff.trim();
458582 let reason = a.reason.trim();
465589 let Some(live) = self.live_account("username", &a.username.trim().to_lowercase()).await? else {
466590 return Ok(Outcome::fail(FailureCode::NotFound, "No such account, or it is already deleted."));
467591 };
468− let deletion = self.account_deletion_facts(&live.id, &live.username, None).await?;
469− if let Err((code, message)) = may_delete(&deletion, &a.confirm) {
470− // Staff read "you" as the account's.
471− let message = message.replacen("You are the only owner", &format!("{} is the only owner", live.username), 1);
472− return Ok(Outcome::fail(code, message));
473− }
474− self.soft_delete(&live, &deletion, None, Some((staff, reason))).await?;
592+ // Billing is asked only when it matters: the workspaces go too.
593+ let ask = if a.with_sole_workspaces { AskBilling::Staff } else { AskBilling::No };
594+ let deletion = self.account_deletion_facts(&live.id, &live.username, ask).await?;
595+ let steps = match staff_steps(&deletion, a.with_sole_workspaces, &a.confirm) {
596+ Ok(steps) => steps,
597+ Err((code, message)) => return Ok(Outcome::fail(code, message)),
598+ };
599+ let (owner_id, owner, live_ref, deletion_ref) = (live.id.as_str(), live.username.as_str(), &live, &deletion);
600+ let done = run_steps(
601+ &live.username,
602+ &steps,
603+ move |slug| async move { self.staff_delete_workspace(&slug, owner_id, owner, staff, reason).await },
604+ move |workspaces| async move {
605+ self.soft_delete(live_ref, deletion_ref, None, Some((staff, reason)), workspaces).await
606+ },
607+ )
608+ .await?;
609+ let workspaces = match done {
610+ Outcome::Ok(workspaces) => workspaces,
611+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
612+ };
613+ let with = if workspaces.is_empty() {
614+ String::new()
615+ } else {
616+ let slugs: Vec<&str> = workspaces.iter().map(|workspace| workspace.slug.as_str()).collect();
617+ format!(" and the workspaces it alone owned ({})", slugs.join(", "))
618+ };
475619 self.record_for_staff(
476620 &live.username,
477621 "account_deleted",
478− &format!("Deleted the account {}: {reason}", live.username),
622+ &format!("Deleted the account {}{with}: {reason}", live.username),
479623 staff,
480624 )
481625 .await;
489633 deletion: &AccountDeletion,
490634 deleted_by: Option<&str>,
491635 staff: Option<(&str, &str)>,
636+ workspaces: Vec<WorkspaceDeletedWith>,
492637 ) -> Result<()> {
493638 let id = live.id.as_str();
494− let snapshot = self.snapshot(id, deletion, staff).await?;
639+ let mut snapshot = self.snapshot(id, deletion, staff).await?;
640+ snapshot.went.deleted_workspaces = workspaces;
495641 let now = now_ms();
496642 let at = rfc3339(now);
497643 let purge = purge_after(now);
571717 ssh_keys: deletion.ssh_keys,
572718 staff: staff.map(|(who, _)| who.to_owned()),
573719 reason: staff.map(|(_, why)| why.to_owned()),
720+ deleted_workspaces: Vec::new(),
574721 },
575722 memberships,
576723 teams,
760907 }
761908
762909 fn sole(slug: &str) -> SoleOwnedWorkspace {
763− SoleOwnedWorkspace { slug: slug.into(), name: slug.into(), members: 2, billing: None }
910+ SoleOwnedWorkspace { slug: slug.into(), name: slug.into(), members: 2, billing: None, protected: false }
911+ }
912+
913+ /// Polls a future to its end. What these tests run never waits.
914+ fn block_on<F: Future>(future: F) -> F::Output {
915+ use std::task::{Context, Poll, Waker};
916+ let mut future = std::pin::pin!(future);
917+ let mut cx = Context::from_waker(Waker::noop());
918+ loop {
919+ if let Poll::Ready(value) = future.as_mut().poll(&mut cx) {
920+ return value;
921+ }
922+ }
923+ }
924+
925+ fn gone(slug: &str) -> WorkspaceDeletedWith {
926+ WorkspaceDeletedWith { workspace_id: format!("wsp_{slug}"), slug: slug.into() }
927+ }
928+
929+ /// Runs `steps` against a record of what was done, with the workspaces
930+ /// in `failing` refusing.
931+ fn run(steps: &[Step], failing: &[&str]) -> (Outcome<Vec<WorkspaceDeletedWith>>, Vec<String>) {
932+ let done = std::cell::RefCell::new(Vec::<String>::new());
933+ let outcome = block_on(run_steps(
934+ "ada",
935+ steps,
936+ |slug| {
937+ let refused = failing.contains(&slug.as_str());
938+ if !refused {
939+ done.borrow_mut().push(format!("workspace {slug}"));
940+ }
941+ async move {
942+ if refused {
943+ Ok(Outcome::fail(FailureCode::PaymentRequired, format!("The card on file was declined for {slug}.")))
944+ } else {
945+ Ok(Outcome::Ok(gone(&slug)))
946+ }
947+ }
948+ },
949+ |workspaces| {
950+ let slugs: Vec<String> = workspaces.iter().map(|workspace| workspace.slug.clone()).collect();
951+ done.borrow_mut().push(format!("account with [{}]", slugs.join(", ")));
952+ async { Ok(()) }
953+ },
954+ ))
955+ .unwrap();
956+ (outcome, done.into_inner())
957+ }
958+
959+ #[test]
960+ fn with_its_workspaces_staff_delete_each_then_the_account() {
961+ let owner = AccountDeletion { sole_owner_of: vec![sole("ada"), sole("ada-labs")], ..deletion("ada") };
962+ let steps = staff_steps(&owner, true, "ada").unwrap();
963+ assert_eq!(steps, vec![Step::Workspace("ada".into()), Step::Workspace("ada-labs".into()), Step::Account]);
964+ let (outcome, done) = run(&steps, &[]);
965+ assert_eq!(done, vec!["workspace ada", "workspace ada-labs", "account with [ada, ada-labs]"]);
966+ match outcome {
967+ Outcome::Ok(workspaces) => assert_eq!(workspaces, vec![gone("ada"), gone("ada-labs")]),
968+ Outcome::Fail(failure) => panic!("{}", failure.message),
969+ }
970+ // Without them, only the account, and only when it owns none alone.
971+ assert_eq!(staff_steps(&deletion("ada"), false, "ada").unwrap(), vec![Step::Account]);
972+ assert_eq!(staff_steps(&deletion("ada"), true, "ada").unwrap(), vec![Step::Account]);
973+ }
974+
975+ #[test]
976+ fn without_asking_for_its_workspaces_staff_are_told_it_owns_them() {
977+ let owner = AccountDeletion { sole_owner_of: vec![sole("acme")], ..deletion("ada") };
978+ assert_eq!(
979+ staff_steps(&owner, false, "ada").unwrap_err(),
980+ (
981+ FailureCode::Conflict,
982+ "ada is the only owner of acme. Make someone else an owner of it, or delete it, first.".to_owned()
983+ )
984+ );
985+ }
986+
987+ #[test]
988+ fn a_protected_workspace_refuses_the_whole_deletion_before_anything_goes() {
989+ let flagon = SoleOwnedWorkspace { protected: true, ..sole("flagon-io") };
990+ let owner = AccountDeletion { sole_owner_of: vec![sole("ada"), flagon], ..deletion("ada") };
991+ let (code, message) = staff_steps(&owner, true, "ada").unwrap_err();
992+ assert_eq!(code, FailureCode::Forbidden);
993+ assert_eq!(message, "Nothing was deleted. flagon-io is protected and can never be deleted.");
994+ // A protected account, whatever it owns.
995+ let protected = AccountDeletion { protected: true, ..deletion("g1t") };
996+ assert_eq!(staff_steps(&protected, true, "g1t").unwrap_err().0, FailureCode::Forbidden);
997+ }
998+
999+ #[test]
1000+ fn billing_that_cannot_settle_refuses_the_whole_deletion_before_anything_goes() {
1001+ let owing = SoleOwnedWorkspace { billing: Some("ada-labs has an unpaid invoice. Pay it from the workspace's Billing page first.".into()), ..sole("ada-labs") };
1002+ let owner = AccountDeletion { sole_owner_of: vec![sole("ada"), owing], ..deletion("ada") };
1003+ let (code, message) = staff_steps(&owner, true, "ada").unwrap_err();
1004+ assert_eq!(code, FailureCode::PaymentRequired);
1005+ assert_eq!(message, "Nothing was deleted. ada-labs has an unpaid invoice. Pay it from the workspace's Billing page first.");
7641006 }
7651007
7661008 #[test]
1009+ fn staff_type_the_username_after_everything_else() {
1010+ let owner = AccountDeletion { sole_owner_of: vec![sole("ada")], ..deletion("ada") };
1011+ assert_eq!(staff_steps(&owner, true, "").unwrap_err(), (FailureCode::Invalid, "Type ada to confirm.".into()));
1012+ assert_eq!(staff_steps(&owner, true, "grace").unwrap_err().0, FailureCode::Invalid);
1013+ }
1014+
1015+ #[test]
1016+ fn a_workspace_failing_on_the_way_stops_before_the_account() {
1017+ let steps = vec![Step::Workspace("ada".into()), Step::Workspace("ada-labs".into()), Step::Workspace("ada-old".into()), Step::Account];
1018+ let (outcome, done) = run(&steps, &["ada-labs"]);
1019+ // ada went; ada-labs refused; ada-old and the account were not tried.
1020+ assert_eq!(done, vec!["workspace ada"]);
1021+ let Outcome::Fail(failure) = outcome else { panic!("the account must not be deleted") };
1022+ assert_eq!(failure.code, FailureCode::PaymentRequired);
1023+ assert_eq!(
1024+ failure.message,
1025+ "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."
1026+ );
1027+ // Failing first, nothing went at all.
1028+ let (outcome, done) = run(&steps, &["ada"]);
1029+ assert!(done.is_empty());
1030+ let Outcome::Fail(failure) = outcome else { panic!("the account must not be deleted") };
1031+ assert_eq!(failure.message, "ada could not be deleted: The card on file was declined for ada. Nothing was deleted.");
1032+ }
1033+
1034+ #[test]
1035+ fn what_stopped_it_names_what_went_before() {
1036+ assert_eq!(
1037+ stopped_at("ada", &[gone("a"), gone("b")], "c", "Declined. "),
1038+ "c could not be deleted: Declined. a and b were deleted already (restore from Deleted workspaces if need be); ada was not deleted."
1039+ );
1040+ }
1041+
1042+ #[test]
7671043 fn only_the_person_typing_their_username() {
7681044 assert!(may_delete_own(true, &deletion("ada"), " ADA ").is_ok());
7691045 assert_eq!(may_delete_own(false, &deletion("ada"), "ada").unwrap_err().0, FailureCode::Forbidden);
8251101 #[test]
8261102 fn what_it_left_is_kept_for_a_restore_and_read_back() {
8271103 let snapshot = Snapshot {
828− went: AccountWent { workspaces: 2, teams: 1, repositories: 1, tokens: 3, ssh_keys: 1, staff: Some("s@g1t.sh".into()), reason: Some("asked".into()) },
1104+ went: AccountWent {
1105+ workspaces: 2,
1106+ teams: 1,
1107+ repositories: 1,
1108+ tokens: 3,
1109+ ssh_keys: 1,
1110+ staff: Some("s@g1t.sh".into()),
1111+ reason: Some("asked".into()),
1112+ deleted_workspaces: vec![gone("ada")],
1113+ },
8291114 memberships: vec![Member {
8301115 workspace_id: "wsp_1".into(),
8311116 role: "owner".into(),
+160−32
3535 //!
3636 //! A person keeps their account whatever workspaces they lose: an account
3737 //! with no workspace, or with only other people's, works as any other.
38+//!
39+//! g1t's staff delete a workspace only together with the account that is
40+//! its only owner (account_deletion.rs, `with_sole_workspaces`), through
41+//! [`Identity::staff_delete_workspace`]: the same steps, the same refusals
42+//! (protected, billing that cannot settle), with the staff member as
43+//! `deleted_by` and a line in sudo's audit log with the reason.
3844
3945 use g1t_contracts::audit::{
4046 AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface,
4450 use g1t_contracts::identity::*;
4551 use g1t_contracts::repos::NamespaceCountArgs;
4652 use g1t_contracts::time::rfc3339;
47−use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, new_id};
53+use g1t_contracts::account_deletion::WorkspaceDeletedWith;
54+use g1t_contracts::{FailureCode, Membership, Outcome, PrincipalKind, Role, User, new_id};
4855 use g1t_kit::now_ms;
4956 use serde::Deserialize;
5057 use serde_json::json;
145152 Ok(())
146153 }
147154
155+/// Who billing's `close_workspace` sees when staff delete a workspace with
156+/// the account that is its only owner: the account, as owner of `slug`
157+/// (billing closes only for an owner), named by the staff member so
158+/// billing's record says who closed it.
159+pub fn staff_billing_actor(owner_id: &str, staff: &str, slug: &str) -> User {
160+ User {
161+ id: owner_id.to_owned(),
162+ username: staff.to_owned(),
163+ kind: PrincipalKind::User,
164+ verified: true,
165+ workspaces: vec![Membership { role: Role::Owner, ..Membership::member(slug) }],
166+ ..User::default()
167+ }
168+}
169+
170+/// Who deletes a workspace, for its row, its audit log and the event.
171+struct Deleter<'a> {
172+ /// Who billing closes it for: an owner.
173+ billing: &'a User,
174+ /// `deleted_by` on its row: the owner's id, or the staff member.
175+ deleted_by: &'a str,
176+ /// `by` on `workspace.deleting`: the owner's username, or the staff
177+ /// member.
178+ by: &'a str,
179+ audit: AuditActor,
180+ surface: Surface,
181+ /// The audit log's rule: `owner` or `staff`.
182+ rule: &'static str,
183+ /// What the audit log says, given when it can be restored until.
184+ message: Box<dyn Fn(&str) -> String + 'a>,
185+ /// The event's actor.
186+ actor_id: Option<&'a str>,
187+}
188+
148189 /// What `deleted_went` holds: what went with the workspace.
149190 fn went_json(went: &WorkspaceDeletion) -> String {
150191 json!({
262303 Ok(is_protected(&names, id, slug, flagged, &old))
263304 }
264305
265− /// What would go with the workspace, and whether billing can close it.
266− async fn deletion_facts(&self, a: &DeleteWorkspaceArgs, slug: &str, target: &Target) -> Result<WorkspaceDeletion> {
306+ /// Why billing could not close `slug` now for `actor` (an owner), or
307+ /// `None` when it could. Changes nothing.
308+ pub(crate) async fn billing_refusal(&self, actor: &User, slug: &str) -> Result<Option<String>> {
309+ let closing: Outcome<bool> = g1t_kit::call(
310+ &self.env.service("BILLING")?,
311+ "close_workspace",
312+ &CloseWorkspaceArgs {
313+ actor: actor.clone(),
314+ workspace: slug.to_owned(),
315+ dry_run: true,
316+ },
317+ )
318+ .await?;
319+ Ok(match closing {
320+ Outcome::Ok(_) => None,
321+ Outcome::Fail(failure) => Some(failure.message),
322+ })
323+ }
324+
325+ /// What would go with the workspace, and whether billing can close it
326+ /// for `actor`.
327+ async fn deletion_facts(&self, actor: &User, slug: &str, target: &Target) -> Result<WorkspaceDeletion> {
267328 let repositories: u32 = g1t_kit::call(
268329 &self.env.service("REPOS")?,
269330 "namespace_count",
289350 .first::<Count>(None)
290351 .await?
291352 .map_or(0, |count| count.n);
292− let closing: Outcome<bool> = g1t_kit::call(
293− &self.env.service("BILLING")?,
294− "close_workspace",
295− &CloseWorkspaceArgs {
296− actor: a.actor.clone(),
297− workspace: slug.to_owned(),
298− dry_run: true,
299− },
300− )
301− .await?;
302353 Ok(WorkspaceDeletion {
303354 repositories,
304355 projects,
305356 members,
306− billing: match closing {
307− Outcome::Ok(_) => None,
308− Outcome::Fail(failure) => Some(failure.message),
309− },
357+ billing: self.billing_refusal(actor, slug).await?,
310358 protected: self.is_protected(&target.id, slug, target.protected != 0).await?,
311359 })
312360 }
345393 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
346394 };
347395 let slug = a.slug.trim().to_lowercase();
348− Ok(Outcome::Ok(self.deletion_facts(&a, &slug, &target).await?))
396+ Ok(Outcome::Ok(self.deletion_facts(&a.actor, &slug, &target).await?))
349397 }
350398
351399 pub async fn delete_workspace(&self, a: DeleteWorkspaceArgs) -> Result<Outcome<bool>> {
354402 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
355403 };
356404 let slug = a.slug.trim().to_lowercase();
357− let went = self.deletion_facts(&a, &slug, &workspace).await?;
358− if let Some(reason) = went.reason(&slug) {
405+ let deleter = Deleter {
406+ billing: &a.actor,
407+ deleted_by: &a.actor.id,
408+ by: &a.actor.username,
409+ audit: AuditActor::of(&a.actor),
410+ surface: a.surface.unwrap_or(Surface::Web),
411+ rule: "owner",
412+ message: Box::new(|purge| format!("Deleted {slug}; restorable by g1t's staff until {purge}")),
413+ actor_id: Some(&a.actor.id),
414+ };
415+ self.soft_delete_workspace(&workspace, &slug, &deleter).await
416+ }
417+
418+ /// g1t's staff delete a live workspace that `owner_id` (`owner`) is the
419+ /// only owner of, as part of deleting that account (account_deletion.rs):
420+ /// exactly as its owner would, refused the same way, with the staff
421+ /// member as `deleted_by` and in sudo's audit log with `reason`.
422+ pub(crate) async fn staff_delete_workspace(
423+ &self,
424+ slug: &str,
425+ owner_id: &str,
426+ owner: &str,
427+ staff: &str,
428+ reason: &str,
429+ ) -> Result<Outcome<WorkspaceDeletedWith>> {
430+ let slug = slug.trim().to_lowercase();
431+ let Some(workspace) = self
432+ .db
433+ .prepare("SELECT id, protected FROM workspaces WHERE slug = ? AND deleted_at IS NULL")
434+ .bind(&[slug.as_str().into()])?
435+ .first::<Target>(None)
436+ .await?
437+ else {
438+ return Ok(Outcome::fail(FailureCode::NotFound, format!("{slug} is not a live workspace any more.")));
439+ };
440+ let billing = staff_billing_actor(owner_id, staff, &slug);
441+ let deleter = Deleter {
442+ billing: &billing,
443+ deleted_by: staff,
444+ by: staff,
445+ // The workspace's members read its audit log: it says g1t's
446+ // staff did it, and sudo's log says who and why.
447+ audit: AuditActor::system(),
448+ surface: Surface::Web,
449+ rule: "staff",
450+ message: Box::new(|purge| {
451+ format!("Deleted {slug} by g1t's staff, with the account {owner} that was its only owner; restorable by g1t's staff until {purge}")
452+ }),
453+ actor_id: None,
454+ };
455+ let id = workspace.id.clone();
456+ match self.soft_delete_workspace(&workspace, &slug, &deleter).await? {
457+ Outcome::Ok(_) => {}
458+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
459+ }
460+ self.record_for_staff(
461+ &slug,
462+ "workspace_deleted",
463+ &format!("Deleted {slug} with the account {owner}, its only owner: {reason}"),
464+ staff,
465+ )
466+ .await;
467+ Ok(Outcome::Ok(WorkspaceDeletedWith { workspace_id: id, slug: slug.clone() }))
468+ }
469+
470+ /// Deletes a live workspace softly, as every deletion does: refused if
471+ /// it is protected or billing cannot settle it; billing closes it for
472+ /// real first; then its row is marked, its audit log says so and
473+ /// `workspace.deleting` tells every service.
474+ async fn soft_delete_workspace(&self, workspace: &Target, slug: &str, deleter: &Deleter<'_>) -> Result<Outcome<bool>> {
475+ let went = self.deletion_facts(deleter.billing, slug, workspace).await?;
476+ if let Some(reason) = went.reason(slug) {
359477 let code = if went.protected { FailureCode::Forbidden } else { FailureCode::PaymentRequired };
360478 return Ok(Outcome::fail(code, reason));
361479 }
366484 &self.env.service("BILLING")?,
367485 "close_workspace",
368486 &CloseWorkspaceArgs {
369− actor: a.actor.clone(),
370− workspace: slug.clone(),
487+ actor: deleter.billing.clone(),
488+ workspace: slug.to_owned(),
371489 dry_run: false,
372490 },
373491 )
384502 )
385503 .bind(&[
386504 rfc3339(now).into(),
387− a.actor.id.as_str().into(),
505+ deleter.deleted_by.into(),
388506 purge.as_str().into(),
389507 went_json(&went).into(),
390508 workspace.id.as_str().into(),
392510 .run()
393511 .await?;
394512 self.record_on_workspace(
395− &slug,
396− AuditActor::of(&a.actor),
397− a.surface.unwrap_or(Surface::Web),
513+ slug,
514+ deleter.audit.clone(),
515+ deleter.surface,
398516 "workspace.deleted",
399− "owner",
400− format!("Deleted {slug}; restorable by g1t's staff until {purge}"),
517+ deleter.rule,
518+ (deleter.message)(&purge),
401519 )
402520 .await;
403521 self.announce(
404522 "workspace.deleting",
405− Some(&a.actor.id),
523+ deleter.actor_id,
406524 WorkspaceDeleting {
407− workspace_id: workspace.id,
408− slug,
409− by: a.actor.username.clone(),
525+ workspace_id: workspace.id.clone(),
526+ slug: slug.to_owned(),
527+ by: deleter.by.to_owned(),
410528 purge_after: purge,
411529 },
412530 )
821939 }
822940
823941 #[test]
942+ fn billing_sees_staff_as_the_owner_closing_it_named_by_the_staff_member() {
943+ let actor = staff_billing_actor("usr_ada", "staff@g1t.sh", "ada");
944+ assert_eq!(actor.role_in("ada"), Some(Role::Owner));
945+ assert_eq!(actor.role_in("globex"), None);
946+ assert_eq!(actor.username, "staff@g1t.sh");
947+ assert_eq!(actor.id, "usr_ada");
948+ assert_eq!(actor.kind, PrincipalKind::User);
949+ }
950+
951+ #[test]
824952 fn a_deleted_slug_is_reclaimed_only_by_its_namesake() {
825953 assert!(may_reclaim("syntaqx", "syntaqx"));
826954 assert!(may_reclaim("syntaqx", "Syntaqx"));
+6−2
11291129 let log = self.security_events(user_id, true).await?;
11301130 let emails = view(&account, rows);
11311131 // Whether it can be deleted, and its deletion while it waits to be
1132− // purged (account_deletion.rs).
1132+ // purged (account_deletion.rs). For each workspace it owns alone,
1133+ // whether staff could delete it with the account: protected, or
1134+ // billing that cannot settle.
11331135 let deleted = self.deleted_account(&account.id).await?;
1134− let deletion = self.account_deletion_facts(&account.id, &account.username, None).await?;
1136+ let deletion = self
1137+ .account_deletion_facts(&account.id, &account.username, crate::account_deletion::AskBilling::Staff)
1138+ .await?;
11351139 Ok(Some(AdminUser {
11361140 id: account.id,
11371141 username: account.username,