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:
2026-09-11 10:49:53 +02:00
parent 652e762ad4
commit 11f5731029
2 changed files with 112 additions and 54 deletions
+112
View File
@@ -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'),
);
});
});
-54
View File
@@ -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();
}
}