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:
@@ -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,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user