Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/services/auth/admin-mfa-gate.server.ts
T

95 lines
3.3 KiB
TypeScript

/**
* 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<AdminMfaGateResult> {
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 };
}