95 lines
3.3 KiB
TypeScript
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 };
|
|
}
|