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

+31 -2
View File
@@ -5,6 +5,7 @@ import * as crypto from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from './permissions/permission-resolver.service';
import { AuthenticatedUser } from './types/authenticated-user';
import { parseDurationToMs } from './util/duration';
@@ -13,6 +14,16 @@ interface AuthTokens {
refreshToken: string;
}
export interface CurrentUserProfile {
id: string;
email: string;
name: string;
role: string;
roleKey: string;
roleName: string;
permissions: string[];
}
const REFRESH_TOKEN_BYTES = 40;
@Injectable()
@@ -22,8 +33,26 @@ export class AuthService {
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
) {}
async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> {
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
include: { role: { select: { key: true, name: true } } },
});
const permissions = await this.permissionResolver.getPermissions(userId);
return {
id: user.id,
email: user.email,
name: user.name,
role: user.legacyRole,
roleKey: user.role.key,
roleName: user.role.name,
permissions: Array.from(permissions),
};
}
private hashRefreshToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
@@ -77,7 +106,7 @@ export class AuthService {
const authenticatedUser: AuthenticatedUser = {
id: user.id,
email: user.email,
role: user.role,
role: user.legacyRole,
};
const accessToken = this.signAccessToken(authenticatedUser);
const refreshToken = await this.issueRefreshToken(user.id);
@@ -109,7 +138,7 @@ export class AuthService {
const authenticatedUser: AuthenticatedUser = {
id: user.id,
email: user.email,
role: user.role,
role: user.legacyRole,
};
const accessToken = this.signAccessToken(authenticatedUser);
const newRefreshToken = await this.issueRefreshToken(user.id);