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