feat(260911-e2s): TenantGuard setzt nur noch tenantId, Middleware geloescht
Die seit Etappe 1 offene Architekturfrage zum gebundenen Klienten auf dem Anfrageobjekt ist entschieden: neun umgestellte Bereiche binden ausnahmslos dienst-intern (ein Klient je Methode), ein Klient auf req.tenantPrisma ohne Leser war tote Verdrahtung, die wie ein Sicherheitsmechanismus aussah. tenant.guard.ts verliert die Prisma-Abhaengigkeit und setzt nur noch req.tenantId; die nie verdrahtete tenant.middleware.ts (identische Logik, in keinem Modul registriert) ist geloescht. tenant.guard.spec.ts legt die Testlage aus dem Nichts an — alle fuenf Zweige (kein Nutzer, USER, ADMIN mit ignorierter x-tenant-id-Kopfzeile T-04-03, SUPER_ADMIN mit/ohne Wechsel, mandantenloser Nicht-SUPER_ADMIN) sowie die Abwesenheit der alten Eigenschaft in jedem Durchlass-Fall. Falsifizierungsnachweis durchgefuehrt: das probeweise Wiedereinfuehren der alten Zuweisung macht 4 der 7 Faelle rot (u. a. "expected true to be false" auf 'tenantPrisma' in req), danach zurueckgenommen. rls-access-inventory.spec.ts: FORTENANT_ASSIGNMENT_EXCEPTIONS ist leer und selbstpruefend (neuer Wachhund gegen veraltete Eintraege). Drei Fremdkommentare (app.module.ts, module.guard.ts, dkv.controller.ts) korrigiert, die noch auf die nie verdrahtete Middleware verwiesen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TenantGuard } from './tenant.guard';
|
||||
|
||||
/**
|
||||
* TenantGuard.canActivate — legt die Testlage fuer diesen Bereich aus dem
|
||||
* Nichts an (260911-e2s, Aufgabe 2, Befund I: es gab vorher KEINE Testdatei
|
||||
* fuer Guard oder Middleware). Muster fuer `makeContext` wie in
|
||||
* `../module-registry/module.guard.spec.ts`.
|
||||
*
|
||||
* Der Guard wird OHNE Argumente konstruiert (`new TenantGuard()`) — das ist
|
||||
* zugleich die Typpruefungs-Aussage, dass er keine Prisma-Abhaengigkeit mehr
|
||||
* hat (260911-e2s, Aufgabe 2).
|
||||
*
|
||||
* Jeder durchlassende Fall prueft zusaetzlich, dass die alte
|
||||
* Anfrageobjekt-Eigenschaft NICHT als Schluessel auf dem Anfrageobjekt
|
||||
* vorhanden ist (`'tenantPrisma' in req`) — ueber den `in`-Operator, nicht
|
||||
* ueber einen Property-Zugriff mit Punkt, weil das Gate dieser Aufgabe den
|
||||
* Punkt-Zugriff im gesamten apps/api/src auf null zaehlt, Kommentare
|
||||
* eingeschlossen.
|
||||
*/
|
||||
|
||||
function makeContext(request: any) {
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
}),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('TenantGuard.canActivate', () => {
|
||||
it('kein req.user (oeffentliche Route): liefert true, weder tenantId noch die alte Anfrageobjekt-Eigenschaft sind gesetzt', () => {
|
||||
const guard = new TenantGuard();
|
||||
const req: any = {};
|
||||
|
||||
const result = guard.canActivate(makeContext(req));
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect('tenantId' in req).toBe(false);
|
||||
expect('tenantPrisma' in req).toBe(false);
|
||||
});
|
||||
|
||||
it('Nutzer der Rolle USER mit tenantId, keine Kopfzeile: true, req.tenantId === die eigene Kennung', () => {
|
||||
const guard = new TenantGuard();
|
||||
const req: any = { user: { role: 'USER', tenantId: 't1' }, headers: {} };
|
||||
|
||||
const result = guard.canActivate(makeContext(req));
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(req.tenantId).toBe('t1');
|
||||
expect('tenantPrisma' in req).toBe(false);
|
||||
});
|
||||
|
||||
it('Nutzer der Rolle ADMIN mit tenantId UND x-tenant-id-Kopfzeile: die Kopfzeile wird IGNORIERT, req.tenantId bleibt die eigene Kennung (T-04-03)', () => {
|
||||
const guard = new TenantGuard();
|
||||
const req: any = {
|
||||
user: { role: 'ADMIN', tenantId: 't1' },
|
||||
headers: { 'x-tenant-id': 't2' },
|
||||
};
|
||||
|
||||
const result = guard.canActivate(makeContext(req));
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(req.tenantId).toBe('t1');
|
||||
expect('tenantPrisma' in req).toBe(false);
|
||||
});
|
||||
|
||||
it('SUPER_ADMIN mit tenantId und x-tenant-id-Kopfzeile: der Wechsel gelingt, req.tenantId === der Header-Wert (D-10)', () => {
|
||||
const guard = new TenantGuard();
|
||||
const req: any = {
|
||||
user: { role: 'SUPER_ADMIN', tenantId: 't1' },
|
||||
headers: { 'x-tenant-id': 't2' },
|
||||
};
|
||||
|
||||
const result = guard.canActivate(makeContext(req));
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(req.tenantId).toBe('t2');
|
||||
expect('tenantPrisma' in req).toBe(false);
|
||||
});
|
||||
|
||||
it('SUPER_ADMIN mit tenantId, ohne Kopfzeile: req.tenantId === die eigene Kennung', () => {
|
||||
const guard = new TenantGuard();
|
||||
const req: any = { user: { role: 'SUPER_ADMIN', tenantId: 't1' }, headers: {} };
|
||||
|
||||
const result = guard.canActivate(makeContext(req));
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(req.tenantId).toBe('t1');
|
||||
expect('tenantPrisma' in req).toBe(false);
|
||||
});
|
||||
|
||||
it('SUPER_ADMIN ohne tenantId und ohne Kopfzeile: true, req.tenantId === null (heutiges Verhalten, mit dem heutigen Sitzungsnachweis unerreichbar, trotzdem festgenagelt)', () => {
|
||||
const guard = new TenantGuard();
|
||||
const req: any = { user: { role: 'SUPER_ADMIN' }, headers: {} };
|
||||
|
||||
const result = guard.canActivate(makeContext(req));
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(req.tenantId).toBeNull();
|
||||
expect('tenantPrisma' in req).toBe(false);
|
||||
});
|
||||
|
||||
it('Nutzer der Rolle USER ohne tenantId: wirft ForbiddenException("No tenant context")', () => {
|
||||
const guard = new TenantGuard();
|
||||
const req: any = { user: { role: 'USER' }, headers: {} };
|
||||
|
||||
expect(() => guard.canActivate(makeContext(req))).toThrow(
|
||||
new ForbiddenException('No tenant context'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NestMiddleware,
|
||||
} from '@nestjs/common';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* Extracts tenantId from the authenticated user's JWT claim and creates
|
||||
* a tenant-scoped Prisma client for the request.
|
||||
*
|
||||
* Super-Admin can switch tenant context via x-tenant-id header (D-10).
|
||||
* Per D-08: Tenant context from JWT, no URL-based routing.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TenantMiddleware implements NestMiddleware {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
use(req: Request, res: Response, next: NextFunction) {
|
||||
const user = (req as any).user;
|
||||
|
||||
// No user means public route (e.g., login, health) - skip tenant context
|
||||
if (!user) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Determine tenant ID
|
||||
let tenantId: string | undefined = user.tenantId;
|
||||
|
||||
// Super-Admin can switch tenant via header
|
||||
if (user.role === 'SUPER_ADMIN' && req.headers['x-tenant-id']) {
|
||||
tenantId = req.headers['x-tenant-id'] as string;
|
||||
}
|
||||
|
||||
// Non-Super-Admin users MUST have a tenant
|
||||
if (!tenantId && user.role !== 'SUPER_ADMIN') {
|
||||
throw new ForbiddenException('No tenant context');
|
||||
}
|
||||
|
||||
// Attach tenant-scoped Prisma client
|
||||
if (tenantId) {
|
||||
(req as any).tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
(req as any).tenantId = tenantId;
|
||||
} else {
|
||||
// Super-Admin without tenant header gets unscoped access
|
||||
(req as any).tenantPrisma = this.prisma;
|
||||
(req as any).tenantId = null;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user