mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
1 parent
ef141f457b
commit
107db1bb55
28 files changed
+828
-95
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user