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,51 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { PERMISSION_CATALOG } from './permission-catalog';
/**
* Keeps the Permission table in step with the code-owned PERMISSION_CATALOG
* so a future feature only needs to add an entry to the catalog file, not
* hand-write a migration to seed it too. Runs once on boot: inserts any
* catalog entry missing from the database. Never deletes a Permission row
* that fell out of the catalog (only warns) — a role's grants must not
* silently disappear because of a typo or a reordered catalog edit.
*/
@Injectable()
export class PermissionCatalogSyncService implements OnModuleInit {
private readonly logger = new Logger(PermissionCatalogSyncService.name);
constructor(private readonly prisma: PrismaService) {}
async onModuleInit(): Promise<void> {
const existing = await this.prisma.permission.findMany({
select: { key: true },
});
const existingKeys = new Set(existing.map((permission) => permission.key));
const missing = PERMISSION_CATALOG.filter(
(entry) => !existingKeys.has(entry.key),
);
for (const entry of missing) {
await this.prisma.permission.create({ data: entry });
}
if (missing.length > 0) {
this.logger.log(
`Synced ${missing.length} new permission(s) from the code catalog: ${missing
.map((entry) => entry.key)
.join(', ')}`,
);
}
const catalogKeys = new Set(PERMISSION_CATALOG.map((entry) => entry.key));
const orphaned = existing.filter(
(permission) => !catalogKeys.has(permission.key),
);
if (orphaned.length > 0) {
this.logger.warn(
`${orphaned.length} permission(s) exist in the database but not in the code catalog (left untouched): ${orphaned
.map((permission) => permission.key)
.join(', ')}`,
);
}
}
}
@@ -0,0 +1,266 @@
export interface PermissionCatalogEntry {
key: string;
resource: string;
action: string;
labelDe: string;
groupDe: string;
sortOrder: number;
}
function group(
groupDe: string,
resource: string,
actions: [action: string, labelDe: string][],
startOrder: number,
): PermissionCatalogEntry[] {
return actions.map(([action, labelDe], index) => ({
key: `${resource}:${action}`,
resource,
action,
labelDe,
groupDe,
sortOrder: startOrder + index,
}));
}
export const PERMISSION_CATALOG = [
...group(
'Bienenstände',
'apiaries',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['deactivate', 'Deaktivieren'],
],
100,
),
...group(
'Völker',
'colonies',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['deactivate', 'Deaktivieren'],
['manage-lifecycle', 'Umsetzen/Teilen/Vereinigen/Auflösen'],
],
200,
),
...group(
'Königinnen',
'queens',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['assign', 'Volk zuweisen'],
['evaluate', 'Bewerten'],
],
300,
),
...group(
'Durchsichten',
'inspections',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
400,
),
...group(
'Aufgaben',
'tasks',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['complete', 'Erledigen'],
],
500,
),
...group(
'Wiederkehrende Aufgaben',
'task-templates',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['deactivate', 'Deaktivieren'],
],
600,
),
...group(
'Varroa',
'varroa',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
700,
),
...group(
'Schwarmkontrolle',
'swarm-control',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
800,
),
...group(
'Behandlungen',
'treatments',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
900,
),
...group(
'Fütterungen',
'feedings',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
1000,
),
...group(
'Ernten',
'harvests',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
1100,
),
...group(
'Schleuderungen',
'extractions',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
1200,
),
...group(
'Honigchargen',
'honey-batches',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['relocate', 'Umlagern'],
['measure', 'Messwerte erfassen'],
],
1300,
),
...group(
'Lager',
'storage',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['move', 'Bewegung buchen'],
],
1400,
),
...group(
'Produkte',
'products',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
1500,
),
...group(
'Abfüllungen',
'bottling',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
1600,
),
...group(
'Inventar',
'equipment',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
1700,
),
...group(
'Dokumente',
'documents',
[
['read', 'Ansehen'],
['upload', 'Hochladen'],
['delete', 'Löschen'],
],
1800,
),
...group(
'Webhooks',
'webhooks',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['delete', 'Löschen'],
],
1900,
),
...group(
'Automatisierungen',
'automation',
[
['read', 'Ansehen'],
['update', 'Bearbeiten'],
],
2000,
),
...group('Import', 'import', [['execute', 'Ausführen']], 2100),
...group('Export', 'export', [['read', 'Ansehen']], 2200),
...group(
'Benutzer',
'users',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['deactivate', 'Deaktivieren'],
],
2300,
),
...group(
'Rollen & Rechte',
'roles',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['deactivate', 'Deaktivieren'],
],
2400,
),
...group('Audit-Log', 'audit', [['read', 'Ansehen']], 2500),
...group('Kalender', 'calendar', [['read', 'Ansehen']], 2600),
...group('Dashboard', 'dashboard', [['read', 'Ansehen']], 2700),
...group('Statistik', 'statistics', [['read', 'Ansehen']], 2800),
...group('Suche', 'search', [['read', 'Ansehen']], 2900),
...group('QR-Codes', 'qr', [['read', 'Ansehen']], 3000),
] as const satisfies readonly PermissionCatalogEntry[];
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
@@ -0,0 +1,78 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { PermissionKey } from './permission-catalog';
interface CacheEntry {
permissions: ReadonlySet<string>;
expiresAt: number;
}
const CACHE_TTL_MS = 30_000;
/**
* Resolves a user's effective permission set fresh from the database on
* every request (not embedded in the JWT, so revoking a role or editing a
* role's grants takes effect without forcing a re-login), with a short TTL
* cache to keep that from becoming a query-per-request cost. Call
* invalidateUser/invalidateAll after any write to Role, Permission,
* RolePermission, or User.roleId so the change is visible immediately
* instead of waiting out the cache window.
*/
@Injectable()
export class PermissionResolverService {
private readonly cache = new Map<string, CacheEntry>();
constructor(private readonly prisma: PrismaService) {}
async getPermissions(userId: string): Promise<ReadonlySet<string>> {
const cached = this.cache.get(userId);
if (cached && cached.expiresAt > Date.now()) {
return cached.permissions;
}
const permissions = await this.loadPermissions(userId);
this.cache.set(userId, {
permissions,
expiresAt: Date.now() + CACHE_TTL_MS,
});
return permissions;
}
async hasAny(
userId: string,
keys: readonly PermissionKey[],
): Promise<boolean> {
if (keys.length === 0) {
return true;
}
const permissions = await this.getPermissions(userId);
return keys.some((key) => permissions.has(key));
}
invalidateUser(userId: string): void {
this.cache.delete(userId);
}
invalidateAll(): void {
this.cache.clear();
}
private async loadPermissions(userId: string): Promise<ReadonlySet<string>> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
role: {
select: {
status: true,
permissions: {
select: { permission: { select: { key: true } } },
},
},
},
},
});
if (!user || user.role.status !== 'ACTIVE') {
return new Set();
}
return new Set(user.role.permissions.map((grant) => grant.permission.key));
}
}
@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { PermissionCatalogSyncService } from './permission-catalog-sync.service';
import { PermissionResolverService } from './permission-resolver.service';
@Global()
@Module({
providers: [PermissionResolverService, PermissionCatalogSyncService],
exports: [PermissionResolverService],
})
export class PermissionsModule {}