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.
| 59 | 59 | | `package.linked`, `package.unlinked` | A package was linked to a repository (from its settings, or by an image's source label), or unlinked. | | |
| 60 | 60 | | `workspace.residency_changed` | An owner changed where the workspace's new repositories are stored. See [data residency](/guides/workspaces/#data-residency). | | |
| 61 | 61 | | `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). | | |
| 63 | 63 | ||
| 64 | 64 | Through the API and the MCP server, the call itself is recorded under its | |
| 65 | 65 | operation's name too, such as `delete_repo`. See |
| 994 | 994 | Billing belongs to workspaces, not to accounts, so once no workspace | |
| 995 | 995 | depends on you alone there is nothing for billing to settle. | |
| 996 | 996 | ||
| 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 | + | ||
| 997 | 1008 | ### What happens | |
| 998 | 1009 | ||
| 999 | 1010 | At once, when you delete it: |
| 79 | 79 | account from Settings does: signs them out everywhere, ends their tokens, | |
| 80 | 80 | SSH keys, deploy keys they added and applications, takes them out of | |
| 81 | 81 | 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`, | |
| 88 | 104 | and whatever identity's `PROTECTED_ACCOUNTS` names, by username or id) | |
| 89 | 105 | are marked **Protected** and offer no form. A deleted account's page | |
| 90 | 106 | 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. | |
| 92 | 115 | - **Deleted accounts** (`/users/deleted`, linked from Workspaces): | |
| 93 | 116 | accounts deleted by the person or by staff, newest first | |
| 94 | 117 | (`admin_deleted_accounts`), each with who deleted it (the person, or the |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 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"; | |
| 5 | 13 | ||
| 6 | 14 | const deletion = { | |
| 7 | 15 | username: "ada", | |
| ⋯ | |||
| 14 | 22 | protected: false, | |
| 15 | 23 | }; | |
| 16 | 24 | ||
| 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 | + | ||
| 17 | 29 | 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"); | |
| 22 | 31 | }); | |
| 23 | 32 | ||
| 24 | − | test("an account that owns workspaces alone, or is protected, is refused", () => { | |
| 33 | + | test("only a protected account is refused outright", () => { | |
| 25 | 34 | 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); | |
| 27 | 37 | 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.", | |
| 30 | 40 | ); | |
| 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 | + | }; | |
| 31 | 59 | 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.", | |
| 34 | 62 | ); | |
| 35 | 63 | }); | |
| 36 | 64 | ||
| ⋯ | |||
| 39 | 67 | assert.ok(!confirmsUsername("ada", "")); | |
| 40 | 68 | assert.ok(!confirmsUsername("ada", "grace")); | |
| 41 | 69 | }); | |
| 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 | + | }); | |
| 4 | 4 | * and what staff type to confirm. Identity checks all of it again. No | |
| 5 | 5 | * Workers imports, so it can be tested under Node. | |
| 6 | 6 | */ | |
| 7 | − | import type { AccountDeletion, AccountWent } from "@g1t/contracts"; | |
| 7 | + | import type { AccountDeletion, AccountWent, DeletedWorkspace, SoleOwnedWorkspace, WorkspaceDeletedWith } from "@g1t/contracts"; | |
| 8 | 8 | ||
| 9 | 9 | const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; | |
| 10 | 10 | ||
| ⋯ | |||
| 19 | 19 | ].join(", "); | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 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 | + | */ | |
| 23 | 26 | export function staffDeletionRefusal(deletion: AccountDeletion): string | null { | |
| 24 | 27 | 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(" ")}`; | |
| 28 | 53 | } | |
| 29 | 54 | ||
| 30 | 55 | /** Whether what staff typed is the username. Identity checks it again. */ | |
| ⋯ | |||
| 32 | 57 | const value = typed.trim(); | |
| 33 | 58 | return value !== "" && value.toLowerCase() === username.toLowerCase(); | |
| 34 | 59 | } | |
| 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 | + | } | |
| 1 | 1 | import { ArrowLeft } from "lucide-react"; | |
| 2 | 2 | import { Form, Link, data, redirect } from "react-router"; | |
| 3 | 3 | ||
| 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"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/user"; | |
| 7 | 7 | 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"; | |
| 10 | 20 | import { text } from "~/lib/forms"; | |
| 11 | − | import { accountsAdmin } from "~/lib/services.server"; | |
| 21 | + | import { accountsAdmin, identity } from "~/lib/services.server"; | |
| 12 | 22 | import { settle } from "~/lib/settle"; | |
| 13 | 23 | import { requireStaff } from "~/lib/staff"; | |
| 14 | 24 | ||
| ⋯ | |||
| 23 | 33 | if (result.ok && !result.value) throw data("No such account.", { status: 404 }); | |
| 24 | 34 | const url = new URL(request.url); | |
| 25 | 35 | 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 | + | }; | |
| 26 | 54 | return { | |
| 27 | − | user: result.ok ? result.value : null, | |
| 55 | + | user, | |
| 28 | 56 | error: result.ok ? null : result.error, | |
| 29 | 57 | 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, | |
| 31 | 61 | now: Date.now(), | |
| 32 | 62 | }; | |
| 33 | 63 | } | |
| 34 | 64 | ||
| 35 | 65 | /** | |
| 36 | 66 | * 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. | |
| 40 | 71 | */ | |
| 41 | 72 | export async function action({ params, request, context }: Route.ActionArgs) { | |
| 42 | 73 | const staff = requireStaff(context); | |
| ⋯ | |||
| 46 | 77 | if (intent === "delete-account") { | |
| 47 | 78 | const reason = text(form, "reason"); | |
| 48 | 79 | 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); | |
| 54 | 90 | 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"); | |
| 56 | 92 | } | |
| 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 | + | } | |
| 57 | 102 | if (intent === "restore-account") { | |
| 58 | 103 | const result = await accountsAdmin.restoreAccount(text(form, "id"), staff.email); | |
| 59 | 104 | if (!result.ok) return data({ error: result.error.message, account: true }, { status: 422 }); | |
| ⋯ | |||
| 77 | 122 | } | |
| 78 | 123 | ||
| 79 | 124 | 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; | |
| 81 | 126 | const accountError = actionData && "account" in actionData ? actionData.error : null; | |
| 127 | + | const workspaceError = actionData && "workspace" in actionData ? { id: actionData.workspace, error: actionData.error } : null; | |
| 82 | 128 | if (!user) { | |
| 83 | 129 | return ( | |
| 84 | 130 | <main className="mx-auto max-w-4xl px-4 py-8 sm:py-10"> | |
| ⋯ | |||
| 183 | 229 | )} | |
| 184 | 230 | </Section> | |
| 185 | 231 | ||
| 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 | + | /> | |
| 187 | 240 | </main> | |
| 188 | 241 | ); | |
| 189 | 242 | } | |
| 190 | 243 | ||
| 191 | 244 | /** | |
| 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. | |
| 194 | 248 | */ | |
| 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 | + | }) { | |
| 196 | 264 | const deleted = user.deleted; | |
| 197 | 265 | if (deleted) { | |
| 198 | 266 | const left = daysLeft(deleted.purgeAfter, now); | |
| ⋯ | |||
| 200 | 268 | <Section | |
| 201 | 269 | id="account" | |
| 202 | 270 | 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.`} | |
| 204 | 272 | className="mt-6" | |
| 205 | 273 | > | |
| 206 | 274 | <div className="space-y-3 text-sm"> | |
| ⋯ | |||
| 218 | 286 | </p> | |
| 219 | 287 | {deleted.went.reason && <p className="text-muted">Reason: {deleted.went.reason}</p>} | |
| 220 | 288 | <p className="text-muted">Left: {accountWentSummary(deleted.went)}</p> | |
| 289 | + | {workspaces.length > 0 && ( | |
| 290 | + | <DeletedWorkspaces workspaces={workspaces} error={workspacesError} workspaceError={workspaceError} now={now} /> | |
| 291 | + | )} | |
| 221 | 292 | {error && <Notice tone="error">{error}</Notice>} | |
| 222 | 293 | <div className="flex flex-col gap-3 border-t border-line pt-4 sm:flex-row sm:items-end sm:justify-between"> | |
| 223 | 294 | <form method="post"> | |
| ⋯ | |||
| 250 | 321 | ); | |
| 251 | 322 | } | |
| 252 | 323 | const refusal = staffDeletionRefusal(user.deletion); | |
| 324 | + | const sole = user.deletion.sole_owner_of; | |
| 325 | + | const blocked = soleWorkspacesRefusal(user.deletion); | |
| 253 | 326 | return ( | |
| 254 | 327 | <Section | |
| 255 | 328 | id="account" | |
| 256 | 329 | 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.`} | |
| 258 | 331 | className="mt-6" | |
| 259 | 332 | > | |
| 260 | 333 | {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 ? ( | |
| 279 | 336 | <details className="rounded-md border border-danger/30 px-3 py-2" open={Boolean(error)}> | |
| 280 | 337 | <summary className="cursor-pointer text-sm text-danger">Delete this account</summary> | |
| 281 | 338 | <form method="post" className="mt-3 grid gap-3 sm:max-w-md"> | |
| ⋯ | |||
| 294 | 351 | </div> | |
| 295 | 352 | </form> | |
| 296 | 353 | </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> | |
| 297 | 424 | )} | |
| 298 | 425 | </Section> | |
| 299 | 426 | ); | |
| 300 | 427 | } | |
| 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 | + | } | |
| 14 | 14 | protected: false, | |
| 15 | 15 | }; | |
| 16 | 16 | ||
| 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 }); | |
| 18 | 18 | ||
| 19 | 19 | test("the dialog lists only what the account has", () => { | |
| 20 | 20 | assert.deepEqual(whatAccountDeletionTakes(nothing), []); |
| 6 | 6 | //! themselves, typing their username and proving it is them | |
| 7 | 7 | //! ([`crate::accounts::Reauth`]); g1t's staff can delete one from sudo with | |
| 8 | 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 | |
| 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 | |
| 10 | 14 | //! that run g1t, and the names g1t shows for itself, can never be deleted | |
| 11 | 15 | //! ([`is_protected_account`]). | |
| 12 | 16 | //! | |
| ⋯ | |||
| 64 | 68 | pub members: u32, | |
| 65 | 69 | /// Why billing could not close it yet if it were deleted now, in words | |
| 66 | 70 | /// for its owner: what deleting it first would need. Null when nothing | |
| 67 | − | /// is owed, and always for staff. | |
| 71 | + | /// is owed. | |
| 68 | 72 | #[serde(default)] | |
| 69 | 73 | 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, | |
| 70 | 78 | } | |
| 71 | 79 | ||
| 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 | + | ||
| 72 | 111 | /// `check_account_deletion` (takes `UserArgs`, people only) returns | |
| 73 | 112 | /// `Outcome<AccountDeletion>`: what deleting the account would take with | |
| 74 | 113 | /// it, and what stands in the way, changing nothing. Nothing does when | |
| ⋯ | |||
| 170 | 209 | /// Why staff deleted it. | |
| 171 | 210 | #[serde(default)] | |
| 172 | 211 | 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>, | |
| 173 | 217 | } | |
| 174 | 218 | ||
| 175 | 219 | /// An account deleted and kept until `purge_after` for staff to restore. | |
| ⋯ | |||
| 191 | 235 | ||
| 192 | 236 | /// `admin_delete_account`: staff delete an account, with a reason (kept in | |
| 193 | 237 | /// 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>`. | |
| 196 | 241 | #[derive(Debug, Serialize, Deserialize)] | |
| 197 | 242 | #[serde(rename_all = "camelCase")] | |
| 198 | 243 | pub struct AdminDeleteAccountArgs { | |
| ⋯ | |||
| 202 | 247 | pub confirm: String, | |
| 203 | 248 | /// The staff member, by email. | |
| 204 | 249 | 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, | |
| 205 | 260 | } | |
| 206 | 261 | ||
| 207 | 262 | /// `admin_restore_account` and `admin_purge_account`: staff restore a | |
| ⋯ | |||
| 223 | 278 | use crate::identity::protected_names; | |
| 224 | 279 | ||
| 225 | 280 | 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); | |
| 227 | 306 | } | |
| 228 | 307 | ||
| 229 | 308 | #[test] | |
| 601 | 601 | pub name: String, | |
| 602 | 602 | /// RFC 3339. | |
| 603 | 603 | 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. | |
| 605 | 606 | pub deleted_by: String, | |
| 606 | 607 | /// RFC 3339: when it is purged unless restored first. | |
| 607 | 608 | pub purge_after: String, |
| 5 | 5 | * A person deletes their own account from Settings, typing their username | |
| 6 | 6 | * and proving it is them; g1t's staff can delete one from sudo with a | |
| 7 | 7 | * 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 | |
| 9 | 11 | * could sign in with ends at once and it leaves every workspace, and it is | |
| 10 | 12 | * kept for `ACCOUNT_RESTORE_DAYS` for staff to restore. Then it is purged, | |
| 11 | 13 | * its username is never given to anyone again, and what it wrote shows as | |
| ⋯ | |||
| 27 | 29 | name: string; | |
| 28 | 30 | /** Everyone in it, the account included. */ | |
| 29 | 31 | 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. */ | |
| 31 | 33 | billing: string | null; | |
| 34 | + | /** It can never be deleted, by anyone, so staff cannot delete it with the account either. */ | |
| 35 | + | protected: boolean; | |
| 32 | 36 | }; | |
| 33 | 37 | ||
| 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 | + | ||
| 34 | 44 | /** What deleting an account takes with it, and what stands in the way. */ | |
| 35 | 45 | export type AccountDeletion = { | |
| 36 | 46 | username: string; | |
| ⋯ | |||
| 58 | 68 | /** The staff member who deleted it; null when the person did. */ | |
| 59 | 69 | staff: string | null; | |
| 60 | 70 | 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[]; | |
| 61 | 73 | }; | |
| 62 | 74 | ||
| 63 | 75 | /** An account deleted and kept until `purgeAfter` for staff to restore. */ | |
| 197 | 197 | removeEmail(username: string, email: string, reason: string, staff: string): Promise<Result<AdminUser>>; | |
| 198 | 198 | /** | |
| 199 | 199 | * 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`). | |
| 202 | 206 | */ | |
| 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>>; | |
| 204 | 214 | /** Deleted accounts not purged yet, newest first. */ | |
| 205 | 215 | deletedAccounts(): Promise<DeletedAccount[]>; | |
| 206 | 216 | /** Brings a deleted account back within its window, with the memberships it left. Publishes `user.restored`. */ | |
| ⋯ | |||
| 245 | 255 | return { | |
| 246 | 256 | user: (username) => call(identity, "admin_user", { username }), | |
| 247 | 257 | 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 }), | |
| 249 | 260 | deletedAccounts: () => call(identity, "admin_deleted_accounts", {}), | |
| 250 | 261 | restoreAccount: (userId, staff) => call(identity, "admin_restore_account", { userId, staff }), | |
| 251 | 262 | purgeAccount: (userId, staff, confirm) => call(identity, "admin_purge_account", { userId, staff, confirm }), | |
| 591 | 591 | name: string; | |
| 592 | 592 | /** RFC 3339. */ | |
| 593 | 593 | 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. */ | |
| 595 | 595 | deletedBy: string; | |
| 596 | 596 | /** RFC 3339: when it is purged unless restored first. */ | |
| 597 | 597 | purgeAfter: string; |
| 13 | 13 | //! the account co-owns is someone else's to pay for, and one it owns alone | |
| 14 | 14 | //! is in the way already. | |
| 15 | 15 | //! | |
| 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 | + | //! | |
| 16 | 28 | //! Deleting is soft first, as for a workspace. At once, in one batch: the | |
| 17 | 29 | //! row gets `deleted_at`, `deleted_by` and `purge_after` | |
| 18 | 30 | //! ([`ACCOUNT_RESTORE_DAYS`] on); its sessions, access tokens (classic, | |
| ⋯ | |||
| 43 | 55 | //! | |
| 44 | 56 | //! There is no API route for any of this: only the site and sudo call it. | |
| 45 | 57 | ||
| 58 | + | use std::future::Future; | |
| 59 | + | ||
| 46 | 60 | use g1t_contracts::FailureCode; | |
| 47 | 61 | use g1t_contracts::Outcome; | |
| 48 | 62 | use g1t_contracts::User; | |
| 49 | 63 | use g1t_contracts::account_deletion::*; | |
| 50 | − | use g1t_contracts::billing::CloseWorkspaceArgs; | |
| 51 | 64 | use g1t_contracts::events::{UserDeleted, UserDeleting, UserRestored}; | |
| 52 | 65 | use g1t_contracts::identity::{UserArgs, protected_names}; | |
| 53 | 66 | use g1t_contracts::time::rfc3339; | |
| ⋯ | |||
| 57 | 70 | use worker::wasm_bindgen::JsValue; | |
| 58 | 71 | ||
| 59 | 72 | use crate::Identity; | |
| 73 | + | use crate::deletion::staff_billing_actor; | |
| 60 | 74 | use crate::security::is_person; | |
| 61 | 75 | ||
| 62 | 76 | type Refusal = (FailureCode, String); | |
| ⋯ | |||
| 101 | 115 | Ok(()) | |
| 102 | 116 | } | |
| 103 | 117 | ||
| 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 | + | ||
| 104 | 224 | /// Whether staff may restore a deleted account, now `now`. | |
| 105 | 225 | pub fn may_restore(username: &str, purge_after: &str, now: &str) -> std::result::Result<(), Refusal> { | |
| 106 | 226 | if !restorable(purge_after, now) { | |
| ⋯ | |||
| 336 | 456 | async fn sole_owned(&self, user_id: &str) -> Result<Vec<SoleOwnedWorkspace>> { | |
| 337 | 457 | #[derive(Deserialize)] | |
| 338 | 458 | struct Row { | |
| 459 | + | id: String, | |
| 339 | 460 | slug: String, | |
| 340 | 461 | name: String, | |
| 341 | 462 | members: u32, | |
| 463 | + | #[serde(default)] | |
| 464 | + | protected: u8, | |
| 342 | 465 | } | |
| 343 | 466 | let rows = self | |
| 344 | 467 | .db | |
| 345 | 468 | .prepare( | |
| 346 | − | "SELECT w.slug, w.name, | |
| 469 | + | "SELECT w.id, w.slug, w.name, w.protected, | |
| 347 | 470 | (SELECT count(*) FROM workspace_members a WHERE a.workspace_id = w.id) AS members | |
| 348 | 471 | FROM workspace_members m JOIN workspaces w ON w.id = m.workspace_id | |
| 349 | 472 | WHERE m.user_id = ?1 AND m.role = 'owner' AND w.deleted_at IS NULL | |
| ⋯ | |||
| 355 | 478 | .all() | |
| 356 | 479 | .await? | |
| 357 | 480 | .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) | |
| 362 | 487 | } | |
| 363 | 488 | ||
| 364 | 489 | /// 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> { | |
| 368 | 494 | #[derive(Deserialize)] | |
| 369 | 495 | struct Counts { | |
| 370 | 496 | workspaces: u32, | |
| ⋯ | |||
| 390 | 516 | .await? | |
| 391 | 517 | .unwrap_or(Counts { workspaces: 0, tokens: 0, ssh_keys: 0, applications: 0, repositories: 0 }); | |
| 392 | 518 | 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 | + | }; | |
| 409 | 531 | } | |
| 410 | 532 | Ok(AccountDeletion { | |
| 411 | 533 | username: username.to_owned(), | |
| ⋯ | |||
| 428 | 550 | let Some(live) = self.live_account("id", &a.user.id).await? else { | |
| 429 | 551 | return Ok(Outcome::fail(FailureCode::NotFound, "Account not found.")); | |
| 430 | 552 | }; | |
| 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?)) | |
| 432 | 554 | } | |
| 433 | 555 | ||
| 434 | 556 | /// `delete_account`: the person deletes their own account. | |
| ⋯ | |||
| 439 | 561 | let Some(live) = self.live_account("id", &a.user.id).await? else { | |
| 440 | 562 | return Ok(Outcome::fail(FailureCode::NotFound, "Account not found.")); | |
| 441 | 563 | }; | |
| 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?; | |
| 443 | 565 | if let Err((code, message)) = may_delete_own(true, &deletion, &a.confirm) { | |
| 444 | 566 | return Ok(Outcome::fail(code, message)); | |
| 445 | 567 | } | |
| ⋯ | |||
| 448 | 570 | if let Some(refusal) = self.proof(&live.id, &a.reauth).await?.refusal() { | |
| 449 | 571 | return Ok(refusal); | |
| 450 | 572 | } | |
| 451 | − | self.soft_delete(&live, &deletion, Some(&live.id), None).await?; | |
| 573 | + | self.soft_delete(&live, &deletion, Some(&live.id), None, Vec::new()).await?; | |
| 452 | 574 | Ok(Outcome::Ok(true)) | |
| 453 | 575 | } | |
| 454 | 576 | ||
| 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. | |
| 456 | 580 | pub async fn admin_delete_account(&self, a: AdminDeleteAccountArgs) -> Result<Outcome<bool>> { | |
| 457 | 581 | let staff = a.staff.trim(); | |
| 458 | 582 | let reason = a.reason.trim(); | |
| ⋯ | |||
| 465 | 589 | let Some(live) = self.live_account("username", &a.username.trim().to_lowercase()).await? else { | |
| 466 | 590 | return Ok(Outcome::fail(FailureCode::NotFound, "No such account, or it is already deleted.")); | |
| 467 | 591 | }; | |
| 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 | + | }; | |
| 475 | 619 | self.record_for_staff( | |
| 476 | 620 | &live.username, | |
| 477 | 621 | "account_deleted", | |
| 478 | − | &format!("Deleted the account {}: {reason}", live.username), | |
| 622 | + | &format!("Deleted the account {}{with}: {reason}", live.username), | |
| 479 | 623 | staff, | |
| 480 | 624 | ) | |
| 481 | 625 | .await; | |
| ⋯ | |||
| 489 | 633 | deletion: &AccountDeletion, | |
| 490 | 634 | deleted_by: Option<&str>, | |
| 491 | 635 | staff: Option<(&str, &str)>, | |
| 636 | + | workspaces: Vec<WorkspaceDeletedWith>, | |
| 492 | 637 | ) -> Result<()> { | |
| 493 | 638 | 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; | |
| 495 | 641 | let now = now_ms(); | |
| 496 | 642 | let at = rfc3339(now); | |
| 497 | 643 | let purge = purge_after(now); | |
| ⋯ | |||
| 571 | 717 | ssh_keys: deletion.ssh_keys, | |
| 572 | 718 | staff: staff.map(|(who, _)| who.to_owned()), | |
| 573 | 719 | reason: staff.map(|(_, why)| why.to_owned()), | |
| 720 | + | deleted_workspaces: Vec::new(), | |
| 574 | 721 | }, | |
| 575 | 722 | memberships, | |
| 576 | 723 | teams, | |
| ⋯ | |||
| 760 | 907 | } | |
| 761 | 908 | ||
| 762 | 909 | 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."); | |
| 764 | 1006 | } | |
| 765 | 1007 | ||
| 766 | 1008 | #[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] | |
| 767 | 1043 | fn only_the_person_typing_their_username() { | |
| 768 | 1044 | assert!(may_delete_own(true, &deletion("ada"), " ADA ").is_ok()); | |
| 769 | 1045 | assert_eq!(may_delete_own(false, &deletion("ada"), "ada").unwrap_err().0, FailureCode::Forbidden); | |
| ⋯ | |||
| 825 | 1101 | #[test] | |
| 826 | 1102 | fn what_it_left_is_kept_for_a_restore_and_read_back() { | |
| 827 | 1103 | 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 | + | }, | |
| 829 | 1114 | memberships: vec![Member { | |
| 830 | 1115 | workspace_id: "wsp_1".into(), | |
| 831 | 1116 | role: "owner".into(), | |
| 35 | 35 | //! | |
| 36 | 36 | //! A person keeps their account whatever workspaces they lose: an account | |
| 37 | 37 | //! 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. | |
| 38 | 44 | ||
| 39 | 45 | use g1t_contracts::audit::{ | |
| 40 | 46 | AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface, | |
| ⋯ | |||
| 44 | 50 | use g1t_contracts::identity::*; | |
| 45 | 51 | use g1t_contracts::repos::NamespaceCountArgs; | |
| 46 | 52 | 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}; | |
| 48 | 55 | use g1t_kit::now_ms; | |
| 49 | 56 | use serde::Deserialize; | |
| 50 | 57 | use serde_json::json; | |
| ⋯ | |||
| 145 | 152 | Ok(()) | |
| 146 | 153 | } | |
| 147 | 154 | ||
| 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 | + | ||
| 148 | 189 | /// What `deleted_went` holds: what went with the workspace. | |
| 149 | 190 | fn went_json(went: &WorkspaceDeletion) -> String { | |
| 150 | 191 | json!({ | |
| ⋯ | |||
| 262 | 303 | Ok(is_protected(&names, id, slug, flagged, &old)) | |
| 263 | 304 | } | |
| 264 | 305 | ||
| 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> { | |
| 267 | 328 | let repositories: u32 = g1t_kit::call( | |
| 268 | 329 | &self.env.service("REPOS")?, | |
| 269 | 330 | "namespace_count", | |
| ⋯ | |||
| 289 | 350 | .first::<Count>(None) | |
| 290 | 351 | .await? | |
| 291 | 352 | .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?; | |
| 302 | 353 | Ok(WorkspaceDeletion { | |
| 303 | 354 | repositories, | |
| 304 | 355 | projects, | |
| 305 | 356 | 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?, | |
| 310 | 358 | protected: self.is_protected(&target.id, slug, target.protected != 0).await?, | |
| 311 | 359 | }) | |
| 312 | 360 | } | |
| ⋯ | |||
| 345 | 393 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 346 | 394 | }; | |
| 347 | 395 | 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?)) | |
| 349 | 397 | } | |
| 350 | 398 | ||
| 351 | 399 | pub async fn delete_workspace(&self, a: DeleteWorkspaceArgs) -> Result<Outcome<bool>> { | |
| ⋯ | |||
| 354 | 402 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 355 | 403 | }; | |
| 356 | 404 | 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) { | |
| 359 | 477 | let code = if went.protected { FailureCode::Forbidden } else { FailureCode::PaymentRequired }; | |
| 360 | 478 | return Ok(Outcome::fail(code, reason)); | |
| 361 | 479 | } | |
| ⋯ | |||
| 366 | 484 | &self.env.service("BILLING")?, | |
| 367 | 485 | "close_workspace", | |
| 368 | 486 | &CloseWorkspaceArgs { | |
| 369 | − | actor: a.actor.clone(), | |
| 370 | − | workspace: slug.clone(), | |
| 487 | + | actor: deleter.billing.clone(), | |
| 488 | + | workspace: slug.to_owned(), | |
| 371 | 489 | dry_run: false, | |
| 372 | 490 | }, | |
| 373 | 491 | ) | |
| ⋯ | |||
| 384 | 502 | ) | |
| 385 | 503 | .bind(&[ | |
| 386 | 504 | rfc3339(now).into(), | |
| 387 | − | a.actor.id.as_str().into(), | |
| 505 | + | deleter.deleted_by.into(), | |
| 388 | 506 | purge.as_str().into(), | |
| 389 | 507 | went_json(&went).into(), | |
| 390 | 508 | workspace.id.as_str().into(), | |
| ⋯ | |||
| 392 | 510 | .run() | |
| 393 | 511 | .await?; | |
| 394 | 512 | 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, | |
| 398 | 516 | "workspace.deleted", | |
| 399 | − | "owner", | |
| 400 | − | format!("Deleted {slug}; restorable by g1t's staff until {purge}"), | |
| 517 | + | deleter.rule, | |
| 518 | + | (deleter.message)(&purge), | |
| 401 | 519 | ) | |
| 402 | 520 | .await; | |
| 403 | 521 | self.announce( | |
| 404 | 522 | "workspace.deleting", | |
| 405 | − | Some(&a.actor.id), | |
| 523 | + | deleter.actor_id, | |
| 406 | 524 | 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(), | |
| 410 | 528 | purge_after: purge, | |
| 411 | 529 | }, | |
| 412 | 530 | ) | |
| ⋯ | |||
| 821 | 939 | } | |
| 822 | 940 | ||
| 823 | 941 | #[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] | |
| 824 | 952 | fn a_deleted_slug_is_reclaimed_only_by_its_namesake() { | |
| 825 | 953 | assert!(may_reclaim("syntaqx", "syntaqx")); | |
| 826 | 954 | assert!(may_reclaim("syntaqx", "Syntaqx")); | |
| 1129 | 1129 | let log = self.security_events(user_id, true).await?; | |
| 1130 | 1130 | let emails = view(&account, rows); | |
| 1131 | 1131 | // 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. | |
| 1133 | 1135 | 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?; | |
| 1135 | 1139 | Ok(Some(AdminUser { | |
| 1136 | 1140 | id: account.id, | |
| 1137 | 1141 | username: account.username, |