Iteration 1: Auth (lokale Accounts), RBAC, RLS-Policies, i18n

- Auth.js v5 mit Credentials-Provider: Argon2id-Verifikation, JWT-Session
  mit Mandanten-Kontext, Rollen und Permissions; Login-/Logout-Flow (deutsch)
- RBAC-Katalog (28 Permissions, 5 Rollen-Blueprints) mit serverseitigem
  requirePermission; Seed legt Demo-Mandant und 4 Demo-Nutzer an
- Route-Gate über Next-16-proxy.ts (UX-Ebene), autoritative Prüfung
  serverseitig via requireSession/requirePermission
- Prisma-Migrationen: init + Row-Level-Security-Policies (zweite
  Verteidigungslinie, Scharfschaltung in Härtungs-Iteration dokumentiert)
- i18n-Gerüst mit next-intl (de aktiv, en vorbereitet), Audit-Log-Helper
- Login/Logout end-to-end im Browser verifiziert; Build/Lint/Typecheck grün

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Martin
2026-07-02 12:09:54 +02:00
co-authored by Claude Fable 5
parent 87e933b3c0
commit 880afed391
24 changed files with 1992 additions and 84 deletions
+28
View File
@@ -0,0 +1,28 @@
import { prisma } from "./db";
/**
* Audit trail (SPEC §5 AuditLog, §10): every writing action creates an entry.
* Uses the raw client on purpose — audit writes must never be silently
* filtered, and tenantId is passed explicitly by the caller.
*/
export async function writeAuditLog(entry: {
tenantId: string;
actorId?: string;
action: "create" | "update" | "delete" | "login" | "logout" | "export" | "import";
entity: string;
entityId?: string;
before?: unknown;
after?: unknown;
}) {
await prisma.auditLog.create({
data: {
tenantId: entry.tenantId,
actorId: entry.actorId,
action: entry.action,
entity: entry.entity,
entityId: entry.entityId,
before: entry.before as object | undefined,
after: entry.after as object | undefined,
},
});
}
+119
View File
@@ -0,0 +1,119 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { verify } from "@node-rs/argon2";
import { z } from "zod";
import { prisma } from "./db";
/**
* Auth.js (NextAuth v5) with local credentials (MVP, see docs/SPEC.md §0).
* JWT strategy: tenant context, roles and permissions are resolved at login
* and carried in the token — a role change becomes effective at next login.
* TOTP-2FA and OIDC/SAML are planned extensions (SPEC §13).
*/
const credentialsSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
// Optional: disambiguates when the same e-mail exists in several tenants.
tenant: z.string().optional(),
});
export class LoginError extends Error {}
export const { handlers, auth, signIn, signOut } = NextAuth({
session: { strategy: "jwt" },
pages: { signIn: "/login" },
providers: [
Credentials({
credentials: {
email: {},
password: {},
tenant: {},
},
authorize: async (raw) => {
const parsed = credentialsSchema.safeParse(raw);
if (!parsed.success) return null;
const { email, password, tenant } = parsed.data;
const users = await prisma.user.findMany({
where: {
email: email.toLowerCase(),
status: "ACTIVE",
tenant: {
status: "ACTIVE",
...(tenant ? { slug: tenant.toLowerCase() } : {}),
},
},
include: {
tenant: true,
userRoles: {
include: {
role: {
include: {
rolePermissions: { include: { permission: true } },
},
},
},
},
},
});
// Same e-mail in several tenants and no slug given → cannot decide.
if (users.length !== 1) return null;
const user = users[0];
const valid = await verify(user.passwordHash, password);
if (!valid) return null;
const roles = user.userRoles.map((ur) => ur.role.key);
const permissions = [
...new Set(
user.userRoles.flatMap((ur) =>
ur.role.rolePermissions.map((rp) => rp.permission.key)
)
),
];
return {
id: user.id,
email: user.email,
name: user.name,
tenantId: user.tenantId,
tenantSlug: user.tenant.slug,
roles,
permissions,
isPlatformAdmin: user.isPlatformAdmin,
};
},
}),
],
callbacks: {
jwt({ token, user }) {
if (user) {
token.userId = user.id!;
token.tenantId = user.tenantId;
token.tenantSlug = user.tenantSlug;
token.roles = user.roles;
token.permissions = user.permissions;
token.isPlatformAdmin = user.isPlatformAdmin;
}
return token;
},
session({ session, token }) {
session.user.id = token.userId;
session.user.tenantId = token.tenantId;
session.user.tenantSlug = token.tenantSlug;
session.user.roles = token.roles;
session.user.permissions = token.permissions;
session.user.isPlatformAdmin = token.isPlatformAdmin;
return session;
},
},
});
/** Session guard for server components / route handlers. */
export async function requireSession() {
const session = await auth();
if (!session?.user) throw new Error("Nicht angemeldet");
return session;
}
+152
View File
@@ -0,0 +1,152 @@
/**
* RBAC catalog: granular permissions bundled into per-tenant roles.
* See docs/SPEC.md §3. Permissions are global rows (no tenantId),
* roles are created per tenant from ROLE_DEFS at tenant provisioning.
*/
export const PERMISSIONS = [
// Administration
"tenant:manage",
"user:read",
"user:manage",
// Assets & BIA (one module)
"asset:read",
"asset:write",
"bia:read",
"bia:write",
// Risk
"risk:read",
"risk:write",
"risk:accept",
// Compliance
"soa:read",
"soa:write",
// Measures / tasks
"measure:read",
"measure:write",
// Policies
"policy:read",
"policy:write",
"policy:approve",
// Incidents
"incident:report",
"incident:manage",
// Audits
"audit:read",
"audit:conduct",
// Suppliers
"supplier:read",
"supplier:write",
// Evidence / documents
"evidence:read",
"evidence:write",
// Reporting / management review
"report:read",
"review:manage",
// Chat
"chat:use",
] as const;
export type Permission = (typeof PERMISSIONS)[number];
/** Role blueprints instantiated for every tenant (key → name + permissions). */
export const ROLE_DEFS: Record<
string,
{ name: string; permissions: readonly Permission[] }
> = {
"tenant-admin": {
name: "Mandanten-Admin",
permissions: ["tenant:manage", "user:read", "user:manage", "report:read", "chat:use"],
},
isb: {
name: "ISB / CISO",
permissions: [
"user:read",
"asset:read",
"asset:write",
"bia:read",
"bia:write",
"risk:read",
"risk:write",
"risk:accept",
"soa:read",
"soa:write",
"measure:read",
"measure:write",
"policy:read",
"policy:write",
"policy:approve",
"incident:report",
"incident:manage",
"audit:read",
"supplier:read",
"supplier:write",
"evidence:read",
"evidence:write",
"report:read",
"review:manage",
"chat:use",
],
},
auditor: {
name: "Auditor",
permissions: [
"asset:read",
"bia:read",
"risk:read",
"soa:read",
"measure:read",
"policy:read",
"audit:read",
"audit:conduct",
"supplier:read",
"evidence:read",
"report:read",
],
},
owner: {
name: "Asset-/Risk-Owner",
permissions: [
"asset:read",
"asset:write",
"bia:read",
"risk:read",
"risk:write",
"measure:read",
"measure:write",
"policy:read",
"incident:report",
"evidence:read",
"evidence:write",
"chat:use",
],
},
user: {
name: "Mitarbeiter",
permissions: ["policy:read", "incident:report", "measure:read", "chat:use"],
},
};
export class ForbiddenError extends Error {
constructor(permission: Permission) {
super(`Fehlende Berechtigung: ${permission}`);
this.name = "ForbiddenError";
}
}
/** Server-side permission check — UI hiding is convenience, this is security. */
export function requirePermission(
session: { user?: { permissions?: string[] } } | null,
permission: Permission
): void {
if (!session?.user?.permissions?.includes(permission)) {
throw new ForbiddenError(permission);
}
}
export function hasPermission(
session: { user?: { permissions?: string[] } } | null,
permission: Permission
): boolean {
return session?.user?.permissions?.includes(permission) ?? false;
}