feat: RBAC Rollen & Rechte (Phase 1 - Datenmodell, Roles-Verwaltung, Guard-Infrastruktur)

Replaces the fixed 5-value Role enum with a database-driven Role/Permission
model: admins can create custom roles with a precise per-resource/action
permission set (83-key code-owned catalog), instead of being locked to
ADMIN/BEEKEEPER/STAFF/READONLY/API_CLIENT.

- New Role/Permission/RolePermission tables; legacy Role enum renamed to
  LegacyRole and kept as User.legacyRole so all existing @Roles() call
  sites keep working unchanged during the migration.
- New RolesController/RolesService (CRUD, permission-set editing,
  deactivate/reactivate) with lockout-prevention invariants: system roles
  (ADMIN) can't be edited or deactivated, and a role still assigned to
  users can't be deactivated.
- New PermissionResolverService (30s-cached, DB-fresh per user) and
  PermissionsGuard + @RequirePermission(), registered as an additional
  APP_GUARD alongside the legacy RolesGuard so both systems run
  side-by-side until controllers are migrated in Phase 2.
- PermissionCatalogSyncService seeds any new catalog entries into the DB
  on boot, so future features only touch the TS catalog.
- New GET /auth/me returns the caller's role and effective permissions.
- Migration hand-written (destructive column rename) with grants derived
  from a fresh grep of every existing @Roles() call site, verified
  against 5 controllers read in full for STAFF's exact write boundary.
- test/roles.e2e-spec.ts covers the new endpoints and invariants end to
  end; test/authorization-coverage.e2e-spec.ts added but skipped until
  Phase 2 gives every controller a @RequirePermission.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-08-26 06:52:24 +02:00
1 parent d31aa79f50
commit 139ea6fdd7
84 files changed
+1819 -103

No files matched your search

@@ -0,0 +1,244 @@
-- RBAC migration: replaces the fixed User.role enum with a database-driven
-- Role/Permission/RolePermission model. See docs/lastenheft.md RBAC notes
-- for the phased rollout this belongs to (Phase 1 of 5).
--
-- Grants below reproduce today's effective access exactly, derived from a
-- fresh grep of every @Roles(...) call site in src/**/*.controller.ts at
-- the time this migration was written. Do not hand-tune without re-deriving
-- from the code.
BEGIN;
-- 1. Preserve the old enum + column under a new name so every existing
-- @Roles(Role.ADMIN, ...) call site keeps compiling untouched during the
-- migration (Phase 1-2 alias `LegacyRole as Role` in TS).
ALTER TYPE "Role" RENAME TO "LegacyRole";
ALTER TABLE "User" RENAME COLUMN "role" TO "legacyRole";
-- 2. Drop the old index on the renamed column (replaced by an index on the
-- new roleId column in step 10).
DROP INDEX "User_role_idx";
-- 3. New RBAC tables.
CREATE TYPE "RoleStatus" AS ENUM ('ACTIVE', 'INACTIVE');
CREATE TABLE "Role" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
"status" "RoleStatus" NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deactivatedAt" TIMESTAMP(3),
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Role_key_key" ON "Role"("key");
CREATE INDEX "Role_status_idx" ON "Role"("status");
CREATE TABLE "Permission" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"resource" TEXT NOT NULL,
"action" TEXT NOT NULL,
"labelDe" TEXT NOT NULL,
"groupDe" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL,
CONSTRAINT "Permission_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Permission_key_key" ON "Permission"("key");
CREATE UNIQUE INDEX "Permission_resource_action_key" ON "Permission"("resource", "action");
CREATE INDEX "Permission_resource_idx" ON "Permission"("resource");
CREATE TABLE "RolePermission" (
"roleId" TEXT NOT NULL,
"permissionId" TEXT NOT NULL,
"grantedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RolePermission_pkey" PRIMARY KEY ("roleId", "permissionId")
);
CREATE INDEX "RolePermission_permissionId_idx" ON "RolePermission"("permissionId");
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_roleId_fkey"
FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_permissionId_fkey"
FOREIGN KEY ("permissionId") REFERENCES "Permission"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- 4. Seed the fixed permission catalog (generated from
-- src/auth/permissions/permission-catalog.ts — the code-owned source of
-- truth this table mirrors).
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
SELECT gen_random_uuid()::text, v.key, v.resource, v.action, v."labelDe", v."groupDe", v."sortOrder"
FROM (VALUES
('apiaries:read', 'apiaries', 'read', 'Ansehen', 'Bienenstände', 100),
('apiaries:create', 'apiaries', 'create', 'Anlegen', 'Bienenstände', 101),
('apiaries:update', 'apiaries', 'update', 'Bearbeiten', 'Bienenstände', 102),
('apiaries:deactivate', 'apiaries', 'deactivate', 'Deaktivieren', 'Bienenstände', 103),
('colonies:read', 'colonies', 'read', 'Ansehen', 'Völker', 200),
('colonies:create', 'colonies', 'create', 'Anlegen', 'Völker', 201),
('colonies:update', 'colonies', 'update', 'Bearbeiten', 'Völker', 202),
('colonies:deactivate', 'colonies', 'deactivate', 'Deaktivieren', 'Völker', 203),
('colonies:manage-lifecycle', 'colonies', 'manage-lifecycle', 'Umsetzen/Teilen/Vereinigen/Auflösen', 'Völker', 204),
('queens:read', 'queens', 'read', 'Ansehen', 'Königinnen', 300),
('queens:create', 'queens', 'create', 'Anlegen', 'Königinnen', 301),
('queens:update', 'queens', 'update', 'Bearbeiten', 'Königinnen', 302),
('queens:assign', 'queens', 'assign', 'Volk zuweisen', 'Königinnen', 303),
('queens:evaluate', 'queens', 'evaluate', 'Bewerten', 'Königinnen', 304),
('inspections:read', 'inspections', 'read', 'Ansehen', 'Durchsichten', 400),
('inspections:create', 'inspections', 'create', 'Anlegen', 'Durchsichten', 401),
('inspections:update', 'inspections', 'update', 'Bearbeiten', 'Durchsichten', 402),
('tasks:read', 'tasks', 'read', 'Ansehen', 'Aufgaben', 500),
('tasks:create', 'tasks', 'create', 'Anlegen', 'Aufgaben', 501),
('tasks:update', 'tasks', 'update', 'Bearbeiten', 'Aufgaben', 502),
('tasks:complete', 'tasks', 'complete', 'Erledigen', 'Aufgaben', 503),
('task-templates:read', 'task-templates', 'read', 'Ansehen', 'Wiederkehrende Aufgaben', 600),
('task-templates:create', 'task-templates', 'create', 'Anlegen', 'Wiederkehrende Aufgaben', 601),
('task-templates:update', 'task-templates', 'update', 'Bearbeiten', 'Wiederkehrende Aufgaben', 602),
('task-templates:deactivate', 'task-templates', 'deactivate', 'Deaktivieren', 'Wiederkehrende Aufgaben', 603),
('varroa:read', 'varroa', 'read', 'Ansehen', 'Varroa', 700),
('varroa:create', 'varroa', 'create', 'Anlegen', 'Varroa', 701),
('varroa:update', 'varroa', 'update', 'Bearbeiten', 'Varroa', 702),
('swarm-control:read', 'swarm-control', 'read', 'Ansehen', 'Schwarmkontrolle', 800),
('swarm-control:create', 'swarm-control', 'create', 'Anlegen', 'Schwarmkontrolle', 801),
('swarm-control:update', 'swarm-control', 'update', 'Bearbeiten', 'Schwarmkontrolle', 802),
('treatments:read', 'treatments', 'read', 'Ansehen', 'Behandlungen', 900),
('treatments:create', 'treatments', 'create', 'Anlegen', 'Behandlungen', 901),
('treatments:update', 'treatments', 'update', 'Bearbeiten', 'Behandlungen', 902),
('feedings:read', 'feedings', 'read', 'Ansehen', 'Fütterungen', 1000),
('feedings:create', 'feedings', 'create', 'Anlegen', 'Fütterungen', 1001),
('feedings:update', 'feedings', 'update', 'Bearbeiten', 'Fütterungen', 1002),
('harvests:read', 'harvests', 'read', 'Ansehen', 'Ernten', 1100),
('harvests:create', 'harvests', 'create', 'Anlegen', 'Ernten', 1101),
('harvests:update', 'harvests', 'update', 'Bearbeiten', 'Ernten', 1102),
('extractions:read', 'extractions', 'read', 'Ansehen', 'Schleuderungen', 1200),
('extractions:create', 'extractions', 'create', 'Anlegen', 'Schleuderungen', 1201),
('extractions:update', 'extractions', 'update', 'Bearbeiten', 'Schleuderungen', 1202),
('honey-batches:read', 'honey-batches', 'read', 'Ansehen', 'Honigchargen', 1300),
('honey-batches:create', 'honey-batches', 'create', 'Anlegen', 'Honigchargen', 1301),
('honey-batches:relocate', 'honey-batches', 'relocate', 'Umlagern', 'Honigchargen', 1302),
('honey-batches:measure', 'honey-batches', 'measure', 'Messwerte erfassen', 'Honigchargen', 1303),
('storage:read', 'storage', 'read', 'Ansehen', 'Lager', 1400),
('storage:create', 'storage', 'create', 'Anlegen', 'Lager', 1401),
('storage:move', 'storage', 'move', 'Bewegung buchen', 'Lager', 1402),
('products:read', 'products', 'read', 'Ansehen', 'Produkte', 1500),
('products:create', 'products', 'create', 'Anlegen', 'Produkte', 1501),
('products:update', 'products', 'update', 'Bearbeiten', 'Produkte', 1502),
('bottling:read', 'bottling', 'read', 'Ansehen', 'Abfüllungen', 1600),
('bottling:create', 'bottling', 'create', 'Anlegen', 'Abfüllungen', 1601),
('bottling:update', 'bottling', 'update', 'Bearbeiten', 'Abfüllungen', 1602),
('equipment:read', 'equipment', 'read', 'Ansehen', 'Inventar', 1700),
('equipment:create', 'equipment', 'create', 'Anlegen', 'Inventar', 1701),
('equipment:update', 'equipment', 'update', 'Bearbeiten', 'Inventar', 1702),
('documents:read', 'documents', 'read', 'Ansehen', 'Dokumente', 1800),
('documents:upload', 'documents', 'upload', 'Hochladen', 'Dokumente', 1801),
('documents:delete', 'documents', 'delete', 'Löschen', 'Dokumente', 1802),
('webhooks:read', 'webhooks', 'read', 'Ansehen', 'Webhooks', 1900),
('webhooks:create', 'webhooks', 'create', 'Anlegen', 'Webhooks', 1901),
('webhooks:delete', 'webhooks', 'delete', 'Löschen', 'Webhooks', 1902),
('automation:read', 'automation', 'read', 'Ansehen', 'Automatisierungen', 2000),
('automation:update', 'automation', 'update', 'Bearbeiten', 'Automatisierungen', 2001),
('import:execute', 'import', 'execute', 'Ausführen', 'Import', 2100),
('export:read', 'export', 'read', 'Ansehen', 'Export', 2200),
('users:read', 'users', 'read', 'Ansehen', 'Benutzer', 2300),
('users:create', 'users', 'create', 'Anlegen', 'Benutzer', 2301),
('users:update', 'users', 'update', 'Bearbeiten', 'Benutzer', 2302),
('users:deactivate', 'users', 'deactivate', 'Deaktivieren', 'Benutzer', 2303),
('roles:read', 'roles', 'read', 'Ansehen', 'Rollen & Rechte', 2400),
('roles:create', 'roles', 'create', 'Anlegen', 'Rollen & Rechte', 2401),
('roles:update', 'roles', 'update', 'Bearbeiten', 'Rollen & Rechte', 2402),
('roles:deactivate', 'roles', 'deactivate', 'Deaktivieren', 'Rollen & Rechte', 2403),
('audit:read', 'audit', 'read', 'Ansehen', 'Audit-Log', 2500),
('calendar:read', 'calendar', 'read', 'Ansehen', 'Kalender', 2600),
('dashboard:read', 'dashboard', 'read', 'Ansehen', 'Dashboard', 2700),
('statistics:read', 'statistics', 'read', 'Ansehen', 'Statistik', 2800),
('search:read', 'search', 'read', 'Ansehen', 'Suche', 2900),
('qr:read', 'qr', 'read', 'Ansehen', 'QR-Codes', 3000)
) AS v(key, resource, action, "labelDe", "groupDe", "sortOrder");
-- 5. Seed the 5 roles that replace the legacy enum values, one-for-one.
INSERT INTO "Role" (id, key, name, description, "isSystem", status, "createdAt", "updatedAt")
VALUES
(gen_random_uuid()::text, 'ADMIN', 'Administrator', 'Voller Zugriff auf alle Bereiche, inkl. Benutzer- und Rechteverwaltung.', true, 'ACTIVE', now(), now()),
(gen_random_uuid()::text, 'BEEKEEPER', 'Imker', 'Voller fachlicher Zugriff, ohne Benutzer-, Rollen- und Systemverwaltung.', false, 'ACTIVE', now(), now()),
(gen_random_uuid()::text, 'STAFF', 'Mitarbeiter', 'Ansicht aller Bereiche, Erfassung der laufenden Tagesarbeit.', false, 'ACTIVE', now(), now()),
(gen_random_uuid()::text, 'READONLY', 'Nur Lesen', 'Lesezugriff auf alle Bereiche, keine Änderungen.', false, 'ACTIVE', now(), now()),
(gen_random_uuid()::text, 'API_CLIENT', 'API-Client', 'Lesezugriff für externe Integrationen.', false, 'ACTIVE', now(), now());
-- 6. Grants, derived from a fresh grep of every @Roles(...) call site.
-- ADMIN: everything.
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE r.key = 'ADMIN';
-- BEEKEEPER: everything except the administrative/system resources that
-- were ADMIN-only via class-level @Roles(Role.ADMIN).
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE r.key = 'BEEKEEPER'
AND p.resource NOT IN ('users', 'roles', 'audit', 'webhooks', 'automation', 'import');
-- STAFF: read access to every non-administrative resource ...
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE r.key = 'STAFF'
AND p.action = 'read'
AND p.resource NOT IN ('task-templates', 'users', 'roles', 'audit', 'webhooks', 'automation', 'import');
-- ... plus exactly the write actions that carried Role.STAFF today.
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE r.key = 'STAFF'
AND p.key IN (
'inspections:create', 'inspections:update',
'tasks:create', 'tasks:update', 'tasks:complete',
'feedings:create', 'feedings:update',
'harvests:create', 'harvests:update',
'honey-batches:relocate',
'documents:upload', 'documents:delete'
);
-- READONLY and API_CLIENT: the same read-only set as STAFF (neither role
-- appears in any @Roles(...) call site today, so both currently mean
-- "read everything" — kept identical here, adjustable later via the UI).
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE r.key IN ('READONLY', 'API_CLIENT')
AND p.action = 'read'
AND p.resource NOT IN ('task-templates', 'users', 'roles', 'audit', 'webhooks', 'automation', 'import');
-- 7. New nullable roleId column on User, backfilled below.
ALTER TABLE "User" ADD COLUMN "roleId" TEXT;
-- 8. Backfill from the legacy enum value.
UPDATE "User" u
SET "roleId" = r.id
FROM "Role" r
WHERE r.key = u."legacyRole"::text;
-- 9. Verification gate: refuse to proceed if any user was left unresolved.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM "User" WHERE "roleId" IS NULL) THEN
RAISE EXCEPTION 'RBAC backfill incomplete: at least one User row has no resolvable roleId';
END IF;
END $$;
-- 10. Lock it down.
ALTER TABLE "User" ALTER COLUMN "roleId" SET NOT NULL;
ALTER TABLE "User" ADD CONSTRAINT "User_roleId_fkey"
FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE INDEX "User_roleId_idx" ON "User"("roleId");
COMMIT;
+61 -8
View File
@@ -16,7 +16,10 @@ datasource db {
// Benutzer / Auth / Audit (Abschnitt 3, 14, 15, 16)
// ---------------------------------------------------------------------------
enum Role {
// Superseded by the Role/Permission/RolePermission models below. Kept only
// as a transitional column (User.legacyRole) until the RBAC rollout's
// Phase 3 drops it — see docs/lastenheft.md RBAC migration notes.
enum LegacyRole {
ADMIN
BEEKEEPER
STAFF
@@ -24,15 +27,65 @@ enum Role {
API_CLIENT
}
enum RoleStatus {
ACTIVE
INACTIVE
}
model Role {
id String @id @default(cuid())
key String @unique
name String
description String?
isSystem Boolean @default(false)
status RoleStatus @default(ACTIVE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deactivatedAt DateTime?
permissions RolePermission[]
users User[]
@@index([status])
}
model Permission {
id String @id @default(cuid())
key String @unique
resource String
action String
labelDe String
groupDe String
sortOrder Int
roles RolePermission[]
@@unique([resource, action])
@@index([resource])
}
model RolePermission {
roleId String
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
permissionId String
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
grantedAt DateTime @default(now())
@@id([roleId, permissionId])
@@index([permissionId])
}
model User {
id String @id @default(cuid())
email String @unique
id String @id @default(cuid())
email String @unique
passwordHash String
name String
role Role @default(STAFF)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
legacyRole LegacyRole @default(STAFF)
roleId String
role Role @relation(fields: [roleId], references: [id])
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deactivatedAt DateTime?
refreshTokens RefreshToken[]
@@ -59,7 +112,7 @@ model User {
documents Document[]
honeyBatchRelocations HoneyBatchRelocation[]
@@index([role])
@@index([roleId])
}
model RefreshToken {
+10 -1
View File
@@ -22,8 +22,17 @@ async function main(): Promise<void> {
}
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
const adminRole = await prisma.role.findUniqueOrThrow({
where: { key: 'ADMIN' },
});
await prisma.user.create({
data: { email, passwordHash, name: 'Administrator', role: 'ADMIN' },
data: {
email,
passwordHash,
name: 'Administrator',
legacyRole: 'ADMIN',
roleId: adminRole.id,
},
});
console.log(`Seed: created admin user ${email}.`);
await prisma.$disconnect();