fix(260911-e2s): Guard-Umbau und Kommentarkorrekturen nachtragen (Aufgabe 2 vollstaendig)

Die vorige Aufgabe-2-Teilcommit (11f5731) hatte nur die Loeschung von
tenant.middleware.ts und die neue tenant.guard.spec.ts erfasst — ein
`git add` mit mehreren Pfaden schlug wegen eines bereits entfernten
Pfads fataler fehl und liess die restlichen fuenf Dateien unstaged,
ohne dass das beim Commit auffiel (Rule 1 — Prozessfehler, hier
korrigiert). Dieser Commit traegt den eigentlichen Umbau nach:
tenant.guard.ts ohne Prisma-Abhaengigkeit, die geleerte
FORTENANT_ASSIGNMENT_EXCEPTIONS samt Wachhund-Test in
rls-access-inventory.spec.ts, und die drei berichtigten
Kommentarzeilen (app.module.ts, module.guard.ts, dkv.controller.ts).
Inhaltlich identisch mit dem, was bereits verifiziert wurde (891 Tests
gruen, Typpruefung sauber) — nur die Staging-Reihenfolge war fehlerhaft.

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:50:26 +02:00
parent 11f5731029
commit 17dca0dfad
5 changed files with 65 additions and 23 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ import { UserModule } from './user/user.module';
provide: APP_GUARD, provide: APP_GUARD,
useClass: JwtAuthGuard, useClass: JwtAuthGuard,
}, },
// Runs after JwtAuthGuard — sets req.tenantId and req.tenantPrisma from req.user // Runs after JwtAuthGuard — sets req.tenantId from req.user (260911-e2s: no longer creates a Prisma client)
{ {
provide: APP_GUARD, provide: APP_GUARD,
useClass: TenantGuard, useClass: TenantGuard,
+1 -1
View File
@@ -30,7 +30,7 @@ import { CreateVehicleDto, UpdateVehicleDto } from './dto/dkv-vehicle.dto';
* Global JwtAuthGuard enforces JWT authentication; RolesGuard enforces the * Global JwtAuthGuard enforces JWT authentication; RolesGuard enforces the
* @Roles decorator. No route is publicly accessible. * @Roles decorator. No route is publicly accessible.
* *
* Tenant extraction: `req.tenantId` set by TenantMiddleware (runs after auth guards). * Tenant extraction: `req.tenantId` set by TenantGuard (runs after auth guards).
* All operations are scoped to the authenticated tenant's data. * All operations are scoped to the authenticated tenant's data.
* *
* Routes: * Routes:
+1 -1
View File
@@ -22,7 +22,7 @@ export const MODULE_SLUG_KEY = 'moduleSlug';
* Gruppen-Grant), D-01. * Gruppen-Grant), D-01.
* *
* Per T-03-04/T-15-10: tenantId, userId und role stammen ausschließlich * Per T-03-04/T-15-10: tenantId, userId und role stammen ausschließlich
* aus dem validierten JWT (via TenantMiddleware/JwtAuthGuard), nie aus * aus dem validierten JWT (via TenantGuard/JwtAuthGuard), nie aus
* Body oder Params — verhindert Elevation of Privilege. * Body oder Params — verhindert Elevation of Privilege.
* *
* T-15-03: Ohne `@UseModule(slug)`-Metadaten gibt der Guard bewusst * T-15-03: Ohne `@UseModule(slug)`-Metadaten gibt der Guard bewusst
@@ -1,4 +1,4 @@
import { readFileSync, readdirSync, statSync } from 'node:fs'; import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path'; import { join, relative } from 'node:path';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
@@ -43,17 +43,27 @@ const DOC_PATH = join(REPO_ROOT, 'docs/mandantentrennung-zugriffsklassifikation.
/** /**
* Dateien, in denen ein `forTenant(`-Aufruf bewusst NICHT der erkannten * Dateien, in denen ein `forTenant(`-Aufruf bewusst NICHT der erkannten
* `const <Name> = forTenant(`-Zuweisungsform folgt. Beide veroeffentlichen * `const <Name> = forTenant(`-Zuweisungsform folgt.
* den gebundenen Client auf dem Anfrageobjekt (`req.tenantPrisma = ...`) *
* statt ihn einer lokalen Konstante zuzuweisen — genau dieser Weg ist die * Bis 260911-e2s standen hier zwei Dateien (`tenant.middleware.ts`,
* offene Architekturfrage aus docs/mandantentrennung-zugriffsklassifikation.md * `tenant.guard.ts`): beide veroeffentlichten einen gebundenen Client auf
* ("Was diese Etappe NICHT entscheidet"), hier bewusst offen gehalten statt * dem Anfrageobjekt statt ihn einer lokalen Konstante zuzuweisen — der Weg
* stillschweigend als Erkennungsluecke durchzurutschen. * war die offene Architekturfrage aus
* docs/mandantentrennung-zugriffsklassifikation.md ("Was diese Etappe NICHT
* entscheidet").
*
* Die Frage ist mit 260911-e2s (Aufgabe 2) ENTSCHIEDEN: die
* dienst-interne Bindung (ein Klient je Methode, wie es alle neun vor
* diesem Bereich umgestellten Bereiche bereits vormachen) ist die
* Konvention; der Guard erzeugt ueberhaupt keinen Client mehr, die
* gleichlautende, nie verdrahtete Middleware ist geloescht. Diese Liste
* startet deshalb leer und bleibt es, bis ein begruendeter neuer
* Ausnahmefall auftritt — dieselbe Form wie
* `INTERACTIVE_TRANSACTION_EXCEPTIONS` unten. Der Test
* "keine veraltete Ausnahmeliste" unter dieser Datei stellt sicher, dass ein
* kuenftiger Eintrag nicht unbemerkt veraltet.
*/ */
const FORTENANT_ASSIGNMENT_EXCEPTIONS = new Set([ const FORTENANT_ASSIGNMENT_EXCEPTIONS = new Set<string>([]);
'apps/api/src/tenant/tenant.middleware.ts',
'apps/api/src/tenant/tenant.guard.ts',
]);
/** /**
* Dateien, in denen eine interaktive Transaktion (`empfaenger.$transaction( * Dateien, in denen eine interaktive Transaktion (`empfaenger.$transaction(
@@ -347,6 +357,28 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst
expect(violations, violations.join('\n')).toEqual([]); expect(violations, violations.join('\n')).toEqual([]);
}); });
it('keine veraltete Ausnahmeliste: jede Datei in [...FORTENANT_ASSIGNMENT_EXCEPTIONS] existiert und traegt tatsaechlich mindestens einen forTenant(-Aufruf ausserhalb der Zuweisungsform (260911-e2s, Aufgabe 2)', () => {
const staleEntries: string[] = [];
const analysesByFile = new Map(analyses.map((a) => [a.file, a]));
for (const file of [...FORTENANT_ASSIGNMENT_EXCEPTIONS]) {
if (!existsSync(join(REPO_ROOT, file))) {
staleEntries.push(`${file}: Datei existiert nicht mehr`);
continue;
}
const analysis = analysesByFile.get(file);
const unmatched = analysis ? analysis.totalForTenantCalls - analysis.assignmentFormCalls : 0;
if (unmatched <= 0) {
staleEntries.push(
`${file}: enthaelt keinen forTenant(-Aufruf ausserhalb der erkannten Zuweisungsform mehr — die Ausnahme ist ueberholt und gehoert entfernt`,
);
}
}
expect(
staleEntries,
`Eine Ausnahmeliste, die Dateien nennt, die es nicht gibt oder die keinen Ausnahmefall mehr enthalten, ist dieselbe tote Verdrahtung, die 260911-e2s im Guard entfernt hat:\n${staleEntries.join('\n')}`,
).toEqual([]);
});
it('jede interaktive Transaktion (empfaenger.$transaction(async ...)) entspricht einer der erkannten Empfaengerformen oder steht in der begruendeten Ausnahmeliste (260909-jts, Befund B)', () => { it('jede interaktive Transaktion (empfaenger.$transaction(async ...)) entspricht einer der erkannten Empfaengerformen oder steht in der begruendeten Ausnahmeliste (260909-jts, Befund B)', () => {
const violations: string[] = []; const violations: string[] = [];
for (const a of analyses) { for (const a of analyses) {
+19 -9
View File
@@ -4,20 +4,32 @@ import {
ForbiddenException, ForbiddenException,
Injectable, Injectable,
} from '@nestjs/common'; } from '@nestjs/common';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
/** /**
* Runs AFTER JwtAuthGuard (guard execution order follows APP_GUARD registration order). * Runs AFTER JwtAuthGuard (guard execution order follows APP_GUARD registration order).
* At this point req.user is populated — middleware ran too early to access it. * At this point req.user is populated — the order is load-bearing.
* *
* Sets req.tenantId and req.tenantPrisma for downstream controllers. * Sets ONLY req.tenantId for downstream code. Super-Admin can override the
* Super-Admin can override tenant via x-tenant-id header (D-10). * tenant via the x-tenant-id header (D-10).
*
* DECISION (260911-e2s, Aufgabe 2): an earlier design also published a
* tenant-scoped Prisma client on the request object, under a property
* named `tenantPrisma` (assigned via `forTenant(...)`), duplicated
* identically in a never-registered Express middleware class with the same
* logic (deleted with 260911-e2s). A full-text search across
* `apps/api/src` found no reader of that property outside those two
* files — every one of the nine areas converted before this one binds
* service-internally instead, one client per method call via the
* tenant-binding helper in
* `prisma-tenant.extension.ts`. That convention, settled by nine-fold
* practice, is why this guard no longer creates a client at all: dead
* wiring that LOOKS like a protection mechanism is worse than none — it
* suggests a safeguard to a later reader that never actually ran. See
* docs/mandantentrennung-etappe2-fehlerrichtung.md, section "## Bereich
* tenant", (n4)(a) for the measurement and the reasoning.
*/ */
@Injectable() @Injectable()
export class TenantGuard implements CanActivate { export class TenantGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest(); const req = context.switchToHttp().getRequest();
const user = req.user; const user = req.user;
@@ -38,10 +50,8 @@ export class TenantGuard implements CanActivate {
} }
if (tenantId) { if (tenantId) {
req.tenantPrisma = forTenant(this.prisma, tenantId);
req.tenantId = tenantId; req.tenantId = tenantId;
} else { } else {
req.tenantPrisma = this.prisma;
req.tenantId = null; req.tenantId = null;
} }