diff --git a/ApiCore-Backend/prisma/migrations/20260826022125_add_rbac/migration.sql b/ApiCore-Backend/prisma/migrations/20260826022125_add_rbac/migration.sql new file mode 100644 index 0000000..c62f136 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260826022125_add_rbac/migration.sql @@ -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; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 59825a2..865b241 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -16,7 +16,10 @@ datasource db { // Benutzer / Auth / Audit (Abschnitt 3, 14, 15, 16) // --------------------------------------------------------------------------- -enum Role { +// Superseded by the Role/Permission/RolePermission models below. Kept only +// as a transitional column (User.legacyRole) until the RBAC rollout's +// Phase 3 drops it — see docs/lastenheft.md RBAC migration notes. +enum LegacyRole { ADMIN BEEKEEPER STAFF @@ -24,15 +27,65 @@ enum Role { API_CLIENT } +enum RoleStatus { + ACTIVE + INACTIVE +} + +model Role { + id String @id @default(cuid()) + key String @unique + name String + description String? + isSystem Boolean @default(false) + status RoleStatus @default(ACTIVE) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deactivatedAt DateTime? + + permissions RolePermission[] + users User[] + + @@index([status]) +} + +model Permission { + id String @id @default(cuid()) + key String @unique + resource String + action String + labelDe String + groupDe String + sortOrder Int + + roles RolePermission[] + + @@unique([resource, action]) + @@index([resource]) +} + +model RolePermission { + roleId String + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + permissionId String + permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade) + grantedAt DateTime @default(now()) + + @@id([roleId, permissionId]) + @@index([permissionId]) +} + model User { - id String @id @default(cuid()) - email String @unique + id String @id @default(cuid()) + email String @unique passwordHash String name String - role Role @default(STAFF) - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + legacyRole LegacyRole @default(STAFF) + roleId String + role Role @relation(fields: [roleId], references: [id]) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt deactivatedAt DateTime? refreshTokens RefreshToken[] @@ -59,7 +112,7 @@ model User { documents Document[] honeyBatchRelocations HoneyBatchRelocation[] - @@index([role]) + @@index([roleId]) } model RefreshToken { diff --git a/ApiCore-Backend/prisma/seed.ts b/ApiCore-Backend/prisma/seed.ts index 0d80a9d..23ef1b9 100644 --- a/ApiCore-Backend/prisma/seed.ts +++ b/ApiCore-Backend/prisma/seed.ts @@ -22,8 +22,17 @@ async function main(): Promise { } const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS); + const adminRole = await prisma.role.findUniqueOrThrow({ + where: { key: 'ADMIN' }, + }); await prisma.user.create({ - data: { email, passwordHash, name: 'Administrator', role: 'ADMIN' }, + data: { + email, + passwordHash, + name: 'Administrator', + legacyRole: 'ADMIN', + roleId: adminRole.id, + }, }); console.log(`Seed: created admin user ${email}.`); await prisma.$disconnect(); diff --git a/ApiCore-Backend/src/apiaries/apiaries.controller.ts b/ApiCore-Backend/src/apiaries/apiaries.controller.ts index 866dece..1d04d57 100644 --- a/ApiCore-Backend/src/apiaries/apiaries.controller.ts +++ b/ApiCore-Backend/src/apiaries/apiaries.controller.ts @@ -8,7 +8,7 @@ import { Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { ApiaryStatus, Role } from '../../generated/prisma'; +import { ApiaryStatus, LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index bfb85d8..4093a22 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -7,6 +7,7 @@ import { HealthModule } from './health/health.module'; import { AuditModule } from './audit/audit.module'; import { UsersModule } from './users/users.module'; import { AuthModule } from './auth/auth.module'; +import { RolesModule } from './roles/roles.module'; import { ApiariesModule } from './apiaries/apiaries.module'; import { ColoniesModule } from './colonies/colonies.module'; import { QueensModule } from './queens/queens.module'; @@ -38,16 +39,20 @@ import { TaskTemplatesModule } from './task-templates/task-templates.module'; import { SwarmControlModule } from './swarm-control/swarm-control.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; +import { PermissionsGuard } from './auth/guards/permissions.guard'; +import { PermissionsModule } from './auth/permissions/permissions.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]), PrismaModule, + PermissionsModule, AuditModule, HealthModule, UsersModule, AuthModule, + RolesModule, ApiariesModule, ColoniesModule, QueensModule, @@ -82,6 +87,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; providers: [ { provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: RolesGuard }, + { provide: APP_GUARD, useClass: PermissionsGuard }, { provide: APP_GUARD, useClass: ThrottlerGuard }, ], }) diff --git a/ApiCore-Backend/src/audit/audit.controller.ts b/ApiCore-Backend/src/audit/audit.controller.ts index c6d405f..4b6161c 100644 --- a/ApiCore-Backend/src/audit/audit.controller.ts +++ b/ApiCore-Backend/src/audit/audit.controller.ts @@ -1,6 +1,6 @@ import { Controller, Get, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { Role } from '../../generated/prisma'; +import { LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { AuditLogService } from './audit-log.service'; diff --git a/ApiCore-Backend/src/auth/auth.controller.ts b/ApiCore-Backend/src/auth/auth.controller.ts index 53b4652..66cd70a 100644 --- a/ApiCore-Backend/src/auth/auth.controller.ts +++ b/ApiCore-Backend/src/auth/auth.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Get, HttpCode, HttpStatus, Post, @@ -8,13 +9,15 @@ import { Res, UnauthorizedException, } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import type { Request, Response } from 'express'; import { Public } from './decorators/public.decorator'; +import { CurrentUser } from './decorators/current-user.decorator'; +import type { AuthenticatedUser } from './types/authenticated-user'; import { LoginDto } from './dto/login.dto'; import { AccessTokenDto } from './dto/access-token.dto'; -import { AuthService } from './auth.service'; +import { AuthService, CurrentUserProfile } from './auth.service'; import { parseDurationToMs } from './util/duration'; const REFRESH_TOKEN_COOKIE = 'refreshToken'; @@ -96,4 +99,10 @@ export class AuthController { await this.authService.logout(this.extractRefreshToken(req)); this.clearRefreshTokenCookie(res); } + + @ApiBearerAuth() + @Get('me') + me(@CurrentUser() user: AuthenticatedUser): Promise { + return this.authService.getCurrentUserProfile(user.id); + } } diff --git a/ApiCore-Backend/src/auth/auth.service.spec.ts b/ApiCore-Backend/src/auth/auth.service.spec.ts index 96c8ad8..8ea6793 100644 --- a/ApiCore-Backend/src/auth/auth.service.spec.ts +++ b/ApiCore-Backend/src/auth/auth.service.spec.ts @@ -18,11 +18,12 @@ describe('AuthService', () => { }; let jwtService: { sign: jest.Mock }; let auditLog: { record: jest.Mock }; + let permissionResolver: { getPermissions: jest.Mock }; const activeUser = { id: 'user-1', email: 'user@example.com', - role: 'STAFF', + legacyRole: 'STAFF', isActive: true, passwordHash: '', }; @@ -40,12 +41,16 @@ describe('AuthService', () => { }; jwtService = { sign: jest.fn().mockReturnValue('signed.jwt.token') }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + permissionResolver = { + getPermissions: jest.fn().mockResolvedValue(new Set()), + }; authService = new AuthService( usersService as unknown as UsersService, prisma as unknown as PrismaService, jwtService as never, auditLog as unknown as AuditLogService, + permissionResolver as never, ); }); diff --git a/ApiCore-Backend/src/auth/auth.service.ts b/ApiCore-Backend/src/auth/auth.service.ts index 65b2747..704be44 100644 --- a/ApiCore-Backend/src/auth/auth.service.ts +++ b/ApiCore-Backend/src/auth/auth.service.ts @@ -5,6 +5,7 @@ import * as crypto from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; import { UsersService } from '../users/users.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { PermissionResolverService } from './permissions/permission-resolver.service'; import { AuthenticatedUser } from './types/authenticated-user'; import { parseDurationToMs } from './util/duration'; @@ -13,6 +14,16 @@ interface AuthTokens { refreshToken: string; } +export interface CurrentUserProfile { + id: string; + email: string; + name: string; + role: string; + roleKey: string; + roleName: string; + permissions: string[]; +} + const REFRESH_TOKEN_BYTES = 40; @Injectable() @@ -22,8 +33,26 @@ export class AuthService { private readonly prisma: PrismaService, private readonly jwtService: JwtService, private readonly auditLog: AuditLogService, + private readonly permissionResolver: PermissionResolverService, ) {} + async getCurrentUserProfile(userId: string): Promise { + const user = await this.prisma.user.findUniqueOrThrow({ + where: { id: userId }, + include: { role: { select: { key: true, name: true } } }, + }); + const permissions = await this.permissionResolver.getPermissions(userId); + return { + id: user.id, + email: user.email, + name: user.name, + role: user.legacyRole, + roleKey: user.role.key, + roleName: user.role.name, + permissions: Array.from(permissions), + }; + } + private hashRefreshToken(token: string): string { return crypto.createHash('sha256').update(token).digest('hex'); } @@ -77,7 +106,7 @@ export class AuthService { const authenticatedUser: AuthenticatedUser = { id: user.id, email: user.email, - role: user.role, + role: user.legacyRole, }; const accessToken = this.signAccessToken(authenticatedUser); const refreshToken = await this.issueRefreshToken(user.id); @@ -109,7 +138,7 @@ export class AuthService { const authenticatedUser: AuthenticatedUser = { id: user.id, email: user.email, - role: user.role, + role: user.legacyRole, }; const accessToken = this.signAccessToken(authenticatedUser); const newRefreshToken = await this.issueRefreshToken(user.id); diff --git a/ApiCore-Backend/src/auth/decorators/require-permission.decorator.ts b/ApiCore-Backend/src/auth/decorators/require-permission.decorator.ts new file mode 100644 index 0000000..fd40ddf --- /dev/null +++ b/ApiCore-Backend/src/auth/decorators/require-permission.decorator.ts @@ -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); diff --git a/ApiCore-Backend/src/auth/decorators/roles.decorator.ts b/ApiCore-Backend/src/auth/decorators/roles.decorator.ts index 244ca5b..be18bfb 100644 --- a/ApiCore-Backend/src/auth/decorators/roles.decorator.ts +++ b/ApiCore-Backend/src/auth/decorators/roles.decorator.ts @@ -1,5 +1,5 @@ import { SetMetadata } from '@nestjs/common'; -import { Role } from '../../../generated/prisma'; +import { LegacyRole as Role } from '../../../generated/prisma'; export const ROLES_KEY = 'roles'; export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); diff --git a/ApiCore-Backend/src/auth/guards/permissions.guard.ts b/ApiCore-Backend/src/auth/guards/permissions.guard.ts new file mode 100644 index 0000000..ae34c1c --- /dev/null +++ b/ApiCore-Backend/src/auth/guards/permissions.guard.ts @@ -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 { + 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(); + const user = request.user; + if (!user) { + return false; + } + return this.permissionResolver.hasAny(user.id, requiredPermissions); + } +} diff --git a/ApiCore-Backend/src/auth/guards/roles.guard.spec.ts b/ApiCore-Backend/src/auth/guards/roles.guard.spec.ts index c9cf8a9..4d36d0a 100644 --- a/ApiCore-Backend/src/auth/guards/roles.guard.spec.ts +++ b/ApiCore-Backend/src/auth/guards/roles.guard.spec.ts @@ -1,7 +1,7 @@ import { ExecutionContext } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { RolesGuard } from './roles.guard'; -import { Role } from '../../../generated/prisma'; +import { LegacyRole as Role } from '../../../generated/prisma'; function createContext(user: { role: Role } | undefined): ExecutionContext { return { diff --git a/ApiCore-Backend/src/auth/guards/roles.guard.ts b/ApiCore-Backend/src/auth/guards/roles.guard.ts index 4f14b09..effd267 100644 --- a/ApiCore-Backend/src/auth/guards/roles.guard.ts +++ b/ApiCore-Backend/src/auth/guards/roles.guard.ts @@ -1,6 +1,6 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { Role } from '../../../generated/prisma'; +import { LegacyRole as Role } from '../../../generated/prisma'; import { ROLES_KEY } from '../decorators/roles.decorator'; import { AuthenticatedUser } from '../types/authenticated-user'; diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog-sync.service.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog-sync.service.ts new file mode 100644 index 0000000..7272e11 --- /dev/null +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog-sync.service.ts @@ -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 { + 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(', ')}`, + ); + } + } +} diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts new file mode 100644 index 0000000..dc7d00f --- /dev/null +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts @@ -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']; diff --git a/ApiCore-Backend/src/auth/permissions/permission-resolver.service.ts b/ApiCore-Backend/src/auth/permissions/permission-resolver.service.ts new file mode 100644 index 0000000..5da9013 --- /dev/null +++ b/ApiCore-Backend/src/auth/permissions/permission-resolver.service.ts @@ -0,0 +1,78 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { PermissionKey } from './permission-catalog'; + +interface CacheEntry { + permissions: ReadonlySet; + 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(); + + constructor(private readonly prisma: PrismaService) {} + + async getPermissions(userId: string): Promise> { + 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 { + 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> { + 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)); + } +} diff --git a/ApiCore-Backend/src/auth/permissions/permissions.module.ts b/ApiCore-Backend/src/auth/permissions/permissions.module.ts new file mode 100644 index 0000000..2112ce3 --- /dev/null +++ b/ApiCore-Backend/src/auth/permissions/permissions.module.ts @@ -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 {} diff --git a/ApiCore-Backend/src/auth/types/authenticated-user.ts b/ApiCore-Backend/src/auth/types/authenticated-user.ts index 595a438..a8f0b39 100644 --- a/ApiCore-Backend/src/auth/types/authenticated-user.ts +++ b/ApiCore-Backend/src/auth/types/authenticated-user.ts @@ -1,4 +1,4 @@ -import { Role } from '../../../generated/prisma'; +import { LegacyRole as Role } from '../../../generated/prisma'; export interface AuthenticatedUser { id: string; diff --git a/ApiCore-Backend/src/automation/automation.controller.ts b/ApiCore-Backend/src/automation/automation.controller.ts index 393a448..a1d665e 100644 --- a/ApiCore-Backend/src/automation/automation.controller.ts +++ b/ApiCore-Backend/src/automation/automation.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Get, Param, Patch } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { Role } from '../../generated/prisma'; +import { LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/bottling/bottling.controller.ts b/ApiCore-Backend/src/bottling/bottling.controller.ts index 8c455aa..3a6259f 100644 --- a/ApiCore-Backend/src/bottling/bottling.controller.ts +++ b/ApiCore-Backend/src/bottling/bottling.controller.ts @@ -8,7 +8,7 @@ import { Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { Role } from '../../generated/prisma'; +import { LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/colonies/colonies.controller.ts b/ApiCore-Backend/src/colonies/colonies.controller.ts index 0f53197..2d9da8d 100644 --- a/ApiCore-Backend/src/colonies/colonies.controller.ts +++ b/ApiCore-Backend/src/colonies/colonies.controller.ts @@ -8,7 +8,7 @@ import { Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { ColonyStatus, Role } from '../../generated/prisma'; +import { ColonyStatus, LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/documents/documents.controller.ts b/ApiCore-Backend/src/documents/documents.controller.ts index cca5d61..36f1b88 100644 --- a/ApiCore-Backend/src/documents/documents.controller.ts +++ b/ApiCore-Backend/src/documents/documents.controller.ts @@ -15,7 +15,7 @@ import { import { FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; -import { DocumentEntityType, Role } from '../../generated/prisma'; +import { DocumentEntityType, LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/equipment/equipment.controller.ts b/ApiCore-Backend/src/equipment/equipment.controller.ts index ba3a7bf..e40239b 100644 --- a/ApiCore-Backend/src/equipment/equipment.controller.ts +++ b/ApiCore-Backend/src/equipment/equipment.controller.ts @@ -8,7 +8,7 @@ import { Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { EquipmentCondition, Role } from '../../generated/prisma'; +import { EquipmentCondition, LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/extractions/extractions.controller.ts b/ApiCore-Backend/src/extractions/extractions.controller.ts index 12529c5..b5e6c7f 100644 --- a/ApiCore-Backend/src/extractions/extractions.controller.ts +++ b/ApiCore-Backend/src/extractions/extractions.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { Role } from '../../generated/prisma'; +import { LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/feedings/feedings.controller.ts b/ApiCore-Backend/src/feedings/feedings.controller.ts index 5c3aea0..330767c 100644 --- a/ApiCore-Backend/src/feedings/feedings.controller.ts +++ b/ApiCore-Backend/src/feedings/feedings.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { Role } from '../../generated/prisma'; +import { LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/harvests/harvests.controller.ts b/ApiCore-Backend/src/harvests/harvests.controller.ts index 17d7268..a4cd68d 100644 --- a/ApiCore-Backend/src/harvests/harvests.controller.ts +++ b/ApiCore-Backend/src/harvests/harvests.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { Role } from '../../generated/prisma'; +import { LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts b/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts index 96ddb19..90717c6 100644 --- a/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts +++ b/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { HoneyBatchStatus, Role } from '../../generated/prisma'; +import { HoneyBatchStatus, LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/import/import.controller.ts b/ApiCore-Backend/src/import/import.controller.ts index ed1830a..04f2dc8 100644 --- a/ApiCore-Backend/src/import/import.controller.ts +++ b/ApiCore-Backend/src/import/import.controller.ts @@ -8,7 +8,7 @@ import { } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger'; -import { Role } from '../../generated/prisma'; +import { LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/inspections/inspections.controller.ts b/ApiCore-Backend/src/inspections/inspections.controller.ts index f2993b2..6cc59d9 100644 --- a/ApiCore-Backend/src/inspections/inspections.controller.ts +++ b/ApiCore-Backend/src/inspections/inspections.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { Role } from '../../generated/prisma'; +import { LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/products/products.controller.ts b/ApiCore-Backend/src/products/products.controller.ts index 8353ee5..7c56440 100644 --- a/ApiCore-Backend/src/products/products.controller.ts +++ b/ApiCore-Backend/src/products/products.controller.ts @@ -8,7 +8,7 @@ import { Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { ProductStatus, Role } from '../../generated/prisma'; +import { ProductStatus, LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/queens/queens.controller.ts b/ApiCore-Backend/src/queens/queens.controller.ts index 0783acc..2303751 100644 --- a/ApiCore-Backend/src/queens/queens.controller.ts +++ b/ApiCore-Backend/src/queens/queens.controller.ts @@ -8,7 +8,7 @@ import { Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { QueenStatus, Role } from '../../generated/prisma'; +import { QueenStatus, LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/roles/dto/create-role.dto.ts b/ApiCore-Backend/src/roles/dto/create-role.dto.ts new file mode 100644 index 0000000..6ccc061 --- /dev/null +++ b/ApiCore-Backend/src/roles/dto/create-role.dto.ts @@ -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[]; +} diff --git a/ApiCore-Backend/src/roles/dto/update-role.dto.ts b/ApiCore-Backend/src/roles/dto/update-role.dto.ts new file mode 100644 index 0000000..5309ec3 --- /dev/null +++ b/ApiCore-Backend/src/roles/dto/update-role.dto.ts @@ -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[]; +} diff --git a/ApiCore-Backend/src/roles/roles.controller.ts b/ApiCore-Backend/src/roles/roles.controller.ts new file mode 100644 index 0000000..89af986 --- /dev/null +++ b/ApiCore-Backend/src/roles/roles.controller.ts @@ -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); + } +} diff --git a/ApiCore-Backend/src/roles/roles.module.ts b/ApiCore-Backend/src/roles/roles.module.ts new file mode 100644 index 0000000..cf4a096 --- /dev/null +++ b/ApiCore-Backend/src/roles/roles.module.ts @@ -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 {} diff --git a/ApiCore-Backend/src/roles/roles.service.ts b/ApiCore-Backend/src/roles/roles.service.ts new file mode 100644 index 0000000..979498d --- /dev/null +++ b/ApiCore-Backend/src/roles/roles.service.ts @@ -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 { + 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 { + 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 { + return this.toSummary(await this.findById(id)); + } + + async create( + dto: CreateRoleDto, + actingUserId?: string, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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, + })), + }); + } + } +} diff --git a/ApiCore-Backend/src/storage/storage.controller.ts b/ApiCore-Backend/src/storage/storage.controller.ts index 5a2c849..d8457b0 100644 --- a/ApiCore-Backend/src/storage/storage.controller.ts +++ b/ApiCore-Backend/src/storage/storage.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Get, Param, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { Role } from '../../generated/prisma'; +import { LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/swarm-control/swarm-control.controller.ts b/ApiCore-Backend/src/swarm-control/swarm-control.controller.ts index 483f595..f9c7d19 100644 --- a/ApiCore-Backend/src/swarm-control/swarm-control.controller.ts +++ b/ApiCore-Backend/src/swarm-control/swarm-control.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { Role } from '../../generated/prisma'; +import { LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/task-templates/task-templates.controller.ts b/ApiCore-Backend/src/task-templates/task-templates.controller.ts index 159d6a4..4a2cf49 100644 --- a/ApiCore-Backend/src/task-templates/task-templates.controller.ts +++ b/ApiCore-Backend/src/task-templates/task-templates.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { Role } from '../../generated/prisma'; +import { LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/tasks/tasks.controller.ts b/ApiCore-Backend/src/tasks/tasks.controller.ts index 92676ea..69b2e10 100644 --- a/ApiCore-Backend/src/tasks/tasks.controller.ts +++ b/ApiCore-Backend/src/tasks/tasks.controller.ts @@ -8,7 +8,11 @@ import { Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { Role, TaskPriority, TaskStatus } from '../../generated/prisma'; +import { + LegacyRole as Role, + TaskPriority, + TaskStatus, +} from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/treatments/treatments.controller.ts b/ApiCore-Backend/src/treatments/treatments.controller.ts index 1c59889..d54b5e7 100644 --- a/ApiCore-Backend/src/treatments/treatments.controller.ts +++ b/ApiCore-Backend/src/treatments/treatments.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { Role } from '../../generated/prisma'; +import { LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/users/dto/create-user.dto.ts b/ApiCore-Backend/src/users/dto/create-user.dto.ts index f19efa4..2f9ddc7 100644 --- a/ApiCore-Backend/src/users/dto/create-user.dto.ts +++ b/ApiCore-Backend/src/users/dto/create-user.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEmail, IsEnum, IsString, MinLength } from 'class-validator'; -import { Role } from '../../../generated/prisma'; +import { LegacyRole as Role } from '../../../generated/prisma'; export class CreateUserDto { @ApiProperty() diff --git a/ApiCore-Backend/src/users/dto/update-user.dto.ts b/ApiCore-Backend/src/users/dto/update-user.dto.ts index 9c122d4..b7f127e 100644 --- a/ApiCore-Backend/src/users/dto/update-user.dto.ts +++ b/ApiCore-Backend/src/users/dto/update-user.dto.ts @@ -1,6 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsEnum, IsOptional, IsString } from 'class-validator'; -import { Role } from '../../../generated/prisma'; +import { LegacyRole as Role } from '../../../generated/prisma'; export class UpdateUserDto { @ApiPropertyOptional() diff --git a/ApiCore-Backend/src/users/users.controller.ts b/ApiCore-Backend/src/users/users.controller.ts index eefcc1b..d4f0990 100644 --- a/ApiCore-Backend/src/users/users.controller.ts +++ b/ApiCore-Backend/src/users/users.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { Role } from '../../generated/prisma'; +import { LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/users/users.service.spec.ts b/ApiCore-Backend/src/users/users.service.spec.ts index f6ae9de..9a5bf2b 100644 --- a/ApiCore-Backend/src/users/users.service.spec.ts +++ b/ApiCore-Backend/src/users/users.service.spec.ts @@ -12,6 +12,9 @@ describe('UsersService', () => { create: jest.Mock; update: jest.Mock; }; + role: { + findUniqueOrThrow: jest.Mock; + }; }; let auditLog: { record: jest.Mock }; @@ -20,7 +23,8 @@ describe('UsersService', () => { email: 'existing@example.com', passwordHash: 'hashed', name: 'Existing User', - role: 'STAFF', + legacyRole: 'STAFF', + roleId: 'role-staff', isActive: true, createdAt: new Date(), deactivatedAt: null, @@ -34,6 +38,11 @@ describe('UsersService', () => { create: jest.fn(), update: jest.fn(), }, + role: { + findUniqueOrThrow: jest + .fn() + .mockResolvedValue({ id: 'role-staff', key: 'STAFF' }), + }, }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; usersService = new UsersService( @@ -52,7 +61,7 @@ describe('UsersService', () => { id: existingUser.id, email: existingUser.email, name: existingUser.name, - role: existingUser.role, + role: existingUser.legacyRole, isActive: existingUser.isActive, createdAt: existingUser.createdAt, deactivatedAt: existingUser.deactivatedAt, diff --git a/ApiCore-Backend/src/users/users.service.ts b/ApiCore-Backend/src/users/users.service.ts index 1887ca9..80aa0d2 100644 --- a/ApiCore-Backend/src/users/users.service.ts +++ b/ApiCore-Backend/src/users/users.service.ts @@ -30,8 +30,29 @@ export class UsersService { ) {} private toSummary(user: User): UserSummary { - const { id, email, name, role, isActive, createdAt, deactivatedAt } = user; - return { id, email, name, role, isActive, createdAt, deactivatedAt }; + const { id, email, name, legacyRole, isActive, createdAt, deactivatedAt } = + user; + return { + id, + email, + name, + role: legacyRole, + isActive, + createdAt, + deactivatedAt, + }; + } + + /** + * Resolves the RBAC roleId that corresponds to a legacy role key, so + * User.roleId stays in sync while callers still speak in terms of the + * legacy enum (Phase 1 of the RBAC rollout; see Role/Permission models). + */ + private async resolveRoleId(legacyRole: string): Promise { + const role = await this.prisma.role.findUniqueOrThrow({ + where: { key: legacyRole }, + }); + return role.id; } async findAll(): Promise { @@ -66,8 +87,15 @@ export class UsersService { throw new ConflictException('Email already in use'); } const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS); + const roleId = await this.resolveRoleId(dto.role); const user = await this.prisma.user.create({ - data: { email: dto.email, passwordHash, name: dto.name, role: dto.role }, + data: { + email: dto.email, + passwordHash, + name: dto.name, + legacyRole: dto.role, + roleId, + }, }); await this.auditLog.record({ entityType: 'User', @@ -85,9 +113,14 @@ export class UsersService { actingUserId?: string, ): Promise { const before = await this.findById(id); + const roleId = dto.role ? await this.resolveRoleId(dto.role) : undefined; const user = await this.prisma.user.update({ where: { id }, - data: { ...dto }, + data: { + name: dto.name, + legacyRole: dto.role, + roleId, + }, }); await this.auditLog.record({ entityType: 'User', diff --git a/ApiCore-Backend/src/varroa/varroa.controller.ts b/ApiCore-Backend/src/varroa/varroa.controller.ts index bd54101..9bb511b 100644 --- a/ApiCore-Backend/src/varroa/varroa.controller.ts +++ b/ApiCore-Backend/src/varroa/varroa.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { Role } from '../../generated/prisma'; +import { LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/src/webhooks/webhooks.controller.ts b/ApiCore-Backend/src/webhooks/webhooks.controller.ts index eda8173..c96b1ff 100644 --- a/ApiCore-Backend/src/webhooks/webhooks.controller.ts +++ b/ApiCore-Backend/src/webhooks/webhooks.controller.ts @@ -8,7 +8,7 @@ import { Post, } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { Role } from '../../generated/prisma'; +import { LegacyRole as Role } from '../../generated/prisma'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; diff --git a/ApiCore-Backend/test/apiaries.e2e-spec.ts b/ApiCore-Backend/test/apiaries.e2e-spec.ts index 9cb1eb5..a288061 100644 --- a/ApiCore-Backend/test/apiaries.e2e-spec.ts +++ b/ApiCore-Backend/test/apiaries.e2e-spec.ts @@ -34,7 +34,8 @@ describe('Apiaries (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; @@ -43,7 +44,8 @@ describe('Apiaries (e2e)', () => { email: readonlyEmail, passwordHash, name: 'Readonly', - role: 'READONLY', + legacyRole: 'READONLY', + role: { connect: { key: 'READONLY' } }, }, }); readonlyId = readonly.id; diff --git a/ApiCore-Backend/test/audit-log.e2e-spec.ts b/ApiCore-Backend/test/audit-log.e2e-spec.ts index e34cbc4..2dbf223 100644 --- a/ApiCore-Backend/test/audit-log.e2e-spec.ts +++ b/ApiCore-Backend/test/audit-log.e2e-spec.ts @@ -30,7 +30,13 @@ describe('Audit log (e2e)', () => { const passwordHash = await bcrypt.hash(password, 12); 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; const beekeeper = await prisma.user.create({ @@ -38,7 +44,8 @@ describe('Audit log (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; diff --git a/ApiCore-Backend/test/auth-throttle.e2e-spec.ts b/ApiCore-Backend/test/auth-throttle.e2e-spec.ts index d3f38e9..5dd9bfc 100644 --- a/ApiCore-Backend/test/auth-throttle.e2e-spec.ts +++ b/ApiCore-Backend/test/auth-throttle.e2e-spec.ts @@ -20,7 +20,13 @@ describe('Auth login throttling (e2e)', () => { const passwordHash = await bcrypt.hash(password, 12); 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; }); diff --git a/ApiCore-Backend/test/auth.e2e-spec.ts b/ApiCore-Backend/test/auth.e2e-spec.ts index 434ac17..b7f6f70 100644 --- a/ApiCore-Backend/test/auth.e2e-spec.ts +++ b/ApiCore-Backend/test/auth.e2e-spec.ts @@ -19,7 +19,13 @@ describe('Auth (e2e)', () => { const passwordHash = await bcrypt.hash(password, 12); 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; }); diff --git a/ApiCore-Backend/test/authorization-coverage.e2e-spec.ts b/ApiCore-Backend/test/authorization-coverage.e2e-spec.ts new file mode 100644 index 0000000..856ff29 --- /dev/null +++ b/ApiCore-Backend/test/authorization-coverage.e2e-spec.ts @@ -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( + 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(IS_PUBLIC_KEY, constructor) === true; + + for (const methodName of scanner.getAllMethodNames(prototype)) { + const handler = (prototype as Record unknown>)[ + methodName + ]; + const isRouteHandler = + Reflect.getMetadata(PATH_METADATA, handler) !== undefined; + if (!isRouteHandler) { + continue; + } + const routeKey = `${controllerName}.${methodName}`; + const isPublicMethod = + reflector.get(IS_PUBLIC_KEY, handler) === true; + if (isPublicClass || isPublicMethod || ALLOWLIST.has(routeKey)) { + continue; + } + + const roles = reflector.getAllAndOverride(ROLES_KEY, [ + handler, + constructor, + ]); + const permissions = reflector.getAllAndOverride( + 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(); + }); +}); diff --git a/ApiCore-Backend/test/automation.e2e-spec.ts b/ApiCore-Backend/test/automation.e2e-spec.ts index 2ca37c7..0162502 100644 --- a/ApiCore-Backend/test/automation.e2e-spec.ts +++ b/ApiCore-Backend/test/automation.e2e-spec.ts @@ -38,7 +38,8 @@ describe('Automation rules (e2e)', () => { email: adminEmail, passwordHash, name: 'Automation E2E Admin', - role: 'ADMIN', + legacyRole: 'ADMIN', + role: { connect: { key: 'ADMIN' } }, }, }); adminId = admin.id; @@ -47,7 +48,8 @@ describe('Automation rules (e2e)', () => { email: staffEmail, passwordHash, name: 'Automation E2E Staff', - role: 'STAFF', + legacyRole: 'STAFF', + role: { connect: { key: 'STAFF' } }, }, }); staffId = staff.id; diff --git a/ApiCore-Backend/test/bottling.e2e-spec.ts b/ApiCore-Backend/test/bottling.e2e-spec.ts index dff33bc..5bd6962 100644 --- a/ApiCore-Backend/test/bottling.e2e-spec.ts +++ b/ApiCore-Backend/test/bottling.e2e-spec.ts @@ -41,7 +41,8 @@ describe('Bottling (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; diff --git a/ApiCore-Backend/test/calendar.e2e-spec.ts b/ApiCore-Backend/test/calendar.e2e-spec.ts index 6390bd1..f508f34 100644 --- a/ApiCore-Backend/test/calendar.e2e-spec.ts +++ b/ApiCore-Backend/test/calendar.e2e-spec.ts @@ -32,7 +32,8 @@ describe('Calendar (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; diff --git a/ApiCore-Backend/test/colonies.e2e-spec.ts b/ApiCore-Backend/test/colonies.e2e-spec.ts index 9208781..3f66569 100644 --- a/ApiCore-Backend/test/colonies.e2e-spec.ts +++ b/ApiCore-Backend/test/colonies.e2e-spec.ts @@ -46,12 +46,19 @@ describe('Colonies (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; 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; diff --git a/ApiCore-Backend/test/dashboard.e2e-spec.ts b/ApiCore-Backend/test/dashboard.e2e-spec.ts index e2412ed..3465fa8 100644 --- a/ApiCore-Backend/test/dashboard.e2e-spec.ts +++ b/ApiCore-Backend/test/dashboard.e2e-spec.ts @@ -37,7 +37,8 @@ describe('Dashboard (e2e)', () => { email: readonlyEmail, passwordHash, name: 'Readonly', - role: 'READONLY', + legacyRole: 'READONLY', + role: { connect: { key: 'READONLY' } }, }, }); readonlyId = readonly.id; diff --git a/ApiCore-Backend/test/documents.e2e-spec.ts b/ApiCore-Backend/test/documents.e2e-spec.ts index 8059061..c933440 100644 --- a/ApiCore-Backend/test/documents.e2e-spec.ts +++ b/ApiCore-Backend/test/documents.e2e-spec.ts @@ -41,7 +41,8 @@ describe('Documents (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Documents E2E Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; @@ -50,7 +51,8 @@ describe('Documents (e2e)', () => { email: readonlyEmail, passwordHash, name: 'Documents E2E Readonly', - role: 'READONLY', + legacyRole: 'READONLY', + role: { connect: { key: 'READONLY' } }, }, }); readonlyId = readonly.id; diff --git a/ApiCore-Backend/test/equipment.e2e-spec.ts b/ApiCore-Backend/test/equipment.e2e-spec.ts index b424e55..c14068e 100644 --- a/ApiCore-Backend/test/equipment.e2e-spec.ts +++ b/ApiCore-Backend/test/equipment.e2e-spec.ts @@ -34,12 +34,19 @@ describe('Equipment (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; 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; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; diff --git a/ApiCore-Backend/test/export.e2e-spec.ts b/ApiCore-Backend/test/export.e2e-spec.ts index 423cf33..cae7602 100644 --- a/ApiCore-Backend/test/export.e2e-spec.ts +++ b/ApiCore-Backend/test/export.e2e-spec.ts @@ -29,7 +29,8 @@ describe('Export (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; diff --git a/ApiCore-Backend/test/extractions.e2e-spec.ts b/ApiCore-Backend/test/extractions.e2e-spec.ts index 7f74314..c91114d 100644 --- a/ApiCore-Backend/test/extractions.e2e-spec.ts +++ b/ApiCore-Backend/test/extractions.e2e-spec.ts @@ -36,12 +36,19 @@ describe('Extractions (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; 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; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; diff --git a/ApiCore-Backend/test/feedings.e2e-spec.ts b/ApiCore-Backend/test/feedings.e2e-spec.ts index 56d6dfa..f56833b 100644 --- a/ApiCore-Backend/test/feedings.e2e-spec.ts +++ b/ApiCore-Backend/test/feedings.e2e-spec.ts @@ -36,7 +36,13 @@ describe('Feedings (e2e)', () => { const passwordHash = await bcrypt.hash(password, 12); 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; const readonly = await prisma.user.create({ @@ -44,7 +50,8 @@ describe('Feedings (e2e)', () => { email: readonlyEmail, passwordHash, name: 'Readonly', - role: 'READONLY', + legacyRole: 'READONLY', + role: { connect: { key: 'READONLY' } }, }, }); readonlyId = readonly.id; diff --git a/ApiCore-Backend/test/harvests.e2e-spec.ts b/ApiCore-Backend/test/harvests.e2e-spec.ts index 1712bc1..936b6e6 100644 --- a/ApiCore-Backend/test/harvests.e2e-spec.ts +++ b/ApiCore-Backend/test/harvests.e2e-spec.ts @@ -33,7 +33,13 @@ describe('Harvests (e2e)', () => { const passwordHash = await bcrypt.hash(password, 12); 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; const readonly = await prisma.user.create({ @@ -41,7 +47,8 @@ describe('Harvests (e2e)', () => { email: readonlyEmail, passwordHash, name: 'Readonly', - role: 'READONLY', + legacyRole: 'READONLY', + role: { connect: { key: 'READONLY' } }, }, }); readonlyId = readonly.id; diff --git a/ApiCore-Backend/test/honey-batches.e2e-spec.ts b/ApiCore-Backend/test/honey-batches.e2e-spec.ts index bbc0797..401a065 100644 --- a/ApiCore-Backend/test/honey-batches.e2e-spec.ts +++ b/ApiCore-Backend/test/honey-batches.e2e-spec.ts @@ -53,12 +53,19 @@ describe('Honey batches (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; 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; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; diff --git a/ApiCore-Backend/test/import.e2e-spec.ts b/ApiCore-Backend/test/import.e2e-spec.ts index 87b3d96..c5f595a 100644 --- a/ApiCore-Backend/test/import.e2e-spec.ts +++ b/ApiCore-Backend/test/import.e2e-spec.ts @@ -49,7 +49,8 @@ describe('Import (e2e)', () => { email: adminEmail, passwordHash, name: 'Import E2E Admin', - role: 'ADMIN', + legacyRole: 'ADMIN', + role: { connect: { key: 'ADMIN' } }, }, }); adminId = admin.id; @@ -58,7 +59,8 @@ describe('Import (e2e)', () => { email: staffEmail, passwordHash, name: 'Import E2E Staff', - role: 'STAFF', + legacyRole: 'STAFF', + role: { connect: { key: 'STAFF' } }, }, }); staffId = staff.id; diff --git a/ApiCore-Backend/test/inspections.e2e-spec.ts b/ApiCore-Backend/test/inspections.e2e-spec.ts index 5fe07a3..178eb67 100644 --- a/ApiCore-Backend/test/inspections.e2e-spec.ts +++ b/ApiCore-Backend/test/inspections.e2e-spec.ts @@ -37,7 +37,13 @@ describe('Inspections (e2e)', () => { const passwordHash = await bcrypt.hash(password, 12); 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; const readonly = await prisma.user.create({ @@ -45,7 +51,8 @@ describe('Inspections (e2e)', () => { email: readonlyEmail, passwordHash, name: 'Readonly', - role: 'READONLY', + legacyRole: 'READONLY', + role: { connect: { key: 'READONLY' } }, }, }); readonlyId = readonly.id; diff --git a/ApiCore-Backend/test/notifications.e2e-spec.ts b/ApiCore-Backend/test/notifications.e2e-spec.ts index 2692d72..d569749 100644 --- a/ApiCore-Backend/test/notifications.e2e-spec.ts +++ b/ApiCore-Backend/test/notifications.e2e-spec.ts @@ -34,12 +34,19 @@ describe('Notifications (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; 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; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; diff --git a/ApiCore-Backend/test/products.e2e-spec.ts b/ApiCore-Backend/test/products.e2e-spec.ts index 837cee3..37f1ceb 100644 --- a/ApiCore-Backend/test/products.e2e-spec.ts +++ b/ApiCore-Backend/test/products.e2e-spec.ts @@ -34,12 +34,19 @@ describe('Products (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; 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; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; diff --git a/ApiCore-Backend/test/public.e2e-spec.ts b/ApiCore-Backend/test/public.e2e-spec.ts index d2aa4b4..dd26fff 100644 --- a/ApiCore-Backend/test/public.e2e-spec.ts +++ b/ApiCore-Backend/test/public.e2e-spec.ts @@ -42,7 +42,8 @@ describe('Public batch origin page (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; diff --git a/ApiCore-Backend/test/qr.e2e-spec.ts b/ApiCore-Backend/test/qr.e2e-spec.ts index 98584a1..78007df 100644 --- a/ApiCore-Backend/test/qr.e2e-spec.ts +++ b/ApiCore-Backend/test/qr.e2e-spec.ts @@ -26,7 +26,8 @@ describe('QR codes (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; diff --git a/ApiCore-Backend/test/queens.e2e-spec.ts b/ApiCore-Backend/test/queens.e2e-spec.ts index 9118a45..5546173 100644 --- a/ApiCore-Backend/test/queens.e2e-spec.ts +++ b/ApiCore-Backend/test/queens.e2e-spec.ts @@ -33,7 +33,8 @@ describe('Queens (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; diff --git a/ApiCore-Backend/test/roles.e2e-spec.ts b/ApiCore-Backend/test/roles.e2e-spec.ts new file mode 100644 index 0000000..bb2449d --- /dev/null +++ b/ApiCore-Backend/test/roles.e2e-spec.ts @@ -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; + 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']), + ); + }); +}); diff --git a/ApiCore-Backend/test/search.e2e-spec.ts b/ApiCore-Backend/test/search.e2e-spec.ts index d3f7dd0..e657299 100644 --- a/ApiCore-Backend/test/search.e2e-spec.ts +++ b/ApiCore-Backend/test/search.e2e-spec.ts @@ -39,7 +39,8 @@ describe('Search and filters (e2e)', () => { email: readonlyEmail, passwordHash, name: 'Readonly', - role: 'READONLY', + legacyRole: 'READONLY', + role: { connect: { key: 'READONLY' } }, }, }); readonlyId = readonly.id; diff --git a/ApiCore-Backend/test/statistics.e2e-spec.ts b/ApiCore-Backend/test/statistics.e2e-spec.ts index 133e417..122e826 100644 --- a/ApiCore-Backend/test/statistics.e2e-spec.ts +++ b/ApiCore-Backend/test/statistics.e2e-spec.ts @@ -60,7 +60,8 @@ describe('Statistics (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; diff --git a/ApiCore-Backend/test/storage.e2e-spec.ts b/ApiCore-Backend/test/storage.e2e-spec.ts index bee00d3..a81b85e 100644 --- a/ApiCore-Backend/test/storage.e2e-spec.ts +++ b/ApiCore-Backend/test/storage.e2e-spec.ts @@ -33,12 +33,19 @@ describe('Storage (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; 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; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; diff --git a/ApiCore-Backend/test/swarm-control.e2e-spec.ts b/ApiCore-Backend/test/swarm-control.e2e-spec.ts index f0bc5d8..016aeca 100644 --- a/ApiCore-Backend/test/swarm-control.e2e-spec.ts +++ b/ApiCore-Backend/test/swarm-control.e2e-spec.ts @@ -36,12 +36,19 @@ describe('Swarm control checks (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; 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; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; diff --git a/ApiCore-Backend/test/task-templates.e2e-spec.ts b/ApiCore-Backend/test/task-templates.e2e-spec.ts index 8b22df8..fd2adfd 100644 --- a/ApiCore-Backend/test/task-templates.e2e-spec.ts +++ b/ApiCore-Backend/test/task-templates.e2e-spec.ts @@ -31,7 +31,8 @@ describe('TaskTemplates (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; diff --git a/ApiCore-Backend/test/tasks.e2e-spec.ts b/ApiCore-Backend/test/tasks.e2e-spec.ts index ac553d1..0c618de 100644 --- a/ApiCore-Backend/test/tasks.e2e-spec.ts +++ b/ApiCore-Backend/test/tasks.e2e-spec.ts @@ -30,7 +30,13 @@ describe('Tasks (e2e)', () => { const passwordHash = await bcrypt.hash(password, 12); 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; const readonly = await prisma.user.create({ @@ -38,7 +44,8 @@ describe('Tasks (e2e)', () => { email: readonlyEmail, passwordHash, name: 'Readonly', - role: 'READONLY', + legacyRole: 'READONLY', + role: { connect: { key: 'READONLY' } }, }, }); readonlyId = readonly.id; diff --git a/ApiCore-Backend/test/treatments.e2e-spec.ts b/ApiCore-Backend/test/treatments.e2e-spec.ts index c4e8cd9..a1be7c3 100644 --- a/ApiCore-Backend/test/treatments.e2e-spec.ts +++ b/ApiCore-Backend/test/treatments.e2e-spec.ts @@ -35,12 +35,19 @@ describe('Treatments (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; 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; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; diff --git a/ApiCore-Backend/test/users.e2e-spec.ts b/ApiCore-Backend/test/users.e2e-spec.ts index fb8c31f..a5d1fbe 100644 --- a/ApiCore-Backend/test/users.e2e-spec.ts +++ b/ApiCore-Backend/test/users.e2e-spec.ts @@ -28,7 +28,8 @@ describe('Users (e2e)', () => { email: adminEmail, passwordHash, name: 'Users E2E Admin', - role: 'ADMIN', + legacyRole: 'ADMIN', + role: { connect: { key: 'ADMIN' } }, }, }); adminId = admin.id; @@ -37,7 +38,8 @@ describe('Users (e2e)', () => { email: staffEmail, passwordHash, name: 'Users E2E Staff', - role: 'STAFF', + legacyRole: 'STAFF', + role: { connect: { key: 'STAFF' } }, }, }); staffId = staff.id; diff --git a/ApiCore-Backend/test/varroa.e2e-spec.ts b/ApiCore-Backend/test/varroa.e2e-spec.ts index 28a1dc3..a0c4077 100644 --- a/ApiCore-Backend/test/varroa.e2e-spec.ts +++ b/ApiCore-Backend/test/varroa.e2e-spec.ts @@ -35,12 +35,19 @@ describe('Varroa measurements (e2e)', () => { email: beekeeperEmail, passwordHash, name: 'Beekeeper', - role: 'BEEKEEPER', + legacyRole: 'BEEKEEPER', + role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; 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; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; diff --git a/ApiCore-Backend/test/webhooks.e2e-spec.ts b/ApiCore-Backend/test/webhooks.e2e-spec.ts index bf4fe8f..caf5f49 100644 --- a/ApiCore-Backend/test/webhooks.e2e-spec.ts +++ b/ApiCore-Backend/test/webhooks.e2e-spec.ts @@ -48,7 +48,8 @@ describe('Webhooks (e2e)', () => { email: adminEmail, passwordHash, name: 'Webhooks E2E Admin', - role: 'ADMIN', + legacyRole: 'ADMIN', + role: { connect: { key: 'ADMIN' } }, }, }); adminId = admin.id; @@ -57,7 +58,8 @@ describe('Webhooks (e2e)', () => { email: staffEmail, passwordHash, name: 'Webhooks E2E Staff', - role: 'STAFF', + legacyRole: 'STAFF', + role: { connect: { key: 'STAFF' } }, }, }); staffId = staff.id;