199 lines
7.7 KiB
TypeScript
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);
|
|
}
|