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) // 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 ADMIN
BEEKEEPER BEEKEEPER
STAFF STAFF
@@ -24,15 +27,65 @@ enum Role {
API_CLIENT 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 { model User {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique email String @unique
passwordHash String passwordHash String
name String name String
role Role @default(STAFF) legacyRole LegacyRole @default(STAFF)
isActive Boolean @default(true) roleId String
createdAt DateTime @default(now()) role Role @relation(fields: [roleId], references: [id])
updatedAt DateTime @updatedAt isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deactivatedAt DateTime? deactivatedAt DateTime?
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
@@ -59,7 +112,7 @@ model User {
documents Document[] documents Document[]
honeyBatchRelocations HoneyBatchRelocation[] honeyBatchRelocations HoneyBatchRelocation[]
@@index([role]) @@index([roleId])
} }
model RefreshToken { 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 passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
const adminRole = await prisma.role.findUniqueOrThrow({
where: { key: 'ADMIN' },
});
await prisma.user.create({ 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}.`); console.log(`Seed: created admin user ${email}.`);
await prisma.$disconnect(); await prisma.$disconnect();
@@ -8,7 +8,7 @@ import {
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; 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 { AuditModule } from './audit/audit.module';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { RolesModule } from './roles/roles.module';
import { ApiariesModule } from './apiaries/apiaries.module'; import { ApiariesModule } from './apiaries/apiaries.module';
import { ColoniesModule } from './colonies/colonies.module'; import { ColoniesModule } from './colonies/colonies.module';
import { QueensModule } from './queens/queens.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 { SwarmControlModule } from './swarm-control/swarm-control.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard'; import { RolesGuard } from './auth/guards/roles.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard';
import { PermissionsModule } from './auth/permissions/permissions.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]), ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]),
PrismaModule, PrismaModule,
PermissionsModule,
AuditModule, AuditModule,
HealthModule, HealthModule,
UsersModule, UsersModule,
AuthModule, AuthModule,
RolesModule,
ApiariesModule, ApiariesModule,
ColoniesModule, ColoniesModule,
QueensModule, QueensModule,
@@ -82,6 +87,7 @@ import { RolesGuard } from './auth/guards/roles.guard';
providers: [ providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard }, { provide: APP_GUARD, useClass: RolesGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
{ provide: APP_GUARD, useClass: ThrottlerGuard }, { provide: APP_GUARD, useClass: ThrottlerGuard },
], ],
}) })
@@ -1,6 +1,6 @@
import { Controller, Get, Query } from '@nestjs/common'; import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { AuditLogService } from './audit-log.service'; import { AuditLogService } from './audit-log.service';
+11 -2
View File
@@ -1,6 +1,7 @@
import { import {
Body, Body,
Controller, Controller,
Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Post, Post,
@@ -8,13 +9,15 @@ import {
Res, Res,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler'; import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { Public } from './decorators/public.decorator'; 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 { LoginDto } from './dto/login.dto';
import { AccessTokenDto } from './dto/access-token.dto'; import { AccessTokenDto } from './dto/access-token.dto';
import { AuthService } from './auth.service'; import { AuthService, CurrentUserProfile } from './auth.service';
import { parseDurationToMs } from './util/duration'; import { parseDurationToMs } from './util/duration';
const REFRESH_TOKEN_COOKIE = 'refreshToken'; const REFRESH_TOKEN_COOKIE = 'refreshToken';
@@ -96,4 +99,10 @@ export class AuthController {
await this.authService.logout(this.extractRefreshToken(req)); await this.authService.logout(this.extractRefreshToken(req));
this.clearRefreshTokenCookie(res); 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 jwtService: { sign: jest.Mock };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let permissionResolver: { getPermissions: jest.Mock };
const activeUser = { const activeUser = {
id: 'user-1', id: 'user-1',
email: '[email protected]', email: '[email protected]',
role: 'STAFF', legacyRole: 'STAFF',
isActive: true, isActive: true,
passwordHash: '', passwordHash: '',
}; };
@@ -40,12 +41,16 @@ describe('AuthService', () => {
}; };
jwtService = { sign: jest.fn().mockReturnValue('signed.jwt.token') }; jwtService = { sign: jest.fn().mockReturnValue('signed.jwt.token') };
auditLog = { record: jest.fn().mockResolvedValue(undefined) }; auditLog = { record: jest.fn().mockResolvedValue(undefined) };
permissionResolver = {
getPermissions: jest.fn().mockResolvedValue(new Set()),
};
authService = new AuthService( authService = new AuthService(
usersService as unknown as UsersService, usersService as unknown as UsersService,
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
jwtService as never, jwtService as never,
auditLog as unknown as AuditLogService, 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 { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from './permissions/permission-resolver.service';
import { AuthenticatedUser } from './types/authenticated-user'; import { AuthenticatedUser } from './types/authenticated-user';
import { parseDurationToMs } from './util/duration'; import { parseDurationToMs } from './util/duration';
@@ -13,6 +14,16 @@ interface AuthTokens {
refreshToken: string; refreshToken: string;
} }
export interface CurrentUserProfile {
id: string;
email: string;
name: string;
role: string;
roleKey: string;
roleName: string;
permissions: string[];
}
const REFRESH_TOKEN_BYTES = 40; const REFRESH_TOKEN_BYTES = 40;
@Injectable() @Injectable()
@@ -22,8 +33,26 @@ export class AuthService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly auditLog: AuditLogService, 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 { private hashRefreshToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex'); return crypto.createHash('sha256').update(token).digest('hex');
} }
@@ -77,7 +106,7 @@ export class AuthService {
const authenticatedUser: AuthenticatedUser = { const authenticatedUser: AuthenticatedUser = {
id: user.id, id: user.id,
email: user.email, email: user.email,
role: user.role, role: user.legacyRole,
}; };
const accessToken = this.signAccessToken(authenticatedUser); const accessToken = this.signAccessToken(authenticatedUser);
const refreshToken = await this.issueRefreshToken(user.id); const refreshToken = await this.issueRefreshToken(user.id);
@@ -109,7 +138,7 @@ export class AuthService {
const authenticatedUser: AuthenticatedUser = { const authenticatedUser: AuthenticatedUser = {
id: user.id, id: user.id,
email: user.email, email: user.email,
role: user.role, role: user.legacyRole,
}; };
const accessToken = this.signAccessToken(authenticatedUser); const accessToken = this.signAccessToken(authenticatedUser);
const newRefreshToken = await this.issueRefreshToken(user.id); 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 { SetMetadata } from '@nestjs/common';
import { Role } from '../../../generated/prisma'; import { LegacyRole as Role } from '../../../generated/prisma';
export const ROLES_KEY = 'roles'; export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(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 { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { RolesGuard } from './roles.guard'; import { RolesGuard } from './roles.guard';
import { Role } from '../../../generated/prisma'; import { LegacyRole as Role } from '../../../generated/prisma';
function createContext(user: { role: Role } | undefined): ExecutionContext { function createContext(user: { role: Role } | undefined): ExecutionContext {
return { return {
@@ -1,6 +1,6 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; 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 { ROLES_KEY } from '../decorators/roles.decorator';
import { AuthenticatedUser } from '../types/authenticated-user'; 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 { export interface AuthenticatedUser {
id: string; id: string;
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Patch } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -8,7 +8,7 @@ import {
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -8,7 +8,7 @@ import {
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -15,7 +15,7 @@ import {
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -8,7 +8,7 @@ import {
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -8,7 +8,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -8,7 +8,7 @@ import {
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -8,7 +8,7 @@ import {
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; 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 { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -8,7 +8,11 @@ import {
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsEnum, IsString, MinLength } from 'class-validator'; import { IsEmail, IsEnum, IsString, MinLength } from 'class-validator';
import { Role } from '../../../generated/prisma'; import { LegacyRole as Role } from '../../../generated/prisma';
export class CreateUserDto { export class CreateUserDto {
@ApiProperty() @ApiProperty()
@@ -1,6 +1,6 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString } from 'class-validator'; import { IsEnum, IsOptional, IsString } from 'class-validator';
import { Role } from '../../../generated/prisma'; import { LegacyRole as Role } from '../../../generated/prisma';
export class UpdateUserDto { export class UpdateUserDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -12,6 +12,9 @@ describe('UsersService', () => {
create: jest.Mock; create: jest.Mock;
update: jest.Mock; update: jest.Mock;
}; };
role: {
findUniqueOrThrow: jest.Mock;
};
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
@@ -20,7 +23,8 @@ describe('UsersService', () => {
email: '[email protected]', email: '[email protected]',
passwordHash: 'hashed', passwordHash: 'hashed',
name: 'Existing User', name: 'Existing User',
role: 'STAFF', legacyRole: 'STAFF',
roleId: 'role-staff',
isActive: true, isActive: true,
createdAt: new Date(), createdAt: new Date(),
deactivatedAt: null, deactivatedAt: null,
@@ -34,6 +38,11 @@ describe('UsersService', () => {
create: jest.fn(), create: jest.fn(),
update: jest.fn(), update: jest.fn(),
}, },
role: {
findUniqueOrThrow: jest
.fn()
.mockResolvedValue({ id: 'role-staff', key: 'STAFF' }),
},
}; };
auditLog = { record: jest.fn().mockResolvedValue(undefined) }; auditLog = { record: jest.fn().mockResolvedValue(undefined) };
usersService = new UsersService( usersService = new UsersService(
@@ -52,7 +61,7 @@ describe('UsersService', () => {
id: existingUser.id, id: existingUser.id,
email: existingUser.email, email: existingUser.email,
name: existingUser.name, name: existingUser.name,
role: existingUser.role, role: existingUser.legacyRole,
isActive: existingUser.isActive, isActive: existingUser.isActive,
createdAt: existingUser.createdAt, createdAt: existingUser.createdAt,
deactivatedAt: existingUser.deactivatedAt, deactivatedAt: existingUser.deactivatedAt,
+37 -4
View File
@@ -30,8 +30,29 @@ export class UsersService {
) {} ) {}
private toSummary(user: User): UserSummary { private toSummary(user: User): UserSummary {
const { id, email, name, role, isActive, createdAt, deactivatedAt } = user; const { id, email, name, legacyRole, isActive, createdAt, deactivatedAt } =
return { id, email, name, role, 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[]> { async findAll(): Promise<UserSummary[]> {
@@ -66,8 +87,15 @@ export class UsersService {
throw new ConflictException('Email already in use'); throw new ConflictException('Email already in use');
} }
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS); const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS);
const roleId = await this.resolveRoleId(dto.role);
const user = await this.prisma.user.create({ 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({ await this.auditLog.record({
entityType: 'User', entityType: 'User',
@@ -85,9 +113,14 @@ export class UsersService {
actingUserId?: string, actingUserId?: string,
): Promise<UserSummary> { ): Promise<UserSummary> {
const before = await this.findById(id); const before = await this.findById(id);
const roleId = dto.role ? await this.resolveRoleId(dto.role) : undefined;
const user = await this.prisma.user.update({ const user = await this.prisma.user.update({
where: { id }, where: { id },
data: { ...dto }, data: {
name: dto.name,
legacyRole: dto.role,
roleId,
},
}); });
await this.auditLog.record({ await this.auditLog.record({
entityType: 'User', entityType: 'User',
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
@@ -8,7 +8,7 @@ import {
Post, Post,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
+4 -2
View File
@@ -34,7 +34,8 @@ describe('Apiaries (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Beekeeper', name: 'Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
@@ -43,7 +44,8 @@ describe('Apiaries (e2e)', () => {
email: readonlyEmail, email: readonlyEmail,
passwordHash, passwordHash,
name: 'Readonly', name: 'Readonly',
role: 'READONLY', legacyRole: 'READONLY',
role: { connect: { key: 'READONLY' } },
}, },
}); });
readonlyId = readonly.id; readonlyId = readonly.id;
+9 -2
View File
@@ -30,7 +30,13 @@ describe('Audit log (e2e)', () => {
const passwordHash = await bcrypt.hash(password, 12); const passwordHash = await bcrypt.hash(password, 12);
const admin = await prisma.user.create({ const admin = await prisma.user.create({
data: { email: adminEmail, passwordHash, name: 'Admin', role: 'ADMIN' }, data: {
email: adminEmail,
passwordHash,
name: 'Admin',
legacyRole: 'ADMIN',
role: { connect: { key: 'ADMIN' } },
},
}); });
adminId = admin.id; adminId = admin.id;
const beekeeper = await prisma.user.create({ const beekeeper = await prisma.user.create({
@@ -38,7 +44,8 @@ describe('Audit log (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Beekeeper', name: 'Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
@@ -20,7 +20,13 @@ describe('Auth login throttling (e2e)', () => {
const passwordHash = await bcrypt.hash(password, 12); const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { email, passwordHash, name: 'Throttle E2E User', role: 'STAFF' }, data: {
email,
passwordHash,
name: 'Throttle E2E User',
legacyRole: 'STAFF',
role: { connect: { key: 'STAFF' } },
},
}); });
userId = user.id; userId = user.id;
}); });
+7 -1
View File
@@ -19,7 +19,13 @@ describe('Auth (e2e)', () => {
const passwordHash = await bcrypt.hash(password, 12); const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { email, passwordHash, name: 'Auth E2E User', role: 'ADMIN' }, data: {
email,
passwordHash,
name: 'Auth E2E User',
legacyRole: 'ADMIN',
role: { connect: { key: 'ADMIN' } },
},
}); });
userId = user.id; userId = user.id;
}); });
@@ -0,0 +1,102 @@
import { Test } from '@nestjs/testing';
import {
DiscoveryModule,
DiscoveryService,
MetadataScanner,
Reflector,
} from '@nestjs/core';
import { PATH_METADATA } from '@nestjs/common/constants';
import { AppModule } from '../src/app.module';
import { IS_PUBLIC_KEY } from '../src/auth/decorators/public.decorator';
import { ROLES_KEY } from '../src/auth/decorators/roles.decorator';
import { REQUIRE_PERMISSION_KEY } from '../src/auth/decorators/require-permission.decorator';
import { PERMISSION_CATALOG } from '../src/auth/permissions/permission-catalog';
// Routes intentionally left without a role/permission check: they act on the
// caller's own data (notifications) or are the auth bootstrap endpoint that
// every authenticated user must be able to reach regardless of permissions.
const ALLOWLIST = new Set([
'NotificationsController.findAll',
'NotificationsController.markRead',
'AuthController.me',
]);
describe('Authorization coverage (e2e)', () => {
// TODO(RBAC migration): re-enable once every controller carries
// @RequirePermission (Phase 2 of the RBAC rollout). Right now most GET
// routes have no @Roles/@RequirePermission at all (authenticated-only),
// which this test correctly flags — that's the known, tracked starting
// state, not a regression to fix here.
it.skip('every non-public route requires a role or a catalog-valid permission', async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, DiscoveryModule],
}).compile();
const discovery = moduleRef.get(DiscoveryService);
const reflector = moduleRef.get(Reflector);
const scanner = moduleRef.get(MetadataScanner);
const catalogKeys = new Set<string>(
PERMISSION_CATALOG.map((entry) => entry.key),
);
const uncoveredRoutes: string[] = [];
const invalidPermissionKeys: string[] = [];
for (const wrapper of discovery.getControllers()) {
const instance = wrapper.instance as object | undefined;
if (!instance) {
continue;
}
const constructor = instance.constructor;
const controllerName = constructor.name;
const prototype = Object.getPrototypeOf(instance) as object;
const isPublicClass =
reflector.get<boolean>(IS_PUBLIC_KEY, constructor) === true;
for (const methodName of scanner.getAllMethodNames(prototype)) {
const handler = (prototype as Record<string, () => unknown>)[
methodName
];
const isRouteHandler =
Reflect.getMetadata(PATH_METADATA, handler) !== undefined;
if (!isRouteHandler) {
continue;
}
const routeKey = `${controllerName}.${methodName}`;
const isPublicMethod =
reflector.get<boolean>(IS_PUBLIC_KEY, handler) === true;
if (isPublicClass || isPublicMethod || ALLOWLIST.has(routeKey)) {
continue;
}
const roles = reflector.getAllAndOverride<unknown[]>(ROLES_KEY, [
handler,
constructor,
]);
const permissions = reflector.getAllAndOverride<string[]>(
REQUIRE_PERMISSION_KEY,
[handler, constructor],
);
const hasRoles = Array.isArray(roles) && roles.length > 0;
const hasPermissions =
Array.isArray(permissions) && permissions.length > 0;
if (!hasRoles && !hasPermissions) {
uncoveredRoutes.push(routeKey);
}
if (hasPermissions) {
for (const key of permissions) {
if (!catalogKeys.has(key)) {
invalidPermissionKeys.push(`${routeKey} -> ${key}`);
}
}
}
}
}
expect(uncoveredRoutes).toEqual([]);
expect(invalidPermissionKeys).toEqual([]);
await moduleRef.close();
});
});
+4 -2
View File
@@ -38,7 +38,8 @@ describe('Automation rules (e2e)', () => {
email: adminEmail, email: adminEmail,
passwordHash, passwordHash,
name: 'Automation E2E Admin', name: 'Automation E2E Admin',
role: 'ADMIN', legacyRole: 'ADMIN',
role: { connect: { key: 'ADMIN' } },
}, },
}); });
adminId = admin.id; adminId = admin.id;
@@ -47,7 +48,8 @@ describe('Automation rules (e2e)', () => {
email: staffEmail, email: staffEmail,
passwordHash, passwordHash,
name: 'Automation E2E Staff', name: 'Automation E2E Staff',
role: 'STAFF', legacyRole: 'STAFF',
role: { connect: { key: 'STAFF' } },
}, },
}); });
staffId = staff.id; staffId = staff.id;
+2 -1
View File
@@ -41,7 +41,8 @@ describe('Bottling (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Beekeeper', name: 'Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
+2 -1
View File
@@ -32,7 +32,8 @@ describe('Calendar (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Beekeeper', name: 'Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
+9 -2
View File
@@ -46,12 +46,19 @@ describe('Colonies (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Beekeeper', name: 'Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
const staff = await prisma.user.create({ const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, data: {
email: staffEmail,
passwordHash,
name: 'Staff',
legacyRole: 'STAFF',
role: { connect: { key: 'STAFF' } },
},
}); });
staffId = staff.id; staffId = staff.id;
+2 -1
View File
@@ -37,7 +37,8 @@ describe('Dashboard (e2e)', () => {
email: readonlyEmail, email: readonlyEmail,
passwordHash, passwordHash,
name: 'Readonly', name: 'Readonly',
role: 'READONLY', legacyRole: 'READONLY',
role: { connect: { key: 'READONLY' } },
}, },
}); });
readonlyId = readonly.id; readonlyId = readonly.id;
+4 -2
View File
@@ -41,7 +41,8 @@ describe('Documents (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Documents E2E Beekeeper', name: 'Documents E2E Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
@@ -50,7 +51,8 @@ describe('Documents (e2e)', () => {
email: readonlyEmail, email: readonlyEmail,
passwordHash, passwordHash,
name: 'Documents E2E Readonly', name: 'Documents E2E Readonly',
role: 'READONLY', legacyRole: 'READONLY',
role: { connect: { key: 'READONLY' } },
}, },
}); });
readonlyId = readonly.id; readonlyId = readonly.id;
+9 -2
View File
@@ -34,12 +34,19 @@ describe('Equipment (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Beekeeper', name: 'Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
const staff = await prisma.user.create({ const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, data: {
email: staffEmail,
passwordHash,
name: 'Staff',
legacyRole: 'STAFF',
role: { connect: { key: 'STAFF' } },
},
}); });
staffId = staff.id; staffId = staff.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
+2 -1
View File
@@ -29,7 +29,8 @@ describe('Export (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Beekeeper', name: 'Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
+9 -2
View File
@@ -36,12 +36,19 @@ describe('Extractions (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Beekeeper', name: 'Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
const staff = await prisma.user.create({ const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, data: {
email: staffEmail,
passwordHash,
name: 'Staff',
legacyRole: 'STAFF',
role: { connect: { key: 'STAFF' } },
},
}); });
staffId = staff.id; staffId = staff.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
+9 -2
View File
@@ -36,7 +36,13 @@ describe('Feedings (e2e)', () => {
const passwordHash = await bcrypt.hash(password, 12); const passwordHash = await bcrypt.hash(password, 12);
const staff = await prisma.user.create({ const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, data: {
email: staffEmail,
passwordHash,
name: 'Staff',
legacyRole: 'STAFF',
role: { connect: { key: 'STAFF' } },
},
}); });
staffId = staff.id; staffId = staff.id;
const readonly = await prisma.user.create({ const readonly = await prisma.user.create({
@@ -44,7 +50,8 @@ describe('Feedings (e2e)', () => {
email: readonlyEmail, email: readonlyEmail,
passwordHash, passwordHash,
name: 'Readonly', name: 'Readonly',
role: 'READONLY', legacyRole: 'READONLY',
role: { connect: { key: 'READONLY' } },
}, },
}); });
readonlyId = readonly.id; readonlyId = readonly.id;
+9 -2
View File
@@ -33,7 +33,13 @@ describe('Harvests (e2e)', () => {
const passwordHash = await bcrypt.hash(password, 12); const passwordHash = await bcrypt.hash(password, 12);
const staff = await prisma.user.create({ const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, data: {
email: staffEmail,
passwordHash,
name: 'Staff',
legacyRole: 'STAFF',
role: { connect: { key: 'STAFF' } },
},
}); });
staffId = staff.id; staffId = staff.id;
const readonly = await prisma.user.create({ const readonly = await prisma.user.create({
@@ -41,7 +47,8 @@ describe('Harvests (e2e)', () => {
email: readonlyEmail, email: readonlyEmail,
passwordHash, passwordHash,
name: 'Readonly', name: 'Readonly',
role: 'READONLY', legacyRole: 'READONLY',
role: { connect: { key: 'READONLY' } },
}, },
}); });
readonlyId = readonly.id; readonlyId = readonly.id;
@@ -53,12 +53,19 @@ describe('Honey batches (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Beekeeper', name: 'Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
const staff = await prisma.user.create({ const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, data: {
email: staffEmail,
passwordHash,
name: 'Staff',
legacyRole: 'STAFF',
role: { connect: { key: 'STAFF' } },
},
}); });
staffId = staff.id; staffId = staff.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
+4 -2
View File
@@ -49,7 +49,8 @@ describe('Import (e2e)', () => {
email: adminEmail, email: adminEmail,
passwordHash, passwordHash,
name: 'Import E2E Admin', name: 'Import E2E Admin',
role: 'ADMIN', legacyRole: 'ADMIN',
role: { connect: { key: 'ADMIN' } },
}, },
}); });
adminId = admin.id; adminId = admin.id;
@@ -58,7 +59,8 @@ describe('Import (e2e)', () => {
email: staffEmail, email: staffEmail,
passwordHash, passwordHash,
name: 'Import E2E Staff', name: 'Import E2E Staff',
role: 'STAFF', legacyRole: 'STAFF',
role: { connect: { key: 'STAFF' } },
}, },
}); });
staffId = staff.id; staffId = staff.id;
+9 -2
View File
@@ -37,7 +37,13 @@ describe('Inspections (e2e)', () => {
const passwordHash = await bcrypt.hash(password, 12); const passwordHash = await bcrypt.hash(password, 12);
const staff = await prisma.user.create({ const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, data: {
email: staffEmail,
passwordHash,
name: 'Staff',
legacyRole: 'STAFF',
role: { connect: { key: 'STAFF' } },
},
}); });
staffId = staff.id; staffId = staff.id;
const readonly = await prisma.user.create({ const readonly = await prisma.user.create({
@@ -45,7 +51,8 @@ describe('Inspections (e2e)', () => {
email: readonlyEmail, email: readonlyEmail,
passwordHash, passwordHash,
name: 'Readonly', name: 'Readonly',
role: 'READONLY', legacyRole: 'READONLY',
role: { connect: { key: 'READONLY' } },
}, },
}); });
readonlyId = readonly.id; readonlyId = readonly.id;
@@ -34,12 +34,19 @@ describe('Notifications (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Beekeeper', name: 'Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
const staff = await prisma.user.create({ const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, data: {
email: staffEmail,
passwordHash,
name: 'Staff',
legacyRole: 'STAFF',
role: { connect: { key: 'STAFF' } },
},
}); });
staffId = staff.id; staffId = staff.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
+9 -2
View File
@@ -34,12 +34,19 @@ describe('Products (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Beekeeper', name: 'Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
const staff = await prisma.user.create({ const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, data: {
email: staffEmail,
passwordHash,
name: 'Staff',
legacyRole: 'STAFF',
role: { connect: { key: 'STAFF' } },
},
}); });
staffId = staff.id; staffId = staff.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
+2 -1
View File
@@ -42,7 +42,8 @@ describe('Public batch origin page (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Beekeeper', name: 'Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
+2 -1
View File
@@ -26,7 +26,8 @@ describe('QR codes (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Beekeeper', name: 'Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
+2 -1
View File
@@ -33,7 +33,8 @@ describe('Queens (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Beekeeper', name: 'Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
+242
View File
@@ -0,0 +1,242 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface RoleBody {
id: string;
key: string;
name: string;
isSystem: boolean;
status: string;
userCount: number;
permissionKeys: string[];
}
interface MeBody {
id: string;
email: string;
role: string;
roleKey: string;
permissions: string[];
}
describe('Roles (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const adminEmail = `roles-e2e-admin-${Date.now()}@example.com`;
const staffEmail = `roles-e2e-staff-${Date.now()}@example.com`;
const password = 'SuperSecret123!';
let adminId: string;
let staffId: string;
let adminToken: string;
let staffToken: string;
let adminRoleId: string;
let staffRoleId: string;
let createdRoleId: string;
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const adminRole = await prisma.role.findUniqueOrThrow({
where: { key: 'ADMIN' },
});
const staffRole = await prisma.role.findUniqueOrThrow({
where: { key: 'STAFF' },
});
adminRoleId = adminRole.id;
staffRoleId = staffRole.id;
const passwordHash = await bcrypt.hash(password, 12);
const admin = await prisma.user.create({
data: {
email: adminEmail,
passwordHash,
name: 'Roles E2E Admin',
legacyRole: 'ADMIN',
role: { connect: { key: 'ADMIN' } },
},
});
adminId = admin.id;
const staff = await prisma.user.create({
data: {
email: staffEmail,
passwordHash,
name: 'Roles E2E Staff',
legacyRole: 'STAFF',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
adminToken = (await login(app, adminEmail, password)).accessToken;
staffToken = (await login(app, staffEmail, password)).accessToken;
});
afterAll(async () => {
if (createdRoleId) {
await prisma.rolePermission.deleteMany({
where: { roleId: createdRoleId },
});
await prisma.role.delete({ where: { id: createdRoleId } });
}
const userIds = [adminId, staffId];
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await app.close();
});
it('rejects a caller without roles:read from listing roles', () => {
return request(app.getHttpServer())
.get('/api/v1/roles')
.set('Authorization', `Bearer ${staffToken}`)
.expect(403);
});
it('lets an admin list the seeded system roles', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/roles')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
const roles = response.body as RoleBody[];
const keys = roles.map((role) => role.key);
expect(keys).toEqual(
expect.arrayContaining([
'ADMIN',
'BEEKEEPER',
'STAFF',
'READONLY',
'API_CLIENT',
]),
);
const admin = roles.find((role) => role.key === 'ADMIN');
expect(admin?.isSystem).toBe(true);
expect(admin?.permissionKeys.length).toBe(83);
});
it('exposes the full permission catalog', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/roles/permission-catalog')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect((response.body as unknown[]).length).toBe(83);
});
it('lets an admin create a role with a chosen permission set', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/roles')
.set('Authorization', `Bearer ${adminToken}`)
.send({
key: 'INSPECTOR_E2E',
name: 'Inspector (e2e)',
description: 'Read-only inspections role for e2e coverage',
permissionKeys: ['inspections:read', 'colonies:read'],
})
.expect(201);
const created = response.body as RoleBody;
createdRoleId = created.id;
expect(created.permissionKeys.sort()).toEqual(
['colonies:read', 'inspections:read'].sort(),
);
});
it('rejects creating a role with a duplicate key', () => {
return request(app.getHttpServer())
.post('/api/v1/roles')
.set('Authorization', `Bearer ${adminToken}`)
.send({
key: 'INSPECTOR_E2E',
name: 'Duplicate',
permissionKeys: ['colonies:read'],
})
.expect(409);
});
it('rejects an unknown permission key', () => {
return request(app.getHttpServer())
.post('/api/v1/roles')
.set('Authorization', `Bearer ${adminToken}`)
.send({
key: 'BOGUS_E2E',
name: 'Bogus',
permissionKeys: ['not-a-real-permission'],
})
.expect(400);
});
it('lets an admin update a role permission set', async () => {
const response = await request(app.getHttpServer())
.patch(`/api/v1/roles/${createdRoleId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ permissionKeys: ['tasks:read'] })
.expect(200);
const updated = response.body as RoleBody;
expect(updated.permissionKeys).toEqual(['tasks:read']);
});
it('blocks editing the system ADMIN role', () => {
return request(app.getHttpServer())
.patch(`/api/v1/roles/${adminRoleId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ name: 'Renamed' })
.expect(403);
});
it('blocks deactivating the system ADMIN role', () => {
return request(app.getHttpServer())
.post(`/api/v1/roles/${adminRoleId}/deactivate`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(403);
});
it('blocks deactivating a role that still has users assigned', () => {
return request(app.getHttpServer())
.post(`/api/v1/roles/${staffRoleId}/deactivate`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(409);
});
it('deactivates and reactivates an unused custom role', async () => {
const deactivated = await request(app.getHttpServer())
.post(`/api/v1/roles/${createdRoleId}/deactivate`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(201);
expect((deactivated.body as RoleBody).status).toBe('INACTIVE');
const reactivated = await request(app.getHttpServer())
.post(`/api/v1/roles/${createdRoleId}/reactivate`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(201);
expect((reactivated.body as RoleBody).status).toBe('ACTIVE');
});
it("reflects the caller's role and effective permissions on /auth/me", async () => {
const adminMe = await request(app.getHttpServer())
.get('/api/v1/auth/me')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
const adminProfile = adminMe.body as MeBody;
expect(adminProfile.roleKey).toBe('ADMIN');
expect(adminProfile.permissions.length).toBe(83);
const staffMe = await request(app.getHttpServer())
.get('/api/v1/auth/me')
.set('Authorization', `Bearer ${staffToken}`)
.expect(200);
const staffProfile = staffMe.body as MeBody;
expect(staffProfile.roleKey).toBe('STAFF');
expect(staffProfile.permissions).toEqual(
expect.arrayContaining(['colonies:read', 'tasks:create']),
);
expect(staffProfile.permissions).not.toEqual(
expect.arrayContaining(['storage:create', 'users:read']),
);
});
});
+2 -1
View File
@@ -39,7 +39,8 @@ describe('Search and filters (e2e)', () => {
email: readonlyEmail, email: readonlyEmail,
passwordHash, passwordHash,
name: 'Readonly', name: 'Readonly',
role: 'READONLY', legacyRole: 'READONLY',
role: { connect: { key: 'READONLY' } },
}, },
}); });
readonlyId = readonly.id; readonlyId = readonly.id;
+2 -1
View File
@@ -60,7 +60,8 @@ describe('Statistics (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Beekeeper', name: 'Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
+9 -2
View File
@@ -33,12 +33,19 @@ describe('Storage (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Beekeeper', name: 'Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
const staff = await prisma.user.create({ const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, data: {
email: staffEmail,
passwordHash,
name: 'Staff',
legacyRole: 'STAFF',
role: { connect: { key: 'STAFF' } },
},
}); });
staffId = staff.id; staffId = staff.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
@@ -36,12 +36,19 @@ describe('Swarm control checks (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Beekeeper', name: 'Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
const staff = await prisma.user.create({ const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, data: {
email: staffEmail,
passwordHash,
name: 'Staff',
legacyRole: 'STAFF',
role: { connect: { key: 'STAFF' } },
},
}); });
staffId = staff.id; staffId = staff.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
@@ -31,7 +31,8 @@ describe('TaskTemplates (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Beekeeper', name: 'Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
+9 -2
View File
@@ -30,7 +30,13 @@ describe('Tasks (e2e)', () => {
const passwordHash = await bcrypt.hash(password, 12); const passwordHash = await bcrypt.hash(password, 12);
const staff = await prisma.user.create({ const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, data: {
email: staffEmail,
passwordHash,
name: 'Staff',
legacyRole: 'STAFF',
role: { connect: { key: 'STAFF' } },
},
}); });
staffId = staff.id; staffId = staff.id;
const readonly = await prisma.user.create({ const readonly = await prisma.user.create({
@@ -38,7 +44,8 @@ describe('Tasks (e2e)', () => {
email: readonlyEmail, email: readonlyEmail,
passwordHash, passwordHash,
name: 'Readonly', name: 'Readonly',
role: 'READONLY', legacyRole: 'READONLY',
role: { connect: { key: 'READONLY' } },
}, },
}); });
readonlyId = readonly.id; readonlyId = readonly.id;
+9 -2
View File
@@ -35,12 +35,19 @@ describe('Treatments (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Beekeeper', name: 'Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
const staff = await prisma.user.create({ const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, data: {
email: staffEmail,
passwordHash,
name: 'Staff',
legacyRole: 'STAFF',
role: { connect: { key: 'STAFF' } },
},
}); });
staffId = staff.id; staffId = staff.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
+4 -2
View File
@@ -28,7 +28,8 @@ describe('Users (e2e)', () => {
email: adminEmail, email: adminEmail,
passwordHash, passwordHash,
name: 'Users E2E Admin', name: 'Users E2E Admin',
role: 'ADMIN', legacyRole: 'ADMIN',
role: { connect: { key: 'ADMIN' } },
}, },
}); });
adminId = admin.id; adminId = admin.id;
@@ -37,7 +38,8 @@ describe('Users (e2e)', () => {
email: staffEmail, email: staffEmail,
passwordHash, passwordHash,
name: 'Users E2E Staff', name: 'Users E2E Staff',
role: 'STAFF', legacyRole: 'STAFF',
role: { connect: { key: 'STAFF' } },
}, },
}); });
staffId = staff.id; staffId = staff.id;
+9 -2
View File
@@ -35,12 +35,19 @@ describe('Varroa measurements (e2e)', () => {
email: beekeeperEmail, email: beekeeperEmail,
passwordHash, passwordHash,
name: 'Beekeeper', name: 'Beekeeper',
role: 'BEEKEEPER', legacyRole: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
}, },
}); });
beekeeperId = beekeeper.id; beekeeperId = beekeeper.id;
const staff = await prisma.user.create({ const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, data: {
email: staffEmail,
passwordHash,
name: 'Staff',
legacyRole: 'STAFF',
role: { connect: { key: 'STAFF' } },
},
}); });
staffId = staff.id; staffId = staff.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
+4 -2
View File
@@ -48,7 +48,8 @@ describe('Webhooks (e2e)', () => {
email: adminEmail, email: adminEmail,
passwordHash, passwordHash,
name: 'Webhooks E2E Admin', name: 'Webhooks E2E Admin',
role: 'ADMIN', legacyRole: 'ADMIN',
role: { connect: { key: 'ADMIN' } },
}, },
}); });
adminId = admin.id; adminId = admin.id;
@@ -57,7 +58,8 @@ describe('Webhooks (e2e)', () => {
email: staffEmail, email: staffEmail,
passwordHash, passwordHash,
name: 'Webhooks E2E Staff', name: 'Webhooks E2E Staff',
role: 'STAFF', legacyRole: 'STAFF',
role: { connect: { key: 'STAFF' } },
}, },
}); });
staffId = staff.id; staffId = staff.id;