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