diff --git a/CLAUDE.md b/CLAUDE.md index 1c51f73..a617915 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,6 +78,12 @@ Decorate controllers/handlers with: @RequirePermissions(Permission.VIEW_SCANNER) ``` +`PermissionsGuard` is **deny by default**: every route needs `@RequirePermissions(...)`, +`@AllowAnyAuthenticated()` (any logged-in user, e.g. own user settings) or `@Public()`. +API keys carry no permissions and are rejected everywhere except routes marked +`@AllowApiKey()` (Paperless webhook, external upload, label-print agent). `routen-rechte.spec.ts` +fails if a route has no explicit decision or if the API-key route list changes. + Permissions map from OIDC groups (`PM_Admin`, `PM_Buchhaltung`, `PM_Posteingang`, etc.) to the `Permission` enum in `src/auth/permissions.enum.ts`. **This mapping is duplicated in `paperless-frontend/src/auth/permissions.ts` and both copies must list the same group names** — @@ -89,6 +95,9 @@ every authenticated request — do not re-introduce ad-hoc writes to those ident Use `@Public()` to bypass auth guards entirely. +Paperless custom fields 15 (Freigabe) and 16 (Zahlung) may only be set by the matching role — +see `src/paperless/status-felder.util.ts`. + ### Database - TypeORM with MySQL/MariaDB, UTF8MB4 diff --git a/docs/BACKEND_API.md b/docs/BACKEND_API.md index f00aa88..57e0e2d 100644 --- a/docs/BACKEND_API.md +++ b/docs/BACKEND_API.md @@ -12,7 +12,9 @@ Alle Endpunkte erfordern einen Bearer Token (JWT oder API-Key): Authorization: Bearer {token} ``` -`/jobs/next`, `/jobs/:id/image`, `/jobs/:id/printed` und `/jobs/:id/error` benötigen **keine** spezifische Permission (nur gültigen Token). `POST /jobs` und `POST /preview` erfordern `VIEW_SCANNER`. +`/events`, `/jobs/next`, `/jobs/:id/image`, `/jobs/:id/printed` und `/jobs/:id/error` sind die Agent-Routen: Sie nehmen einen **API-Key** an (`X-API-Key`, `?apiKey=` oder `Authorization: Bearer pm_…`); Benutzer mit JWT brauchen dort `MANAGE_SETTINGS`. `POST /jobs` und `POST /preview` erfordern `VIEW_SCANNER` und nehmen **keinen** API-Key an. + +`printed` und `error` werden nur für vom Agenten abgeholte, noch offene Jobs angenommen, sonst `409 Conflict`. Ein wiederholtes `printed` bzw. `error` für einen bereits so abgeschlossenen Job ist ein No-op (kein zweiter Callback). --- diff --git a/paperless-backend/src/app.controller.ts b/paperless-backend/src/app.controller.ts index cce879e..5d67f8c 100644 --- a/paperless-backend/src/app.controller.ts +++ b/paperless-backend/src/app.controller.ts @@ -1,11 +1,13 @@ import { Controller, Get } from '@nestjs/common'; import { AppService } from './app.service'; +import { AllowAnyAuthenticated } from './auth/permissions.decorator'; @Controller() export class AppController { constructor(private readonly appService: AppService) {} @Get() + @AllowAnyAuthenticated() getHello(): string { return this.appService.getHello(); } diff --git a/paperless-backend/src/auth/permissions.decorator.ts b/paperless-backend/src/auth/permissions.decorator.ts index 8618a10..1554081 100644 --- a/paperless-backend/src/auth/permissions.decorator.ts +++ b/paperless-backend/src/auth/permissions.decorator.ts @@ -2,5 +2,24 @@ import { SetMetadata } from '@nestjs/common'; import { Permission } from './permissions.enum'; export const PERMISSIONS_KEY = 'permissions'; +export const ALLOW_ANY_AUTHENTICATED_KEY = 'allowAnyAuthenticated'; +export const ALLOW_API_KEY_KEY = 'allowApiKey'; + +/** Route nur für Benutzer mit mindestens einer der Berechtigungen. */ export const RequirePermissions = (...permissions: Permission[]) => SetMetadata(PERMISSIONS_KEY, permissions); + +/** + * Route für jeden angemeldeten Benutzer – auch ohne PM-Gruppe (z.B. eigene + * Benutzereinstellungen). Muss ausdrücklich gesetzt werden: Routen ohne + * Rechteangabe lehnt der PermissionsGuard ab. + */ +export const AllowAnyAuthenticated = () => + SetMetadata(ALLOW_ANY_AUTHENTICATED_KEY, true); + +/** + * Route darf (auch) mit API-Key aufgerufen werden. API-Keys haben keine + * eigenen Rechte; ohne diese Markierung lehnt der PermissionsGuard sie ab. + * Für Benutzer (JWT) gilt zusätzlich @RequirePermissions. + */ +export const AllowApiKey = () => SetMetadata(ALLOW_API_KEY_KEY, true); diff --git a/paperless-backend/src/auth/permissions.guard.spec.ts b/paperless-backend/src/auth/permissions.guard.spec.ts new file mode 100644 index 0000000..b43aaa5 --- /dev/null +++ b/paperless-backend/src/auth/permissions.guard.spec.ts @@ -0,0 +1,148 @@ +/* eslint-disable @typescript-eslint/unbound-method -- Handler dienen nur als Metadaten-Schlüssel */ +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { PermissionsGuard } from './permissions.guard'; +import { + AllowAnyAuthenticated, + AllowApiKey, + RequirePermissions, +} from './permissions.decorator'; +import { Public } from './public.decorator'; +import { Permission } from './permissions.enum'; + +class Routen { + @RequirePermissions(Permission.VIEW_MAIL) + mail() {} + + @AllowAnyAuthenticated() + eigeneEinstellungen() {} + + ohneAngabe() {} + + @Public() + oeffentlich() {} + + @AllowApiKey() + @RequirePermissions(Permission.MANAGE_SETTINGS) + agent() {} + + @AllowApiKey() + nurApiKey() {} +} + +@RequirePermissions(Permission.VIEW_INBOX) +class KlasseMitRecht { + route() {} +} + +function ctx( + handler: (...args: unknown[]) => unknown, + request: Record, + cls: object = Routen, +): ExecutionContext { + return { + getHandler: () => handler, + getClass: () => cls, + switchToHttp: () => ({ getRequest: () => request }), + } as unknown as ExecutionContext; +} + +const r = Routen.prototype; +const nutzer = (...permissions: Permission[]) => ({ + method: 'GET', + url: '/x', + user: { permissions }, +}); +const apiKey = { + method: 'GET', + url: '/x', + apiKeyMetadata: { id: '1', name: 'Agent' }, +}; + +describe('PermissionsGuard', () => { + const guard = new PermissionsGuard(new Reflector()); + + it('erlaubt Benutzer mit passender Berechtigung', () => { + expect(guard.canActivate(ctx(r.mail, nutzer(Permission.VIEW_MAIL)))).toBe( + true, + ); + }); + + it('verweigert Benutzer ohne passende Berechtigung', () => { + expect(guard.canActivate(ctx(r.mail, nutzer(Permission.VIEW_INBOX)))).toBe( + false, + ); + }); + + it('erlaubt Admins (MANAGE_ALL) überall mit Rechteangabe', () => { + expect(guard.canActivate(ctx(r.mail, nutzer(Permission.MANAGE_ALL)))).toBe( + true, + ); + }); + + it('übernimmt Rechte von der Klasse', () => { + const k = KlasseMitRecht.prototype; + expect( + guard.canActivate( + ctx(k.route, nutzer(Permission.VIEW_INBOX), KlasseMitRecht), + ), + ).toBe(true); + expect( + guard.canActivate( + ctx(k.route, nutzer(Permission.VIEW_MAIL), KlasseMitRecht), + ), + ).toBe(false); + }); + + it('verweigert Routen ohne Rechteangabe (deny by default) – auch Admins', () => { + expect( + guard.canActivate(ctx(r.ohneAngabe, nutzer(Permission.MANAGE_ALL))), + ).toBe(false); + }); + + it('erlaubt @AllowAnyAuthenticated jedem angemeldeten Benutzer, auch ohne PM-Gruppe', () => { + expect(guard.canActivate(ctx(r.eigeneEinstellungen, nutzer()))).toBe(true); + expect( + guard.canActivate( + ctx(r.eigeneEinstellungen, { method: 'GET', url: '/x' }), + ), + ).toBe(false); + }); + + it('lässt @Public-Routen immer durch', () => { + expect( + guard.canActivate(ctx(r.oeffentlich, { method: 'GET', url: '/x' })), + ).toBe(true); + }); + + describe('API-Keys', () => { + it('gelten nur auf Routen mit @AllowApiKey', () => { + expect(guard.canActivate(ctx(r.agent, apiKey))).toBe(true); + expect(guard.canActivate(ctx(r.nurApiKey, apiKey))).toBe(true); + }); + + it('umgehen keine Rechteprüfung mehr auf anderen Routen', () => { + expect(() => guard.canActivate(ctx(r.mail, apiKey))).toThrow( + ForbiddenException, + ); + expect(() => + guard.canActivate(ctx(r.eigeneEinstellungen, apiKey)), + ).toThrow(ForbiddenException); + expect(() => guard.canActivate(ctx(r.ohneAngabe, apiKey))).toThrow( + ForbiddenException, + ); + }); + + it('Benutzer brauchen auf @AllowApiKey-Routen weiterhin die Berechtigung', () => { + expect( + guard.canActivate(ctx(r.agent, nutzer(Permission.VIEW_SCANNER))), + ).toBe(false); + expect( + guard.canActivate(ctx(r.agent, nutzer(Permission.MANAGE_SETTINGS))), + ).toBe(true); + expect( + guard.canActivate(ctx(r.nurApiKey, nutzer(Permission.MANAGE_ALL))), + ).toBe(false); + }); + }); +}); diff --git a/paperless-backend/src/auth/permissions.guard.ts b/paperless-backend/src/auth/permissions.guard.ts index 9be61d7..2f28883 100644 --- a/paperless-backend/src/auth/permissions.guard.ts +++ b/paperless-backend/src/auth/permissions.guard.ts @@ -1,34 +1,77 @@ -import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, + Logger, +} from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { PERMISSIONS_KEY } from './permissions.decorator'; +import { + ALLOW_ANY_AUTHENTICATED_KEY, + ALLOW_API_KEY_KEY, + PERMISSIONS_KEY, +} from './permissions.decorator'; +import { IS_PUBLIC_KEY } from './public.decorator'; import { Permission } from './permissions.enum'; +interface GeprueftesRequest { + method: string; + url: string; + user?: { permissions?: Permission[] }; + apiKeyMetadata?: { id: string; name: string }; +} + +/** + * Prüft Berechtigungen – deny by default: + * - @Public: immer erlaubt + * - API-Key: nur auf Routen mit @AllowApiKey + * - Benutzer: @RequirePermissions (eine genügt, MANAGE_ALL immer) oder + * @AllowAnyAuthenticated; Routen ohne beides werden abgelehnt. + */ @Injectable() export class PermissionsGuard implements CanActivate { + private readonly logger = new Logger(PermissionsGuard.name); + constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { - const requiredPermissions = this.reflector.getAllAndOverride( - PERMISSIONS_KEY, - [context.getHandler(), context.getClass()], - ); + const targets = [context.getHandler(), context.getClass()]; + const meta = (key: string) => + this.reflector.getAllAndOverride(key, targets); - if (!requiredPermissions) { - return true; - } + if (meta(IS_PUBLIC_KEY)) return true; - const request = context.switchToHttp().getRequest(); - const { user } = request; + const request = context.switchToHttp().getRequest(); + const route = `[${request.method} ${request.url}]`; if (request.apiKeyMetadata) { - return true; + if (meta(ALLOW_API_KEY_KEY)) return true; + this.logger.warn( + `${route} API-Key "${request.apiKeyMetadata.name}" ist für diese Route nicht freigegeben`, + ); + throw new ForbiddenException( + 'API-Key ist für diese Route nicht freigegeben', + ); + } + + const { user } = request; + const requiredPermissions = meta(PERMISSIONS_KEY); + + if (!requiredPermissions) { + if (meta(ALLOW_ANY_AUTHENTICATED_KEY)) return !!user; + if (!meta(ALLOW_API_KEY_KEY)) { + this.logger.error( + `${route} Route ohne @RequirePermissions/@AllowAnyAuthenticated – abgelehnt`, + ); + } + return false; } if (!user || !user.permissions) { return false; } - const userPermissions = user.permissions as Permission[]; + const userPermissions = user.permissions; if (userPermissions.includes(Permission.MANAGE_ALL)) { return true; diff --git a/paperless-backend/src/auth/routen-rechte.spec.ts b/paperless-backend/src/auth/routen-rechte.spec.ts new file mode 100644 index 0000000..17d47de --- /dev/null +++ b/paperless-backend/src/auth/routen-rechte.spec.ts @@ -0,0 +1,99 @@ +import 'reflect-metadata'; +import { readdirSync, statSync } from 'fs'; +import { join } from 'path'; +import { METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants'; +import { RequestMethod } from '@nestjs/common'; +import { + ALLOW_ANY_AUTHENTICATED_KEY, + ALLOW_API_KEY_KEY, + PERMISSIONS_KEY, +} from './permissions.decorator'; +import { IS_PUBLIC_KEY } from './public.decorator'; + +// Der Agrarmonitor-Connector zieht jsdom (ESM) – für die reine Metadatenprüfung +// genügt ein Platzhalter. +jest.mock('agrarmonitor-connector', () => ({})); + +/** Alle *.controller.ts unter src/ (ohne Tests). */ +function controllerDateien(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const pfad = join(dir, name); + if (statSync(pfad).isDirectory()) return controllerDateien(pfad); + return name.endsWith('.controller.ts') ? [pfad] : []; + }); +} + +type Klasse = { name: string; prototype: Record }; + +function routen() { + const ergebnis: { + route: string; + meta: (key: string) => unknown; + }[] = []; + for (const datei of controllerDateien(join(__dirname, '..'))) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const modul = require(datei) as Record; + for (const exp of Object.values(modul)) { + if (typeof exp !== 'function') continue; + const klasse = exp as unknown as Klasse; + const basis = Reflect.getMetadata(PATH_METADATA, klasse) as + | string + | undefined; + if (basis === undefined) continue; + for (const name of Object.getOwnPropertyNames(klasse.prototype)) { + const handler = klasse.prototype[name]; + if (typeof handler !== 'function') continue; + const methode: unknown = Reflect.getMetadata(METHOD_METADATA, handler); + if (methode === undefined) continue; + const pfad = String(Reflect.getMetadata(PATH_METADATA, handler)); + ergebnis.push({ + route: `${RequestMethod[methode as number]} /${basis}/${pfad} (${klasse.name}.${name})`, + meta: (key) => + (Reflect.getMetadata(key, handler) as unknown) ?? + (Reflect.getMetadata(key, klasse) as unknown), + }); + } + } + } + return ergebnis; +} + +describe('Rechteangabe aller Routen', () => { + const alle = routen(); + + it('findet die Controller', () => { + expect(alle.length).toBeGreaterThan(100); + }); + + it('jede Route hat @RequirePermissions, @AllowAnyAuthenticated, @AllowApiKey oder @Public', () => { + const ohne = alle + .filter( + ({ meta }) => + !meta(PERMISSIONS_KEY) && + !meta(ALLOW_ANY_AUTHENTICATED_KEY) && + !meta(ALLOW_API_KEY_KEY) && + !meta(IS_PUBLIC_KEY), + ) + .map(({ route }) => route); + expect(ohne).toEqual([]); + }); + + it('API-Keys sind nur auf den bekannten Agent-/Integrationsrouten erlaubt', () => { + const mitApiKey = alle + .filter(({ meta }) => meta(ALLOW_API_KEY_KEY)) + .map(({ route }) => route.split(' (')[0]) + .sort(); + expect(mitApiKey).toEqual( + [ + 'GET /api/label-print-agent/events', + 'GET /api/label-print-agent/jobs/:id/image', + 'GET /api/label-print-agent/jobs/next', + 'POST /api/label-print-agent/jobs/:id/error', + 'POST /api/label-print-agent/jobs/:id/printed', + 'POST /api/paperless/checksum', + 'POST /api/paperless/external-upload', + 'POST /api/webhook/paperless', + ].sort(), + ); + }); +}); diff --git a/paperless-backend/src/daily-digest/daily-digest.controller.ts b/paperless-backend/src/daily-digest/daily-digest.controller.ts index 936fe9d..d92e33b 100644 --- a/paperless-backend/src/daily-digest/daily-digest.controller.ts +++ b/paperless-backend/src/daily-digest/daily-digest.controller.ts @@ -1,11 +1,13 @@ import { Controller, Post, Request, HttpCode } from '@nestjs/common'; import { DailyDigestService } from './daily-digest.service'; +import { AllowAnyAuthenticated } from '../auth/permissions.decorator'; @Controller('api/daily-digest') export class DailyDigestController { constructor(private readonly dailyDigestService: DailyDigestService) {} @Post('send-now') + @AllowAnyAuthenticated() @HttpCode(200) async sendNow(@Request() req: any) { const { userId, email, preferredUsername, groups } = req.user; diff --git a/paperless-backend/src/freigabe/freigabe.service.ts b/paperless-backend/src/freigabe/freigabe.service.ts index f0562a8..183b565 100644 --- a/paperless-backend/src/freigabe/freigabe.service.ts +++ b/paperless-backend/src/freigabe/freigabe.service.ts @@ -3,8 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { DocumentType } from '../database/entities/document-type.entity'; import { PaperlessService } from '../paperless/paperless.service'; - -const FREIGABE_FIELD_ID = 15; +import { FREIGABE_FIELD_ID } from '../paperless/status-felder.util'; @Injectable() export class FreigabeService { diff --git a/paperless-backend/src/inbox/clients.controller.ts b/paperless-backend/src/inbox/clients.controller.ts index 8baa58b..588cf2d 100644 --- a/paperless-backend/src/inbox/clients.controller.ts +++ b/paperless-backend/src/inbox/clients.controller.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; import { Client } from '../database/entities/client.entity'; import { UserClient } from '../database/entities/user-client.entity'; +import { AllowAnyAuthenticated } from '../auth/permissions.decorator'; @Controller('api/clients') export class ClientsController { @@ -15,6 +16,7 @@ export class ClientsController { ) {} @Get() + @AllowAnyAuthenticated() async getMyClients(@Request() req: any) { const userId = req.user.userId; const mappings = await this.userClientRepo.find({ diff --git a/paperless-backend/src/kontonummern/kontonummern.controller.ts b/paperless-backend/src/kontonummern/kontonummern.controller.ts index 256db68..6bfb2db 100644 --- a/paperless-backend/src/kontonummern/kontonummern.controller.ts +++ b/paperless-backend/src/kontonummern/kontonummern.controller.ts @@ -1,6 +1,7 @@ -import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common'; +import { Controller, Get, Post, Body, Param } from '@nestjs/common'; import { KontonummernService } from './kontonummern.service'; -import { JwtOrApiKeyGuard } from '../auth/jwt-or-apikey.guard'; +import { RequirePermissions } from '../auth/permissions.decorator'; +import { Permission } from '../auth/permissions.enum'; export class CreateKontonummerDto { correspondentId: number; @@ -8,7 +9,8 @@ export class CreateKontonummerDto { } @Controller('api/kontonummern') -@UseGuards(JwtOrApiKeyGuard) +// Bankverbindungen: nur für die Dokumentbearbeitung (Posteingang, manuell) +@RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY) export class KontonummernController { constructor(private readonly kontonummernService: KontonummernService) {} diff --git a/paperless-backend/src/label-print-agent/label-print-agent.controller.ts b/paperless-backend/src/label-print-agent/label-print-agent.controller.ts index 47ffeff..9e0288a 100644 --- a/paperless-backend/src/label-print-agent/label-print-agent.controller.ts +++ b/paperless-backend/src/label-print-agent/label-print-agent.controller.ts @@ -17,7 +17,7 @@ import { import type { Response } from 'express'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { RequirePermissions } from '../auth/permissions.decorator'; +import { AllowApiKey, RequirePermissions } from '../auth/permissions.decorator'; import { Permission } from '../auth/permissions.enum'; import { LabelPrintAgentService } from './label-print-agent.service'; @@ -57,6 +57,8 @@ export class LabelPrintAgentController { // Agent: SSE-Stream für neue Druckaufträge @Sse('events') + @AllowApiKey() + @RequirePermissions(Permission.MANAGE_SETTINGS) sseEvents( @Res({ passthrough: true }) res: Response, ): Observable { @@ -68,6 +70,8 @@ export class LabelPrintAgentController { // Agent: nächsten Job abholen (Polling) @Get('jobs/next') + @AllowApiKey() + @RequirePermissions(Permission.MANAGE_SETTINGS) async getNextJob(@Query('agentId') agentId: string, @Res() res: Response) { const job = await this.service.claimNextJob(agentId ?? 'unknown'); if (!job) { @@ -87,6 +91,8 @@ export class LabelPrintAgentController { // Agent: Bild separat abrufen @Get('jobs/:id/image') + @AllowApiKey() + @RequirePermissions(Permission.MANAGE_SETTINGS) async getImage( @Param('id', ParseIntPipe) id: number, @Res({ passthrough: true }) res: Response, @@ -100,6 +106,8 @@ export class LabelPrintAgentController { // Agent: Druck erfolgreich @Post('jobs/:id/printed') + @AllowApiKey() + @RequirePermissions(Permission.MANAGE_SETTINGS) @HttpCode(HttpStatus.OK) async markPrinted( @Param('id', ParseIntPipe) id: number, @@ -115,6 +123,8 @@ export class LabelPrintAgentController { // Agent: Druckfehler @Post('jobs/:id/error') + @AllowApiKey() + @RequirePermissions(Permission.MANAGE_SETTINGS) @HttpCode(HttpStatus.OK) async markError( @Param('id', ParseIntPipe) id: number, diff --git a/paperless-backend/src/label-print-agent/label-print-agent.service.spec.ts b/paperless-backend/src/label-print-agent/label-print-agent.service.spec.ts new file mode 100644 index 0000000..18f7475 --- /dev/null +++ b/paperless-backend/src/label-print-agent/label-print-agent.service.spec.ts @@ -0,0 +1,135 @@ +import { ConflictException } from '@nestjs/common'; +import { LabelPrintAgentService } from './label-print-agent.service'; +import type { LabelPrintJob } from '../database/entities/label-print-job.entity'; +import type { BarcodeTemplate } from '../database/entities/barcode-template.entity'; +import type { LabelRendererService } from './label-renderer.service'; + +const TEMPLATE = { + Id: 7, + LabelEnabled: true, + LabelWidthMm: 57, + LabelHeightMm: 32, + LabelInputFields: [{ name: 'kunde', type: 'text' }], + LabelGetUrl: 'https://beleg.example/next?kunde={kunde}', + LabelPrintedUrl: 'https://beleg.example/printed/{number}?kunde={kunde}', + LabelReleaseUrl: 'https://beleg.example/release/{number}', +} as unknown as BarcodeTemplate; + +function aufbau(job?: Partial) { + const gespeichert: Partial[] = []; + const jobRepo = { + findOne: jest.fn().mockResolvedValue(job ? { ...job } : null), + create: jest.fn((j: Partial) => j), + save: jest.fn((j: Partial) => { + gespeichert.push({ ...j }); + return Promise.resolve(j); + }), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const templateRepo = { findOne: jest.fn().mockResolvedValue(TEMPLATE) }; + const service = new LabelPrintAgentService( + jobRepo as never, + templateRepo as never, + {} as LabelRendererService, + ); + return { service, jobRepo, gespeichert }; +} + +const fetchMock = jest.fn(); +beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue({ text: () => Promise.resolve('2026-0042') }); + global.fetch = fetchMock as unknown as typeof fetch; +}); + +const BASIS = { + Id: 1, + BarcodeTemplateId: 7, + LabelVariables: { number: '2026-0042', kunde: 'Hof' }, +}; +const gesperrt = { ...BASIS, Status: 'pending' as const, LockedAt: new Date() }; + +describe('LabelPrintAgentService – Statusübergänge', () => { + it('printed: nur für abgeholte, offene Jobs – ruft dann die Printed-URL auf', async () => { + const { service, jobRepo } = aufbau(gesperrt); + await service.markPrinted(1, 'agent', 'drucker'); + expect(jobRepo.update).toHaveBeenCalledWith( + expect.objectContaining({ Id: 1, Status: 'pending' }), + expect.objectContaining({ Status: 'printed' }), + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('printed: erneute Meldung ist ein No-op ohne zweiten Callback', async () => { + const { service, jobRepo } = aufbau({ ...BASIS, Status: 'printed' }); + await service.markPrinted(1, 'agent', 'drucker'); + expect(jobRepo.update).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('error nach printed wird abgelehnt – keine Freigabe der vergebenen Nummer', async () => { + const { service } = aufbau({ ...BASIS, Status: 'printed' }); + await expect(service.markError(1, 'a', 'd', 'x')).rejects.toThrow( + ConflictException, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('printed/error für noch nicht abgeholte Jobs werden abgelehnt', async () => { + const offen = { ...BASIS, Status: 'pending' as const, LockedAt: null }; + await expect( + aufbau(offen).service.markPrinted(1, 'a', 'd'), + ).rejects.toThrow(ConflictException); + await expect( + aufbau(offen).service.markError(1, 'a', 'd', 'x'), + ).rejects.toThrow(ConflictException); + }); + + it('gleichzeitige Meldung: verliert das bedingte Update, gibt es keinen Callback', async () => { + const { service, jobRepo } = aufbau(gesperrt); + jobRepo.update.mockResolvedValue({ affected: 0 }); + await expect(service.markError(1, 'a', 'd', 'x')).rejects.toThrow( + ConflictException, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('error für abgeholte Jobs gibt die Nummer frei', async () => { + const { service } = aufbau(gesperrt); + await service.markError(1, 'a', 'd', 'Papierstau'); + expect(fetchMock).toHaveBeenCalledWith( + 'https://beleg.example/release/2026-0042', + { method: 'POST' }, + ); + }); +}); + +describe('LabelPrintAgentService.createJob – Feldwerte', () => { + it('übernimmt nur Felder der Vorlage – "number" kann nicht vorgegeben werden', async () => { + fetchMock.mockResolvedValue({ text: () => Promise.resolve('') }); + const { service, gespeichert } = aufbau(); + await service.createJob(7, { kunde: 'Hof', number: '1234', fremd: 'x' }); + const vars = gespeichert[0].LabelVariables as Record; + expect(vars).toEqual({ kunde: 'Hof', number: '' }); + }); + + it('kodiert Feldwerte in URLs', async () => { + const { service } = aufbau(); + await service.createJob(7, { kunde: 'a/../admin?x=1#' }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://beleg.example/next?kunde=a%2F..%2Fadmin%3Fx%3D1%23', + ); + }); + + it('kodiert auch die Nummer im Printed-Callback', async () => { + const { service } = aufbau({ + ...gesperrt, + LabelVariables: { number: '../../x', kunde: 'A B' }, + }); + await service.markPrinted(1, 'a', 'd'); + expect(fetchMock).toHaveBeenCalledWith( + 'https://beleg.example/printed/..%2F..%2Fx?kunde=A%20B', + { method: 'POST' }, + ); + }); +}); diff --git a/paperless-backend/src/label-print-agent/label-print-agent.service.ts b/paperless-backend/src/label-print-agent/label-print-agent.service.ts index 617bba2..dc371f4 100644 --- a/paperless-backend/src/label-print-agent/label-print-agent.service.ts +++ b/paperless-backend/src/label-print-agent/label-print-agent.service.ts @@ -3,9 +3,10 @@ import { Logger, NotFoundException, BadRequestException, + ConflictException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, LessThan, IsNull } from 'typeorm'; +import { Repository, LessThan, IsNull, Not } from 'typeorm'; import { Subject, Observable } from 'rxjs'; import { LabelPrintJob } from '../database/entities/label-print-job.entity'; import { BarcodeTemplate } from '../database/entities/barcode-template.entity'; @@ -20,17 +21,22 @@ function isSafeUrl(url: string): boolean { } } -function applyVars(template: string, vars: Record): string { +/** + * Setzt `{name}` bzw. `{name:breite}` in eine URL-Vorlage ein. Die Werte + * stammen teils vom Benutzer bzw. aus einer externen Antwort und werden + * deshalb URL-kodiert – sonst ließe sich Pfad oder Query der Admin-URL ändern. + */ +function applyUrlVars(template: string, vars: Record): string { return template.replace(/\{([^}]+)\}/g, (_, key: string) => { const colonIdx = key.indexOf(':'); if (colonIdx !== -1) { const varName = key.slice(0, colonIdx); const width = parseInt(key.slice(colonIdx + 1), 10); if (!isNaN(width) && varName in vars) { - return vars[varName].padStart(width, '0'); + return encodeURIComponent(vars[varName].padStart(width, '0')); } } - return vars[key] ?? ''; + return encodeURIComponent(vars[key] ?? ''); }); } @@ -70,13 +76,20 @@ export class LabelPrintAgentService { 'Etikett-Druck für dieses Template nicht aktiviert', ); - // Variablen aufbauen - const vars: Record = { ...fieldValues }; + // Variablen aufbauen – nur Eingabefelder der Vorlage. Abgeleitete Werte + // wie {number} (aus der GET-URL) darf der Aufrufer nicht vorgeben. + const vars: Record = {}; + for (const field of template.LabelInputFields ?? []) { + const wert: unknown = fieldValues?.[field.name]; + if (typeof wert === 'string' || typeof wert === 'number') { + vars[field.name] = String(wert); + } + } // Datum-Felder: year/month/day separat ablegen for (const field of template.LabelInputFields ?? []) { - if (field.type === 'date' && fieldValues[field.name]) { - const raw = fieldValues[field.name]; // erwartet YYYY-MM-DD + if (field.type === 'date' && vars[field.name]) { + const raw = vars[field.name]; // erwartet YYYY-MM-DD const parts = raw.split('-'); if (parts.length === 3) { vars[field.name] = `${parts[2]}.${parts[1]}.${parts[0]}`; // dd.MM.yyyy @@ -89,7 +102,7 @@ export class LabelPrintAgentService { // GET-URL aufrufen → {number} if (template.LabelGetUrl) { - const url = applyVars(template.LabelGetUrl, vars); + const url = applyUrlVars(template.LabelGetUrl, vars); if (isSafeUrl(url)) { try { const res = await fetch(url); @@ -170,15 +183,15 @@ export class LabelPrintAgentService { agentId: string, printerName: string, ): Promise { - const job = await this.jobRepo.findOne({ where: { Id: jobId } }); - if (!job) throw new NotFoundException('Job nicht gefunden'); - - job.Status = 'printed'; - job.PrintedAt = new Date(); - job.PrintedByAgent = agentId; - job.PrinterName = printerName; - await this.jobRepo.save(job); - + const job = await this.ladeJob(jobId); + // Wiederholte Meldung (z.B. Retry nach Netzfehler): nichts mehr tun. + if (job.Status === 'printed') return; + await this.schliesseJobAb(job, { + Status: 'printed', + PrintedAt: new Date(), + PrintedByAgent: agentId, + PrinterName: printerName, + }); await this.callUrl('PRINTED', job); } @@ -188,16 +201,45 @@ export class LabelPrintAgentService { printerName: string, errorMessage: string, ): Promise { + const job = await this.ladeJob(jobId); + if (job.Status === 'error') return; + await this.schliesseJobAb(job, { + Status: 'error', + PrintedByAgent: agentId, + PrinterName: printerName, + ErrorMessage: errorMessage, + }); + // Gibt die reservierte Nummer frei – darf nur für nie gedruckte Jobs passieren. + await this.callUrl('RELEASE', job); + } + + private async ladeJob(jobId: number): Promise { const job = await this.jobRepo.findOne({ where: { Id: jobId } }); if (!job) throw new NotFoundException('Job nicht gefunden'); + return job; + } - job.Status = 'error'; - job.PrintedByAgent = agentId; - job.PrinterName = printerName; - job.ErrorMessage = errorMessage; - await this.jobRepo.save(job); - - await this.callUrl('RELEASE', job); + /** + * Abschluss nur für abgeholte, noch offene Jobs – bedingt in der DB, damit + * bei gleichzeitigen Meldungen genau eine gewinnt und nur ein Callback läuft. + */ + private async schliesseJobAb( + job: LabelPrintJob, + aenderung: Partial, + ): Promise { + if (job.Status !== 'pending' || !job.LockedAt) { + throw new ConflictException( + `Job ${job.Id} ist nicht in Bearbeitung (Status: ${job.Status})`, + ); + } + const { affected } = await this.jobRepo.update( + { Id: job.Id, Status: 'pending', LockedAt: Not(IsNull()) }, + aenderung, + ); + if (!affected) { + throw new ConflictException(`Job ${job.Id} wurde bereits abgeschlossen`); + } + Object.assign(job, aenderung); } async renderPreview( @@ -248,7 +290,7 @@ export class LabelPrintAgentService { type === 'PRINTED' ? template.LabelPrintedUrl : template.LabelReleaseUrl; if (!urlTemplate) return; - const url = applyVars(urlTemplate, job.LabelVariables ?? {}); + const url = applyUrlVars(urlTemplate, job.LabelVariables ?? {}); if (!isSafeUrl(url)) { this.logger.warn( `${type}-URL übersprungen (ungültiges Protokoll): ${url}`, diff --git a/paperless-backend/src/paperless/paperless.controller.ts b/paperless-backend/src/paperless/paperless.controller.ts index d62d796..b75cd16 100644 --- a/paperless-backend/src/paperless/paperless.controller.ts +++ b/paperless-backend/src/paperless/paperless.controller.ts @@ -19,8 +19,9 @@ import { Request, } from '@nestjs/common'; import type { Response } from 'express'; -import { RequirePermissions } from '../auth/permissions.decorator'; +import { AllowApiKey, RequirePermissions } from '../auth/permissions.decorator'; import { Permission } from '../auth/permissions.enum'; +import { entferneGeschuetzteStatusfelder } from './status-felder.util'; import { FileInterceptor } from '@nestjs/platform-express'; import { InjectRepository } from '@nestjs/typeorm'; @@ -71,11 +72,14 @@ export class PaperlessController { } @Get('steuertags') + @RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY) async getSteuertags() { return { ids: await this.getSteuertagIds() }; } @Post('checksum') + @AllowApiKey() + @RequirePermissions(Permission.MANAGE_SETTINGS) async checksumExists(@Body('checksum') checksum: string) { const exists = await this.paperlessService.checksumExists(checksum); return { exists }; @@ -89,6 +93,7 @@ export class PaperlessController { } @Get('documents') + @RequirePermissions(Permission.MANAGE_SETTINGS) async getDocuments( @Query('search') search?: string, @Query('page') page?: string, @@ -105,16 +110,19 @@ export class PaperlessController { } @Get('documents/:id') + @RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY) async getDocument(@Param('id') id: string) { return this.paperlessService.getDocument(parseInt(id, 10)); } @Get('tags') + @RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY) async getTags() { return this.paperlessService.getTags(); } @Get('tags/:id') + @RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY) async getTag(@Param('id') id: string) { // If the service doesn't have getTag(id), I should add it or just fetch all and find const tags = await this.paperlessService.getTags(); @@ -122,11 +130,18 @@ export class PaperlessController { } @Get('document-types') + @RequirePermissions( + Permission.VIEW_INBOX, + Permission.PROCESS_MANUALLY, + Permission.VIEW_FREIGABE, + Permission.VIEW_ZAHLUNG, + ) async getDocumentTypes() { return this.paperlessService.getDocumentTypes(); } @Get('custom-fields') + @RequirePermissions(Permission.MANAGE_SETTINGS) async getCustomFields() { return this.paperlessService.getCustomFields(); } @@ -138,6 +153,13 @@ export class PaperlessController { } @Get('correspondents') + @RequirePermissions( + Permission.VIEW_INBOX, + Permission.PROCESS_MANUALLY, + Permission.VIEW_MAIL, + Permission.VIEW_FREIGABE, + Permission.VIEW_ZAHLUNG, + ) async getCorrespondents(@Query('search') search?: string) { const params: any = { page_size: 9999 }; if (search) { @@ -148,6 +170,7 @@ export class PaperlessController { } @Get('correspondents/:id') + @RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY) async getCorrespondent(@Param('id') id: string) { return this.paperlessService.getCorrespondent(parseInt(id, 10)); } @@ -252,6 +275,7 @@ export class PaperlessController { } @Get('requirements/:id') + @RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY) async getRequirements( @Param('id') id: string, @Query('Posteingang') posteingang: string, @@ -347,8 +371,24 @@ export class PaperlessController { @Put('inbox/:id') @RequirePermissions(Permission.VIEW_INBOX) - async putInboxDocument(@Param('id') id: string, @Body() body: any) { + async putInboxDocument( + @Param('id') id: string, + @Body() body: any, + @Request() + req: { user?: { permissions?: Permission[]; preferredUsername?: string } }, + ) { const documentId = parseInt(id, 10); + + const { felder, entfernt } = entferneGeschuetzteStatusfelder( + body.customFields as Record | undefined, + req.user?.permissions ?? [], + ); + if (entfernt.length) { + this.logger.warn( + `Dokument ${documentId}: Statusfeld(er) ${entfernt.join(', ')} ohne Berechtigung ignoriert (${req.user?.preferredUsername ?? 'unbekannt'})`, + ); + } + body.customFields = felder; // Fetch from paperless const oldDocument = await this.paperlessService.getDocument(documentId); @@ -539,6 +579,7 @@ export class PaperlessController { } @Post('external-upload') + @AllowApiKey() @UseGuards(ApiKeyGuard) @UseInterceptors(FileInterceptor('document')) async externalUpload( diff --git a/paperless-backend/src/paperless/status-felder.util.spec.ts b/paperless-backend/src/paperless/status-felder.util.spec.ts new file mode 100644 index 0000000..60ed179 --- /dev/null +++ b/paperless-backend/src/paperless/status-felder.util.spec.ts @@ -0,0 +1,45 @@ +import { Permission } from '../auth/permissions.enum'; +import { + FREIGABE_FIELD_ID, + ZAHLUNG_FIELD_ID, + entferneGeschuetzteStatusfelder, +} from './status-felder.util'; + +describe('entferneGeschuetzteStatusfelder', () => { + const felder = { + [FREIGABE_FIELD_ID]: 'freigegeben', + [ZAHLUNG_FIELD_ID]: 'bezahlt', + 3: 'Rechnungsnummer', + }; + + it('entfernt Freigabe und Zahlung für die Posteingangs-Rolle', () => { + expect( + entferneGeschuetzteStatusfelder(felder, [Permission.VIEW_INBOX]), + ).toEqual({ felder: { 3: 'Rechnungsnummer' }, entfernt: [15, 16] }); + }); + + it('lässt jeder Rolle nur ihr eigenes Statusfeld', () => { + expect( + entferneGeschuetzteStatusfelder(felder, [ + Permission.VIEW_INBOX, + Permission.VIEW_FREIGABE, + ]).felder, + ).toEqual({ 15: 'freigegeben', 3: 'Rechnungsnummer' }); + expect( + entferneGeschuetzteStatusfelder(felder, [Permission.VIEW_ZAHLUNG]).felder, + ).toEqual({ 16: 'bezahlt', 3: 'Rechnungsnummer' }); + }); + + it('lässt Admins alles', () => { + expect( + entferneGeschuetzteStatusfelder(felder, [Permission.MANAGE_ALL]), + ).toEqual({ felder, entfernt: [] }); + }); + + it('kommt mit fehlenden Feldern klar', () => { + expect(entferneGeschuetzteStatusfelder(undefined, [])).toEqual({ + felder: undefined, + entfernt: [], + }); + }); +}); diff --git a/paperless-backend/src/paperless/status-felder.util.ts b/paperless-backend/src/paperless/status-felder.util.ts new file mode 100644 index 0000000..cb2e230 --- /dev/null +++ b/paperless-backend/src/paperless/status-felder.util.ts @@ -0,0 +1,34 @@ +import { Permission } from '../auth/permissions.enum'; + +/** Paperless-Custom-Field „Freigabe“ – setzen darf es nur die Freigabe-Rolle. */ +export const FREIGABE_FIELD_ID = 15; +/** Paperless-Custom-Field „Zahlung“ – setzen darf es nur die Zahlungs-Rolle. */ +export const ZAHLUNG_FIELD_ID = 16; + +const SCHUTZ: [number, Permission][] = [ + [FREIGABE_FIELD_ID, Permission.VIEW_FREIGABE], + [ZAHLUNG_FIELD_ID, Permission.VIEW_ZAHLUNG], +]; + +/** + * Entfernt Freigabe-/Zahlungsfeld aus einer Custom-Field-Änderung, wenn der + * Benutzer die zugehörige Rolle nicht hat (Funktionstrennung: wer Belege im + * Posteingang bearbeitet, darf sie nicht zugleich freigeben oder bezahlen). + */ +export function entferneGeschuetzteStatusfelder( + felder: Record | undefined, + permissions: Permission[], +): { felder: Record | undefined; entfernt: number[] } { + if (!felder || permissions.includes(Permission.MANAGE_ALL)) { + return { felder, entfernt: [] }; + } + const ergebnis = { ...felder }; + const entfernt: number[] = []; + for (const [feldId, recht] of SCHUTZ) { + if (String(feldId) in ergebnis && !permissions.includes(recht)) { + delete ergebnis[String(feldId)]; + entfernt.push(feldId); + } + } + return { felder: ergebnis, entfernt }; +} diff --git a/paperless-backend/src/stats/stats.controller.ts b/paperless-backend/src/stats/stats.controller.ts index 2074362..634da3a 100644 --- a/paperless-backend/src/stats/stats.controller.ts +++ b/paperless-backend/src/stats/stats.controller.ts @@ -1,11 +1,13 @@ import { Controller, Get, Request } from '@nestjs/common'; import { StatsService } from './stats.service'; +import { AllowAnyAuthenticated } from '../auth/permissions.decorator'; @Controller('api/stats') export class StatsController { constructor(private readonly statsService: StatsService) {} @Get('counts') + @AllowAnyAuthenticated() async getCounts(@Request() req: any) { return this.statsService.getDashboardCounts(req.user?.preferredUsername); } diff --git a/paperless-backend/src/user-settings/user-settings.controller.ts b/paperless-backend/src/user-settings/user-settings.controller.ts index 145b3f2..41ac186 100644 --- a/paperless-backend/src/user-settings/user-settings.controller.ts +++ b/paperless-backend/src/user-settings/user-settings.controller.ts @@ -8,7 +8,10 @@ import { Request, } from '@nestjs/common'; import { UserSettingsService } from './user-settings.service'; -import { RequirePermissions } from '../auth/permissions.decorator'; +import { + AllowAnyAuthenticated, + RequirePermissions, +} from '../auth/permissions.decorator'; import { Permission } from '../auth/permissions.enum'; @Controller('api/user-settings') @@ -16,16 +19,19 @@ export class UserSettingsController { constructor(private readonly userSettingsService: UserSettingsService) {} @Get() + @AllowAnyAuthenticated() async getSettings(@Request() req: any) { return this.userSettingsService.getSettings(req.user.userId); } @Put() + @AllowAnyAuthenticated() async updateSettings(@Request() req: any, @Body() body: any) { return this.userSettingsService.updateSettings(req.user.userId, body); } @Get('senders') + @AllowAnyAuthenticated() async getSenders(@Request() req: any) { return this.userSettingsService.getAvailableSenders(req.user.userId); } diff --git a/paperless-backend/src/webhook/webhook.controller.ts b/paperless-backend/src/webhook/webhook.controller.ts index 07eb00e..764b944 100644 --- a/paperless-backend/src/webhook/webhook.controller.ts +++ b/paperless-backend/src/webhook/webhook.controller.ts @@ -11,6 +11,8 @@ import { } from '@nestjs/common'; import { ApiKeyGuard } from '../auth/api-key.guard'; import { WebhookQueueService } from './webhook-queue.service'; +import { AllowApiKey, RequirePermissions } from '../auth/permissions.decorator'; +import { Permission } from '../auth/permissions.enum'; export interface PaperlessWebhookPayload { doc_url?: string; @@ -27,6 +29,7 @@ export class WebhookController { @UseGuards(ApiKeyGuard) @Post('paperless') + @AllowApiKey() @HttpCode(HttpStatus.OK) async handlePaperlessWebhook(@Body() payload: PaperlessWebhookPayload) { const documentId = this.extractDocumentId(payload); @@ -59,6 +62,7 @@ export class WebhookController { * per JWT oder API-Key zugänglich. */ @Get('status') + @RequirePermissions(Permission.MANAGE_SETTINGS) async getWebhookStatus(): Promise<{ lastCall: unknown; queueSize: number }> { return this.webhookQueue.getStatus(); } diff --git a/paperless-backend/src/zahlung/zahlung.service.ts b/paperless-backend/src/zahlung/zahlung.service.ts index aeb8fbc..a5b48c4 100644 --- a/paperless-backend/src/zahlung/zahlung.service.ts +++ b/paperless-backend/src/zahlung/zahlung.service.ts @@ -3,9 +3,11 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { DocumentType } from '../database/entities/document-type.entity'; import { PaperlessService } from '../paperless/paperless.service'; +import { + FREIGABE_FIELD_ID, + ZAHLUNG_FIELD_ID, +} from '../paperless/status-felder.util'; -const FREIGABE_FIELD_ID = 15; -const ZAHLUNG_FIELD_ID = 16; const FREIGABE_WERT_FREIGEGEBEN = 'freigegeben'; export type ZahlungFilter = 'ausstehend' | 'freigegeben' | 'alle';