Berechtigungssystem (RBAC)
Das Berechtigungssystem implementiert ein granulares Role-Based Access Control (RBAC) nach dem resource:action-Format. Es sitzt direkt in Server Components und Server Actions. Die Next.js-Middleware (webapp/proxy.ts) prüft nur die grundsätzliche Authentifizierung und den Account-Status — die feingranularen Permission-Checks finden nicht in der Middleware statt.
Konzept
Systemrollen vs. Custom-Roles
Es gibt zwei Ebenen:
| Ebene | Beschreibung |
|---|---|
Systemrolle ADMIN | Bypasses alle Permission-Checks. Kein DB-Lookup notwendig. |
Systemrolle MEMBER | Benötigt explizit zugewiesene Custom-Roles. Ohne Custom-Role hat ein MEMBER nur dashboard:read. |
| Custom-Roles | Vom Tenant verwaltete Rollen mit beliebigen Permissions. Werden MEMBER-Usern zugewiesen. |
ADMIN ist eine privilegierte Systemrolle, keine fachliche Rolle. Fachliche Rollen (z. B. „Klassenlehrer", „Sekretariat") werden über Custom-Roles abgebildet.
resource:action Format
Alle Permissions folgen dem Schema resource:action:
students:read
students:create
students:write
students:delete
users:read
users:write
einstellungen:read
einstellungen:write
Die vollständige Liste aller verfügbaren Permissions ist in lib/permissions-registry.ts definiert.
API-Referenz
hasPermission(session, resource, action)
Prüft eine einzelne Permission. Kern-Funktion für granulare Checks.
import { hasPermission } from '@/lib/permissions';
const allowed = await hasPermission(session, 'students', 'read');
// true, wenn ADMIN oder 'students:read' in den Custom-Role-Permissions
Verhalten:
ADMIN-User → gibt soforttruezurück (kein DB-Hit)MEMBER-User → lädt Permissions aus der DB (gecacht via Reactcache())
getResourcePermissions(session, resource)
Lädt create, write und delete für eine Ressource in einem Aufruf. Ideal für List-Pages, die mehrere Flags gleichzeitig benötigen.
import { getResourcePermissions } from '@/lib/permissions';
const { canCreate, canWrite, canDelete } = await getResourcePermissions(
session,
'students'
);
Verhalten:
ADMIN→ gibt{ canCreate: true, canWrite: true, canDelete: true }zurück ohne DB-HitMEMBER→ ein gecachter DB-Lookup für alle drei Flags
checkPermissions(permissions, resource, action, isAdmin)
Reine Funktion ohne Datenbankzugriff. Geeignet für Unit-Tests und Inline-Checks auf einem bereits geladenen Permission-Set.
import { checkPermissions } from '@/lib/permissions';
const result = checkPermissions(permissionSet, 'students', 'delete', false);
Caching-Strategie
getUserPermissions() ist mit React cache() gewrappt. Der DB-Lookup erfolgt maximal einmal pro Request, auch wenn hasPermission oder getResourcePermissions mehrfach aufgerufen werden:
const getUserPermissions = cache(async (userId: string): Promise<Set<string>> => {
// Einmal pro Request gecacht — kein N+1 Problem
const user = await prisma.user.findUnique({ ... });
// ...
return permSet;
});
Jeder MEMBER-User hat mindestens dashboard:read im Set, auch ohne Custom-Roles.
Verwendung in Server Components
Standardmuster für eine geschützte Page:
import { auth } from '@/auth';
import { hasPermission, getResourcePermissions } from '@/lib/permissions';
import { redirect } from 'next/navigation';
export default async function StudentsPage() {
const session = await auth();
if (!session?.user) redirect('/login');
// Guard: Zugriff auf diese Seite prüfen
const allowed = await hasPermission(session, 'students', 'read');
if (!allowed) redirect('/dashboard?no_access=1');
// Einmal laden, dreifach nutzen — nur ein DB-Hit dank Cache
const { canCreate, canWrite, canDelete } = await getResourcePermissions(
session,
'students'
);
// canCreate, canWrite, canDelete an Client-Komponenten übergeben
}
Verwendung in Server Actions
Server Actions prüfen Permissions unabhängig von der UI-Ebene:
'use server';
import { auth } from '@/auth';
import { hasPermission } from '@/lib/permissions';
export async function deleteUser(user: { id: string }) {
const session = await auth();
if (!session?.user?.tenantId) throw new Error('Nicht angemeldet');
const allowed = await hasPermission(session, 'users', 'delete');
if (!allowed) throw new Error('Keine Berechtigung');
// ...
}
is_owner Schutzregeln
Das is_owner-Flag auf dem User-Modell schützt den Tenant-Inhaber unabhängig vom Permission-System:
| Operation | Verhalten bei is_owner = true |
|---|---|
deleteUserById(id) | Wirft DatabaseError — Owner kann nicht gelöscht werden |
updateUserById(id, { role: 'MEMBER' }) | Wirft DatabaseError — Owner kann nicht degradiert werden |
transferOwnership(newOwnerId) | Überträgt is_owner = true auf neuen Owner (Transaktion) |
Die Inhaberschaft kann nur an einen User mit role = 'ADMIN' übertragen werden.
transferOwnership-Flow
// Server Action (lib/user/actions.ts)
export async function transferOwnership(newOwnerId: string): Promise<void> {
const session = await auth();
if (!session?.user?.id) throw new DatabaseError('Nicht angemeldet.');
// Delegiert an DB-Funktion mit Session-Sicherheitscheck
await transferOwnershipDb(session.user.id, newOwnerId);
revalidatePath('/users');
}
// DB-Funktion (lib/user/db.ts) — atomare Transaktion
await prisma.$transaction([
prisma.user.update({ where: { id: currentOwnerId }, data: { is_owner: false } }),
prisma.user.update({ where: { id: newOwnerId }, data: { is_owner: true } })
]);
fetchAdminsAction
Gibt alle ADMIN-User des Tenants zurück (ohne den aktuellen Owner), für den Transfer-Dialog:
export async function fetchAdminsAction(): Promise<
{ id: string; name: string | null; email: string }[]
>
Access Denied Verhalten
Fehlende Permission führt zu einem serverseitigen Redirect auf das Dashboard mit Query-Parameter:
redirect('/dashboard?no_access=1')
Das Dashboard zeigt bei ?no_access=1 einen Hinweis-Banner. Es gibt keine separate 403-Seite.
Datenbankmodell
model User {
// ...
role UserRole @default(MEMBER)
is_owner Boolean @default(false)
custom_roles UserCustomRole[]
}
model UserCustomRole {
user_id String
role_id String
user User @relation(fields: [user_id], references: [id])
role Role @relation(fields: [role_id], references: [id])
}
model Role {
// Tenant-verwaltete Rolle (z. B. "Klassenlehrer")
permissions RolePermission[]
}
model RolePermission {
role_id String
permission_id String
permission Permission @relation(fields: [permission_id], references: [id])
}
model Permission {
resource String // z. B. 'students'
action String // z. B. 'read'
}
Neue Permission nachträglich für Bestandsmandanten ausrollen
Neue Permissions werden über prisma/seed/seed-permissions.ts in die Tabelle Permission geschrieben (seedPermissions(), liest generisch aus lib/permissions-registry.ts). Dieser Seed läuft aber nicht automatisch bei einem Deploy: scripts/migrate-deploy.mjs führt beim Vercel-Build nur prisma migrate deploy aus (automatisch für Preview, manuell vor dem main-Merge für Production) — ohne Seed-Schritt. Solange es nur die lokale Entwicklungsdatenbank bzw. frisch angelegte Mandanten gibt, reicht ein manueller Seed-Lauf trotzdem aus.
Fehlen in einer Umgebung Zeilen in der Tabelle permissions, bleibt die Rechtematrix dort leer oder unvollständig, denn sie baut sich ausschließlich aus dieser Tabelle auf. Für Umgebungen ohne Seed-Lauf, etwa Preview, gleicht pnpm permissions:sync (webapp/scripts/sync-permissions.ts) nur diese Tabelle mit der Registry ab. Rollen, Zuweisungen und Mandantendaten bleiben unberührt, der Lauf ist wiederholbar. Ohne gesetzte DATABASE_URL nutzt der Befehl den Wert aus webapp/.env, für eine andere Umgebung also die URL explizit setzen:
cd webapp
DATABASE_URL="<URL der Zielumgebung>" pnpm permissions:sync
Die letzte Zeile der Ausgabe nennt die Anzahl vorher und jetzt. Passt vorher nicht zum erwarteten Stand, zeigt die URL vermutlich auf eine andere Datenbank.
Sobald der erste produktive Mandant mit Bestandsdaten existiert, muss jede neue Permission zusätzlich zum Registry-Eintrag über eine eigene Migration ausgerollt werden, die die Zeile per Raw-SQL idempotent einfügt:
-- prisma/migrations/<timestamp>_add_permission_<resource>_<action>/migration.sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;
INSERT INTO "permissions" ("id", "resource", "action", "label")
VALUES (gen_random_uuid(), '<resource>', '<action>', '<Label>')
ON CONFLICT ("resource", "action") DO NOTHING;
Entscheidung zu bestehenden Rollen: Custom-Roles erhalten eine neu hinzugefügte Permission nicht automatisch — das folgt dem Least-Privilege-Prinzip, nach dem Permissions in diesem System grundsätzlich bewusst vom Tenant-Admin über die Rechtematrix-UI (einstellungen/rollen-und-rechte) vergeben werden. Einzige Ausnahme ist die Systemrolle ADMIN, die ohnehin alle Permissions ohne DB-Lookup erhält (siehe oben). Ein automatisches „Nachziehen" bestehender Rollen würde unbeabsichtigt Rechte ausweiten, ohne dass ein Admin das bewusst entschieden hat.
Siehe auch die Checkliste in .claude/rules/permissions-matrix.md.
Verfügbare Ressourcen
Alle registrierten Permissions sind in lib/permissions-registry.ts definiert:
| Ressource | Verfügbare Actions |
|---|---|
dashboard | read |
students | read, create, write, delete |
school-classes | read, create, write, delete |
klassenbuch | read, create, write, delete |
vertretungsplan | read, create, write, delete |
abwesenheiten | read, create, write, delete |
timetable | read, create, write, delete |
users | read, create, write, delete |
subjects | read, create, write, delete |
lesson-times | read, create, write, delete |
school-years | read, create, write, delete |
grade-levels | read, create, write, delete |
job-roles | read, create, write, delete |
einstellungen | read, write |