feat(quick-260910-exd): ModuleRegistryService binden, Klassifikation abschliessen

- module-registry.service.ts: findActiveForTenant, activateForTenant,
  isModuleActive binden je einen Aktivierungszugriff, deactivateForTenant
  bindet beide (Lesen+Schreiben) ueber EINEN Klienten unter tenantPrisma;
  alle sechs Katalogzugriffe (findAll/findBySlug/beide
  Existenzpruefungen/isModuleActive-Katalogsuche/seedModule) bleiben
  bewusst ungebunden, mit Kommentar der Messung von Bedingung trennt
- isModuleActive-Kopfkommentar richtiggestellt: der Waechter ruft sie
  nicht auf (0 Aufrufer, TEIL 3 von Aufgabe 1) — Waechter nimmt findBySlug
  + ModuleAccessService.getAccessibleModuleIds
- module-registry.service.spec.ts: NEU, Zwei-Klienten-Nachweis, deckt die
  bislang ungetestete Datei mit elf der siebzehn Zugriffe des Bereichs ab,
  inkl. der lauten (deactivate ohne Aktivierung) und stillen (isModuleActive
  ohne Aktivierung) Richtung und dem Katalog-Wachhund
- tender-scheduler.service.spec.ts: forTenant() auf Identitaet gemockt
  (dieselbe Konvention wie ldap.service.spec.ts) — cross-area Bruch durch
  die Umstellung von activateForTenant behoben (Rule 1/3)
- docs/mandantentrennung-zugriffsklassifikation.md: alle fuenf
  handgepflegten Stellen nachgezogen (Bestandsaufnahme, Uebersichtszeile
  7/10, Summenzeile 108/134, Klassen-Verteilung unveraendert bei 63 Paaren,
  Hintergrunddienst-Abschnitt haelt die Abwesenheit eines sechsten Falls
  fest) — alle gemessen, nicht abgeschrieben, Befund K haelt exakt
- docs/mandantentrennung-etappe2-fehlerrichtung.md: Nachtrag mit
  tatsaechlich umgesetzten Pfaden, beiden Falsifizierungsnachweisen
  (Testname+Meldung), und der Feststellung zum unveraenderten
  Controller-Kommentar
- .planning/WINDOWS.md: neuer offener Eintrag #23 (deviation) — kein Signal
  unterscheidet "keine Freigabe" von "Abfrage fand nichts", mit
  Vorabpruefung fuer Etappe 4 und begruendeter Verwerfung einer
  Laufzeitwarnung
- 833 Tests gruen (56 Dateien), Typpruefung sauber, Wegwerf-Werkzeug 66/66

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-10 11:30:37 +02:00
parent 3df72687c1
commit 9c0eefee90
6 changed files with 515 additions and 17 deletions
+16 -3
View File
@@ -1,10 +1,10 @@
---
schema_version: 1
open_count: 5
open_count: 6
waived_count: 1
fixed_count: 16
total_count: 22
last_updated: 2026-09-10T08:17:20.009Z
total_count: 23
last_updated: 2026-09-10T09:28:45.310Z
---
# Broken Windows Ledger
@@ -37,6 +37,7 @@ last_updated: 2026-09-10T08:17:20.009Z
| 20 | 2 | unmet-truth | apps/api/src/prisma/prisma-tenant.extension.ts | | forTenant() setzt den Mandantenkontext auf einer anderen Verbindung als die Abfrage laeuft — die Mandantentrennung hat damit nie funktioniert, auch nicht dort, wo sie scheinbar benutzt wird. Die Erweiterung oeffnet prisma.$transaction, setzt app.current_tenant per set_config(..., true) auf tx, ruft dann aber query(args) auf, das ueber den AEUSSEREN Client dispatcht. set_config mit local=true gilt nur in der Transaktion und nur auf deren Verbindung. Am 2026-09-09 gegen die lokale Datenbank reproduziert: set_config landete auf Backend-PID 254999, die eigentliche Abfrage auf 255000, und dort war current_setting('app.current_tenant') NULL. Heute ohne sichtbare Folge, weil die Anwendungsrolle BYPASSRLS hat (#18) und deshalb ohnehin alles sieht. NACH dem Scharfschalten kehrt sich das um: die betroffenen Abfragen liefern dann NULL ZEILEN statt zu vieler. Besonders gefaehrlich in ldap.service.ts (Loeschzweig um Zeile 1559): der Sync deutet die Leere als 'Gruppe im Verzeichnis verschwunden' und loescht sie samt Mitgliedschaften und Modulfreigaben — aus einem stillen Trennungsfehler wuerde stiller Datenverlust. Zusatzbefund: von den 36 vermeintlichen forTenant-Vorkommen sind die meisten Kommentare, die erklaeren, warum forTenant FEHLT; echte Aufrufstellen sind 6, echte mandantengebundene Abfragen 9, alle in ldap.service.ts. Ausserdem setzen tenant.middleware.ts:44 und tenant.guard.ts:41 ein req.tenantPrisma, das in apps/api/src von NIEMANDEM gelesen wird. Muss vor jedem weiteren Umbau repariert werden, sonst baut alles Weitere auf einem Helfer auf, der nicht traegt. NACHTRAG (260909-eor, Aufgabe 1/4): der beschriebene Verbindungsfehler ist behoben (Array-Form von $transaction, prisma-tenant.extension.ts) und gegen eine Wegwerf-Datenbank mit einer Rolle ohne BYPASSRLS live nachgewiesen (rls-scratch-check.mjs, 8/8 Pruefungen bestanden). Bleibt dennoch bewusst OPEN, nicht fixed: die Wirkung unter der echten Anwendungsrolle tessera_app ist erst nach dem Scharfschalten (#18) beobachtbar — bis dahin bleibt #20 an dieselbe Bedingung gebunden wie #18 und #19. | open | | 2026-09-09T08:44:18.496Z | |
| 21 | 2 | deviation | apps/api/src/dkv/dkv-scheduler.service.ts | | DKV-Planer-Startpfad (DkvSchedulerService.onModuleInit -> DkvService.loadAnyActiveConfigForScheduler, vormals loadConfig() ohne Mandant) bleibt bewusst UNGEBUNDEN, als benannte Altlast aus 07-04 (260909-mir, Befund D). Zwei Zustaende, beide gehoeren genannt: HEUTE bereits falsch -- findFirst() ohne jede Bedingung zieht bei mehreren Mandanten EINEN beliebigen und bedient die uebrigen NIE (isActive-Pruefung kann den Planer sogar ganz leer laufen lassen, wenn ausgerechnet die gezogene Zeile inaktiv ist, obwohl ein zweiter Mandant aktiv waere). NACH DEM SCHARFSCHALTEN (#18) verstummt sie zusaetzlich -- dieselbe Abfrage liefert dann null, der Planer protokolliert 'no active config found' und richtet fuer JEDEN Mandanten nichts ein, ohne Alarm. Drei erwogene Formen prufen: (a) an einen konkret aufgeloesten Mandanten binden -- nicht moeglich, onModuleInit() hat beim Boot strukturell keinen Mandantenkontext. (b) Umbau auf einmal-abfragen-viele-bedienen -- abgelehnt, das ist die in 07-04 zurueckgestellte Mehrmandanten-Planung (neue Auftragsverwaltung je Mandant statt des heutigen setInterval() mit GENAU EINEM Auftrag) und damit eine Funktionsaenderung, kein Bindungsumbau. (c) Als benannte Altlast weiterfuehren, mit Markierung -- GEWAEHLT, Praezedenzfall LdapConfigService.getAllActiveConfigs() (260909-ipc, Befund B). Die Unsymmetrie zu diesem Praezedenzfall: getAllActiveConfigs ist HEUTE korrekt und verstummt erst spaeter: der DKV-Planer ist HEUTE bereits falsch UND verstummt zusaetzlich spaeter. Markierung dreifach: eigene benannte Methode loadAnyActiveConfigForScheduler() mit Kopfkommentar (dkv.service.ts), fortgeschriebener Kopfkommentar in dkv-scheduler.service.ts, Abschnitt (d4) in docs/mandantentrennung-etappe2-fehlerrichtung.md. Signal fuer das Verstummen gehoert in die Vorabpruefung von Etappe 4 (rls-preflight.mjs), NICHT in diesen Durchlauf. | open | | 2026-09-09T14:41:07.256Z | |
| 22 | quick-260910-das | deviation | apps/api/src/user/user.service.ts | | Plattformweite Eindeutigkeit von username/email (kein tenantId-Anteil im Unique-Index): die gemessene Kette unsichtbare Zeile -> falsches 'frei' -> harter Eindeutigkeitsfehler (SQLSTATE 23505) ist in dieser Etappe im Anwendungscode entschaerft (Konfliktmeldung bei create/update, Startsperre in admin-seed.service.ts abgefangen), nicht an der Ursache geloest. Die ehrliche Reparatur waere eine Schemaaenderung (Eindeutigkeit mit Mandantendimension) und ist als Produktentscheidung fuer Etappe 3 vorgemerkt. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich user' (u1/u4). | open | | 2026-09-10T08:17:20.009Z | |
| 23 | quick-260910-exd | deviation | apps/api/src/module-registry/module-access.service.ts | | Es gibt heute KEIN Signal, das 'wirklich keine Freigabe' (USER hat tatsaechlich keinen Grant) von 'die Abfrage hat nichts gefunden' (z. B. eine nach dem Scharfschalten ungebunden gebliebene Abfrage) unterscheidet: dieselbe ForbiddenException-Meldung im Waechter, dieselbe leere Modulliste mit Status 200, kein Protokolleintrag. Nach dem Scharfschalten ist ein zu kleines Ergebnis in diesem Bereich TOTAL und lautlos -- JEDER Benutzer JEDES Mandanten verliert gleichzeitig jedes Modul, waehrend die Aktivierungs- und Freigabetabellen weiterhin Zeilen halten -- und sieht fuer den Betroffenen wie ein absichtlicher Rechteentzug aus, nicht wie ein Fehler (der Betroffene hat eine fertige, falsche Erklaerung zur Hand und meldet deshalb keinen Fehler). Konkrete Vorabpruefung fuer Etappe 4 (rls-preflight.mjs): aktive Aktivierungszeilen vorhanden, aber die Aufloesung liefert fuer einen bekannten Administrator eine leere Menge. Eine Laufzeitwarnung an den betroffenen Stellen wurde erwogen und begruendet verworfen (Dauerlaerm auf einer frischen Installation, dieselbe Begruendung wie bei getAllActiveConfigs im Bereich ldap und den fuenf Stellen im Bereich tenders). In module.guard.spec.ts als Testfall festgenagelt, damit die Aufzeichnung rot wird, sobald jemand ein unterscheidendes Signal einbaut. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich module-registry' (m3). | open | | 2026-09-10T09:28:45.310Z | |
````json
[
@@ -303,6 +304,18 @@ last_updated: 2026-09-10T08:17:20.009Z
"reason": "",
"recorded_at": "2026-09-10T08:17:20.009Z",
"resolved_at": null
},
{
"id": 23,
"kind": "deviation",
"phase": "quick-260910-exd",
"file": "apps/api/src/module-registry/module-access.service.ts",
"line": null,
"description": "Es gibt heute KEIN Signal, das 'wirklich keine Freigabe' (USER hat tatsaechlich keinen Grant) von 'die Abfrage hat nichts gefunden' (z. B. eine nach dem Scharfschalten ungebunden gebliebene Abfrage) unterscheidet: dieselbe ForbiddenException-Meldung im Waechter, dieselbe leere Modulliste mit Status 200, kein Protokolleintrag. Nach dem Scharfschalten ist ein zu kleines Ergebnis in diesem Bereich TOTAL und lautlos -- JEDER Benutzer JEDES Mandanten verliert gleichzeitig jedes Modul, waehrend die Aktivierungs- und Freigabetabellen weiterhin Zeilen halten -- und sieht fuer den Betroffenen wie ein absichtlicher Rechteentzug aus, nicht wie ein Fehler (der Betroffene hat eine fertige, falsche Erklaerung zur Hand und meldet deshalb keinen Fehler). Konkrete Vorabpruefung fuer Etappe 4 (rls-preflight.mjs): aktive Aktivierungszeilen vorhanden, aber die Aufloesung liefert fuer einen bekannten Administrator eine leere Menge. Eine Laufzeitwarnung an den betroffenen Stellen wurde erwogen und begruendet verworfen (Dauerlaerm auf einer frischen Installation, dieselbe Begruendung wie bei getAllActiveConfigs im Bereich ldap und den fuenf Stellen im Bereich tenders). In module.guard.spec.ts als Testfall festgenagelt, damit die Aufzeichnung rot wird, sobald jemand ein unterscheidendes Signal einbaut. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich module-registry' (m3).",
"status": "open",
"reason": "",
"recorded_at": "2026-09-10T09:28:45.310Z",
"resolved_at": null
}
]
````
@@ -0,0 +1,343 @@
import { NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { ModuleRegistryService } from './module-registry.service';
/**
* ModuleRegistryService — bisher OHNE Testdatei (260910-exd, Aufgabe 3,
* Befund C, zweite Form). Diese Datei haelt elf der siebzehn Zugriffe des
* Bereichs `module-registry`, darunter JEDEN Schreibpfad.
*
* Bindung an forTenant() (dasselbe Muster wie
* `module-grants.service.spec.ts` und `module-access.service.spec.ts`): der
* gebundene Klient ist ein ZWEITES, von `prisma` unterscheidbares Objekt
* ueber DEMSELBEN Speicher, das protokolliert, welche Aufrufe ueber ihn
* liefen. `module` wird NICHT gewrappt — der Katalogzugriff bleibt bewusst
* ungebunden (Aufgabe 1, Befund E).
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
}));
const BOUND_MODEL_NAMES = ['tenantModuleActivation'];
function makeFakePrisma() {
const modules = new Map<string, any>();
const activations = new Map<string, any>(); // key: tenantId::moduleId
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
const fake: any = {
__seedModule(m: { id: string; slug: string; name: string; version: string; category: string; description: any; icon?: string | null; isSystem?: boolean }) {
modules.set(m.id, { icon: null, isSystem: false, ...m });
},
__seedActivation(a: { tenantId: string; moduleId: string; isActive: boolean }) {
activations.set(`${a.tenantId}::${a.moduleId}`, { ...a, activatedAt: new Date() });
},
module: {
findMany: vi.fn(async () => Array.from(modules.values()).sort((a, b) => a.name.localeCompare(b.name))),
findUnique: vi.fn(async ({ where }: any) => {
if (where.id) return modules.get(where.id) ?? null;
if (where.slug) return Array.from(modules.values()).find((m) => m.slug === where.slug) ?? null;
return null;
}),
upsert: vi.fn(async ({ where, update, create }: any) => {
const existing = Array.from(modules.values()).find((m) => m.slug === where.slug);
if (existing) {
const updated = { ...existing, ...update };
modules.set(existing.id, updated);
return updated;
}
const record = { id: `mod-${modules.size + 1}`, ...create };
modules.set(record.id, record);
return record;
}),
},
tenantModuleActivation: {
findMany: vi.fn(async ({ where }: any) => {
return Array.from(activations.values())
.filter((a) => a.tenantId === where.tenantId && (where.isActive === undefined || a.isActive === where.isActive))
.map((a) => ({ ...a, module: modules.get(a.moduleId) ?? null }));
}),
findUnique: vi.fn(async ({ where }: any) => {
const { tenantId, moduleId } = where.tenantId_moduleId;
return activations.get(`${tenantId}::${moduleId}`) ?? null;
}),
upsert: vi.fn(async ({ where, update, create }: any) => {
const key = `${where.tenantId_moduleId.tenantId}::${where.tenantId_moduleId.moduleId}`;
const existing = activations.get(key);
const record = existing
? { ...existing, ...update }
: { ...create, activatedAt: new Date() };
activations.set(key, record);
return { ...record, module: modules.get(record.moduleId) ?? null };
}),
update: vi.fn(async ({ where, data }: any) => {
const { tenantId, moduleId } = where.tenantId_moduleId;
const key = `${tenantId}::${moduleId}`;
const existing = activations.get(key);
const record = { ...existing, ...data };
activations.set(key, record);
return { ...record, module: modules.get(record.moduleId) ?? null };
}),
},
// --- Bindungsnachweis (260910-exd, Befund C uebertragen) ---------------
__boundCallLog: boundCallLog,
__makeBoundClient(tenantId: string) {
const bound: any = { __isBoundClient: true, __tenantId: tenantId };
for (const modelName of BOUND_MODEL_NAMES) {
const model = fake[modelName];
const wrapped: any = {};
for (const method of Object.keys(model)) {
wrapped[method] = async (...args: any[]) => {
boundCallLog.push({ tenantId, model: modelName, method });
return model[method](...args);
};
}
bound[modelName] = wrapped;
}
return bound;
},
};
return fake;
}
function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) {
const found = prisma.__boundCallLog.some(
(c: any) => c.tenantId === tenantId && c.model === model && c.method === method,
);
expect(
found,
`erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
).toBe(true);
}
function expectNeverBound(prisma: any, model: string) {
const found = prisma.__boundCallLog.some((c: any) => c.model === model);
expect(
found,
`Modell "${model}" darf nie im Bindungsprotokoll auftauchen (Katalog bleibt ungebunden): ${JSON.stringify(prisma.__boundCallLog)}`,
).toBe(false);
}
describe('ModuleRegistryService.findAll/findBySlug — Katalog (bewusst UNGEBUNDEN)', () => {
it('findAll liefert alle registrierten Module, sortiert nach Namen', async () => {
const prisma = makeFakePrisma();
prisma.__seedModule({ id: 'mod-b', slug: 'b', name: 'B-Modul', version: '1', category: 'ops', description: {} });
prisma.__seedModule({ id: 'mod-a', slug: 'a', name: 'A-Modul', version: '1', category: 'ops', description: {} });
const service = new ModuleRegistryService(prisma as any);
const result = await service.findAll();
expect(result.map((m: any) => m.id)).toEqual(['mod-a', 'mod-b']);
expectNeverBound(prisma, 'module');
});
it('findBySlug findet ein Modul über seinen Slug', async () => {
const prisma = makeFakePrisma();
prisma.__seedModule({ id: 'mod-1', slug: 'domaincheck', name: 'Domaincheck', version: '1', category: 'ops', description: {} });
const service = new ModuleRegistryService(prisma as any);
const result = await service.findBySlug('domaincheck');
expect(result?.id).toBe('mod-1');
expectNeverBound(prisma, 'module');
});
it('findBySlug liefert null für einen unbekannten Slug, ohne zu werfen', async () => {
const prisma = makeFakePrisma();
const service = new ModuleRegistryService(prisma as any);
const result = await service.findBySlug('unknown');
expect(result).toBeNull();
});
});
describe('ModuleRegistryService.findActiveForTenant', () => {
it('bindet den Lesezugriff an den Mandanten UND liefert die Katalogseite mit (die verbundene Modellform überlebt die Bindung)', async () => {
const prisma = makeFakePrisma();
prisma.__seedModule({ id: 'mod-1', slug: 'domaincheck', name: 'Domaincheck', version: '1', category: 'ops', description: {} });
prisma.__seedActivation({ tenantId: 't1', moduleId: 'mod-1', isActive: true });
const service = new ModuleRegistryService(prisma as any);
const result = await service.findActiveForTenant('t1');
expect(result).toEqual([expect.objectContaining({ id: 'mod-1', name: 'Domaincheck' })]);
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany');
});
it('die Aktivierungsliste eines zweiten Mandanten liefert keine Zeile des ersten', async () => {
const prisma = makeFakePrisma();
prisma.__seedModule({ id: 'mod-1', slug: 'domaincheck', name: 'Domaincheck', version: '1', category: 'ops', description: {} });
prisma.__seedActivation({ tenantId: 't1', moduleId: 'mod-1', isActive: true });
const service = new ModuleRegistryService(prisma as any);
const result = await service.findActiveForTenant('t2');
expect(result).toEqual([]);
expectBoundCall(prisma, 't2', 'tenantModuleActivation', 'findMany');
});
});
describe('ModuleRegistryService.activateForTenant', () => {
it('erreicht die Katalog-Existenzprüfung UNGEBUNDEN und schreibt die Aktivierung GEBUNDEN, an den übergebenen Mandanten', async () => {
const prisma = makeFakePrisma();
prisma.__seedModule({ id: 'mod-1', slug: 'domaincheck', name: 'Domaincheck', version: '1', category: 'ops', description: {} });
const service = new ModuleRegistryService(prisma as any);
const result = await service.activateForTenant('t1', 'mod-1');
expect(result.isActive).toBe(true);
expect(prisma.module.findUnique).toHaveBeenCalled();
expectNeverBound(prisma, 'module');
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'upsert');
});
it('wirft die Nicht-gefunden-Ausnahme für eine unbekannte Modulkennung, BEVOR irgendein gebundener Schreibzugriff im Protokoll steht', async () => {
const prisma = makeFakePrisma();
const service = new ModuleRegistryService(prisma as any);
await expect(service.activateForTenant('t1', 'mod-unknown')).rejects.toBeInstanceOf(NotFoundException);
expect(
prisma.__boundCallLog,
`kein gebundener Aufruf erwartet, wenn die Katalog-Existenzpruefung bereits scheitert: ${JSON.stringify(prisma.__boundCallLog)}`,
).toEqual([]);
});
});
describe('ModuleRegistryService.deactivateForTenant', () => {
it('bindet beide Aktivierungszugriffe (Lesen, Schreiben) an denselben Mandanten, über einen Klienten', async () => {
const prisma = makeFakePrisma();
prisma.__seedModule({ id: 'mod-1', slug: 'domaincheck', name: 'Domaincheck', version: '1', category: 'ops', description: {} });
prisma.__seedActivation({ tenantId: 't1', moduleId: 'mod-1', isActive: true });
const service = new ModuleRegistryService(prisma as any);
const result = await service.deactivateForTenant('t1', 'mod-1');
expect(result.isActive).toBe(false);
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findUnique');
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'update');
expectNeverBound(prisma, 'module');
});
/**
* Die LAUTE, harmlose Richtung dieses Bereichs (260910-exd, m3) — hier
* ausdruecklich festgenagelt, damit sie bei einem spaeteren Umbau nicht
* versehentlich in ein stilles `false` verwandelt wird.
*/
it('wirft die Nicht-gefunden-Ausnahme, wenn keine Aktivierung vorliegt (LAUT, nicht still)', async () => {
const prisma = makeFakePrisma();
prisma.__seedModule({ id: 'mod-1', slug: 'domaincheck', name: 'Domaincheck', version: '1', category: 'ops', description: {} });
const service = new ModuleRegistryService(prisma as any);
await expect(service.deactivateForTenant('t1', 'mod-1')).rejects.toBeInstanceOf(NotFoundException);
});
it('wirft die Nicht-gefunden-Ausnahme für eine unbekannte Modulkennung', async () => {
const prisma = makeFakePrisma();
const service = new ModuleRegistryService(prisma as any);
await expect(service.deactivateForTenant('t1', 'mod-unknown')).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('ModuleRegistryService.isModuleActive', () => {
it('bindet ihren Aktivierungs-Lesezugriff und erreicht den Katalog ungebunden', async () => {
const prisma = makeFakePrisma();
prisma.__seedModule({ id: 'mod-1', slug: 'domaincheck', name: 'Domaincheck', version: '1', category: 'ops', description: {} });
prisma.__seedActivation({ tenantId: 't1', moduleId: 'mod-1', isActive: true });
const service = new ModuleRegistryService(prisma as any);
const result = await service.isModuleActive('t1', 'domaincheck');
expect(result).toBe(true);
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findUnique');
expectNeverBound(prisma, 'module');
});
/**
* Die STILLE Richtung dieses Bereichs (260910-exd, m3) — ebenfalls
* festgenagelt, mit diesem Kommentar als Markierung: heute ohne Aufrufer
* (Aufgabe 1, TEIL 3), aber die Falle für morgen, sollte diese Methode
* verdrahtet werden.
*/
it('liefert `false`, ohne Aktivierung (STILL, keine Ausnahme)', async () => {
const prisma = makeFakePrisma();
prisma.__seedModule({ id: 'mod-1', slug: 'domaincheck', name: 'Domaincheck', version: '1', category: 'ops', description: {} });
const service = new ModuleRegistryService(prisma as any);
const result = await service.isModuleActive('t1', 'domaincheck');
expect(result).toBe(false);
});
it('liefert `false` für einen unbekannten Slug, ohne die Aktivierungstabelle überhaupt zu befragen', async () => {
const prisma = makeFakePrisma();
const service = new ModuleRegistryService(prisma as any);
const result = await service.isModuleActive('t1', 'unknown-slug');
expect(result).toBe(false);
expect(prisma.__boundCallLog).toEqual([]);
});
});
describe('ModuleRegistryService.seedModule — Katalogpflege beim Start (bewusst UNGEBUNDEN)', () => {
it('legt ein neues Modul an, wenn der Slug noch nicht existiert', async () => {
const prisma = makeFakePrisma();
const service = new ModuleRegistryService(prisma as any);
const result = await service.seedModule({
slug: 'domaincheck',
name: 'Domaincheck',
version: '1.0.0',
category: 'ops',
description: { de: 'Test' },
});
expect(result.slug).toBe('domaincheck');
expectNeverBound(prisma, 'module');
});
it('aktualisiert ein bestehendes Modul über denselben Slug (Upsert)', async () => {
const prisma = makeFakePrisma();
prisma.__seedModule({ id: 'mod-1', slug: 'domaincheck', name: 'Alt', version: '1', category: 'ops', description: {} });
const service = new ModuleRegistryService(prisma as any);
const result = await service.seedModule({
slug: 'domaincheck',
name: 'Neu',
version: '2.0.0',
category: 'ops',
description: { de: 'Test' },
});
expect(result.name).toBe('Neu');
expect(result.version).toBe('2.0.0');
});
});
describe('ModuleRegistryService — Katalogzugriffe tauchen nie im Bindungsprotokoll auf (Wachhund, 260910-exd)', () => {
it('kein Katalogzugriff des Dienstes — weder Gesamtliste, noch Kennzeichen-Suche, noch Existenzprüfungen, noch Katalogpflege — taucht im Bindungsprotokoll auf', async () => {
const prisma = makeFakePrisma();
prisma.__seedModule({ id: 'mod-1', slug: 'domaincheck', name: 'Domaincheck', version: '1', category: 'ops', description: {} });
prisma.__seedActivation({ tenantId: 't1', moduleId: 'mod-1', isActive: true });
const service = new ModuleRegistryService(prisma as any);
await service.findAll();
await service.findBySlug('domaincheck');
await service.activateForTenant('t1', 'mod-1');
await service.deactivateForTenant('t1', 'mod-1');
await service.isModuleActive('t1', 'domaincheck');
await service.seedModule({
slug: 'domaincheck',
name: 'Domaincheck',
version: '1.0.0',
category: 'ops',
description: {},
});
expectNeverBound(prisma, 'module');
});
});
@@ -1,5 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/**
* Service managing the module registry and per-tenant activations.
@@ -13,6 +14,13 @@ export class ModuleRegistryService {
/**
* Returns all registered modules.
*
* Bewusst UNGEBUNDEN (260910-exd, Aufgabe 1, Befund E): "Module" traegt
* heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht
* katastrophal. Katastrophal wuerde sie erst, WENN Etappe 3 dieser
* Tabelle eine Regel gibt — dann verschwaende der gesamte Katalog fuer
* jeden Mandanten. Diese Bedingung steht hier als Bedingung, nicht als
* heute beobachtbare Tatsache.
*/
async findAll() {
return this.prisma.module.findMany({
@@ -22,6 +30,12 @@ export class ModuleRegistryService {
/**
* Finds a module by its unique slug.
*
* Bewusst UNGEBUNDEN, dieselbe Begruendung wie `findAll` oben. Diese
* Methode ist zusaetzlich die Stelle, die `ModuleGuard` bei JEDER
* Modulanfrage aufruft — eine Bindung wuerde jede Modulanfrage mit einer
* Meldung abweisen, die faelschlich von einer fehlenden Aktivierung
* spricht.
*/
async findBySlug(slug: string) {
return this.prisma.module.findUnique({
@@ -33,7 +47,8 @@ export class ModuleRegistryService {
* Returns all active modules for a given tenant.
*/
async findActiveForTenant(tenantId: string) {
const activations = await this.prisma.tenantModuleActivation.findMany({
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const activations = await tenantPrisma.tenantModuleActivation.findMany({
where: {
tenantId,
isActive: true,
@@ -43,7 +58,7 @@ export class ModuleRegistryService {
},
});
return activations.map((a) => a.module);
return activations.map((a: { module: unknown }) => a.module);
}
/**
@@ -51,7 +66,10 @@ export class ModuleRegistryService {
* Per D-08: dynamic activation without restart.
*/
async activateForTenant(tenantId: string, moduleId: string) {
// Verify module exists
// Verify module exists — bewusst UNGEBUNDEN, dieselbe Begruendung wie
// `findAll` oben (Aufgabe 1, Befund E). Laeuft VOR jedem gebundenen
// Schreibzugriff: eine unbekannte moduleId wirft, bevor der gebundene
// Klient ueberhaupt erzeugt wird.
const moduleExists = await this.prisma.module.findUnique({
where: { id: moduleId },
});
@@ -59,7 +77,8 @@ export class ModuleRegistryService {
throw new NotFoundException(`Module with id '${moduleId}' not found`);
}
return this.prisma.tenantModuleActivation.upsert({
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
return tenantPrisma.tenantModuleActivation.upsert({
where: {
tenantId_moduleId: {
tenantId,
@@ -86,7 +105,8 @@ export class ModuleRegistryService {
* Does not remove the activation record, preserving audit trail.
*/
async deactivateForTenant(tenantId: string, moduleId: string) {
// Verify module exists
// Verify module exists — bewusst UNGEBUNDEN, dieselbe Begruendung wie
// `findAll` oben.
const moduleExists = await this.prisma.module.findUnique({
where: { id: moduleId },
});
@@ -94,8 +114,13 @@ export class ModuleRegistryService {
throw new NotFoundException(`Module with id '${moduleId}' not found`);
}
// EIN gebundener Klient fuer beide Aktivierungszugriffe dieser Methode
// (Lesen, Schreiben) — nicht ein Klient je Zugriff (260910-exd,
// Aufgabe 3, dieselbe Konvention wie `module-access.service.ts`).
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
// Check if activation record exists
const activation = await this.prisma.tenantModuleActivation.findUnique({
const activation = await tenantPrisma.tenantModuleActivation.findUnique({
where: {
tenantId_moduleId: {
tenantId,
@@ -110,7 +135,7 @@ export class ModuleRegistryService {
);
}
return this.prisma.tenantModuleActivation.update({
return tenantPrisma.tenantModuleActivation.update({
where: {
tenantId_moduleId: {
tenantId,
@@ -128,7 +153,15 @@ export class ModuleRegistryService {
/**
* Checks whether a module (by slug) is active for a given tenant.
* Used by ModuleGuard to gate access to module-specific endpoints.
*
* Richtiggestellt (260910-exd, Aufgabe 1, Befund G): der vorherige
* Kommentar behauptete, `ModuleGuard` benutze diese Methode — er tut es
* NICHT. Gemessen (Aufgabe 1, TEIL 3, `grep -rn "isModuleActive"
* apps/api/src apps/web/src packages`): genau EIN Treffer, die Definition
* selbst, kein Aufrufer. Der Waechter nimmt stattdessen `findBySlug` plus
* `ModuleAccessService.getAccessibleModuleIds`. Diese Methode bleibt
* TROTZDEM umgestellt: heute toter, ungebunden gelassener Code ist die
* Falle fuer den, der ihn morgen verdrahtet.
*/
async isModuleActive(tenantId: string, moduleSlug: string): Promise<boolean> {
const module = await this.prisma.module.findUnique({
@@ -139,7 +172,8 @@ export class ModuleRegistryService {
return false;
}
const activation = await this.prisma.tenantModuleActivation.findUnique({
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const activation = await tenantPrisma.tenantModuleActivation.findUnique({
where: {
tenantId_moduleId: {
tenantId,
@@ -154,6 +188,11 @@ export class ModuleRegistryService {
/**
* Registers or updates a module in the registry by slug (upsert).
* Used during application startup to seed built-in modules.
*
* Bewusst UNGEBUNDEN, dieselbe Begruendung wie `findAll` oben — mit einem
* zusaetzlichen Grund, den nur diese Methode hat: sie laeuft beim
* Anwendungsstart aus vier Seed-Dateien, ohne Anfrage und ohne Mandanten
* — ein gebundener Aufruf haette dort strukturell keinen Kontext.
*/
async seedModule(manifest: {
slug: string;
@@ -15,7 +15,17 @@ import { TenderSchedulerService } from './tender-scheduler.service';
* tender-ingestion.service.spec.ts / ldap.service.spec.ts, driving the REAL
* ModuleRegistryService (unmocked) so the activation call path is genuine,
* not a stand-in.
*
* forTenant() just returns the same client in these tests (identical
* convention to ldap.service.spec.ts) — tenant scoping/RLS binding is not
* what this file tests, only ModuleRegistryService.activateForTenant's
* poll-once-fan-out-many behavior. Needed since 260910-exd (Aufgabe 3)
* converted ModuleRegistryService.activateForTenant to forTenant(), and the
* hand-rolled fake below does not implement `$extends`.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
}));
function makeFakePrisma() {
const modules = new Map<string, any>();
@@ -1457,6 +1457,89 @@ und verlöre ihr Signal.
- Schema und Migrationen — geprüft und bewusst gelassen, keine
Schemaänderung in dieser Etappe.
**Nachtrag (260910-exd, Aufgabe 3).** Wie in den vorherigen Durchläufen wird
der Text oben NICHT umgeschrieben — er beschreibt korrekt den Stand zum
Zeitpunkt der Messung (Aufgabe 1); dieser Nachtrag hält fest, was Aufgabe 2/3
tatsächlich umgesetzt haben.
*Tatsächlich umgesetzte Pfade gegen die in (m2) angekündigten gehalten:*
alle in (m2) genannten Pfade sind wie beschrieben umgestellt.
`ModuleAccessService.getAccessibleModuleIds` bindet Kurzschlusszweig,
Direktweg, Gruppenweg und Schnittmenge über EINEN Klienten je Aufruf;
`getCatalogFlags` bindet ihren eigenen Aktivierungs-Lesezugriff, die
geschachtelte `getAccessibleModuleIds`-Auflösung erzeugt ihren eigenen
Klienten; `findAccessibleModules` erreicht den Katalog weiterhin über den
ungebundenen Klienten. `ModuleRegistryService.findActiveForTenant`,
`activateForTenant` und `isModuleActive` binden ihren jeweiligen
Aktivierungszugriff; `deactivateForTenant` bindet beide Aktivierungszugriffe
(Lesen, Schreiben) über EINEN Klienten. Alle sechs Katalogzugriffe in
`module-registry.service.ts` (`findAll`, `findBySlug`, die beiden
Katalog-Existenzprüfungen, die Katalogsuche in `isModuleActive`,
`seedModule`) und der eine Katalogzugriff in `module-access.service.ts`
(`findAccessibleModules`) blieben wie angekündigt ungebunden, mit
Kommentaren, die Messung und Bedingung trennen. Beide falschen
Kopfkommentare aus Befund G sind behandelt: `isModuleActive`s Kommentar ist
richtiggestellt (mit Bezug auf die Aufrufermessung aus TEIL 3); der
irreführende Satz im Kopfkommentar von `module-registry.controller.ts`
("findActiveForTenant on ModuleRegistryService stays unchanged for Plan
15-03's marketplace catalog") wurde NICHT mitgeändert — der Controller ist
nicht Teil dieses Plans. Die Feststellung, dass diese Aussage falsch ist
(der Marktplatz-Katalog wird nachweislich von `getCatalogFlags` bedient,
nicht von `findActiveForTenant`), steht stattdessen hier in (m5).
*Deviation (Rule 1/3): eine cross-area Testabhängigkeit brach durch die
Umstellung.* `tender-scheduler.service.spec.ts` instanziiert
`ModuleRegistryService` unmocked gegen einen hand-gerollten Fake ohne
`$extends` (derselbe Zweck wie in `ldap.service.spec.ts`: der Aktivierungs-
Aufruf soll echt sein, nicht ein Stand-in). Nach der Umstellung von
`activateForTenant` auf `forTenant()` scheiterte dieser Test mit
`prisma.$extends is not a function`. Behoben mit derselben Konvention wie
`ldap.service.spec.ts` — `forTenant` in dieser einen Datei über `vi.mock`
auf eine Identitätsfunktion gelegt (`forTenant: vi.fn((p) => p)`), weil die
Datei RLS-Bindungsmechanik nicht testet, nur das Poll-once-fan-out-many-
Verhalten des Schedulers. Kein anderer Aufrufer von `new
ModuleRegistryService(...)` existiert im Quelltext (geprüft).
*Deviation (Rule 3): die Klassifikationsdokument-Stände für
`module-access.service.ts` wurden bereits in Aufgabe 2 nachgezogen*, nicht
erst in dieser Aufgabe — `rls-access-inventory.spec.ts` ist Teil der von
Aufgabe 2 verlangten vollständigen Testsuite und wäre sonst am Ende von
Aufgabe 2 bereits rot gewesen. Diese Abweichung von der Aufgabenaufteilung
(die Klassifikationsdatei war für Aufgabe 3 vorgesehen) ist auf das
Notwendige beschränkt: nur die `Stand`-Spalte der beiden betroffenen Zeilen,
keine Begründung, keine Zahlen. Die vollständige Nachziehung (fünf
Bestandsaufnahme-Zeilen inklusive `module-registry.service.ts`,
Übersichtszeile, Summenzeile, Klassen-Verteilung, Hintergrunddienst-
Abschnitt) erfolgte wie geplant in dieser Aufgabe.
*Falsifizierungsnachweise (Aufgabe 2 und 3), je einmal durchgeführt und
zurückgenommen:* in Aufgabe 2 wurde die Gruppenweg-Bindung der
Freigabe-Auflösung probeweise zurückgebaut (`tenantPrisma.moduleGrant` →
`this.prisma.moduleGrant` im Gruppenweg von `getAccessibleModuleIds`) —
genau `module-access.service.spec.ts`, Test "ModuleAccessService — Bindung
an forTenant() (260910-exd) > USER-Zweig bindet BEIDE
Freigabe-Lesezugriffe (Direktweg und Gruppenweg) UND den
Schnittmengen-Lesezugriff an DIESELBE Mandantenkennung", wurde rot, mit der
Meldung `expected 1 to be 2`; der Rückbau wurde zurückgenommen, derselbe
Testlauf danach wieder grün (23/23). In Aufgabe 3 wurde der
Schreibzugriff des Deaktivierens probeweise zurückgebaut
(`tenantPrisma.tenantModuleActivation.update` →
`this.prisma.tenantModuleActivation.update` in `deactivateForTenant`) —
genau `module-registry.service.spec.ts`, Test
"ModuleRegistryService.deactivateForTenant > bindet beide
Aktivierungszugriffe (Lesen, Schreiben) an denselben Mandanten, über einen
Klienten", wurde rot, mit der Meldung "erwarteter gebundener Aufruf
tenantModuleActivation.update(tenant=t1) fehlt im Protokoll:
[{"tenantId":"t1","model":"tenantModuleActivation","method":"findUnique"}]:
expected false to be true"; der Rückbau wurde zurückgenommen, derselbe
Testlauf danach wieder grün (16/16).
*Die offene WINDOWS-Aufzeichnung.* Eintrag #23 (`deviation`) hält fest, dass
es kein Signal gibt, das "wirklich keine Freigabe" von "die Abfrage hat
nichts gefunden" unterscheidet, mit der Vorabprüfung für Etappe 4 und der
begründeten Verwerfung einer Laufzeitwarnung — siehe (m3) oben und
`.planning/WINDOWS.md`.
## Verweis
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
@@ -101,14 +101,14 @@ autoritative Quelle.
| ldap | 4 | 26 | **war 21/0** — Aufgabe 2/3 (260909-ipc) haben `ldap-config.service.ts` (5 Methoden) und `ldap.service.ts` (6 Methoden, 11 Abfragen) auf `forTenant()` umgestellt. Die 4 verbleibenden ungebundenen Treffer sind bewusst: `getAllActiveConfigs`/`onApplicationBootstrap` (Befund B) und `resolveEmailForWrite` (Befund A, T-IPC-04) |
| dkv | 1 | 22 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer ist der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21) — bewusst, mit dreifacher Markierung |
| user | 8 | 14 | **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) |
| module-registry | 17 | 0 | unverändert |
| module-registry | 7 | 10 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) |
| dashboard | 13 | 0 | unverändert |
| auth | 8 | 5 | unverändert gegenüber dem in Etappe 1 (260909-eor) gemessenen Stand |
| calendar | 12 | 0 | unverändert |
| tenant | 8 | 0 | unverändert |
| favorites | 7 | 0 | unverändert |
| settings | 4 | 0 | unverändert |
| **Summe** | **118** | **124** | Ungebunden: war 127 nach 260909-mir, Delta = die 9 in Aufgabe 2/3 (260910-das) gesunkenen `user`-Rohtreffer (17→8). Gebunden: war 110, jetzt zusätzlich 14 in `user`. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
| **Summe** | **108** | **134** | Ungebunden: war 118 nach 260910-das, Delta = die 10 in Aufgabe 2/3 (260910-exd) gesunkenen `module-registry`-Rohtreffer (17→7). Gebunden: war 124, jetzt zusätzlich 10 in `module-registry`. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 63 Paare)
@@ -251,6 +251,16 @@ Verzweigung hinter einem optionalen Parameter, die jemand später
`dkv-scheduler.service.ts`, Ledger-Eintrag WINDOWS #21. Das Signal für das
Verstummen gehört in die Vorabprüfung von Etappe 4 (`rls-preflight.mjs`).
**Stand 260910-exd — kein sechster Fall, gemessen statt angenommen.** Der
Bereich `module-registry` fügt diesem Abschnitt KEINEN sechsten Fall hinzu.
`ModuleRegistryService.seedModule()` (die Katalogpflege beim Start,
aufgerufen aus vier Seed-Dateien) schreibt zwar ohne Mandantenkontext — sie
iteriert aber über NICHTS je Mandant, sondern schreibt genau eine Zeile je
Aufruf auf den plattformweiten Katalog `Module`. Damit fehlt ihr die Bauform
der fünf oben geführten Fälle (übergreifend LESEN über alle Mandanten, dann
je Mandant BINDEN) — sie ist deshalb kein Kandidat für diese Liste. Dieser
Satz hält die Abwesenheit fest, damit sie nicht wie ein Übersehen aussieht.
## Bestandsaufnahme
Maschinell ermittelt, `rls-access-inventory.spec.ts` hält Vollständigkeit
@@ -288,11 +298,11 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
| apps/api/src/ldap/ldap.service.ts | groupMembership | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `Group`. `syncGroupMembershipsForTenant` laeuft vollstaendig ueber `forTenant()` (Plan 16-03/16-05) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. |
| apps/api/src/ldap/ldap.service.ts | ldapConfig | beides | gebunden | Die `lastSyncAt`-Fortschreibung am Ende von `syncUsersForTenant` ist mit Aufgabe 3 (260909-ipc) auf den in derselben Methode bereits vorhandenen `forTenant()`-Client umgestellt — es entsteht kein zweiter. Damit ist der einzige `ldapConfig`-Zugriff dieser Datei gebunden. |
| apps/api/src/ldap/ldap.service.ts | user | beides | gemischt | Mit Aufgabe 3 (260909-ipc) sind `upsertMappedUser` (Identitaetssuche und Aktualisierung), `searchUsers` (die "bereits importiert"-Markierung), `importUsersByDn` (Dedup und ldapDn-Nachtrag) und die Deaktivierungsschleife in `syncUsersForTenant` auf `forTenant()` umgestellt. `resolveEmailForWrite` bleibt ausdruecklich UNGEBUNDEN (Befund A, T-IPC-04): `email`/`username` sind plattformweit eindeutig, eine mandantengebundene Suche saehe einen fremden Halter nicht mehr und meldete faelschlich "frei" — die geloeste Klasse waere `muss-mandantengebunden` gewesen, bleibt wegen dieser einen bewusst uebergreifenden Abfrage `beides`. Der Loeschzweig um `syncBoundGroupsForTenant` (WINDOWS #20) ist bereits seit Etappe 1 gebunden und war nie Teil dieses Befunds. |
| apps/api/src/module-registry/module-access.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId`. |
| apps/api/src/module-registry/module-access.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId`. Bleibt bewusst ungebunden (260910-exd, Aufgabe 1, Befund E): keine Regel auf der Tabelle, eine Bindung waere heute wirkungslos, nicht katastrophal — katastrophal erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. |
| apps/api/src/module-registry/module-access.service.ts | moduleGrant | muss-mandantengebunden | gebunden | Modulfreigaben je Mandant. Seit 260910-exd (Aufgabe 2) laufen Direktweg und Gruppenweg von `getAccessibleModuleIds` ueber `forTenant()`, EIN Klient je Methode. |
| apps/api/src/module-registry/module-access.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Aktivierung je Mandant, `tenantId`-Spalte vorhanden. Seit 260910-exd (Aufgabe 2) laufen Kurzschlusszweig, Schnittmengenabfrage und der eigene Lesezugriff von `getCatalogFlags` ueber `forTenant()`. |
| apps/api/src/module-registry/module-registry.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit. |
| apps/api/src/module-registry/module-registry.service.ts | tenantModuleActivation | muss-mandantengebunden | ungebunden | Aktivierung je Mandant. |
| apps/api/src/module-registry/module-registry.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit. Bleibt bewusst ungebunden (260910-exd, Aufgabe 1, Befund E): keine Regel auf der Tabelle, eine Bindung waere heute wirkungslos, nicht katastrophal — katastrophal erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. `findBySlug` ist zusaetzlich die Stelle, die `ModuleGuard` bei JEDER Modulanfrage aufruft. |
| apps/api/src/module-registry/module-registry.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Aktivierung je Mandant. Seit 260910-exd (Aufgabe 3) laufen `findActiveForTenant`, `activateForTenant`, beide Zugriffe von `deactivateForTenant` (ueber EINEN Klienten) und `isModuleActive` ueber `forTenant()`, je Methode EIN Klient. |
| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | ungebunden | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. |
| apps/api/src/tenant/tenant.controller.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | `Tenant` ist die Mandantentabelle selbst — hat keine eigene `tenantId`-Spalte, kann sie per Definition nicht haben (Migration 20260909140000, Gruppe b). |
| apps/api/src/tenant/tenant.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Dieselbe Begründung. |