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
@@ -0,0 +1,131 @@
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "vector";
-- CreateEnum
CREATE TYPE "TenantStatus" AS ENUM ('ACTIVE', 'SUSPENDED');
-- CreateEnum
CREATE TYPE "UserStatus" AS ENUM ('ACTIVE', 'INVITED', 'LOCKED', 'DEACTIVATED');
-- CreateTable
CREATE TABLE "tenants" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"status" "TenantStatus" NOT NULL DEFAULT 'ACTIVE',
"config" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tenants_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"status" "UserStatus" NOT NULL DEFAULT 'ACTIVE',
"mfa_secret" TEXT,
"is_platform_admin" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "roles" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "permissions" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_roles" (
"user_id" TEXT NOT NULL,
"role_id" TEXT NOT NULL,
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("user_id","role_id")
);
-- CreateTable
CREATE TABLE "role_permissions" (
"role_id" TEXT NOT NULL,
"permission_id" TEXT NOT NULL,
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("role_id","permission_id")
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"actor_id" TEXT,
"action" TEXT NOT NULL,
"entity" TEXT NOT NULL,
"entity_id" TEXT,
"before" JSONB,
"after" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "tenants_slug_key" ON "tenants"("slug");
-- CreateIndex
CREATE INDEX "users_tenant_id_idx" ON "users"("tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "users_tenant_id_email_key" ON "users"("tenant_id", "email");
-- CreateIndex
CREATE INDEX "roles_tenant_id_idx" ON "roles"("tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "roles_tenant_id_key_key" ON "roles"("tenant_id", "key");
-- CreateIndex
CREATE UNIQUE INDEX "permissions_key_key" ON "permissions"("key");
-- CreateIndex
CREATE INDEX "audit_logs_tenant_id_entity_entity_id_idx" ON "audit_logs"("tenant_id", "entity", "entity_id");
-- CreateIndex
CREATE INDEX "audit_logs_tenant_id_created_at_idx" ON "audit_logs"("tenant_id", "created_at");
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "roles" ADD CONSTRAINT "roles_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_permission_id_fkey" FOREIGN KEY ("permission_id") REFERENCES "permissions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,36 @@
-- Row Level Security als zweite Verteidigungslinie (docs/SPEC.md §2).
-- Primäre Isolation ist der Tenant-Guard in src/server/db.ts.
--
-- Aktivierungsstand: Policies sind definiert; die App verbindet sich aktuell
-- noch als Tabellen-Owner (Policies greifen für Owner ohne FORCE nicht).
-- Härtungs-Iteration (SPEC §11 Punkt 8): App-Verbindung auf die Rolle
-- "isms_app" umstellen und app.tenant_id pro Transaktion setzen — dann sind
-- die Policies scharf. FORCE wird bewusst nicht gesetzt, damit Migrationen,
-- Seeds und der mandantenübergreifende Login-Lookup als Owner funktionieren.
-- Eingeschränkte Anwendungsrolle (für die Härtungs-Iteration)
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'isms_app') THEN
CREATE ROLE isms_app NOLOGIN NOBYPASSRLS;
END IF;
END
$$;
GRANT USAGE ON SCHEMA public TO isms_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO isms_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO isms_app;
-- RLS auf allen mandanten-gebundenen Tabellen
ALTER TABLE "users" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "roles" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "audit_logs" ENABLE ROW LEVEL SECURITY;
-- Isolation: Zugriff nur auf Zeilen des in app.tenant_id gesetzten Mandanten
CREATE POLICY tenant_isolation ON "users"
USING ("tenant_id" = current_setting('app.tenant_id', true));
CREATE POLICY tenant_isolation ON "roles"
USING ("tenant_id" = current_setting('app.tenant_id', true));
CREATE POLICY tenant_isolation ON "audit_logs"
USING ("tenant_id" = current_setting('app.tenant_id', true));
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+92
View File
@@ -0,0 +1,92 @@
import "dotenv/config";
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { hash } from "@node-rs/argon2";
import { PERMISSIONS, ROLE_DEFS } from "../src/server/rbac";
/**
* Seed: global permission catalog + demo tenant with roles and users.
* Idempotent (upserts) — safe to run repeatedly.
*/
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
const DEMO_PASSWORD = "Demo1234!";
async function main() {
// 1. Global permission catalog
for (const key of PERMISSIONS) {
await prisma.permission.upsert({ where: { key }, update: {}, create: { key } });
}
console.log(`${PERMISSIONS.length} Permissions`);
// 2. Demo tenant
const tenant = await prisma.tenant.upsert({
where: { slug: "demo" },
update: {},
create: { name: "Demo GmbH", slug: "demo" },
});
// 3. Roles from blueprints, with permission bundles
for (const [key, def] of Object.entries(ROLE_DEFS)) {
const role = await prisma.role.upsert({
where: { tenantId_key: { tenantId: tenant.id, key } },
update: { name: def.name },
create: { tenantId: tenant.id, key, name: def.name },
});
const perms = await prisma.permission.findMany({
where: { key: { in: [...def.permissions] } },
});
for (const p of perms) {
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: role.id, permissionId: p.id } },
update: {},
create: { roleId: role.id, permissionId: p.id },
});
}
}
console.log(`✔ Mandant "demo" mit ${Object.keys(ROLE_DEFS).length} Rollen`);
// 4. Demo users
const passwordHash = await hash(DEMO_PASSWORD);
const demoUsers: { email: string; name: string; roles: string[] }[] = [
{ email: "admin@demo.example", name: "Anna Admin", roles: ["tenant-admin", "isb"] },
{ email: "auditor@demo.example", name: "Axel Auditor", roles: ["auditor"] },
{ email: "owner@demo.example", name: "Oskar Owner", roles: ["owner"] },
{ email: "user@demo.example", name: "Ulla User", roles: ["user"] },
];
for (const u of demoUsers) {
const user = await prisma.user.upsert({
where: { tenantId_email: { tenantId: tenant.id, email: u.email } },
update: {},
create: {
tenantId: tenant.id,
email: u.email,
name: u.name,
passwordHash,
},
});
const roles = await prisma.role.findMany({
where: { tenantId: tenant.id, key: { in: u.roles } },
});
for (const r of roles) {
await prisma.userRole.upsert({
where: { userId_roleId: { userId: user.id, roleId: r.id } },
update: {},
create: { userId: user.id, roleId: r.id },
});
}
}
console.log(`${demoUsers.length} Demo-Nutzer (Passwort: ${DEMO_PASSWORD})`);
}
main()
.then(() => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});