/** * Phase 09 Task #82 — App-MFA gate for sensitive admin actions. * * Wrapper on top of the existing `enforceAdminMfa` helper (Phase 01 #26 * consolidated otpauth TOTP). Adds an action-keyed taxonomy so callers * inside admin route handlers can declare intent without re-implementing * the step-up plumbing. * * The wrapper itself does NOT touch the database or generate new TOTP * secrets — that is owned by the existing /admin/security/mfa-* routes. * Calling it just (a) checks the step-up cache, (b) throws a Response * redirect when challenge is required. */ import { redirect } from "react-router"; import { loadRequiredWebConfig } from "@toolctrl/config"; import { enforceAdminMfa } from "~/utils/auth/mfa/enforcement.server"; import { checkMfaStepUpState } from "~/utils/auth/mfa/step-up.server"; import { absoluteAppRootPath } from "~/utils/app/CrossHostUrls"; import { getUserSession } from "~/utils/session.server"; import { getUser, getUserTotpStatus } from "~/utils/db/users.db.server"; import { writeAdminSsoAuditEvent } from "~/modules/admin-sso/audit.server"; /** * Canonical taxonomy of sensitive admin actions. Kept as a closed union * so a typo in a caller surfaces as a TS error rather than silently * skipping the gate. */ export const SENSITIVE_ADMIN_ACTIONS = [ "impersonation.start", "tenant.delete", "permission.grant", "audit.export", "data.export", ] as const; export type SensitiveAdminAction = (typeof SENSITIVE_ADMIN_ACTIONS)[number]; export function isSensitiveAdminAction(value: string): value is SensitiveAdminAction { return (SENSITIVE_ADMIN_ACTIONS as readonly string[]).includes(value); } export type AdminMfaGateResult = { readonly userId: string; readonly action: SensitiveAdminAction; }; /** * Throws a Response (redirect to /admin/security/mfa-challenge or to * /admin/security/mfa-enroll) when step-up is missing/stale; otherwise * returns the actor user id for downstream audit logging. */ export async function requireAdminMfaForAction( request: Request, action: SensitiveAdminAction, actorUserId: string, ): Promise { const session = await getUserSession(request); const actor = await getUser(actorUserId); const totpStatus = actor ? await getUserTotpStatus(actor.id) : null; const enforcement = enforceAdminMfa({ requestUrl: request.url, userId: actor?.id, isAdmin: Boolean(actor?.admin), totpStatus, stepUp: actor ? checkMfaStepUpState(session, actor.id) : { verified: false, reason: "missing" }, redirectMode: "host-aware", appFallbackPath: absoluteAppRootPath(loadRequiredWebConfig().routing), }); if (enforcement instanceof Response) { await writeAdminSsoAuditEvent({ action: "admin.mfa.app.challenge.failure", actorAdminUserId: actorUserId, request, meta: { action, reason: "redirect-response" }, }); throw enforcement; } if (!enforcement.allowed) { await writeAdminSsoAuditEvent({ action: "admin.mfa.app.challenge.failure", actorAdminUserId: actorUserId, request, meta: { action, reason: enforcement.reason ?? "denied" }, }); throw redirect(enforcement.redirectTo); } await writeAdminSsoAuditEvent({ action: "admin.mfa.app.challenge.success", actorAdminUserId: actorUserId, request, meta: { action }, }); return { userId: actorUserId, action }; }