L1 Stammdaten: Services, Dublettenprüfung, Server Actions und API v1
Kunden (Nummernkreis, Ansprechpartner, vorläufig bestätigen, Zusammenführen mit Bestätigung), Objekte inkl. Historie, Teams mit Mitgliedschaften, Dublettenlogik (lib + Service), API-Kontext/Antwortformat unter src/server/api und die Endpunkte /api/v1/customers, /api/v1/sites, /api/v1/sites/[id]/history. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant, prisma } from "@/server/db";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { isTokenStillValid } from "@/server/sessions";
|
||||
import { assertModuleEnabled, requireModule } from "@/server/modules";
|
||||
import type { Permission } from "@/server/rbac";
|
||||
import type { ModuleKey } from "@/lib/modules";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { ApiError } from "@/server/api/respond";
|
||||
|
||||
/**
|
||||
* Service context for /api/v1 route handlers and other route handlers (e.g. /files/<id>).
|
||||
*
|
||||
* Same authority model as `moduleGuard` (src/server/action-guard.ts, F-06): session cookie
|
||||
* (Auth.js), then membership status, identity status, session kill switch, forced password
|
||||
* change and the EFFECTIVE permissions are read from the database — never from the JWT.
|
||||
* Differences: failures are thrown as `ApiError` (401/403) so handlers can answer with JSON,
|
||||
* and `moduleKey` may be `null` for cross-module endpoints (document downloads are needed
|
||||
* by field, reports and documents alike).
|
||||
*/
|
||||
export async function requireApiContext(moduleKey: ModuleKey | null, ...permissions: Permission[]): Promise<ServiceCtx> {
|
||||
let session;
|
||||
try {
|
||||
session = await requireSession();
|
||||
} catch {
|
||||
throw new ApiError("unauthorized", "authentication required");
|
||||
}
|
||||
const tenantId = session.user.tenantId;
|
||||
const db = dbForTenant(tenantId);
|
||||
|
||||
const account = await db.user.findFirst({
|
||||
where: { id: session.user.id, status: "ACTIVE" },
|
||||
select: {
|
||||
userRoles: { select: { role: { select: { rolePermissions: { select: { permission: { select: { key: true } } } } } } } },
|
||||
},
|
||||
});
|
||||
const identity = session.user.identityId
|
||||
? await prisma.identity.findUnique({
|
||||
where: { id: session.user.identityId },
|
||||
select: { status: true, mustChangePassword: true, sessionsValidAfter: true },
|
||||
})
|
||||
: null;
|
||||
if (!account || !identity || identity.status !== "ACTIVE") {
|
||||
await writeAuditLog({ tenantId, actorId: session.user.id, action: "denied", entity: "account_inactive", entityId: session.user.id });
|
||||
throw new ApiError("unauthorized", "account inactive");
|
||||
}
|
||||
if (!isTokenStillValid(session.user.tokenIssuedAt, identity.sessionsValidAfter)) {
|
||||
throw new ApiError("unauthorized", "session invalidated");
|
||||
}
|
||||
if (identity.mustChangePassword) throw new ApiError("forbidden", "password change required");
|
||||
|
||||
const effective = new Set(account.userRoles.flatMap((ur) => ur.role.rolePermissions.map((rp) => rp.permission.key)));
|
||||
for (const p of permissions) {
|
||||
if (!effective.has(p)) {
|
||||
await writeAuditLog({ tenantId, actorId: session.user.id, action: "denied", entity: "api", entityId: p });
|
||||
throw new ApiError("forbidden", "forbidden");
|
||||
}
|
||||
}
|
||||
if (moduleKey) await assertModuleEnabled(session, moduleKey); // throws ModuleDisabledError → 403
|
||||
|
||||
return { db, tenantId, userId: session.user.id, permissions: effective };
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF defense for cookie-authenticated, state-changing route handlers: reject requests whose
|
||||
* Origin (or Sec-Fetch-Site) shows a foreign site. Server actions have this built in.
|
||||
*/
|
||||
export function assertSameOrigin(req: Request): void {
|
||||
const site = req.headers.get("sec-fetch-site");
|
||||
if (site && site !== "same-origin" && site !== "none") throw new ApiError("forbidden", "cross-site request");
|
||||
const origin = req.headers.get("origin");
|
||||
if (origin) {
|
||||
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
|
||||
let originHost: string | null = null;
|
||||
try {
|
||||
originHost = new URL(origin).host;
|
||||
} catch {
|
||||
originHost = null;
|
||||
}
|
||||
if (!host || originHost !== host) throw new ApiError("forbidden", "cross-site request");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read context for server components (pages). Uses the session's permission set (JWT), which
|
||||
* is the documented behaviour for read paths (AGENTS.md "Rollen"); mutations go through
|
||||
* moduleGuard / requireApiContext with DB-authoritative permissions. Also enforces the module gate.
|
||||
*/
|
||||
export async function requirePageContext(moduleKey: ModuleKey): Promise<ServiceCtx> {
|
||||
const session = await requireModule(moduleKey);
|
||||
return {
|
||||
db: dbForTenant(session.user.tenantId),
|
||||
tenantId: session.user.tenantId,
|
||||
userId: session.user.id,
|
||||
permissions: new Set(session.user.permissions ?? []),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user