fix(domains): Neuabruf ?refresh=1 nur fuer Verwalter (WR-06)

Jeder Neuabruf sind bis zu 20 Aufrufe auf dem geteilten AutoDNS-Takt; Benutzer
ohne Freigabestufe Verwalten koennten damit Bestellung und Abgleich verdraengen.
Der ModuleGuard legt die wirksame Stufe auf den Request, das Flag wird fuer
Nicht-Verwalter ignoriert (Zwischenspeicher bleibt).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 11:27:51 +02:00
parent 268d6d55a4
commit 9eada2e082
5 changed files with 51 additions and 12 deletions
+6 -1
View File
@@ -1,4 +1,4 @@
import type { Role } from '@prisma/client'; import type { ModuleGrantLevel, Role } from '@prisma/client';
import type { Request } from 'express'; import type { Request } from 'express';
/** /**
@@ -83,6 +83,11 @@ export interface AuthUser {
export interface AuthenticatedRequest extends Request { export interface AuthenticatedRequest extends Request {
user?: AuthUser; user?: AuthUser;
tenantId?: string | null; tenantId?: string | null;
/**
* Wirksame Freigabestufe des Aufrufers fuer das Modul der Route; setzt
* `ModuleGuard` (nur auf Routen mit `@UseModule`/`@ModuleManage`).
*/
moduleAccessLevel?: ModuleGrantLevel;
} }
/** /**
@@ -7,6 +7,9 @@ import { DomainsController } from './domains.controller';
const proto = DomainsController.prototype as any; const proto = DomainsController.prototype as any;
const req = (tenantId?: string) => ({ tenantId }) 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 = [ const MANAGE_HANDLERS = [
'getSettings', 'getSettings',
@@ -160,13 +163,13 @@ describe('DomainsController — Verhalten', () => {
await c.createCustomer(req('t1'), { name: 'A' } as any); await c.createCustomer(req('t1'), { name: 'A' } as any);
await c.updateCustomer(req('t1'), id, { name: 'B' } as any); await c.updateCustomer(req('t1'), id, { name: 'B' } as any);
await c.deleteCustomer(req('t1'), id); await c.deleteCustomer(req('t1'), id);
await c.listContacts(req('t1'), '1'); await c.listContacts(managerReq('t1'), '1');
await c.listContacts(req('t1'), 'true'); await c.listContacts(managerReq('t1'), 'true');
await c.listContacts(req('t1'), undefined); await c.listContacts(managerReq('t1'), undefined);
await c.listContacts(req('t1'), '0'); await c.listContacts(managerReq('t1'), '0');
await c.createContact(req('t1'), { type: 'PERSON' } as any); await c.createContact(req('t1'), { type: 'PERSON' } as any);
await c.assignContacts(req('t1'), { contactIds: [1], customerId: null }); 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.listCustomers).toHaveBeenCalledWith('t1');
expect(directory.createCustomer).toHaveBeenCalledWith('t1', { name: 'A' }); expect(directory.createCustomer).toHaveBeenCalledWith('t1', { name: 'A' });
expect(directory.updateCustomer).toHaveBeenCalledWith('t1', id, { name: 'B' }); expect(directory.updateCustomer).toHaveBeenCalledWith('t1', id, { name: 'B' });
@@ -185,6 +188,23 @@ describe('DomainsController — Verhalten', () => {
expect(directory.listDomains).toHaveBeenCalledWith('t1', { refresh: true }); 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 () => { it('ohne Mandantenkontext 403', async () => {
const c = new DomainsController( const c = new DomainsController(
makeSettings() as any, makeSettings() as any,
@@ -222,8 +242,6 @@ describe('DomainsController — Auftraege: Verhalten', () => {
makeDirectory() as any, makeDirectory() as any,
makeOrders() as any, makeOrders() as any,
); );
await expect(c.submitOrder(req(undefined), user, id)).rejects.toBeInstanceOf( await expect(c.submitOrder(req(undefined), user, id)).rejects.toBeInstanceOf(ForbiddenException);
ForbiddenException,
);
}); });
}); });
+16 -3
View File
@@ -11,6 +11,7 @@ import {
Query, Query,
Req, Req,
} from '@nestjs/common'; } from '@nestjs/common';
import { ModuleGrantLevel } from '@prisma/client';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedRequest, AuthUser } from '../auth/types/auth-user'; import type { AuthenticatedRequest, AuthUser } from '../auth/types/auth-user';
import { ModuleManage, UseModule } from '../module-registry/module.guard'; 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 { CheckAvailabilityDto, CreateDomainsOrderDto } from './dto/domains-order.dto';
import { SaveDomainsSettingsDto, TestDomainsConnectionDto } from './dto/domains-settings.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 { function isTruthyFlag(value: string | undefined): boolean {
return value === '1' || value === 'true'; 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. * `@UseModule('domains')` auf Klassenebene — Aktivierung UND Freigabe.
* `tenantId` kommt ausschliesslich aus `req.tenantId`, nie aus Body oder Query. * `tenantId` kommt ausschliesslich aus `req.tenantId`, nie aus Body oder Query.
@@ -105,7 +118,7 @@ export class DomainsController {
@Get('contacts') @Get('contacts')
async listContacts(@Req() req: AuthenticatedRequest, @Query('refresh') refresh?: string) { async listContacts(@Req() req: AuthenticatedRequest, @Query('refresh') refresh?: string) {
return this.directory.listContacts(this.requireTenantId(req), { return this.directory.listContacts(this.requireTenantId(req), {
refresh: isTruthyFlag(refresh), refresh: mayRefresh(req, refresh),
}); });
} }
@@ -124,7 +137,7 @@ export class DomainsController {
@Get('domains') @Get('domains')
async listDomains(@Req() req: AuthenticatedRequest, @Query('refresh') refresh?: string) { async listDomains(@Req() req: AuthenticatedRequest, @Query('refresh') refresh?: string) {
return this.directory.listDomains(this.requireTenantId(req), { return this.directory.listDomains(this.requireTenantId(req), {
refresh: isTruthyFlag(refresh), refresh: mayRefresh(req, refresh),
}); });
} }
@@ -119,6 +119,8 @@ describe('ModuleGuard.canActivate', () => {
expect(result).toBe(true); expect(result).toBe(true);
expect((request as any).moduleAccessLevels).toBe(accessibleIds); expect((request as any).moduleAccessLevels).toBe(accessibleIds);
expect([...(request as any).moduleAccessIds]).toEqual(['mod-1']); 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');
}); });
/** /**
@@ -115,6 +115,7 @@ export class ModuleGuard implements CanActivate {
} }
request.moduleAccessLevels = levels; request.moduleAccessLevels = levels;
request.moduleAccessLevel = level;
request.moduleAccessIds = new Set(levels.keys()); request.moduleAccessIds = new Set(levels.keys());
return true; return true;