diff --git a/apps/api/src/auth/types/auth-user.ts b/apps/api/src/auth/types/auth-user.ts index 9c88687..8653063 100644 --- a/apps/api/src/auth/types/auth-user.ts +++ b/apps/api/src/auth/types/auth-user.ts @@ -1,4 +1,4 @@ -import type { Role } from '@prisma/client'; +import type { ModuleGrantLevel, Role } from '@prisma/client'; import type { Request } from 'express'; /** @@ -83,6 +83,11 @@ export interface AuthUser { export interface AuthenticatedRequest extends Request { user?: AuthUser; tenantId?: string | null; + /** + * Wirksame Freigabestufe des Aufrufers fuer das Modul der Route; setzt + * `ModuleGuard` (nur auf Routen mit `@UseModule`/`@ModuleManage`). + */ + moduleAccessLevel?: ModuleGrantLevel; } /** diff --git a/apps/api/src/domains/domains.controller.spec.ts b/apps/api/src/domains/domains.controller.spec.ts index 46b6172..512bb61 100644 --- a/apps/api/src/domains/domains.controller.spec.ts +++ b/apps/api/src/domains/domains.controller.spec.ts @@ -7,6 +7,9 @@ import { DomainsController } from './domains.controller'; const proto = DomainsController.prototype as any; const req = (tenantId?: string) => ({ tenantId }) as any; +/** Aufrufer mit der Freigabestufe Verwalten (setzt sonst der ModuleGuard). */ +const managerReq = (tenantId?: string) => ({ tenantId, moduleAccessLevel: 'MANAGE' }) as any; +const userReq = (tenantId?: string) => ({ tenantId, moduleAccessLevel: 'USE' }) as any; const MANAGE_HANDLERS = [ 'getSettings', @@ -160,13 +163,13 @@ describe('DomainsController — Verhalten', () => { await c.createCustomer(req('t1'), { name: 'A' } as any); await c.updateCustomer(req('t1'), id, { name: 'B' } as any); await c.deleteCustomer(req('t1'), id); - await c.listContacts(req('t1'), '1'); - await c.listContacts(req('t1'), 'true'); - await c.listContacts(req('t1'), undefined); - await c.listContacts(req('t1'), '0'); + await c.listContacts(managerReq('t1'), '1'); + await c.listContacts(managerReq('t1'), 'true'); + await c.listContacts(managerReq('t1'), undefined); + await c.listContacts(managerReq('t1'), '0'); await c.createContact(req('t1'), { type: 'PERSON' } as any); await c.assignContacts(req('t1'), { contactIds: [1], customerId: null }); - await c.listDomains(req('t1'), 'true'); + await c.listDomains(managerReq('t1'), 'true'); expect(directory.listCustomers).toHaveBeenCalledWith('t1'); expect(directory.createCustomer).toHaveBeenCalledWith('t1', { name: 'A' }); expect(directory.updateCustomer).toHaveBeenCalledWith('t1', id, { name: 'B' }); @@ -185,6 +188,23 @@ describe('DomainsController — Verhalten', () => { expect(directory.listDomains).toHaveBeenCalledWith('t1', { refresh: true }); }); + it('refresh=1 wird ohne Freigabestufe Verwalten ignoriert (kein Neuabruf bei AutoDNS)', async () => { + const directory = makeDirectory(); + const c = new DomainsController(makeSettings() as any, directory as any, makeOrders() as any); + await c.listContacts(userReq('t1'), '1'); + await c.listContacts(req('t1'), 'true'); + await c.listDomains(userReq('t1'), '1'); + await c.listDomains(req('t1'), 'true'); + expect(directory.listContacts.mock.calls.map((x) => x[1])).toEqual([ + { refresh: false }, + { refresh: false }, + ]); + expect(directory.listDomains.mock.calls.map((x) => x[1])).toEqual([ + { refresh: false }, + { refresh: false }, + ]); + }); + it('ohne Mandantenkontext 403', async () => { const c = new DomainsController( makeSettings() as any, @@ -222,8 +242,6 @@ describe('DomainsController — Auftraege: Verhalten', () => { makeDirectory() as any, makeOrders() as any, ); - await expect(c.submitOrder(req(undefined), user, id)).rejects.toBeInstanceOf( - ForbiddenException, - ); + await expect(c.submitOrder(req(undefined), user, id)).rejects.toBeInstanceOf(ForbiddenException); }); }); diff --git a/apps/api/src/domains/domains.controller.ts b/apps/api/src/domains/domains.controller.ts index b269a1a..cb592e4 100644 --- a/apps/api/src/domains/domains.controller.ts +++ b/apps/api/src/domains/domains.controller.ts @@ -11,6 +11,7 @@ import { Query, Req, } from '@nestjs/common'; +import { ModuleGrantLevel } from '@prisma/client'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedRequest, AuthUser } from '../auth/types/auth-user'; import { ModuleManage, UseModule } from '../module-registry/module.guard'; @@ -22,11 +23,23 @@ import { DomainsCustomerDto } from './dto/domains-customer.dto'; import { CheckAvailabilityDto, CreateDomainsOrderDto } from './dto/domains-order.dto'; import { SaveDomainsSettingsDto, TestDomainsConnectionDto } from './dto/domains-settings.dto'; -/** `?refresh=1` oder `?refresh=true` umgeht den Zwischenspeicher. */ +/** `?refresh=1` oder `?refresh=true` verlangt, den Zwischenspeicher zu umgehen. */ function isTruthyFlag(value: string | undefined): boolean { return value === '1' || value === 'true'; } +/** + * Nur Verwalter duerfen den Zwischenspeicher umgehen: jeder Neuabruf sind bis zu + * 20 Aufrufe an AutoDNS auf dem prozessweit geteilten Takt von 3 pro Sekunde, mit + * denen sich Bestellung und Abgleich aller verdraengen liessen. Fuer Benutzer ohne + * Freigabestufe Verwalten wird das Flag ohne Fehlermeldung ignoriert; sie sehen + * den Zwischenspeicher. Die Stufe setzt der `ModuleGuard` aus den Freigaben, nie + * aus Body oder Query. + */ +function mayRefresh(req: AuthenticatedRequest, value: string | undefined): boolean { + return isTruthyFlag(value) && req.moduleAccessLevel === ModuleGrantLevel.MANAGE; +} + /** * `@UseModule('domains')` auf Klassenebene — Aktivierung UND Freigabe. * `tenantId` kommt ausschliesslich aus `req.tenantId`, nie aus Body oder Query. @@ -105,7 +118,7 @@ export class DomainsController { @Get('contacts') async listContacts(@Req() req: AuthenticatedRequest, @Query('refresh') refresh?: string) { return this.directory.listContacts(this.requireTenantId(req), { - refresh: isTruthyFlag(refresh), + refresh: mayRefresh(req, refresh), }); } @@ -124,7 +137,7 @@ export class DomainsController { @Get('domains') async listDomains(@Req() req: AuthenticatedRequest, @Query('refresh') refresh?: string) { return this.directory.listDomains(this.requireTenantId(req), { - refresh: isTruthyFlag(refresh), + refresh: mayRefresh(req, refresh), }); } diff --git a/apps/api/src/module-registry/module.guard.spec.ts b/apps/api/src/module-registry/module.guard.spec.ts index 25316cd..c37443a 100644 --- a/apps/api/src/module-registry/module.guard.spec.ts +++ b/apps/api/src/module-registry/module.guard.spec.ts @@ -119,6 +119,8 @@ describe('ModuleGuard.canActivate', () => { expect(result).toBe(true); expect((request as any).moduleAccessLevels).toBe(accessibleIds); expect([...(request as any).moduleAccessIds]).toEqual(['mod-1']); + // Die wirksame Stufe fuer das Modul der Route steht auf dem Request (Neuabruf nur fuer Verwalter). + expect((request as any).moduleAccessLevel).toBe('USE'); }); /** diff --git a/apps/api/src/module-registry/module.guard.ts b/apps/api/src/module-registry/module.guard.ts index adba798..69b001d 100644 --- a/apps/api/src/module-registry/module.guard.ts +++ b/apps/api/src/module-registry/module.guard.ts @@ -115,6 +115,7 @@ export class ModuleGuard implements CanActivate { } request.moduleAccessLevels = levels; + request.moduleAccessLevel = level; request.moduleAccessIds = new Set(levels.keys()); return true;