Merge sudo: delete an account with the workspaces it alone owns, purge each
15 files+1026−1520/15 viewed
| 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, |