Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/hrAccess.server.ts
T
2026-07-08 16:15:37 +02:00

119 lines
4.9 KiB
TypeScript

// P17 HR-Hub core (AK-P17a) — shared loader/action guard for HR routes.
//
// Resolves the authenticated tenant context and the user's permission set once,
// so routes gate consistently (mirrors taskAccess.server.ts). Module-not-active
// handling differs by intent (Bauprinzip A.5.3, "404 vs locked"):
// - GET loaders pass `requireModule: false` and render a locked/empty VM when
// `hasModule` is false.
// - Write actions pass `requireModule: true` → 404 (CatalogError.notFound) when
// the module is off, and additionally require an explicit permission.
import { CatalogError, foundationErrors, requirePermission } from "@hivegrid/foundation";
import { requireAuth } from "~/utils/loaders.middleware";
import { getTenantIdFromUrl } from "~/utils/services/.server/urlService";
import { getUserInfo } from "~/utils/session.server";
import { tenantHasModule } from "~/utils/connector/tenantConnector.server";
import { getPermissionsByUser } from "~/utils/db/permissions/userRoles.db.server";
import { HrServiceError } from "./hrService.server";
import { HR_MODULE_KEY } from "./hrRoutePaths";
// Re-exported for back-compat; the canonical definition lives in the client-safe
// hrRoutePaths module so route `handle` exports can reference it without pulling
// this server-only guard into the client bundle.
export { HR_MODULE_KEY };
export type HrRouteContext = {
tenantId: string;
userId: string;
/** The user's permission names for this tenant (from getPermissionsByUser). */
permissions: readonly string[];
hasModule: boolean;
};
export async function loadHrContext({
request,
params,
requireModule = false,
}: {
request: Request;
params: Record<string, string | undefined>;
requireModule?: boolean;
}): Promise<HrRouteContext> {
await requireAuth({ request, params });
const [tenantId, userInfo] = await Promise.all([getTenantIdFromUrl(params), getUserInfo(request)]);
if (!tenantId || !userInfo.userId) {
throw new CatalogError(foundationErrors.notFound);
}
const [hasModule, permissions] = await Promise.all([
tenantHasModule({ tenantId, userId: userInfo.userId, moduleKey: HR_MODULE_KEY }),
getPermissionsByUser(userInfo.userId, tenantId),
]);
if (requireModule && !hasModule) {
// Mutations on a disabled module look "not found" (Bauprinzip A.5.3).
throw new CatalogError(foundationErrors.notFound, { module: HR_MODULE_KEY });
}
return { tenantId, userId: userInfo.userId, permissions, hasModule };
}
/** True if the user holds `permission` (or a broader bundle that implies it). */
export function hrHasPermission(ctx: HrRouteContext, permission: string): boolean {
if (ctx.permissions.includes(permission)) {
return true;
}
// Bundle implication (mirrors files/tasks): read_all ⊃ read_own; write ⊃
// read_all; admin ⊃ write. Expanded here at check time so callers register the
// flat permissions only.
if (permission === "hr.read_own") {
return ctx.permissions.includes("hr.read_all") || ctx.permissions.includes("hr.write") || ctx.permissions.includes("hr.admin");
}
if (permission === "hr.read_all") {
return ctx.permissions.includes("hr.write") || ctx.permissions.includes("hr.admin");
}
if (permission === "hr.write") {
return ctx.permissions.includes("hr.admin");
}
return false;
}
/** Require an hr.* permission (bundle-aware); throws 404 if missing. */
export function requireHrPermission(ctx: HrRouteContext, permission: string): HrRouteContext {
if (!hrHasPermission(ctx, permission)) {
// Permission-denied surfaces as 404, never 403 (Bauprinzip A.5.3).
throw new CatalogError(foundationErrors.notFound, { permission });
}
// Defensive: keep the central requirePermission audit/telemetry path warm for
// the exact-match case (no-op when hrHasPermission already passed via bundle).
if (ctx.permissions.includes(permission)) {
requirePermission({ tenantId: ctx.tenantId, userId: ctx.userId, permissions: ctx.permissions }, permission);
}
return ctx;
}
/**
* Bare 404 Response for throwing from HR loaders/actions. The body text is never
* shown to users (the route ErrorBoundary renders ServerError).
*/
export function notFoundResponse(): Response {
return new Response("Not Found", { status: 404 });
}
/** Bare 400 Response for invalid action input. */
export function badRequestResponse(): Response {
return new Response("Bad Request", { status: 400 });
}
/**
* Map an HR action error to a JSON Response for resource routes. CatalogError
* keeps its status + i18nKey; HrServiceError is a 400 with its Klartext message
* key. Unknown errors are rethrown (→ 500).
*/
export function hrActionErrorResponse(error: unknown): Response {
if (error instanceof CatalogError) {
return Response.json({ error: error.i18nKey, code: error.code }, { status: error.status });
}
if (error instanceof HrServiceError) {
return Response.json({ error: error.messageKey }, { status: 400 });
}
throw error;
}