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:
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user