Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/services/admin/admin-action-audit.server.ts
T

199 lines
7.7 KiB
TypeScript

/**
* AK-P19 — Platform-Admin operator-action audit gate (DoD #7).
*
* Sensitive operator actions on `admin.hivegrid.de` (tenant status change,
* manual tenant creation, trial extension, tenant notes, subscription
* override, impersonation) must be (a) backed by a mandatory free-text
* `reason`, (b) for destructive actions confirmed by re-typing a
* confirm-phrase, and (c) recorded into the cross-tenant audit log.
*
* Per the AK-P19 plan this REUSES the existing AuditEvent Querschnitt
* (`@hivegrid/db/audit-events.server`, hash chain owned by AK-P3) rather
* than building a parallel `AdminAuditLog` table: admin actions are
* AuditEvent rows with `tenantId = null` + `actorType = "admin"`. This is
* the same emit path that `modules/admin-sso/audit.server` already uses
* for the login/MFA events — here generalized to operator operations.
*
* The validation + payload-shaping below are pure functions so they unit
* test without a database; only `recordAdminAction` touches the writer.
*/
import {
createAuditEvent,
createAuditEventInExistingTransaction,
type AuditEventInput,
type AuditEventWriterClient,
type AuditOutcome,
} from "@hivegrid/db/audit-events.server";
import type { Prisma } from "@hivegrid/db/prisma-browser";
import { getTrustedClientIpOrNull } from "~/utils/server/IpUtils";
/**
* Closed taxonomy of audited platform-operator actions. Closed union so a
* typo at a call site is a TS error, not a silently-unaudited action. The
* value is suffixed onto `admin.` to form the AuditEvent `action`.
*/
export const ADMIN_OPERATOR_ACTIONS = [
"tenant.created",
"tenant.statusChanged",
"tenant.trialExtended",
"tenant.note.created",
"tenant.note.deleted",
"subscription.overridden",
"subscription.featureToggled",
"subscription.syncRequested",
"impersonation.started",
"queue.paused",
"queue.resumed",
"queue.jobRetried",
"webhook.replayRequested",
"support.replied",
"support.noteAdded",
"support.statusChanged",
"support.assigned",
// AK-P21 — reading a customer-uploaded feedback attachment. A read, not a
// mutation, but it exposes material the customer uploaded, so it is audited
// like the mutating operator actions.
"support.attachmentDownloaded",
"security.sessionRevoked",
"security.trustedDeviceForgotten",
"security.adminUserPrepared",
"security.adminUserDisabled",
"security.adminRolesChanged",
"security.adminRolePermissionsChanged",
"featureFlag.bulkOverride",
"portalDomain.killSwitchEnabled",
"portalDomain.killSwitchDisabled",
] as const;
export type AdminOperatorAction = (typeof ADMIN_OPERATOR_ACTIONS)[number];
/** Minimum trimmed length of a reason before it is considered meaningful. */
export const ADMIN_REASON_MIN_LENGTH = 5;
export type AdminActionInputErrorCode = "REASON_REQUIRED" | "REASON_TOO_SHORT" | "CONFIRM_PHRASE_MISMATCH";
/**
* Input-validation failure for an operator action. Carries a stable `code`
* and an i18n key so the route maps it onto an M002-catalog message — no
* hard-coded UI text leaves this layer (Klartext-Diagnostik A.1).
*/
export class AdminActionInputError extends Error {
override readonly name = "AdminActionInputError";
readonly code: AdminActionInputErrorCode;
readonly i18nKey: string;
constructor(code: AdminActionInputErrorCode) {
super(code);
this.code = code;
this.i18nKey = ADMIN_ACTION_ERROR_I18N_KEYS[code];
}
}
const ADMIN_ACTION_ERROR_I18N_KEYS: Record<AdminActionInputErrorCode, string> = {
REASON_REQUIRED: "admin.audit.errors.reasonRequired",
REASON_TOO_SHORT: "admin.audit.errors.reasonTooShort",
CONFIRM_PHRASE_MISMATCH: "admin.audit.errors.confirmPhraseMismatch",
};
export type ReasonValidationResult =
| { readonly ok: true; readonly value: string }
| { readonly ok: false; readonly code: Extract<AdminActionInputErrorCode, "REASON_REQUIRED" | "REASON_TOO_SHORT"> };
/**
* Validates a mandatory operator reason. Pure — returns a discriminated
* result instead of throwing so callers can branch without try/catch.
*/
export function validateAdminActionReason(reason: string | null | undefined): ReasonValidationResult {
const trimmed = (reason ?? "").trim();
if (trimmed.length === 0) return { ok: false, code: "REASON_REQUIRED" };
if (trimmed.length < ADMIN_REASON_MIN_LENGTH) return { ok: false, code: "REASON_TOO_SHORT" };
return { ok: true, value: trimmed };
}
/** Throwing variant for imperative call sites; returns the trimmed reason. */
export function assertAdminActionReason(reason: string | null | undefined): string {
const result = validateAdminActionReason(reason);
if (!result.ok) throw new AdminActionInputError(result.code);
return result.value;
}
/**
* Exact (trimmed) match of a destructive-action confirm-phrase, e.g. the
* operator re-typing the tenant slug before a status change. Case- and
* whitespace-sensitive beyond the outer trim so "Acme " != "acme".
*/
export function confirmPhraseMatches(expected: string, provided: string | null | undefined): boolean {
return expected.trim().length > 0 && (provided ?? "").trim() === expected.trim();
}
/** Throwing variant: enforces the confirm-phrase or raises a catalog error. */
export function assertConfirmPhrase(expected: string, provided: string | null | undefined): void {
if (!confirmPhraseMatches(expected, provided)) throw new AdminActionInputError("CONFIRM_PHRASE_MISMATCH");
}
export type AdminActionAuditContext = {
readonly action: AdminOperatorAction;
readonly actorUserId: string | null;
readonly actorEmail?: string | null;
readonly targetKind: string;
readonly targetId: string | null;
readonly reason: string;
/** Optional structured before/after diff stored in audit metadata. */
readonly before?: Record<string, unknown> | null;
readonly after?: Record<string, unknown> | null;
readonly outcome?: AuditOutcome;
readonly request?: Request;
/** Extra non-sensitive metadata to merge (redacted by the audit layer). */
readonly extraMeta?: Record<string, unknown>;
};
function clientMeta(request: Request | undefined): { ip: string | null; userAgent: string | null } {
if (!request) return { ip: null, userAgent: null };
const ip = getTrustedClientIpOrNull(request);
const userAgent = request.headers.get("user-agent") || null;
return { ip, userAgent };
}
/**
* Shapes the {@link AuditEventInput} for an operator action. Pure: no DB,
* no clock. `tenantId` is always null (cross-tenant platform log) and the
* actor is always `admin`. The reason + diff land in metadata.
*/
export function buildAdminActionAuditInput(context: AdminActionAuditContext): AuditEventInput {
const { ip, userAgent } = clientMeta(context.request);
return {
tenantId: null,
actorType: "admin",
actorId: context.actorUserId,
actorEmail: context.actorEmail ?? null,
actorIp: ip,
actorUserAgent: userAgent,
action: `admin.${context.action}`,
targetType: context.targetKind,
targetId: context.targetId,
sourceModule: "admin",
outcome: context.outcome ?? "success",
dataSensitivity: "confidential",
regulatoryScope: ["soc2"],
metadata: {
reason: context.reason,
before: context.before ?? null,
after: context.after ?? null,
...context.extraMeta,
} as unknown as Prisma.InputJsonValue,
};
}
/**
* Records an operator action into the cross-tenant audit log. Errors
* propagate (unlike the best-effort SSO logger): a sensitive operator
* action that cannot be audited should surface, not silently succeed.
*/
export async function recordAdminAction(context: AdminActionAuditContext, client?: AuditEventWriterClient): Promise<void> {
const input = buildAdminActionAuditInput(context);
if (client) {
await createAuditEventInExistingTransaction(input, client);
return;
}
await createAuditEvent(input);
}