feat(15-01): ModuleAccessService as single source of truth for module access (D-01)
- ModuleAccessService.getAccessibleModuleIds(tenantId, userId, role): ADMIN/SUPER_ADMIN bypass (D-03) via one query, otherwise a single Promise.all of direct + group ModuleGrant lookups intersected against active TenantModuleActivation (D-02) — no N+1 over the user's groups - findAccessibleModules() adds the name-asc sort for stable sidebar order - ModuleGuard now resolves userId/role from request.user (JWT-sourced, never body/params) and calls getAccessibleModuleIds instead of the tenant-only isModuleActive check; caches the result on request.moduleAccessIds for same-request reuse (D-09, no cross-request caching) - ModuleRegistryController.findActive delegates to ModuleAccessService.findAccessibleModules instead of findActiveForTenant, which stays untouched for Plan 15-03's tenant-wide marketplace catalog - ModuleRegistryModule exports ModuleAccessService for Plan 15-03/15-05 - module-access.service.spec.ts / module.guard.spec.ts cover every case in the plan's <behavior> list with a hand-rolled Prisma mock - End-to-end verified against the running local API: a USER without a grant gets 403 on a @UseModule-protected endpoint and an empty /modules/active list; the same USER with a direct grant gets 200 plus the slug in the list; an ADMIN without any grant also gets 200 (D-03)
This commit is contained in:
@@ -0,0 +1,217 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ModuleAccessService } from './module-access.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ModuleAccessService.getAccessibleModuleIds — Single Source of Truth für
|
||||||
|
* Modulzugriff (D-01, PERM-04/05/06). Deckt die vollständige Behavior-
|
||||||
|
* Liste aus 15-01-PLAN.md, Task 2 ab.
|
||||||
|
*
|
||||||
|
* Hand-gerollter Prisma-Mock (Projektkonvention, siehe
|
||||||
|
* tender-matching.service.spec.ts) statt einer echten DB-Verbindung.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeFakePrisma(opts: {
|
||||||
|
activations?: { moduleId: string }[];
|
||||||
|
directGrants?: { moduleId: string }[];
|
||||||
|
groupGrants?: { moduleId: string }[];
|
||||||
|
} = {}) {
|
||||||
|
const activations = opts.activations ?? [];
|
||||||
|
const directGrants = opts.directGrants ?? [];
|
||||||
|
const groupGrants = opts.groupGrants ?? [];
|
||||||
|
|
||||||
|
const prisma = {
|
||||||
|
tenantModuleActivation: {
|
||||||
|
findMany: vi.fn(async ({ where }: any) => {
|
||||||
|
// filtert die simulierten Aktivierungen zusätzlich auf moduleId,
|
||||||
|
// falls die Query (D-02-Schnittmenge) einen moduleId-in-Filter trägt
|
||||||
|
const inFilter: string[] | undefined = where?.moduleId?.in;
|
||||||
|
if (!inFilter) return activations;
|
||||||
|
return activations.filter((a) => inFilter.includes(a.moduleId));
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
moduleGrant: {
|
||||||
|
findMany: vi.fn(async ({ where }: any) => {
|
||||||
|
// Direkt-Grant-Query trägt `userId` direkt im where, die
|
||||||
|
// Gruppen-Grant-Query trägt `group: { memberships: { some: { userId } } } }`
|
||||||
|
if (where.group) return groupGrants;
|
||||||
|
return directGrants;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
findMany: vi.fn(async ({ where }: any) => {
|
||||||
|
const ids: string[] = where.id.in;
|
||||||
|
return ids.map((id) => ({ id, name: id }));
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ModuleAccessService.getAccessibleModuleIds — ADMIN/SUPER_ADMIN-Kurzschluss (D-03)', () => {
|
||||||
|
it('ADMIN erhält alle mandantenweit aktiven moduleIds ohne jede Grant-Query', async () => {
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
activations: [{ moduleId: 'mod-1' }, { moduleId: 'mod-2' }],
|
||||||
|
});
|
||||||
|
const service = new ModuleAccessService(prisma as any);
|
||||||
|
|
||||||
|
const result = await service.getAccessibleModuleIds('t1', 'admin-1', 'ADMIN');
|
||||||
|
|
||||||
|
expect(result).toEqual(new Set(['mod-1', 'mod-2']));
|
||||||
|
expect(prisma.moduleGrant.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SUPER_ADMIN verhält sich identisch zu ADMIN', async () => {
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
activations: [{ moduleId: 'mod-1' }],
|
||||||
|
});
|
||||||
|
const service = new ModuleAccessService(prisma as any);
|
||||||
|
|
||||||
|
const result = await service.getAccessibleModuleIds('t1', 'sa-1', 'SUPER_ADMIN');
|
||||||
|
|
||||||
|
expect(result).toEqual(new Set(['mod-1']));
|
||||||
|
expect(prisma.moduleGrant.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('idempotency: wiederholte Aufrufe für denselben ADMIN liefern dasselbe Ergebnis, ohne Datensätze anzulegen', async () => {
|
||||||
|
const prisma = makeFakePrisma({ activations: [{ moduleId: 'mod-1' }] });
|
||||||
|
const service = new ModuleAccessService(prisma as any);
|
||||||
|
|
||||||
|
const first = await service.getAccessibleModuleIds('t1', 'admin-1', 'ADMIN');
|
||||||
|
const second = await service.getAccessibleModuleIds('t1', 'admin-1', 'ADMIN');
|
||||||
|
|
||||||
|
expect(first).toEqual(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('concurrency: der Bypass gilt nur für die tenantId aus dem JWT — ein Aufruf für t2 leitet keine Module von t1 ab', async () => {
|
||||||
|
const activationsByTenant: Record<string, { moduleId: string }[]> = {
|
||||||
|
t1: [{ moduleId: 'mod-tenant-1' }],
|
||||||
|
t2: [{ moduleId: 'mod-tenant-2' }],
|
||||||
|
};
|
||||||
|
const prisma = {
|
||||||
|
tenantModuleActivation: {
|
||||||
|
findMany: vi.fn(async ({ where }: any) => activationsByTenant[where.tenantId] ?? []),
|
||||||
|
},
|
||||||
|
moduleGrant: { findMany: vi.fn() },
|
||||||
|
module: { findMany: vi.fn() },
|
||||||
|
};
|
||||||
|
const service = new ModuleAccessService(prisma as any);
|
||||||
|
|
||||||
|
const resultT2 = await service.getAccessibleModuleIds('t2', 'admin-1', 'ADMIN');
|
||||||
|
|
||||||
|
expect(resultT2).toEqual(new Set(['mod-tenant-2']));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ModuleAccessService.getAccessibleModuleIds — USER (Grant-Auflösung, D-02)', () => {
|
||||||
|
it('empty: USER ohne Grants erhält ein leeres Set', async () => {
|
||||||
|
const prisma = makeFakePrisma({});
|
||||||
|
const service = new ModuleAccessService(prisma as any);
|
||||||
|
|
||||||
|
const result = await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
|
||||||
|
|
||||||
|
expect(result).toEqual(new Set());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('USER mit Direkt-Grant auf ein aktives Modul: Set enthält genau diese moduleId', async () => {
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
activations: [{ moduleId: 'mod-1' }],
|
||||||
|
directGrants: [{ moduleId: 'mod-1' }],
|
||||||
|
});
|
||||||
|
const service = new ModuleAccessService(prisma as any);
|
||||||
|
|
||||||
|
const result = await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
|
||||||
|
|
||||||
|
expect(result).toEqual(new Set(['mod-1']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('USER mit Grant über eine Gruppe, in der er Mitglied ist: Set enthält diese moduleId', async () => {
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
activations: [{ moduleId: 'mod-1' }],
|
||||||
|
groupGrants: [{ moduleId: 'mod-1' }],
|
||||||
|
});
|
||||||
|
const service = new ModuleAccessService(prisma as any);
|
||||||
|
|
||||||
|
const result = await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
|
||||||
|
|
||||||
|
expect(result).toEqual(new Set(['mod-1']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adjacency: USER mit Direkt-Grant UND Gruppen-Grant auf dasselbe Modul — die moduleId erscheint genau einmal', async () => {
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
activations: [{ moduleId: 'mod-1' }],
|
||||||
|
directGrants: [{ moduleId: 'mod-1' }],
|
||||||
|
groupGrants: [{ moduleId: 'mod-1' }],
|
||||||
|
});
|
||||||
|
const service = new ModuleAccessService(prisma as any);
|
||||||
|
|
||||||
|
const result = await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
|
||||||
|
|
||||||
|
expect(result.size).toBe(1);
|
||||||
|
expect(result).toEqual(new Set(['mod-1']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adjacency/D-02: ein Grant auf ein mandantenweit deaktiviertes Modul gewährt KEINEN Zugriff', async () => {
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
activations: [], // mod-1 ist NICHT (mehr) aktiv
|
||||||
|
directGrants: [{ moduleId: 'mod-1' }],
|
||||||
|
});
|
||||||
|
const service = new ModuleAccessService(prisma as any);
|
||||||
|
|
||||||
|
const result = await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
|
||||||
|
|
||||||
|
expect(result).toEqual(new Set());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adjacency: eine mandantenweite Aktivierung ohne Grant ergibt ebenfalls keinen Zugriff', async () => {
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
activations: [{ moduleId: 'mod-1' }],
|
||||||
|
// kein Direkt- oder Gruppen-Grant
|
||||||
|
});
|
||||||
|
const service = new ModuleAccessService(prisma as any);
|
||||||
|
|
||||||
|
const result = await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
|
||||||
|
|
||||||
|
expect(result).toEqual(new Set());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('idempotency: getAccessibleModuleIds ist rein lesend — zwei identische Aufrufe schreiben keinen Datensatz und liefern dasselbe Ergebnis', async () => {
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
activations: [{ moduleId: 'mod-1' }],
|
||||||
|
directGrants: [{ moduleId: 'mod-1' }],
|
||||||
|
});
|
||||||
|
const service = new ModuleAccessService(prisma as any);
|
||||||
|
|
||||||
|
const first = await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
|
||||||
|
const second = await service.getAccessibleModuleIds('t1', 'user-1', 'USER');
|
||||||
|
|
||||||
|
expect(first).toEqual(second);
|
||||||
|
expect(prisma.moduleGrant.findMany).toHaveBeenCalledTimes(4); // 2x (direct+group) je Aufruf
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ModuleAccessService.findAccessibleModules — ordering (PERM-04)', () => {
|
||||||
|
it('sortiert die zugänglichen Module deterministisch nach Namen aufsteigend', async () => {
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
activations: [{ moduleId: 'mod-1' }],
|
||||||
|
directGrants: [{ moduleId: 'mod-1' }],
|
||||||
|
});
|
||||||
|
const service = new ModuleAccessService(prisma as any);
|
||||||
|
|
||||||
|
await service.findAccessibleModules('t1', 'user-1', 'USER');
|
||||||
|
|
||||||
|
expect(prisma.module.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ orderBy: { name: 'asc' } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty: ein Benutzer ohne Zugriff erhält eine leere Liste, keinen Fehler', async () => {
|
||||||
|
const prisma = makeFakePrisma({});
|
||||||
|
const service = new ModuleAccessService(prisma as any);
|
||||||
|
|
||||||
|
const result = await service.findAccessibleModules('t1', 'user-1', 'USER');
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(prisma.module.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single Source of Truth für Modulzugriff (D-01, PERM-04/05/06).
|
||||||
|
*
|
||||||
|
* `ModuleGuard` und `GET /modules/active` rufen beide ausschließlich
|
||||||
|
* `getAccessibleModuleIds` auf, damit Sidebar, Modulseiten und API
|
||||||
|
* niemals auseinanderdriften können — genau das Sicherheitsloch, das
|
||||||
|
* D-01 strukturell verhindert.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ModuleAccessService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechnet die Menge der moduleIds, auf die dieser Benutzer Zugriff hat.
|
||||||
|
*
|
||||||
|
* D-03: ADMIN/SUPER_ADMIN umgehen jede Grant-Prüfung — sie erhalten alle
|
||||||
|
* mandantenweit aktiven Module ihres eigenen Mandanten. Diese Rolle
|
||||||
|
* kommt ausschließlich aus dem JWT (Aufrufer), nie aus Body/Params —
|
||||||
|
* der Kurzschluss kann daher keine Module eines fremden Mandanten
|
||||||
|
* liefern, weil `tenantId` ebenfalls aus dem JWT stammt (T-15-10).
|
||||||
|
*
|
||||||
|
* Für alle anderen Rollen (USER): Vereinigungsmenge aus Direkt-Grants
|
||||||
|
* und Grants über Gruppenmitgliedschaften, geschnitten mit den
|
||||||
|
* mandantenweit aktiven Modulen (D-02 — ein Grant auf ein deaktiviertes
|
||||||
|
* Modul gewährt keinen Zugriff). Die Gruppen-Query ist eine einzige
|
||||||
|
* verschachtelte Prisma-Query (`group: { memberships: { some: { userId } } }`)
|
||||||
|
* statt einer Schleife über die Gruppen des Benutzers — sonst entsteht
|
||||||
|
* ein N+1 pro geschütztem Endpoint.
|
||||||
|
*
|
||||||
|
* Rein lesend, kein Caching über Request-Grenzen hinweg (D-09) — ein
|
||||||
|
* Freigabe-Entzug wirkt bei der nächsten Anfrage.
|
||||||
|
*/
|
||||||
|
async getAccessibleModuleIds(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
role: Role,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
if (role === 'ADMIN' || role === 'SUPER_ADMIN') {
|
||||||
|
const activations = await this.prisma.tenantModuleActivation.findMany({
|
||||||
|
where: { tenantId, isActive: true },
|
||||||
|
select: { moduleId: true },
|
||||||
|
});
|
||||||
|
return new Set(activations.map((a) => a.moduleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [direct, viaGroup] = await Promise.all([
|
||||||
|
this.prisma.moduleGrant.findMany({
|
||||||
|
where: { tenantId, userId },
|
||||||
|
select: { moduleId: true },
|
||||||
|
}),
|
||||||
|
this.prisma.moduleGrant.findMany({
|
||||||
|
where: { tenantId, group: { memberships: { some: { userId } } } },
|
||||||
|
select: { moduleId: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const grantedIds = [...direct, ...viaGroup].map((g) => g.moduleId);
|
||||||
|
|
||||||
|
if (grantedIds.length === 0) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
const activations = await this.prisma.tenantModuleActivation.findMany({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
isActive: true,
|
||||||
|
moduleId: { in: grantedIds },
|
||||||
|
},
|
||||||
|
select: { moduleId: true },
|
||||||
|
});
|
||||||
|
return new Set(activations.map((a) => a.moduleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die vollständigen Module-Datensätze, auf die dieser Benutzer
|
||||||
|
* Zugriff hat, sortiert nach Name. Bedient `GET /modules/active` — die
|
||||||
|
* explizite Sortierung hält die Sidebar-Reihenfolge über Aufrufe hinweg
|
||||||
|
* stabil.
|
||||||
|
*/
|
||||||
|
async findAccessibleModules(tenantId: string, userId: string, role: Role) {
|
||||||
|
const accessibleIds = await this.getAccessibleModuleIds(tenantId, userId, role);
|
||||||
|
|
||||||
|
if (accessibleIds.size === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.module.findMany({
|
||||||
|
where: { id: { in: [...accessibleIds] } },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,13 +11,16 @@ import { Role } from '@prisma/client';
|
|||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { ModuleAccessService } from './module-access.service';
|
||||||
import { ModuleRegistryService } from './module-registry.service';
|
import { ModuleRegistryService } from './module-registry.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* REST controller for the module registry.
|
* REST controller for the module registry.
|
||||||
*
|
*
|
||||||
* - GET /modules — list all registered modules (any authenticated user)
|
* - GET /modules — list all registered modules (any authenticated user)
|
||||||
* - GET /modules/active — list active modules for current tenant
|
* - GET /modules/active — list modules accessible to the requesting user
|
||||||
|
* (Aktivierung UND Grant/Rolle, via ModuleAccessService — D-01: dieselbe
|
||||||
|
* Auflösung wie ModuleGuard)
|
||||||
* - POST /modules/:moduleId/activate — activate a module (ADMIN/SUPER_ADMIN)
|
* - POST /modules/:moduleId/activate — activate a module (ADMIN/SUPER_ADMIN)
|
||||||
* - POST /modules/:moduleId/deactivate — deactivate a module (ADMIN/SUPER_ADMIN)
|
* - POST /modules/:moduleId/deactivate — deactivate a module (ADMIN/SUPER_ADMIN)
|
||||||
*
|
*
|
||||||
@@ -28,6 +31,7 @@ import { ModuleRegistryService } from './module-registry.service';
|
|||||||
export class ModuleRegistryController {
|
export class ModuleRegistryController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly moduleRegistryService: ModuleRegistryService,
|
private readonly moduleRegistryService: ModuleRegistryService,
|
||||||
|
private readonly moduleAccessService: ModuleAccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,15 +46,21 @@ export class ModuleRegistryController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /modules/active
|
* GET /modules/active
|
||||||
* Returns modules that are active for the requesting tenant.
|
* Returns modules accessible to the requesting user (D-01): the same
|
||||||
|
* ModuleAccessService.getAccessibleModuleIds resolution ModuleGuard
|
||||||
|
* uses, not just tenant-wide activation. findActiveForTenant on
|
||||||
|
* ModuleRegistryService stays unchanged for Plan 15-03's marketplace
|
||||||
|
* catalog (mandantenweite Sicht, kein Benutzerfilter).
|
||||||
*/
|
*/
|
||||||
@Get('active')
|
@Get('active')
|
||||||
async findActive(@Req() req: Request) {
|
async findActive(@Req() req: Request) {
|
||||||
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
|
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
|
||||||
if (!tenantId) {
|
const userId = (req as any).user?.id;
|
||||||
throw new ForbiddenException('No tenant context');
|
const role = (req as any).user?.role;
|
||||||
|
if (!tenantId || !userId || !role) {
|
||||||
|
throw new ForbiddenException('No user context');
|
||||||
}
|
}
|
||||||
return this.moduleRegistryService.findActiveForTenant(tenantId);
|
return this.moduleAccessService.findAccessibleModules(tenantId, userId, role);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ModuleAccessService } from './module-access.service';
|
||||||
import { ModuleRegistryController } from './module-registry.controller';
|
import { ModuleRegistryController } from './module-registry.controller';
|
||||||
import { ModuleRegistryService } from './module-registry.service';
|
import { ModuleRegistryService } from './module-registry.service';
|
||||||
import { ModuleGuard } from './module.guard';
|
import { ModuleGuard } from './module.guard';
|
||||||
@@ -8,15 +9,18 @@ import { ModuleGuard } from './module.guard';
|
|||||||
*
|
*
|
||||||
* Provides:
|
* Provides:
|
||||||
* - ModuleRegistryService: CRUD for module records + per-tenant activation
|
* - ModuleRegistryService: CRUD for module records + per-tenant activation
|
||||||
|
* - ModuleAccessService: Single Source of Truth for user-level module
|
||||||
|
* access (D-01) — Aktivierung UND (Rolle ODER Grant)
|
||||||
* - ModuleGuard: CanActivate guard for module-specific endpoints
|
* - ModuleGuard: CanActivate guard for module-specific endpoints
|
||||||
* - ModuleRegistryController: REST API for listing, activating, and deactivating modules
|
* - ModuleRegistryController: REST API for listing, activating, and deactivating modules
|
||||||
*
|
*
|
||||||
* Exports ModuleRegistryService and ModuleGuard so downstream feature modules
|
* Exports ModuleRegistryService, ModuleAccessService and ModuleGuard so
|
||||||
* (e.g., DomaincheckModule) can inject and use them.
|
* downstream feature modules (e.g., DomaincheckModule, DashboardModule
|
||||||
|
* Plan 15-05, Grant-Services Plan 15-03) can inject and use them.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [ModuleRegistryController],
|
controllers: [ModuleRegistryController],
|
||||||
providers: [ModuleRegistryService, ModuleGuard],
|
providers: [ModuleRegistryService, ModuleAccessService, ModuleGuard],
|
||||||
exports: [ModuleRegistryService, ModuleGuard],
|
exports: [ModuleRegistryService, ModuleAccessService, ModuleGuard],
|
||||||
})
|
})
|
||||||
export class ModuleRegistryModule {}
|
export class ModuleRegistryModule {}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ModuleGuard } from './module.guard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ModuleGuard.canActivate — deckt die vollständige Behavior-Liste aus
|
||||||
|
* 15-01-PLAN.md, Task 2 ab. ModuleAccessService und ModuleRegistryService
|
||||||
|
* sind gemockt (Guard-Unit-Test, kein DB-Zugriff).
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeContext(request: any) {
|
||||||
|
return {
|
||||||
|
getHandler: () => ({}),
|
||||||
|
getClass: () => ({}),
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => request,
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeReflector(slug: string | undefined) {
|
||||||
|
return { getAllAndOverride: vi.fn(() => slug) } as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ModuleGuard.canActivate', () => {
|
||||||
|
it('gibt true zurück und ruft keinen Service auf, wenn kein @UseModule-Slug in den Metadaten steht', async () => {
|
||||||
|
const moduleRegistryService = { findBySlug: vi.fn() } as any;
|
||||||
|
const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any;
|
||||||
|
const guard = new ModuleGuard(makeReflector(undefined), moduleRegistryService, moduleAccessService);
|
||||||
|
|
||||||
|
const result = await guard.canActivate(makeContext({}));
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(moduleRegistryService.findBySlug).not.toHaveBeenCalled();
|
||||||
|
expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft ForbiddenException("No tenant context"), wenn weder request.tenantId noch request.user.tenantId gesetzt sind', async () => {
|
||||||
|
const moduleRegistryService = { findBySlug: vi.fn() } as any;
|
||||||
|
const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any;
|
||||||
|
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(makeContext({ user: {} }))).rejects.toThrow(
|
||||||
|
new ForbiddenException('No tenant context'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft ForbiddenException("No user context"), wenn tenantId gesetzt ist, aber userId/role fehlen', async () => {
|
||||||
|
const moduleRegistryService = { findBySlug: vi.fn() } as any;
|
||||||
|
const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any;
|
||||||
|
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
guard.canActivate(makeContext({ tenantId: 't1', user: {} })),
|
||||||
|
).rejects.toThrow(new ForbiddenException('No user context'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft ForbiddenException bei unbekanntem Slug (findBySlug liefert null)', async () => {
|
||||||
|
const moduleRegistryService = { findBySlug: vi.fn().mockResolvedValue(null) } as any;
|
||||||
|
const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any;
|
||||||
|
const guard = new ModuleGuard(makeReflector('unknown-slug'), moduleRegistryService, moduleAccessService);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
guard.canActivate(
|
||||||
|
makeContext({ tenantId: 't1', user: { id: 'user-1', role: 'USER' } }),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(ForbiddenException);
|
||||||
|
expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('USER ohne Grant auf ein aktives Modul: wirft ForbiddenException', async () => {
|
||||||
|
const moduleRegistryService = {
|
||||||
|
findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }),
|
||||||
|
} as any;
|
||||||
|
const moduleAccessService = {
|
||||||
|
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()),
|
||||||
|
} as any;
|
||||||
|
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
guard.canActivate(
|
||||||
|
makeContext({ tenantId: 't1', user: { id: 'user-1', role: 'USER' } }),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ADMIN ohne Grant auf ein aktives Modul: gibt true zurück (D-03-Kurzschluss lebt in ModuleAccessService)', async () => {
|
||||||
|
const moduleRegistryService = {
|
||||||
|
findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }),
|
||||||
|
} as any;
|
||||||
|
const moduleAccessService = {
|
||||||
|
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set(['mod-1'])),
|
||||||
|
} as any;
|
||||||
|
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
|
||||||
|
const request = { tenantId: 't1', user: { id: 'admin-1', role: 'ADMIN' } };
|
||||||
|
|
||||||
|
const result = await guard.canActivate(makeContext(request));
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(moduleAccessService.getAccessibleModuleIds).toHaveBeenCalledWith('t1', 'admin-1', 'ADMIN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('USER mit Zugriff: gibt true zurück und legt das Ergebnis auf request.moduleAccessIds ab (Per-Request-Memoisierung, D-09)', async () => {
|
||||||
|
const moduleRegistryService = {
|
||||||
|
findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }),
|
||||||
|
} as any;
|
||||||
|
const accessibleIds = new Set(['mod-1']);
|
||||||
|
const moduleAccessService = {
|
||||||
|
getAccessibleModuleIds: vi.fn().mockResolvedValue(accessibleIds),
|
||||||
|
} as any;
|
||||||
|
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
|
||||||
|
const request = { tenantId: 't1', user: { id: 'user-1', role: 'USER' } };
|
||||||
|
|
||||||
|
const result = await guard.canActivate(makeContext(request));
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect((request as any).moduleAccessIds).toBe(accessibleIds);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ModuleAccessService } from './module-access.service';
|
||||||
import { ModuleRegistryService } from './module-registry.service';
|
import { ModuleRegistryService } from './module-registry.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,17 +17,24 @@ import { ModuleRegistryService } from './module-registry.service';
|
|||||||
export const MODULE_SLUG_KEY = 'moduleSlug';
|
export const MODULE_SLUG_KEY = 'moduleSlug';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Guard that checks whether the requesting tenant has an active
|
* Guard that checks whether the requesting user has access to the module
|
||||||
* module activation for the module identified by its slug.
|
* identified by its slug — Aktivierung UND (Rolle ODER Direkt-Grant ODER
|
||||||
|
* Gruppen-Grant), D-01.
|
||||||
*
|
*
|
||||||
* Per T-03-04: tenantId is sourced from JWT (via TenantMiddleware),
|
* Per T-03-04/T-15-10: tenantId, userId und role stammen ausschließlich
|
||||||
* not from user-supplied input, preventing elevation of privilege.
|
* aus dem validierten JWT (via TenantMiddleware/JwtAuthGuard), nie aus
|
||||||
|
* Body oder Params — verhindert Elevation of Privilege.
|
||||||
|
*
|
||||||
|
* T-15-03: Ohne `@UseModule(slug)`-Metadaten gibt der Guard bewusst
|
||||||
|
* `true` zurück (Durchsetzung hängt am Dekorator) — jeder neue
|
||||||
|
* Modul-Controller MUSS `@UseModule` tragen (Projektregel seit Phase 3).
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ModuleGuard implements CanActivate {
|
export class ModuleGuard implements CanActivate {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly reflector: Reflector,
|
private readonly reflector: Reflector,
|
||||||
private readonly moduleRegistryService: ModuleRegistryService,
|
private readonly moduleRegistryService: ModuleRegistryService,
|
||||||
|
private readonly moduleAccessService: ModuleAccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
@@ -48,24 +56,45 @@ export class ModuleGuard implements CanActivate {
|
|||||||
throw new ForbiddenException('No tenant context');
|
throw new ForbiddenException('No tenant context');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isActive = await this.moduleRegistryService.isModuleActive(
|
const userId = request.user?.id;
|
||||||
tenantId,
|
const role = request.user?.role;
|
||||||
moduleSlug,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isActive) {
|
if (!userId || !role) {
|
||||||
|
throw new ForbiddenException('No user context');
|
||||||
|
}
|
||||||
|
|
||||||
|
const module = await this.moduleRegistryService.findBySlug(moduleSlug);
|
||||||
|
|
||||||
|
if (!module) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
`Module '${moduleSlug}' is not activated for this tenant`,
|
`Module '${moduleSlug}' is not activated for this tenant`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accessibleModuleIds = await this.moduleAccessService.getAccessibleModuleIds(
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
role,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!accessibleModuleIds.has(module.id)) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Module '${moduleSlug}' is not accessible for this user`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-Request-Memoisierung (D-09): ein nachfolgender Handler im
|
||||||
|
// selben Request bezahlt die Auflösung nicht ein zweites Mal. Über
|
||||||
|
// Request-Grenzen hinweg wird nichts zwischengespeichert.
|
||||||
|
request.moduleAccessIds = accessibleModuleIds;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decorator that protects a controller or route handler with the ModuleGuard.
|
* Decorator that protects a controller or route handler with the ModuleGuard.
|
||||||
* Ensures the specified module is activated for the requesting tenant.
|
* Ensures the specified module is accessible for the requesting user.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* @UseModule('domaincheck')
|
* @UseModule('domaincheck')
|
||||||
|
|||||||
Reference in New Issue
Block a user