feat(admin): let an admin delete another account through the grace window

There was no way for an admin to remove someone else's account; the
users list only offered the undo for a deletion the account itself or a
Guardian had asked for.

DELETE /api/admin/users/{id} takes the account's own deletion path,
now shared as requestAccountDeletion: with a grace window the account
is locked at once (credentials revoked, record untouched) and erased by
the purge job when the window ends, and the existing undo gives it back
until then; with ACCOUNT_DELETION_GRACE_DAYS=0 it is erased in the
request. The route is cookie-only behind requireAdmin and the
recent-proof gate, takes the typed DELETE_ACCOUNT confirmation, and
refuses the caller's own account, a managed profile (its Guardians
delete it), an account already pending deletion, the last admin who
can sign in and the last Guardian of a managed profile, the last three
decided under their locks inside the transaction. Both the admin and
the account get an audit row.

The users list says who asked for a pending deletion, now including an
admin, and flags managed profiles. Each other account's row carries a
delete button whose confirmation names the purge date, or says the
erasure is immediate when the server keeps no window.
This commit is contained in:
Marc-André Bombeck committed 2026-10-29 09:10:26 +01:00
1 parent ef141f457b
commit 107db1bb55
28 files changed
+828 -95

No files matched your search

@@ -679,6 +679,17 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "permanent",
confirm: ["AlertDialog"],
},
{
file: "components/admin/delete-user-control.tsx",
destroys:
"another user's whole account, locked at once and erased when the deletion grace window ends (or at once when the window is 0)",
triggers: ["components/admin/user-management-section.tsx"],
// The undo route exists and the row offers it until the purge date
// (`pending-deletion-controls.tsx`); with the window at 0 there is
// nothing to undo, and the dialog says so.
recovery: "restorable",
confirm: ["ConfirmButton"],
},
// ── Integrations ────────────────────────────────────────────────────────
// Every connection renders the shared integration card, whose three-dot
+15 -3
View File
@@ -145,15 +145,27 @@ describe("managed-profile contract", () => {
path.join(root, "src/lib/sharing/grants.ts"),
"utf8",
);
const accountDeletion = await readFile(
path.join(root, "src/app/api/settings/account/route.ts"),
// Both account deletions (the account's own and an admin's) take the one
// request path, and that path decides the Guardian invariant.
const deletionRequest = await readFile(
path.join(root, "src/lib/account-deletion/request.ts"),
"utf8",
);
const deletionRoutes = await Promise.all(
[
"src/app/api/settings/account/route.ts",
"src/app/api/admin/users/[id]/route.ts",
].map((rel) => readFile(path.join(root, rel), "utf8")),
);
expect(lifecycle).toContain("pg_advisory_xact_lock");
expect(lifecycle).toContain("clearManagedProfileMarker");
expect(lifecycle).toContain("deleteManagedProfile");
expect(grants).toContain("reduceManagedProfileGuardian");
expect(accountDeletion).toContain("deleteGuardianAccountWithLifecycle");
expect(deletionRequest).toContain("deleteGuardianAccountWithLifecycle(");
for (const route of deletionRoutes) {
expect(route).toContain("requestAccountDeletion(");
expect(route).not.toContain("lockAccountForDeletion(");
}
});
});
@@ -295,7 +295,7 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
"/api/admin/support-report": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/tokens": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/users": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/users/{id}": { kind: "adminConsole", methods: ["PUT"] },
"/api/admin/users/{id}": { kind: "adminConsole", methods: ["DELETE", "PUT"] },
"/api/admin/users/{id}/force-logout": {
kind: "adminConsole",
methods: ["POST"],
@@ -111,6 +111,7 @@ const COOKIE_PROOF_ROUTES = [
"app/api/admin/backups/upload/route.ts",
"app/api/admin/data/route.ts",
"app/api/admin/users/[id]/reset-password/route.ts",
"app/api/admin/users/[id]/route.ts",
"app/api/auth/reproof/route.ts",
"app/api/tokens/documents/route.ts",
"app/api/tokens/measurements/route.ts",
@@ -112,6 +112,7 @@ function discoverGatedRoutes(): string[] {
*/
const GATED_ROUTES: Record<string, string> = {
"DELETE /api/admin/data": "web",
"DELETE /api/admin/users/[id]": "web",
"DELETE /api/auth/me/mfa/webauthn/[id]": "web",
"DELETE /api/auth/passkeys/[id]": "web",
"DELETE /api/managed-profiles/[id]": "web",
+140 -1
View File
@@ -3,13 +3,21 @@ import {
assertAnotherAdminStays,
LastAdminError,
} from "@/lib/auth/admin-roster";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiHandler,
assertRecentCookieProof,
requireAdmin,
} from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { requestAccountDeletion } from "@/lib/account-deletion/request";
import { lockPendingAccount } from "@/lib/account-deletion/lifecycle";
import { LastManagedGuardianError } from "@/lib/managed-profiles/lifecycle";
import { adminDeleteUserSchema } from "@/lib/validations/admin";
const updateUserSchema = z
.object({
@@ -122,3 +130,134 @@ export const PUT = apiHandler(
});
},
);
/** A refusal decided under the account's row lock, inside the transaction. */
class DeletionRefusal extends Error {
constructor(readonly kind: "not_found" | "managed_profile" | "pending") {
super(`admin deletion refused: ${kind}`);
this.name = "DeletionRefusal";
}
}
function deletionRefusalResponse(kind: DeletionRefusal["kind"]): Response {
if (kind === "not_found") return apiError("User not found", 404);
if (kind === "managed_profile") {
return apiError("A managed profile is deleted by its Guardians", 409, {
errorCode: "admin.users.delete.managed_profile",
});
}
return apiError("This account is already pending deletion", 409, {
errorCode: "account.deletion.already_pending",
});
}
/**
* DELETE /api/admin/users/{id} — an admin deletes another account.
*
* The same request the account's own deletion makes
* (`requestAccountDeletion`): with a grace window the account is locked at
* once (every credential revoked, sign-in refused) and erased by the purge job
* when the window ends, and `POST /api/admin/users/{id}/undo-deletion` gives it
* back until then; with `ACCOUNT_DELETION_GRACE_DAYS=0` it is erased here.
*
* Cookie-only (`requireAdmin`) behind a fresh proof (`assertRecentCookieProof`,
* the gate in front of the other admin acts on someone's data), with the typed
* confirmation the self-deletion asks for. Refused for the caller's own
* account (that is the account settings' deletion, with its own step-up), for
* the last admin who can sign in, for a managed profile (its Guardians delete
* it), for an account already pending deletion, and for the last Guardian of
* a managed profile. Audit-logged for the admin and on the account itself.
*/
export const DELETE = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user: admin, session } = await requireAdmin();
await assertRecentCookieProof(admin, session.id);
const { id } = await params;
annotate({
action: {
name: "admin.users.delete",
entity_type: "user",
entity_id: id,
},
});
const parsed = await parseJsonBody(request, adminDeleteUserSchema, {
maxBytes: 4 * 1024,
});
if (!parsed.ok) return parsed.error;
if (id === admin.id) {
return apiError(
"Delete your own account from your account settings",
400,
{ errorCode: "admin.users.delete.self" },
);
}
const target = await prisma.user.findUnique({
where: { id },
select: { role: true, username: true },
});
if (!target) return apiError("User not found", 404);
let purgeAt: Date | null;
try {
({ purgeAt } = await requestAccountDeletion({
userId: id,
targetIsAdmin: target.role === "ADMIN",
requestedById: admin.id,
inTransaction: async (tx, scheduledPurgeAt) => {
const state = await lockPendingAccount(tx, id);
if (!state) throw new DeletionRefusal("not_found");
if (state.managedProfile)
throw new DeletionRefusal("managed_profile");
if (state.pending) throw new DeletionRefusal("pending");
const details = {
targetUserId: id,
username: target.username,
scheduledPurgeAt: scheduledPurgeAt?.toISOString() ?? null,
};
await auditLog("admin.user.delete", {
userId: admin.id,
ipAddress: getClientIp(request),
details,
client: tx,
});
// On the account itself too, so its own activity shows who asked.
// An immediate erasure takes this row with the account's history.
await auditLog("user.account.delete", {
userId: id,
actorUserId: admin.id,
details: { ...details, requestedBy: "admin" },
client: tx,
});
},
}));
} catch (error) {
if (error instanceof DeletionRefusal) {
return deletionRefusalResponse(error.kind);
}
if (error instanceof LastAdminError) {
return apiError("The last admin account cannot be deleted", 400);
}
if (error instanceof LastManagedGuardianError) {
return apiError(
"This account is the last Guardian of a managed profile. Add another Guardian or delete the profile first",
409,
{ errorCode: "managed_profile.guardian.required" },
);
}
throw error;
}
annotate({ meta: { deletion_scheduled: purgeAt !== null } });
return apiSuccess({
deleted: true,
scheduledPurgeAt: purgeAt?.toISOString() ?? null,
});
},
);
@@ -100,6 +100,7 @@ describe("GET /api/admin/users", () => {
// BigInt in Prisma, plain number on the wire.
documentQuotaBytes: 2_147_483_648,
passkeyCount: 2,
managedProfile: false,
// Not pending deletion.
deletion: null,
});
@@ -128,6 +129,7 @@ describe("GET /api/admin/users", () => {
deletionRequestedAt: true,
deletionPurgeAt: true,
deletionRequestedById: true,
managedProfileAt: true,
_count: { select: { passkeys: true } },
});
expect(args.select).not.toHaveProperty("passwordHash");
+9 -3
View File
@@ -21,6 +21,7 @@ export const GET = apiHandler(async () => {
deletionRequestedAt: true,
deletionPurgeAt: true,
deletionRequestedById: true,
managedProfileAt: true,
_count: { select: { passkeys: true } },
},
orderBy: { createdAt: "asc" },
@@ -42,9 +43,12 @@ export const GET = apiHandler(async () => {
documentQuotaBytes:
u.documentQuotaBytes === null ? null : Number(u.documentQuotaBytes),
passkeyCount: u._count.passkeys,
// A record somebody else runs. Its Guardians delete it, so the list
// offers no admin deletion on it.
managedProfile: u.managedProfileAt != null,
// An account pending deletion: when it was asked for, when the
// purge job erases it, and whether the account asked itself or a
// Guardian deleted the managed profile. Null on every other account.
// purge job erases it, and who asked: the account itself, a Guardian
// (a managed profile), or an admin. Null on every other account.
// The list offers "Undo deletion" on these rows until `purgeAt`.
deletion: u.deletionRequestedAt
? {
@@ -53,7 +57,9 @@ export const GET = apiHandler(async () => {
requestedBy:
u.deletionRequestedById === u.id
? ("self" as const)
: ("guardian" as const),
: u.managedProfileAt != null
? ("guardian" as const)
: ("admin" as const),
}
: null,
})),
+16 -58
View File
@@ -8,24 +8,10 @@ import {
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { kickOffhostPurge } from "@/lib/jobs/offhost-purge";
import {
deleteGuardianAccountWithLifecycle,
LastManagedGuardianError,
} from "@/lib/managed-profiles/lifecycle";
import {
accountDeletionGraceDays,
accountDeletionPurgeAt,
} from "@/lib/account-deletion/grace";
import {
eraseAccount,
lockAccountForDeletion,
} from "@/lib/account-deletion/lifecycle";
import { LastManagedGuardianError } from "@/lib/managed-profiles/lifecycle";
import { requestAccountDeletion } from "@/lib/account-deletion/request";
import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account";
import {
assertAnotherAdminStays,
LastAdminError,
} from "@/lib/auth/admin-roster";
import { LastAdminError } from "@/lib/auth/admin-roster";
export const dynamic = "force-dynamic";
@@ -104,53 +90,27 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
// the elevation would stay redeemable while the erasure was already running.
await auth.commitElevation();
const requestedAt = new Date();
const purgeAt = accountDeletionPurgeAt(
requestedAt,
accountDeletionGraceDays(),
);
let purgeAt: Date | null;
try {
await deleteGuardianAccountWithLifecycle(
({ purgeAt } = await requestAccountDeletion({
userId,
async (tx) => {
// Checked again under the roster lock: two admins deleting at the
// same moment must not each count the other.
if (user.role === "ADMIN") await assertAnotherAdminStays(tx, userId);
// The invariant has now passed, so this success event cannot survive
// a refusal. On an immediate erasure it is purged with the rest of the
// account's audit history; during a grace window it is the account's
// own record of the request.
await auditLog("user.account.delete", {
targetIsAdmin: user.role === "ADMIN",
requestedById: userId,
// The invariants have passed by the time this runs, so this success
// event cannot survive a refusal. On an immediate erasure it is purged
// with the rest of the account's audit history; during a grace window
// it is the account's own record of the request.
inTransaction: (tx, scheduledPurgeAt) =>
auditLog("user.account.delete", {
userId,
ipAddress: getClientIp(request),
details: {
username,
scheduledPurgeAt: purgeAt?.toISOString() ?? null,
scheduledPurgeAt: scheduledPurgeAt?.toISOString() ?? null,
},
client: tx,
});
if (purgeAt) {
// Locked, not erased: credentials revoked in this transaction,
// the record untouched until the purge job reaches `purgeAt`.
await lockAccountForDeletion(tx, {
userId,
requestedById: userId,
requestedAt,
purgeAt,
});
return;
}
// No window: erase now, inside the same record lifecycle transaction
// so a refusal leaves every account state intact (sessions, the
// account's audit history — GDPR Art. 17 — the off-host purge
// request, then the row with everything cascading from it).
await eraseAccount(tx, userId, "account_deleted");
},
{ erasing: purgeAt === null },
);
}),
}));
} catch (error) {
if (error instanceof LastAdminError) {
return apiError("The last admin account cannot be deleted", 400);
@@ -165,8 +125,6 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
throw error;
}
if (!purgeAt) await kickOffhostPurge();
annotate({
action: { name: "settings.account.delete" },
meta: { username, deletion_scheduled: purgeAt !== null },
@@ -30,6 +30,8 @@ const sampleUsers = [
role: "ADMIN" as const,
passkeyCount: 2,
createdAt: "2026-04-01T08:00:00Z",
managedProfile: false,
deletion: null,
},
{
id: "u2",
@@ -38,6 +40,8 @@ const sampleUsers = [
role: "USER" as const,
passkeyCount: 1,
createdAt: "2026-04-15T08:00:00Z",
managedProfile: false,
deletion: null,
},
];
@@ -53,6 +57,10 @@ vi.mock("@tanstack/react-query", () => ({
}),
}));
vi.mock("@/lib/queries/use-privacy-summary", () => ({
useAccountDeletionGrace: () => ({ days: 14, pending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
@@ -115,6 +123,15 @@ describe("UserManagementSection — mobile responsive", () => {
expect(mobileSlice).toContain("Force logout");
});
it("offers deleting every account but the admin's own", () => {
const html = render();
// Two rows, each in the desktop table and the mobile list; only the
// other account carries the control.
const deletes = html.match(/data-slot="admin-user-delete"/g) ?? [];
expect(deletes).toHaveLength(2);
expect(html).toContain('aria-label="Delete account"');
});
it("uses smaller card padding on mobile (p-4 md:p-6)", () => {
const html = render();
expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*md:p-6/);
+6 -4
View File
@@ -34,15 +34,17 @@ export interface AdminUser {
// the instance-wide default applies.
documentQuotaBytes: number | null;
passkeyCount: number;
/** A record its Guardians run; they delete it, an admin does not. */
managedProfile: boolean;
/**
* Set while the account is pending deletion: when the purge job
* erases it, and whether the account asked itself or a Guardian deleted
* the managed profile. The row offers "Undo deletion" until then.
* Set while the account is pending deletion: when the purge job erases
* it, and who asked (the account itself, a Guardian of the managed
* profile, or an admin). The row offers "Undo deletion" until then.
*/
deletion: {
requestedAt: string;
purgeAt: string | null;
requestedBy: "self" | "guardian";
requestedBy: "self" | "guardian" | "admin";
} | null;
}
@@ -0,0 +1,136 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { apiDelete } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { useAccountDeletionGrace } from "@/lib/queries/use-privacy-summary";
import {
recentProofErrorMessage,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
import type { AdminUser } from "./_shared";
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Whether the row offers deleting the account: never the admin's own (that is
* the account settings' deletion), never a managed profile (its Guardians
* delete it), and not one already pending deletion (the row offers the undo).
*/
export function canAdminDelete(user: AdminUser, currentUserId: string | null) {
return (
user.id !== currentUserId && !user.managedProfile && user.deletion === null
);
}
/**
* The day the purge job would erase an account deleted now, or null when the
* window is 0 and the erasure happens at once. The server stamps the real
* date; this is the one the dialog names before asking.
*/
export function deletionDateFrom(graceDays: number, now: Date): Date | null {
return graceDays > 0 ? new Date(now.getTime() + graceDays * DAY_MS) : null;
}
/**
* "Delete account" on a row of the admin users list
* (`DELETE /api/admin/users/{id}`), for the rows `canAdminDelete` allows.
*/
export function DeleteUserControl({
user,
currentUserId,
}: {
user: AdminUser;
currentUserId: string | null;
}) {
if (!canAdminDelete(user, currentUserId)) return null;
return <DeleteUserButton user={user} />;
}
/**
* The dialog names the date the account is erased and that it can be undone
* here until then, or, with no grace window, that it is erased at once. It
* waits for the window before it offers anything, so the sentence is never a
* guess. The fresh proof the route asks for is collected by the shared
* re-proof dialog.
*/
function DeleteUserButton({ user }: { user: AdminUser }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const grace = useAccountDeletionGrace();
const recentProof = useRecentProof();
const remove = useMutation({
mutationFn: () =>
recentProof.run(() =>
apiDelete<{ scheduledPurgeAt: string | null }>(
`/api/admin/users/${user.id}`,
{ confirm: "DELETE_ACCOUNT" },
),
),
onSuccess: (result) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.adminUsers() });
toastWrittenOutcome(
"success",
result.scheduledPurgeAt
? t("admin.section.users.deleteUserScheduled", {
name: user.username,
date: formatDate(result.scheduledPurgeAt),
})
: t("admin.section.users.deleteUserDone", { name: user.username }),
);
},
onError: (err) => {
const message = recentProofErrorMessage(
err,
err instanceof Error && err.message
? err.message
: t("admin.section.users.deleteUserFailed"),
);
if (message) showErrorToast(message, { cause: err });
},
});
const purgeOn =
grace.days === null ? null : deletionDateFrom(grace.days, new Date());
return (
<>
{recentProof.dialog}
<ConfirmButton
slot="admin-user-delete"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 px-2 text-xs"
icon={<Trash2 className="h-3 w-3" aria-hidden="true" />}
label=""
ariaLabel={t("admin.section.users.deleteUser")}
disabled={grace.days === null}
title={t("admin.section.users.deleteUserTitle", {
name: user.username,
})}
body={
purgeOn
? t("admin.section.users.deleteUserBodyGrace", {
name: user.username,
date: formatDate(purgeOn),
})
: t("admin.section.users.deleteUserBodyNow", {
name: user.username,
})
}
confirmLabel={t("admin.section.users.deleteUserConfirm")}
pending={remove.isPending}
onConfirm={() => remove.mutate()}
destructive
/>
</>
);
}
@@ -13,6 +13,13 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { AdminUser } from "./_shared";
/** The undo dialog's sentence, by who asked for the deletion. */
const UNDO_BODY_KEYS = {
self: "admin.section.users.undoDeletionBody",
guardian: "admin.section.users.undoDeletionBodyGuardian",
admin: "admin.section.users.undoDeletionBodyAdmin",
} as const;
/**
* An account pending deletion, in the admin users list: the date the purge
* job erases it, and the one action that stops it.
@@ -64,12 +71,9 @@ export function PendingDeletionControls({ user }: { user: AdminUser }) {
title={t("admin.section.users.undoDeletionTitle", {
name: user.username,
})}
body={t(
user.deletion.requestedBy === "guardian"
? "admin.section.users.undoDeletionBodyGuardian"
: "admin.section.users.undoDeletionBody",
{ name: user.username },
)}
body={t(UNDO_BODY_KEYS[user.deletion.requestedBy], {
name: user.username,
})}
confirmLabel={t("admin.section.users.undoDeletion")}
pending={undo.isPending}
onConfirm={() => undo.mutate()}
@@ -40,6 +40,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { type AdminUser, PasswordInput } from "./_shared";
import { PendingDeletionControls } from "./pending-deletion-controls";
import { DeleteUserControl } from "./delete-user-control";
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import {
recentProofErrorMessage,
@@ -55,6 +56,13 @@ import {
*/
type UserFilter = "all" | "admin" | "user";
/** The quota override as the edit form's gigabyte field shows it. */
function quotaGbInput(bytes: number | null): string {
if (bytes === null) return "";
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- value of a type="number" input, which only takes a full stop
return String(Math.round((bytes / 1_073_741_824) * 10) / 10);
}
export function UserManagementSection() {
const { t } = useTranslations();
const currentUserId = useAuth().user?.id ?? "";
@@ -160,12 +168,7 @@ export function UserManagementSection() {
setEditingUser(u);
setEditUsername(u.username);
setEditEmail(u.email ?? "");
setEditQuotaGb(
u.documentQuotaBytes === null
? ""
: // eslint-disable-next-line healthlog/no-unlocalised-decimal -- value of a type="number" input, which only takes a full stop
String(Math.round((u.documentQuotaBytes / 1_073_741_824) * 10) / 10),
);
setEditQuotaGb(quotaGbInput(u.documentQuotaBytes));
}
function startReset(u: AdminUser) {
@@ -229,7 +232,7 @@ export function UserManagementSection() {
{u.mfaEnforced ? t("admin.mfaEnforcedOn") : t("admin.mfaEnforcedOff")}
<span className="sr-only">, {u.username}</span>
</Button>
{/* The three icon tools travel as one group: on a phone the row wraps
{/* The icon tools travel as one group: on a phone the row wraps
between the labelled toggles and the tools, never leaving the
sign-out icon alone on a line. */}
<span className="ml-auto flex items-center gap-1">
@@ -272,6 +275,7 @@ export function UserManagementSection() {
>
<LogOut className="h-3 w-3" aria-hidden="true" />
</Button>
<DeleteUserControl user={u} currentUserId={currentUserId} />
</span>
</>
);
+81
View File
@@ -0,0 +1,81 @@
/**
* Asking for an account to be deleted: the one path the account's own
* deletion (`DELETE /api/settings/account`) and an admin's deletion of
* another account (`DELETE /api/admin/users/{id}`) both take.
*
* With a grace window (`ACCOUNT_DELETION_GRACE_DAYS`) the account is locked:
* every credential is revoked at once, the record stays exactly as it is, and
* the purge job erases it when the window ends; an admin can undo it until
* then. With the window at 0 it is erased here, in the same transaction.
*
* The Guardian invariant (an account may not leave a managed profile without
* a Guardian) and the admin roster (the last admin who can sign in stays) are
* decided inside the transaction, under their locks, and refuse by throwing
* `LastManagedGuardianError` or `LastAdminError`.
*/
import type { Prisma } from "@/generated/prisma/client";
import { kickOffhostPurge } from "@/lib/jobs/offhost-purge";
import { deleteGuardianAccountWithLifecycle } from "@/lib/managed-profiles/lifecycle";
import { assertAnotherAdminStays } from "@/lib/auth/admin-roster";
import {
accountDeletionGraceDays,
accountDeletionPurgeAt,
} from "@/lib/account-deletion/grace";
import {
eraseAccount,
lockAccountForDeletion,
} from "@/lib/account-deletion/lifecycle";
type Tx = Prisma.TransactionClient;
export async function requestAccountDeletion(input: {
userId: string;
/** Whether the account being deleted is an admin (the roster check). */
targetIsAdmin: boolean;
/** Who asked: the account itself, or the admin acting on it. */
requestedById: string;
/**
* Runs first inside the transaction, before anything is written: the place
* for a refusal that must be decided under the same locks (throw), and for
* the audit rows, which then cannot outlive a refusal.
*/
inTransaction: (tx: Tx, purgeAt: Date | null) => Promise<void>;
}): Promise<{ purgeAt: Date | null }> {
const requestedAt = new Date();
const purgeAt = accountDeletionPurgeAt(
requestedAt,
accountDeletionGraceDays(),
);
await deleteGuardianAccountWithLifecycle(
input.userId,
async (tx) => {
// Checked again under the roster lock: two admins deleting at the same
// moment must not each count the other.
if (input.targetIsAdmin) await assertAnotherAdminStays(tx, input.userId);
await input.inTransaction(tx, purgeAt);
if (purgeAt) {
// Locked, not erased: credentials revoked in this transaction, the
// record untouched until the purge job reaches `purgeAt`.
await lockAccountForDeletion(tx, {
userId: input.userId,
requestedById: input.requestedById,
requestedAt,
purgeAt,
});
return;
}
// No window: erase now, inside the same record lifecycle transaction so
// a refusal leaves every account state intact (sessions, the account's
// audit history — GDPR Art. 17 — the off-host purge request, then the
// row with everything cascading from it).
await eraseAccount(tx, input.userId, "account_deleted");
},
{ erasing: purgeAt === null },
);
if (!purgeAt) await kickOffhostPurge();
return { purgeAt };
}
+6 -1
View File
@@ -40,8 +40,13 @@ import { DEFAULT_ERROR_CODES } from "@/lib/api/default-error-codes";
*/
export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
{
account: ["account.deletion.no_guardian", "account.deletion.not_pending"],
account: [
"account.deletion.already_pending",
"account.deletion.no_guardian",
"account.deletion.not_pending",
],
activity: ["activity.calendar.future_date", "activity.calendar.too_old"],
admin: ["admin.users.delete.managed_profile", "admin.users.delete.self"],
ai: [
"ai.budget.exceeded",
"ai.provider.none",
+8
View File
@@ -106,3 +106,11 @@ export const adminSettingsSchema = z
assistantDocumentAiEnabled: z.boolean().optional(),
})
.strict();
/**
* Body of DELETE /api/admin/users/{id}: the typed confirmation the account's
* own deletion asks for, so a stray request cannot lock an account.
*/
export const adminDeleteUserSchema = z
.object({ confirm: z.literal("DELETE_ACCOUNT") })
.strict();