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

@@ -8,7 +8,7 @@ import {
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ApiaryStatus, Role } from '../../generated/prisma';
import { ApiaryStatus, LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
+6
View File
@@ -7,6 +7,7 @@ import { HealthModule } from './health/health.module';
import { AuditModule } from './audit/audit.module';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { RolesModule } from './roles/roles.module';
import { ApiariesModule } from './apiaries/apiaries.module';
import { ColoniesModule } from './colonies/colonies.module';
import { QueensModule } from './queens/queens.module';
@@ -38,16 +39,20 @@ import { TaskTemplatesModule } from './task-templates/task-templates.module';
import { SwarmControlModule } from './swarm-control/swarm-control.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard';
import { PermissionsModule } from './auth/permissions/permissions.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]),
PrismaModule,
PermissionsModule,
AuditModule,
HealthModule,
UsersModule,
AuthModule,
RolesModule,
ApiariesModule,
ColoniesModule,
QueensModule,
@@ -82,6 +87,7 @@ import { RolesGuard } from './auth/guards/roles.guard';
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
{ provide: APP_GUARD, useClass: ThrottlerGuard },
],
})
@@ -1,6 +1,6 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { AuditLogService } from './audit-log.service';
+11 -2
View File
@@ -1,6 +1,7 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
@@ -8,13 +9,15 @@ import {
Res,
UnauthorizedException,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import { Public } from './decorators/public.decorator';
import { CurrentUser } from './decorators/current-user.decorator';
import type { AuthenticatedUser } from './types/authenticated-user';
import { LoginDto } from './dto/login.dto';
import { AccessTokenDto } from './dto/access-token.dto';
import { AuthService } from './auth.service';
import { AuthService, CurrentUserProfile } from './auth.service';
import { parseDurationToMs } from './util/duration';
const REFRESH_TOKEN_COOKIE = 'refreshToken';
@@ -96,4 +99,10 @@ export class AuthController {
await this.authService.logout(this.extractRefreshToken(req));
this.clearRefreshTokenCookie(res);
}
@ApiBearerAuth()
@Get('me')
me(@CurrentUser() user: AuthenticatedUser): Promise<CurrentUserProfile> {
return this.authService.getCurrentUserProfile(user.id);
}
}
@@ -18,11 +18,12 @@ describe('AuthService', () => {
};
let jwtService: { sign: jest.Mock };
let auditLog: { record: jest.Mock };
let permissionResolver: { getPermissions: jest.Mock };
const activeUser = {
id: 'user-1',
email: '[email protected]',
role: 'STAFF',
legacyRole: 'STAFF',
isActive: true,
passwordHash: '',
};
@@ -40,12 +41,16 @@ describe('AuthService', () => {
};
jwtService = { sign: jest.fn().mockReturnValue('signed.jwt.token') };
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
permissionResolver = {
getPermissions: jest.fn().mockResolvedValue(new Set()),
};
authService = new AuthService(
usersService as unknown as UsersService,
prisma as unknown as PrismaService,
jwtService as never,
auditLog as unknown as AuditLogService,
permissionResolver as never,
);
});
+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);
@@ -0,0 +1,11 @@
import { SetMetadata } from '@nestjs/common';
import { PermissionKey } from '../permissions/permission-catalog';
export const REQUIRE_PERMISSION_KEY = 'require-permission';
/**
* Grants access if the caller holds ANY of the given permission keys
* (OR semantics, matching the legacy @Roles(...) behaviour it replaces).
*/
export const RequirePermission = (...keys: PermissionKey[]) =>
SetMetadata(REQUIRE_PERMISSION_KEY, keys);
@@ -1,5 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { Role } from '../../../generated/prisma';
import { LegacyRole as Role } from '../../../generated/prisma';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
@@ -0,0 +1,39 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { REQUIRE_PERMISSION_KEY } from '../decorators/require-permission.decorator';
import { PermissionKey } from '../permissions/permission-catalog';
import { PermissionResolverService } from '../permissions/permission-resolver.service';
import { AuthenticatedUser } from '../types/authenticated-user';
interface RequestWithUser {
user?: AuthenticatedUser;
}
/**
* Runs alongside the legacy RolesGuard during the RBAC migration: routes
* without @RequirePermission are untouched here (still governed solely by
* @Roles), routes with it are enforced against the caller's live
* permission set. Both guards must pass for a request to proceed.
*/
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly permissionResolver: PermissionResolverService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredPermissions = this.reflector.getAllAndOverride<
PermissionKey[]
>(REQUIRE_PERMISSION_KEY, [context.getHandler(), context.getClass()]);
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
if (!user) {
return false;
}
return this.permissionResolver.hasAny(user.id, requiredPermissions);
}
}
@@ -1,7 +1,7 @@
import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { RolesGuard } from './roles.guard';
import { Role } from '../../../generated/prisma';
import { LegacyRole as Role } from '../../../generated/prisma';
function createContext(user: { role: Role } | undefined): ExecutionContext {
return {
@@ -1,6 +1,6 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '../../../generated/prisma';
import { LegacyRole as Role } from '../../../generated/prisma';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { AuthenticatedUser } from '../types/authenticated-user';
@@ -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 {}
@@ -1,4 +1,4 @@
import { Role } from '../../../generated/prisma';
import { LegacyRole as Role } from '../../../generated/prisma';
export interface AuthenticatedUser {
id: string;
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Patch } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -8,7 +8,7 @@ import {
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -8,7 +8,7 @@ import {
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ColonyStatus, Role } from '../../generated/prisma';
import { ColonyStatus, LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -15,7 +15,7 @@ import {
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { DocumentEntityType, Role } from '../../generated/prisma';
import { DocumentEntityType, LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -8,7 +8,7 @@ import {
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { EquipmentCondition, Role } from '../../generated/prisma';
import { EquipmentCondition, LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { HoneyBatchStatus, Role } from '../../generated/prisma';
import { HoneyBatchStatus, LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -8,7 +8,7 @@ import {
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -8,7 +8,7 @@ import {
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ProductStatus, Role } from '../../generated/prisma';
import { ProductStatus, LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -8,7 +8,7 @@ import {
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { QueenStatus, Role } from '../../generated/prisma';
import { QueenStatus, LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -0,0 +1,44 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsArray,
IsIn,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
import {
PERMISSION_CATALOG,
PermissionKey,
} from '../../auth/permissions/permission-catalog';
const CATALOG_KEYS = PERMISSION_CATALOG.map((entry) => entry.key);
export class CreateRoleDto {
@ApiProperty({ description: 'Eindeutiger Schlüssel, z.B. INSPECTOR' })
@IsString()
@MinLength(2)
@MaxLength(40)
@Matches(/^[A-Z][A-Z0-9_]*$/, {
message: 'key muss GROSS_MIT_UNTERSTRICHEN sein',
})
key!: string;
@ApiProperty()
@IsString()
@MinLength(2)
@MaxLength(100)
name!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@ApiProperty({ enum: CATALOG_KEYS, isArray: true })
@IsArray()
@IsIn(CATALOG_KEYS, { each: true })
permissionKeys!: PermissionKey[];
}
@@ -0,0 +1,36 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsArray,
IsIn,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import {
PERMISSION_CATALOG,
PermissionKey,
} from '../../auth/permissions/permission-catalog';
const CATALOG_KEYS = PERMISSION_CATALOG.map((entry) => entry.key);
export class UpdateRoleDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(100)
name?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@ApiPropertyOptional({ enum: CATALOG_KEYS, isArray: true })
@IsOptional()
@IsArray()
@IsIn(CATALOG_KEYS, { each: true })
permissionKeys?: PermissionKey[];
}
@@ -0,0 +1,62 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { PERMISSION_CATALOG } from '../auth/permissions/permission-catalog';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
import { RolesService } from './roles.service';
@ApiTags('roles')
@ApiBearerAuth()
@Controller('roles')
export class RolesController {
constructor(private readonly rolesService: RolesService) {}
@Get('permission-catalog')
@RequirePermission('roles:read')
getPermissionCatalog() {
return PERMISSION_CATALOG;
}
@Get()
@RequirePermission('roles:read')
findAll() {
return this.rolesService.findAll();
}
@Get(':id')
@RequirePermission('roles:read')
findOne(@Param('id') id: string) {
return this.rolesService.findSummaryById(id);
}
@Post()
@RequirePermission('roles:create')
create(@Body() dto: CreateRoleDto, @CurrentUser() actor: AuthenticatedUser) {
return this.rolesService.create(dto, actor.id);
}
@Patch(':id')
@RequirePermission('roles:update')
update(
@Param('id') id: string,
@Body() dto: UpdateRoleDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.rolesService.update(id, dto, actor.id);
}
@Post(':id/deactivate')
@RequirePermission('roles:deactivate')
deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.rolesService.deactivate(id, actor.id);
}
@Post(':id/reactivate')
@RequirePermission('roles:deactivate')
reactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.rolesService.reactivate(id, actor.id);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { RolesController } from './roles.controller';
import { RolesService } from './roles.service';
@Module({
controllers: [RolesController],
providers: [RolesService],
exports: [RolesService],
})
export class RolesModule {}
+225
View File
@@ -0,0 +1,225 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
export interface RoleSummary {
id: string;
key: string;
name: string;
description: string | null;
isSystem: boolean;
status: string;
userCount: number;
permissionKeys: string[];
createdAt: Date;
updatedAt: Date;
deactivatedAt: Date | null;
}
const ROLE_INCLUDE = {
permissions: { select: { permission: { select: { key: true } } } },
_count: { select: { users: true } },
} satisfies Prisma.RoleInclude;
type RoleWithRelations = Prisma.RoleGetPayload<{
include: typeof ROLE_INCLUDE;
}>;
@Injectable()
export class RolesService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
) {}
private toSummary(role: RoleWithRelations): RoleSummary {
return {
id: role.id,
key: role.key,
name: role.name,
description: role.description,
isSystem: role.isSystem,
status: role.status,
userCount: role._count.users,
permissionKeys: role.permissions.map((grant) => grant.permission.key),
createdAt: role.createdAt,
updatedAt: role.updatedAt,
deactivatedAt: role.deactivatedAt,
};
}
async findAll(): Promise<RoleSummary[]> {
const roles = await this.prisma.role.findMany({
include: ROLE_INCLUDE,
orderBy: [{ isSystem: 'desc' }, { name: 'asc' }],
});
return roles.map((role) => this.toSummary(role));
}
async findById(id: string): Promise<RoleWithRelations> {
const role = await this.prisma.role.findUnique({
where: { id },
include: ROLE_INCLUDE,
});
if (!role) {
throw new NotFoundException('Role not found');
}
return role;
}
async findSummaryById(id: string): Promise<RoleSummary> {
return this.toSummary(await this.findById(id));
}
async create(
dto: CreateRoleDto,
actingUserId?: string,
): Promise<RoleSummary> {
const existing = await this.prisma.role.findUnique({
where: { key: dto.key },
});
if (existing) {
throw new ConflictException('Role key already in use');
}
const role = await this.prisma.$transaction(async (tx) => {
const created = await tx.role.create({
data: {
key: dto.key,
name: dto.name,
description: dto.description,
},
});
await this.replacePermissions(tx, created.id, dto.permissionKeys);
return tx.role.findUniqueOrThrow({
where: { id: created.id },
include: ROLE_INCLUDE,
});
});
this.permissionResolver.invalidateAll();
const summary = this.toSummary(role);
await this.auditLog.record({
entityType: 'Role',
entityId: role.id,
action: 'CREATE',
userId: actingUserId,
afterData: summary,
});
return summary;
}
async update(
id: string,
dto: UpdateRoleDto,
actingUserId?: string,
): Promise<RoleSummary> {
const before = await this.findById(id);
if (before.isSystem) {
throw new ForbiddenException('System roles cannot be edited');
}
const role = await this.prisma.$transaction(async (tx) => {
await tx.role.update({
where: { id },
data: { name: dto.name, description: dto.description },
});
if (dto.permissionKeys) {
await this.replacePermissions(tx, id, dto.permissionKeys);
}
return tx.role.findUniqueOrThrow({
where: { id },
include: ROLE_INCLUDE,
});
});
this.permissionResolver.invalidateAll();
const summary = this.toSummary(role);
await this.auditLog.record({
entityType: 'Role',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: this.toSummary(before),
afterData: summary,
});
return summary;
}
async deactivate(id: string, actingUserId?: string): Promise<RoleSummary> {
const before = await this.findById(id);
if (before.isSystem) {
throw new ForbiddenException('System roles cannot be deactivated');
}
if (before._count.users > 0) {
throw new ConflictException(
'Role still has users assigned; reassign them before deactivating',
);
}
const role = await this.prisma.role.update({
where: { id },
data: { status: 'INACTIVE', deactivatedAt: new Date() },
include: ROLE_INCLUDE,
});
this.permissionResolver.invalidateAll();
const summary = this.toSummary(role);
await this.auditLog.record({
entityType: 'Role',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: this.toSummary(before),
afterData: summary,
});
return summary;
}
async reactivate(id: string, actingUserId?: string): Promise<RoleSummary> {
const before = await this.findById(id);
if (before.status === 'ACTIVE') {
throw new ConflictException('Role is already active');
}
const role = await this.prisma.role.update({
where: { id },
data: { status: 'ACTIVE', deactivatedAt: null },
include: ROLE_INCLUDE,
});
this.permissionResolver.invalidateAll();
const summary = this.toSummary(role);
await this.auditLog.record({
entityType: 'Role',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: this.toSummary(before),
afterData: summary,
});
return summary;
}
private async replacePermissions(
tx: Prisma.TransactionClient,
roleId: string,
permissionKeys: string[],
): Promise<void> {
const permissions = await tx.permission.findMany({
where: { key: { in: permissionKeys } },
select: { id: true },
});
await tx.rolePermission.deleteMany({ where: { roleId } });
if (permissions.length > 0) {
await tx.rolePermission.createMany({
data: permissions.map((permission) => ({
roleId,
permissionId: permission.id,
})),
});
}
}
}
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -8,7 +8,11 @@ import {
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role, TaskPriority, TaskStatus } from '../../generated/prisma';
import {
LegacyRole as Role,
TaskPriority,
TaskStatus,
} from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsEnum, IsString, MinLength } from 'class-validator';
import { Role } from '../../../generated/prisma';
import { LegacyRole as Role } from '../../../generated/prisma';
export class CreateUserDto {
@ApiProperty()
@@ -1,6 +1,6 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { Role } from '../../../generated/prisma';
import { LegacyRole as Role } from '../../../generated/prisma';
export class UpdateUserDto {
@ApiPropertyOptional()
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -12,6 +12,9 @@ describe('UsersService', () => {
create: jest.Mock;
update: jest.Mock;
};
role: {
findUniqueOrThrow: jest.Mock;
};
};
let auditLog: { record: jest.Mock };
@@ -20,7 +23,8 @@ describe('UsersService', () => {
email: '[email protected]',
passwordHash: 'hashed',
name: 'Existing User',
role: 'STAFF',
legacyRole: 'STAFF',
roleId: 'role-staff',
isActive: true,
createdAt: new Date(),
deactivatedAt: null,
@@ -34,6 +38,11 @@ describe('UsersService', () => {
create: jest.fn(),
update: jest.fn(),
},
role: {
findUniqueOrThrow: jest
.fn()
.mockResolvedValue({ id: 'role-staff', key: 'STAFF' }),
},
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
usersService = new UsersService(
@@ -52,7 +61,7 @@ describe('UsersService', () => {
id: existingUser.id,
email: existingUser.email,
name: existingUser.name,
role: existingUser.role,
role: existingUser.legacyRole,
isActive: existingUser.isActive,
createdAt: existingUser.createdAt,
deactivatedAt: existingUser.deactivatedAt,
+37 -4
View File
@@ -30,8 +30,29 @@ export class UsersService {
) {}
private toSummary(user: User): UserSummary {
const { id, email, name, role, isActive, createdAt, deactivatedAt } = user;
return { id, email, name, role, isActive, createdAt, deactivatedAt };
const { id, email, name, legacyRole, isActive, createdAt, deactivatedAt } =
user;
return {
id,
email,
name,
role: legacyRole,
isActive,
createdAt,
deactivatedAt,
};
}
/**
* Resolves the RBAC roleId that corresponds to a legacy role key, so
* User.roleId stays in sync while callers still speak in terms of the
* legacy enum (Phase 1 of the RBAC rollout; see Role/Permission models).
*/
private async resolveRoleId(legacyRole: string): Promise<string> {
const role = await this.prisma.role.findUniqueOrThrow({
where: { key: legacyRole },
});
return role.id;
}
async findAll(): Promise<UserSummary[]> {
@@ -66,8 +87,15 @@ export class UsersService {
throw new ConflictException('Email already in use');
}
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS);
const roleId = await this.resolveRoleId(dto.role);
const user = await this.prisma.user.create({
data: { email: dto.email, passwordHash, name: dto.name, role: dto.role },
data: {
email: dto.email,
passwordHash,
name: dto.name,
legacyRole: dto.role,
roleId,
},
});
await this.auditLog.record({
entityType: 'User',
@@ -85,9 +113,14 @@ export class UsersService {
actingUserId?: string,
): Promise<UserSummary> {
const before = await this.findById(id);
const roleId = dto.role ? await this.resolveRoleId(dto.role) : undefined;
const user = await this.prisma.user.update({
where: { id },
data: { ...dto },
data: {
name: dto.name,
legacyRole: dto.role,
roleId,
},
});
await this.auditLog.record({
entityType: 'User',
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -8,7 +8,7 @@ import {
Post,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { LegacyRole as Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';