feat(quick-260909-ipc): ldap-config.service.ts an forTenant() binden, Loesch-Fremdzugriff schliessen
Aufgabe 2 der Etappe 2: getConfig/createConfig/updateConfig sowie addFieldMapping/removeFieldMapping laufen jetzt ueber forTenant(), gebunden an den aus der Anfrage bekannten Mandanten. removeFieldMapping nimmt den Mandanten neu als Pflichtparameter entgegen und der Controller holt ihn aus dem Sitzungsnachweis statt nur die URL-Kennung weiterzureichen (T-IPC-01) -- ein Administrator konnte bisher die Feldzuordnung eines fremden Mandanten loeschen, wenn er ihre Kennung kannte. getAllActiveConfigs() und die Start-Nachverschluesselung bleiben bewusst uebergreifend, mit ausgeschriebener Begruendung im Code (Befund B). rls-access-inventory.spec.ts erkennt jetzt neben `this.prisma.<Modell>` auch gebundene `<Name>.<Modell>`-Zugriffe (Befund F/G) und prueft eine neue Stand-Spalte (gebunden/ungebunden/gemischt) im Klassifikationsdokument gegen den Quelltext. Das macht zwei bisher unsichtbare, weil schon laenger gebundene Fundstellen sichtbar (auth.service.ts/passwordResetToken, ldap.service.ts/groupMembership) und deckt auf, dass (ldap-config.service.ts, ldapConfig) tatsaechlich "beides" ist, nicht "muss-mandantengebunden" (Befund B). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -1,6 +1,17 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { LdapConfigService } from './ldap-config.service';
|
||||
|
||||
// forTenant gibt in den Bestandstests denselben Client zurueck (tenant
|
||||
// scoping ist dort nicht unter Test) — ohne diesen Mock bricht die Datei am
|
||||
// blanken Prisma-Ersatz beim ersten `$extends`-Aufruf (Befund F,
|
||||
// 260909-ipc-PLAN.md). Der eigene Bindungs-Testblock unten biegt die
|
||||
// Implementierung auf ein zweites, unterscheidbares Client-Objekt um.
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((p: unknown) => p),
|
||||
}));
|
||||
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
|
||||
/**
|
||||
* Das Bind-Passwort ist das einzige Zugangsdatum, das nicht gehasht werden
|
||||
* kann — Tessera muss sich damit am Domain Controller anmelden, braucht es
|
||||
@@ -176,3 +187,125 @@ describe('LdapConfigService — Bind-Passwort verschluesselt at rest', () => {
|
||||
await expect(service.onApplicationBootstrap()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Aufgabe 2 (260909-ipc, WINDOWS #20 Etappe 2, T-IPC-01/T-IPC-02): belegt,
|
||||
* dass die drei pro-Mandant-Methoden gebunden laufen, die beiden
|
||||
* uebergreifenden Methoden es NICHT tun, und dass das Loeschen einer
|
||||
* Feldzuordnung ohne Mandant nicht mehr moeglich ist.
|
||||
*/
|
||||
describe('LdapConfigService — Bindung an forTenant() (260909-ipc)', () => {
|
||||
let prisma: any;
|
||||
let service: LdapConfigService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
prisma = {
|
||||
ldapConfig: {
|
||||
findUnique: vi.fn().mockResolvedValue(CONFIG_ROW),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn((args: any) => Promise.resolve({ ...CONFIG_ROW, ...args.data })),
|
||||
update: vi.fn((args: any) => Promise.resolve({ ...CONFIG_ROW, ...args.data })),
|
||||
},
|
||||
ldapFieldMapping: {
|
||||
create: vi.fn((args: any) => Promise.resolve({ id: 'map1', isDefault: false, ...args.data })),
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn((args: any) => Promise.resolve({ id: args.where.id })),
|
||||
},
|
||||
};
|
||||
service = new LdapConfigService(prisma, crypto as any);
|
||||
});
|
||||
|
||||
it('getConfig() bindet ueber forTenant() an den uebergebenen Mandanten', async () => {
|
||||
await service.getConfig('t1');
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
|
||||
expect(prisma.ldapConfig.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { tenantId: 't1' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('createConfig() bindet ueber forTenant() an den uebergebenen Mandanten', async () => {
|
||||
await service.createConfig('t1', {
|
||||
serverUrl: 'ldap://example',
|
||||
baseDn: 'dc=example,dc=com',
|
||||
} as any);
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
|
||||
expect(prisma.ldapConfig.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updateConfig() bindet ueber forTenant() an den uebergebenen Mandanten', async () => {
|
||||
await service.updateConfig('t1', { serverUrl: 'ldap://anders' } as any);
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
|
||||
expect(prisma.ldapConfig.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { tenantId: 't1' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('addFieldMapping() nimmt den Mandanten entgegen und schreibt gebunden', async () => {
|
||||
await service.addFieldMapping('t1', 'cfg1', {
|
||||
ldapField: 'department',
|
||||
tesseraField: 'department',
|
||||
} as any);
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
|
||||
expect(prisma.ldapFieldMapping.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ ldapConfigId: 'cfg1' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('removeFieldMapping() nimmt den Mandanten entgegen, liest gebunden und loescht gebunden', async () => {
|
||||
prisma.ldapFieldMapping.findUnique.mockResolvedValue({
|
||||
id: 'map1',
|
||||
ldapConfigId: 'cfg1',
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const result = await service.removeFieldMapping('t1', 'map1');
|
||||
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
|
||||
expect(prisma.ldapFieldMapping.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'map1' },
|
||||
});
|
||||
expect(prisma.ldapFieldMapping.delete).toHaveBeenCalledWith({
|
||||
where: { id: 'map1' },
|
||||
});
|
||||
expect(result).toEqual({ id: 'map1' });
|
||||
});
|
||||
|
||||
it('removeFieldMapping() liefert null, wenn die Zuordnung unter diesem Mandanten nicht sichtbar ist (T-IPC-01)', async () => {
|
||||
// Simuliert die RLS-Wirkung: unter dem Mandantenkontext von t1 ist eine
|
||||
// fremde Feldzuordnung (Mandant t2) unsichtbar — findUnique liefert null,
|
||||
// genau wie es die echte Policy nach dem Scharfschalten taete.
|
||||
prisma.ldapFieldMapping.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await service.removeFieldMapping('t1', 'map-fremd');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(prisma.ldapFieldMapping.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removeFieldMapping() schuetzt Vorgabe-Zuordnungen weiterhin, auch gebunden', async () => {
|
||||
prisma.ldapFieldMapping.findUnique.mockResolvedValue({
|
||||
id: 'map1',
|
||||
ldapConfigId: 'cfg1',
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
await expect(service.removeFieldMapping('t1', 'map1')).rejects.toThrow(
|
||||
'Cannot delete default field mappings',
|
||||
);
|
||||
expect(prisma.ldapFieldMapping.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getAllActiveConfigs() bleibt bewusst uebergreifend — kein Mandantenkontext', async () => {
|
||||
await service.getAllActiveConfigs();
|
||||
expect(forTenant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onApplicationBootstrap() bleibt bewusst uebergreifend — kein Mandantenkontext', async () => {
|
||||
prisma.ldapConfig.findMany.mockResolvedValue([]);
|
||||
await service.onApplicationBootstrap();
|
||||
expect(forTenant).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { CryptoService } from '../crypto/crypto.service';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import {
|
||||
CreateFieldMappingDto,
|
||||
CreateLdapConfigDto,
|
||||
@@ -51,6 +52,14 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
||||
* is logged and swallowed: a tenant whose bind password could not be
|
||||
* re-encrypted still authenticates, because the read path below tolerates a
|
||||
* legacy plaintext value.
|
||||
*
|
||||
* BLEIBT bewusst UNGEBUNDEN (WINDOWS #20 Etappe 2, 260909-ipc, Befund B):
|
||||
* dieser Durchlauf muss ALLE Konfigurationen ALLER Mandanten nachziehen,
|
||||
* bevor je ein einzelner Mandantenkontext feststeht — beim Boot existiert
|
||||
* strukturell noch keiner. Nach dem Scharfschalten (Etappe 4) sieht dieser
|
||||
* Zugriff 0 Zeilen; die Nachverschluesselung wird dann stillschweigend zum
|
||||
* Nichtstun statt zu einem Fehler. Die Loesung gehoert nach Etappe 3
|
||||
* (Systemkontext), diese Umstellung entscheidet sie nicht.
|
||||
*/
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
try {
|
||||
@@ -120,9 +129,15 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
||||
|
||||
/**
|
||||
* Get LDAP config for a tenant, including field mappings.
|
||||
*
|
||||
* Mandantengebunden (WINDOWS #20 Etappe 2, 260909-ipc): der Mandant ist
|
||||
* hier bereits aus der Anfrage bekannt (Parameter), also ueber
|
||||
* `forTenant()` gebunden — anders als `getAllActiveConfigs()` unten, die
|
||||
* bewusst ueber alle Mandanten liest.
|
||||
*/
|
||||
async getConfig(tenantId: string) {
|
||||
const config = await this.prisma.ldapConfig.findUnique({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const config = await tenantPrisma.ldapConfig.findUnique({
|
||||
where: { tenantId },
|
||||
include: { fieldMappings: true },
|
||||
});
|
||||
@@ -132,9 +147,17 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
||||
/**
|
||||
* Create LDAP config for a tenant with default field mappings (D-16).
|
||||
* Defaults: displayName -> displayName, mail -> email, sAMAccountName -> username
|
||||
*
|
||||
* Mandantengebunden (WINDOWS #20 Etappe 2, 260909-ipc). Das verschachtelte
|
||||
* Anlegen der drei Vorgabe-Zuordnungen bleibt eine einzige Prisma-Operation
|
||||
* und laeuft damit in derselben `forTenant()`-Transaktion wie das Setzen
|
||||
* des Kontexts — dass diese Schreibweise unter der Policy traegt, ist in
|
||||
* Aufgabe 1 (260909-ipc-PLAN.md) gegen die echte, ausgelieferte Policy
|
||||
* gemessen.
|
||||
*/
|
||||
async createConfig(tenantId: string, dto: CreateLdapConfigDto) {
|
||||
const created = await this.prisma.ldapConfig.create({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const created = await tenantPrisma.ldapConfig.create({
|
||||
data: {
|
||||
tenantId,
|
||||
serverUrl: dto.serverUrl,
|
||||
@@ -172,9 +195,12 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
||||
|
||||
/**
|
||||
* Update LDAP config for a tenant.
|
||||
*
|
||||
* Mandantengebunden (WINDOWS #20 Etappe 2, 260909-ipc).
|
||||
*/
|
||||
async updateConfig(tenantId: string, dto: UpdateLdapConfigDto) {
|
||||
const updated = await this.prisma.ldapConfig.update({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const updated = await tenantPrisma.ldapConfig.update({
|
||||
where: { tenantId },
|
||||
data: {
|
||||
...(dto.serverUrl !== undefined && { serverUrl: dto.serverUrl }),
|
||||
@@ -210,9 +236,19 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
||||
|
||||
/**
|
||||
* Add a custom field mapping to an LDAP config (D-17).
|
||||
*
|
||||
* Mandantengebunden (WINDOWS #20 Etappe 2, 260909-ipc): `LdapFieldMapping`
|
||||
* hat keine eigene `tenantId`-Spalte, ihre RLS-Sichtbarkeit kommt ueber
|
||||
* den Join auf `LdapConfig`. Der Mandant ist typseitig Pflicht (erster
|
||||
* Parameter) — ein Aufruf ohne Mandant ist damit nicht mehr moeglich.
|
||||
*/
|
||||
async addFieldMapping(configId: string, dto: CreateFieldMappingDto) {
|
||||
return this.prisma.ldapFieldMapping.create({
|
||||
async addFieldMapping(
|
||||
tenantId: string,
|
||||
configId: string,
|
||||
dto: CreateFieldMappingDto,
|
||||
) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
return tenantPrisma.ldapFieldMapping.create({
|
||||
data: {
|
||||
ldapConfigId: configId,
|
||||
ldapField: dto.ldapField,
|
||||
@@ -225,9 +261,20 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
||||
/**
|
||||
* Remove a field mapping. Only non-default mappings can be deleted.
|
||||
* System-provided defaults (isDefault=true) are protected.
|
||||
*
|
||||
* Mandantengebunden (WINDOWS #20 Etappe 2, 260909-ipc, T-IPC-01): schliesst
|
||||
* die bisherige Fremdzugriffsluecke — `DELETE /ldap/config/mappings/:id`
|
||||
* nahm bislang ausschliesslich die Kennung entgegen, ein Administrator des
|
||||
* Mandanten A konnte damit die Feldzuordnung des Mandanten B loeschen,
|
||||
* wenn er deren Kennung kannte. Sowohl das Lesen als auch das Loeschen
|
||||
* laufen jetzt ueber `forTenant()`; eine Zuordnung, die unter diesem
|
||||
* Mandanten nicht sichtbar ist (RLS-Join auf `LdapConfig`), liefert
|
||||
* `findUnique` null zurueck — die Steuerung macht daraus 404 statt einer
|
||||
* Loeschung.
|
||||
*/
|
||||
async removeFieldMapping(mappingId: string) {
|
||||
const mapping = await this.prisma.ldapFieldMapping.findUnique({
|
||||
async removeFieldMapping(tenantId: string, mappingId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const mapping = await tenantPrisma.ldapFieldMapping.findUnique({
|
||||
where: { id: mappingId },
|
||||
});
|
||||
|
||||
@@ -239,7 +286,7 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
||||
throw new Error('Cannot delete default field mappings');
|
||||
}
|
||||
|
||||
return this.prisma.ldapFieldMapping.delete({
|
||||
return tenantPrisma.ldapFieldMapping.delete({
|
||||
where: { id: mappingId },
|
||||
});
|
||||
}
|
||||
@@ -247,6 +294,16 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
||||
/**
|
||||
* Get all active LDAP configs. Used by the scheduler to determine which
|
||||
* tenants need auto-sync.
|
||||
*
|
||||
* BLEIBT bewusst UNGEBUNDEN (WINDOWS #20 Etappe 2, 260909-ipc, Befund B):
|
||||
* der Planer braucht die Liste ALLER aktiven Konfigurationen ALLER
|
||||
* Mandanten, um daraus je Mandant einen Sync-Lauf anzustossen — das ist
|
||||
* die Aufgabe dieser Methode, nicht ein vergessener `forTenant()`-Aufruf.
|
||||
* Nach dem Scharfschalten (Etappe 4) sieht dieser Zugriff 0 Zeilen: der
|
||||
* LDAP-Abgleich stellt dann fuer JEDEN Mandanten ohne Fehlermeldung, ohne
|
||||
* Protokolleintrag und ohne sichtbare Aenderung die Arbeit ein (Befund E,
|
||||
* docs/mandantentrennung-etappe2-fehlerrichtung.md). Die Loesung
|
||||
* (Systemkontext) gehoert nach Etappe 3.
|
||||
*/
|
||||
async getAllActiveConfigs() {
|
||||
const configs = await this.prisma.ldapConfig.findMany({
|
||||
|
||||
@@ -337,17 +337,31 @@ export class LdapController {
|
||||
throw new NotFoundException('No LDAP config found for this tenant');
|
||||
}
|
||||
|
||||
return this.ldapConfigService.addFieldMapping(config.id, dto);
|
||||
return this.ldapConfigService.addFieldMapping(tenantId, config.id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /ldap/config/mappings/:id - Remove non-default field mapping.
|
||||
*
|
||||
* Der Mandant kommt aus dem Sitzungsnachweis, NICHT aus der URL (T-IPC-01,
|
||||
* WINDOWS #20 Etappe 2, 260909-ipc): vorher nahm diese Route
|
||||
* ausschliesslich die Kennung entgegen und reichte sie ungebunden an den
|
||||
* Dienst weiter — ein Administrator des Mandanten A konnte damit die
|
||||
* Feldzuordnung des Mandanten B loeschen, wenn er deren Kennung kannte.
|
||||
*/
|
||||
@Delete('config/mappings/:id')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async removeFieldMapping(@Param('id') id: string) {
|
||||
async removeFieldMapping(@Req() req: any, @Param('id') id: string) {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new BadRequestException('No tenant context');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.ldapConfigService.removeFieldMapping(id);
|
||||
const result = await this.ldapConfigService.removeFieldMapping(
|
||||
tenantId,
|
||||
id,
|
||||
);
|
||||
if (!result) {
|
||||
throw new NotFoundException('Field mapping not found');
|
||||
}
|
||||
|
||||
@@ -9,15 +9,45 @@ import { describe, expect, it } from 'vitest';
|
||||
* ein Eintrag ohne Fundstelle. Vergleichsschluessel sind Datei UND
|
||||
* Modellname — eine Zeilennummer traegt nicht, das ueberlebt das
|
||||
* Verschieben einer Zeile.
|
||||
*
|
||||
* Erweitert in Aufgabe 2 (260909-ipc, Befund G): eine Umstellung auf
|
||||
* `forTenant()` laesst `this.prisma.<Modell>` aus dem Quelltext
|
||||
* verschwinden. Ohne eine zweite Erkennung fuer gebundene Zugriffe wuerde
|
||||
* diese Pruefung eine Umstellung als "Fundstelle verschwunden" werten und
|
||||
* zwingen, den Nachweis aus dem Dokument zu LOESCHEN statt ihn
|
||||
* fortzuschreiben. Die zweite Erkennung sammelt je Datei die Zuweisungen
|
||||
* der Form `const <Name> = forTenant(` und sucht danach `<Name>.<Modell>`.
|
||||
* Aus beiden Mengen ergibt sich je Paar (Datei, Modell) ein Stand:
|
||||
* `gebunden`, `ungebunden` oder `gemischt`.
|
||||
*/
|
||||
|
||||
const API_SRC_DIR = join(__dirname, '..');
|
||||
const REPO_ROOT = join(__dirname, '../../../..');
|
||||
const DOC_PATH = join(REPO_ROOT, 'docs/mandantentrennung-zugriffsklassifikation.md');
|
||||
|
||||
interface AccessSite {
|
||||
/**
|
||||
* Dateien, in denen ein `forTenant(`-Aufruf bewusst NICHT der erkannten
|
||||
* `const <Name> = forTenant(`-Zuweisungsform folgt. Beide veroeffentlichen
|
||||
* den gebundenen Client auf dem Anfrageobjekt (`req.tenantPrisma = ...`)
|
||||
* statt ihn einer lokalen Konstante zuzuweisen — genau dieser Weg ist die
|
||||
* offene Architekturfrage aus docs/mandantentrennung-zugriffsklassifikation.md
|
||||
* ("Was diese Etappe NICHT entscheidet"), hier bewusst offen gehalten statt
|
||||
* stillschweigend als Erkennungsluecke durchzurutschen.
|
||||
*/
|
||||
const FORTENANT_ASSIGNMENT_EXCEPTIONS = new Set([
|
||||
'apps/api/src/tenant/tenant.middleware.ts',
|
||||
'apps/api/src/tenant/tenant.guard.ts',
|
||||
]);
|
||||
|
||||
const STAND_TOKENS = ['gebunden', 'ungebunden', 'gemischt'] as const;
|
||||
type Stand = (typeof STAND_TOKENS)[number];
|
||||
|
||||
interface FileAnalysis {
|
||||
file: string;
|
||||
model: string;
|
||||
unboundModels: Set<string>;
|
||||
boundModels: Set<string>;
|
||||
totalForTenantCalls: number;
|
||||
assignmentFormCalls: number;
|
||||
}
|
||||
|
||||
function listTsFiles(dir: string): string[] {
|
||||
@@ -35,8 +65,8 @@ function listTsFiles(dir: string): string[] {
|
||||
|
||||
/**
|
||||
* Filtert Kommentarzeilen (Zeilenkommentare und Blockkommentare) heraus,
|
||||
* bevor nach `this.prisma.<model>` gesucht wird — sonst zaehlt eine
|
||||
* erklaerende Kopfzeile als Fundstelle mit.
|
||||
* bevor nach `this.prisma.<model>` oder `forTenant(` gesucht wird — sonst
|
||||
* zaehlt eine erklaerende Kopfzeile als Fundstelle mit.
|
||||
*/
|
||||
function stripComments(source: string): string {
|
||||
return source
|
||||
@@ -46,26 +76,79 @@ function stripComments(source: string): string {
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function findAccessSites(): AccessSite[] {
|
||||
const files = listTsFiles(API_SRC_DIR);
|
||||
const sites: AccessSite[] = [];
|
||||
function analyzeFile(absPath: string, relPath: string): FileAnalysis {
|
||||
const source = stripComments(readFileSync(absPath, 'utf-8'));
|
||||
|
||||
for (const absPath of files) {
|
||||
const relPath = relative(REPO_ROOT, absPath).split('\\').join('/');
|
||||
const source = stripComments(readFileSync(absPath, 'utf-8'));
|
||||
const matches = source.matchAll(/this\.prisma\.([a-zA-Z]+)/g);
|
||||
const models = new Set<string>();
|
||||
for (const m of matches) {
|
||||
if (m[1]) models.add(m[1]);
|
||||
}
|
||||
for (const model of models) {
|
||||
sites.push({ file: relPath, model });
|
||||
const unboundModels = new Set<string>();
|
||||
for (const m of source.matchAll(/this\.prisma\.([a-zA-Z]+)/g)) {
|
||||
if (m[1]) unboundModels.add(m[1]);
|
||||
}
|
||||
|
||||
const assignmentMatches = [...source.matchAll(/const\s+(\w+)\s*=\s*forTenant\(/g)];
|
||||
const boundNames = new Set(assignmentMatches.map((m) => m[1]).filter(Boolean) as string[]);
|
||||
|
||||
const boundModels = new Set<string>();
|
||||
for (const name of boundNames) {
|
||||
const re = new RegExp(`\\b${name}\\.([a-zA-Z]+)`, 'g');
|
||||
for (const m of source.matchAll(re)) {
|
||||
if (m[1]) boundModels.add(m[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Zaehlt Aufrufstellen von `forTenant(`, aber nicht die Funktionsdefinition
|
||||
// selbst (`export function forTenant(...)` in prisma-tenant.extension.ts) —
|
||||
// die Definition ist kein Aufruf und braucht keine Zuweisungsform.
|
||||
const totalForTenantCalls = [...source.matchAll(/(?<!function )forTenant\(/g)].length;
|
||||
|
||||
return {
|
||||
file: relPath,
|
||||
unboundModels,
|
||||
boundModels,
|
||||
totalForTenantCalls,
|
||||
assignmentFormCalls: assignmentMatches.length,
|
||||
};
|
||||
}
|
||||
|
||||
function analyzeAllFiles(): FileAnalysis[] {
|
||||
const files = listTsFiles(API_SRC_DIR);
|
||||
return files
|
||||
.map((absPath) => {
|
||||
const relPath = relative(REPO_ROOT, absPath).split('\\').join('/');
|
||||
return analyzeFile(absPath, relPath);
|
||||
})
|
||||
.sort((a, b) => a.file.localeCompare(b.file));
|
||||
}
|
||||
|
||||
interface AccessSite {
|
||||
file: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
function findAccessSites(analyses: FileAnalysis[]): AccessSite[] {
|
||||
const sites: AccessSite[] = [];
|
||||
for (const a of analyses) {
|
||||
const allModels = new Set([...a.unboundModels, ...a.boundModels]);
|
||||
for (const model of allModels) {
|
||||
sites.push({ file: a.file, model });
|
||||
}
|
||||
}
|
||||
return sites.sort((a, b) => (a.file + a.model).localeCompare(b.file + b.model));
|
||||
}
|
||||
|
||||
function computeStandByKey(analyses: FileAnalysis[]): Map<string, Stand> {
|
||||
const standByKey = new Map<string, Stand>();
|
||||
for (const a of analyses) {
|
||||
const allModels = new Set([...a.unboundModels, ...a.boundModels]);
|
||||
for (const model of allModels) {
|
||||
const isBound = a.boundModels.has(model);
|
||||
const isUnbound = a.unboundModels.has(model);
|
||||
const stand: Stand = isBound && isUnbound ? 'gemischt' : isBound ? 'gebunden' : 'ungebunden';
|
||||
standByKey.set(`${a.file}::${model}`, stand);
|
||||
}
|
||||
}
|
||||
return standByKey;
|
||||
}
|
||||
|
||||
const CLASS_TOKENS = [
|
||||
'muss-mandantengebunden',
|
||||
'bewusst-uebergreifend',
|
||||
@@ -73,16 +156,25 @@ const CLASS_TOKENS = [
|
||||
'beides',
|
||||
];
|
||||
|
||||
function parseDocEntries(): { file: string; model: string; klasse: string }[] {
|
||||
const raw = readFileSync(DOC_PATH, 'utf-8');
|
||||
const entries: { file: string; model: string; klasse: string }[] = [];
|
||||
interface DocEntry {
|
||||
file: string;
|
||||
model: string;
|
||||
klasse: string;
|
||||
stand: string;
|
||||
}
|
||||
|
||||
// Nur Zeilen aus der Bestandsaufnahme-Tabelle: `| Datei | Modell | Klasse | Begruendung |`
|
||||
const rowPattern = /^\|\s*(apps\/api\/src\/[^\s|]+\.ts)\s*\|\s*([a-zA-Z]+)\s*\|\s*([a-z-]+)\s*\|/gm;
|
||||
function parseDocEntries(): DocEntry[] {
|
||||
const raw = readFileSync(DOC_PATH, 'utf-8');
|
||||
const entries: DocEntry[] = [];
|
||||
|
||||
// Nur Zeilen aus der Bestandsaufnahme-Tabelle:
|
||||
// `| Datei | Modell | Klasse | Stand | Begruendung |`
|
||||
const rowPattern =
|
||||
/^\|\s*(apps\/api\/src\/[^\s|]+\.ts)\s*\|\s*([a-zA-Z]+)\s*\|\s*([a-z-]+)\s*\|\s*([a-z-]+)\s*\|/gm;
|
||||
|
||||
for (const match of raw.matchAll(rowPattern)) {
|
||||
const [, file, model, klasse] = match;
|
||||
entries.push({ file, model, klasse });
|
||||
const [, file, model, klasse, stand] = match;
|
||||
entries.push({ file, model, klasse, stand });
|
||||
}
|
||||
|
||||
return entries;
|
||||
@@ -93,7 +185,9 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst
|
||||
expect(() => statSync(DOC_PATH)).not.toThrow();
|
||||
});
|
||||
|
||||
const sourceSites = findAccessSites();
|
||||
const analyses = analyzeAllFiles();
|
||||
const sourceSites = findAccessSites(analyses);
|
||||
const standByKey = computeStandByKey(analyses);
|
||||
const docEntries = parseDocEntries();
|
||||
const docKeys = new Set(docEntries.map((e) => `${e.file}::${e.model}`));
|
||||
const sourceKeys = new Set(sourceSites.map((s) => `${s.file}::${s.model}`));
|
||||
@@ -109,7 +203,7 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst
|
||||
expect(missing, `Fehlende Eintraege im Dokument:\n${missing.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('jeder im Dokument gefuehrte Eintrag hat eine tatsaechliche Fundstelle im Quelltext', () => {
|
||||
it('jeder im Dokument gefuehrte Eintrag hat eine tatsaechliche Fundstelle im Quelltext (gebunden oder ungebunden)', () => {
|
||||
const stale = [...docKeys].filter((key) => !sourceKeys.has(key));
|
||||
expect(stale, `Eintraege im Dokument ohne Fundstelle im Quelltext:\n${stale.join('\n')}`).toEqual([]);
|
||||
});
|
||||
@@ -119,6 +213,26 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst
|
||||
expect(invalid, JSON.stringify(invalid)).toEqual([]);
|
||||
});
|
||||
|
||||
it('jeder Eintrag traegt einen der drei gueltigen Stand-Werte', () => {
|
||||
const invalid = docEntries.filter((e) => !STAND_TOKENS.includes(e.stand as Stand));
|
||||
expect(invalid, JSON.stringify(invalid)).toEqual([]);
|
||||
});
|
||||
|
||||
it('der eingetragene Stand stimmt mit dem im Quelltext gemessenen ueberein', () => {
|
||||
const mismatches: string[] = [];
|
||||
for (const e of docEntries) {
|
||||
const measured = standByKey.get(`${e.file}::${e.model}`);
|
||||
if (measured && measured !== e.stand) {
|
||||
mismatches.push(
|
||||
`${e.file}::${e.model} — dokumentiert=${e.stand}, gemessen=${measured}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(mismatches, `Abweichender Stand (Dokument vs. Quelltext):\n${mismatches.join('\n')}`).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('keine doppelten (Datei, Modell)-Eintraege in der Tabelle', () => {
|
||||
const seen = new Set<string>();
|
||||
const duplicates: string[] = [];
|
||||
@@ -129,4 +243,17 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst
|
||||
}
|
||||
expect(duplicates).toEqual([]);
|
||||
});
|
||||
|
||||
it('jedes forTenant(-Vorkommen entspricht der erkannten Zuweisungsform `const X = forTenant(` oder steht in der begruendeten Ausnahmeliste', () => {
|
||||
const violations: string[] = [];
|
||||
for (const a of analyses) {
|
||||
const unmatched = a.totalForTenantCalls - a.assignmentFormCalls;
|
||||
if (unmatched > 0 && !FORTENANT_ASSIGNMENT_EXCEPTIONS.has(a.file)) {
|
||||
violations.push(
|
||||
`${a.file}: ${unmatched} forTenant(-Aufruf(e) ausserhalb der erkannten Zuweisungsform`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(violations, violations.join('\n')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,15 +84,24 @@ am 2026-09-09, **nach** den Änderungen aus Aufgabe 1/2 dieses Plans:
|
||||
| settings | 4 | unverändert |
|
||||
| **Summe** | **227** | war 232 in measured_baseline, Delta = die 5 in Aufgabe 2 verschwundenen `auth`-Treffer minus ein bereits vorher fehlerhaft mitgezähltes Kommentarvorkommen in der neuen Kopfzeile von `validateUser()`, das bewusst umformuliert wurde, um einen Eigentreffer der Bestandsaufnahme-Prüfung zu vermeiden |
|
||||
|
||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 59 Paare)
|
||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 61 Paare)
|
||||
|
||||
Stand 260909-ipc (Aufgabe 2): 59 Paare aus der urspruenglichen Zaehlung plus
|
||||
zwei bisher unentdeckte, weil bereits gebundene Fundstellen
|
||||
(`auth.service.ts`/`passwordResetToken`, `ldap.service.ts`/`groupMembership`),
|
||||
die erst die um gebundene Zugriffe erweiterte Erkennung (Befund G) sichtbar
|
||||
macht — sie waren nie Teil der 227 `this.prisma.*`-Rohtrefferzahl, weil sie
|
||||
schon vor diesem Plan über `forTenant()` liefen. Dazu die Korrektur von
|
||||
(`ldap-config.service.ts`, `ldapConfig`) von `muss-mandantengebunden` auf
|
||||
`beides` (Befund B).
|
||||
|
||||
| Klasse | Anzahl Paare |
|
||||
|---|---|
|
||||
| muss-mandantengebunden | 31 |
|
||||
| muss-mandantengebunden | 32 |
|
||||
| keine-mandantengebundene-tabelle | 16 |
|
||||
| beides | 9 |
|
||||
| beides | 10 |
|
||||
| bewusst-uebergreifend | 3 |
|
||||
| **Summe** | **59** |
|
||||
| **Summe** | **61** |
|
||||
|
||||
## Der Hintergrunddienst als Falle — drei `beides`-Fälle
|
||||
|
||||
@@ -124,69 +133,73 @@ betroffen:
|
||||
|
||||
Maschinell ermittelt, `rls-access-inventory.spec.ts` hält Vollständigkeit
|
||||
nach. Spalten: Datei, Modell (Prisma-Modellname wie in `this.prisma.<Modell>`
|
||||
verwendet), Klasse, Begründung.
|
||||
oder — seit 260909-ipc, Befund G — in `<gebundener Client>.<Modell>`
|
||||
verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
||||
260909-ipc maschinell gegen den Quelltext geprüft), Begründung.
|
||||
|
||||
| Datei | Modell | Klasse | Begründung |
|
||||
|---|---|---|---|
|
||||
| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | `getMe`, `changePassword`, `adminResetPassword` suchen über die Benutzerkennung aus dem Sitzungsnachweis — der Mandant ist dort bereits bekannt (Aufgabe 2 fasst sie bewusst nicht an, siehe SUMMARY). |
|
||||
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | Kalenderquellen eines Nutzers, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | `tenantId` nullbar (WINDOWS #19) — heutige, tatsächlich gespeicherte Zeilen sind nutzerangelegt und tragen einen Mandanten; Vorgabe-Anbieter kommen laut 05-02 aus Konstanten, nicht aus der DB. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. |
|
||||
| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. |
|
||||
| apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/groups/groups.service.ts | group | muss-mandantengebunden | Gruppen sind je Mandant, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/groups/groups.service.ts | groupMembership | muss-mandantengebunden | Kein eigenes `tenantId`, RLS über Join auf `Group` (Migration 20260618112133-Nachfolger) — braucht trotzdem `forTenant()`, damit der Join-Kontext gesetzt ist. |
|
||||
| apps/api/src/groups/groups.service.ts | moduleGrant | muss-mandantengebunden | Modulfreigaben je Mandant, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/groups/groups.service.ts | user | muss-mandantengebunden | Nutzerverwaltung innerhalb eines Mandanten. |
|
||||
| apps/api/src/groups/module-grants.service.ts | group | muss-mandantengebunden | Wie groups.service.ts. |
|
||||
| apps/api/src/groups/module-grants.service.ts | groupMembership | muss-mandantengebunden | Kein eigenes `tenantId`, RLS über Join auf `Group`. |
|
||||
| apps/api/src/groups/module-grants.service.ts | moduleGrant | muss-mandantengebunden | Modulfreigaben je Mandant. |
|
||||
| apps/api/src/groups/module-grants.service.ts | tenantModuleActivation | muss-mandantengebunden | Welche Module ein Mandant aktiviert hat, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/groups/module-grants.service.ts | user | muss-mandantengebunden | Zielbenutzer eines Grants innerhalb des Mandanten. |
|
||||
| apps/api/src/ldap/ldap-config.service.ts | ldapConfig | muss-mandantengebunden | LDAP-Konfiguration je Mandant, `tenantId`-Spalte (unique) vorhanden. |
|
||||
| apps/api/src/ldap/ldap-config.service.ts | ldapFieldMapping | muss-mandantengebunden | Kein eigenes `tenantId`, RLS über Join auf `LdapConfig`. |
|
||||
| apps/api/src/ldap/ldap.service.ts | group | beides | AD-Abgleich: 4 echte `forTenant()`-Aufrufstellen decken einen Teil ab, weitere `this.prisma.group`-Zugriffe innerhalb der Sync-Methoden bleiben ungebunden, obwohl der Mandant zu diesem Zeitpunkt bekannt ist (siehe Abschnitt "Der Hintergrunddienst als Falle"). |
|
||||
| apps/api/src/ldap/ldap.service.ts | ldapConfig | beides | Dieselbe Begründung wie `group` — Konfigurationszugriffe innerhalb der Sync-Methoden. |
|
||||
| apps/api/src/ldap/ldap.service.ts | user | beides | Dieselbe Begründung — der Löschzweig um Zeile 1559 (WINDOWS #20) ist der konkrete Risikofall. |
|
||||
| apps/api/src/module-registry/module-access.service.ts | module | keine-mandantengebundene-tabelle | Modulkatalog ist plattformweit, kein `tenantId`. |
|
||||
| apps/api/src/module-registry/module-access.service.ts | moduleGrant | muss-mandantengebunden | Modulfreigaben je Mandant. |
|
||||
| apps/api/src/module-registry/module-access.service.ts | tenantModuleActivation | muss-mandantengebunden | Aktivierung je Mandant, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/module-registry/module-registry.service.ts | module | keine-mandantengebundene-tabelle | Modulkatalog ist plattformweit. |
|
||||
| apps/api/src/module-registry/module-registry.service.ts | tenantModuleActivation | muss-mandantengebunden | Aktivierung je Mandant. |
|
||||
| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/tenant/tenant.controller.ts | tenant | keine-mandantengebundene-tabelle | `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 | Dieselbe Begründung. |
|
||||
| apps/api/src/tenders/adapters/email-alert.adapter.ts | tenderEmailConfig | bewusst-uebergreifend | `fetchTenders()` liest bewusst jede aktive `TenderEmailConfig`-Zeile über ALLE Mandanten in einer Abfrage (Plattform-Scheduler, ein Tick pro Postfach, D-13/D-01) — ausführlich im Dateikopf begründet, darf laut Kommentar niemals in `forTenant()` verpackt werden. |
|
||||
| apps/api/src/tenders/adapters/rss.adapter.ts | tenderRssFeedSource | bewusst-uebergreifend | Fan-out über jeden aktiven Feed, plattformweit UND persönlich, in einer Abfrage (Zeilen 55–83 im Dateikopf begründet) — dieselbe Scheduler-Ebene wie beim E-Mail-Adapter. |
|
||||
| apps/api/src/tenders/tender-dedup.service.ts | tender | keine-mandantengebundene-tabelle | Explizit im Dateikopf: "platform-global, RLS-exempt tables. Never wrap these queries in forTenant()." (D-03) |
|
||||
| apps/api/src/tenders/tender-dedup.service.ts | tenderSource | keine-mandantengebundene-tabelle | Dieselbe Begründung. |
|
||||
| apps/api/src/tenders/tender-digest.scheduler.ts | tenderMatch | beides | Ein einziger globaler Cron-Job liest über ALLE Mandanten (bewusst übergreifend, Pitfall-1-Kommentar im Dateikopf), der Versand je Treffer ist an dessen Mandanten gebunden. |
|
||||
| apps/api/src/tenders/tender-digest.scheduler.ts | tenderNotificationPref | beides | Dieselbe Begründung — Präferenzen werden über alle Mandanten gelesen, aber je Zeile mandantenbezogen ausgewertet. |
|
||||
| apps/api/src/tenders/tender-digest.scheduler.ts | user | beides | E-Mail-Adressen für den Versand werden über alle Mandanten gelesen, der eigentliche Versand ist je Treffer mandantengebunden. |
|
||||
| apps/api/src/tenders/tender-email-config.service.ts | tenderEmailConfig | muss-mandantengebunden | Nutzer-CRUD für die eigene Postfachanbindung (Phase 17, D-01) — anders als der Fan-out-Adapter oben, hier ist der Mandant aus der Anfrage bekannt. |
|
||||
| apps/api/src/tenders/tender-fingerprint-backfill.service.ts | tender | keine-mandantengebundene-tabelle | Einmaliges Backfill-Skript über den plattformweiten `Tender`-Katalog (D-03). |
|
||||
| apps/api/src/tenders/tender-ingestion.service.ts | tender | keine-mandantengebundene-tabelle | Explizit im Dateikopf: "Multi-tenant safety (D-03, T-10-09): uses the plain, non-tenant-scoped ... queries ... these are platform-global". |
|
||||
| apps/api/src/tenders/tender-ingestion.service.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | Plattformweiter Poll-Status, kein `tenantId` (Migration 20260909140000, Gruppe b). |
|
||||
| apps/api/src/tenders/tender-matching.service.ts | tender | keine-mandantengebundene-tabelle | Liest den plattformweiten Katalog (D-03), um Treffer zu berechnen — kein `tenantId`. |
|
||||
| apps/api/src/tenders/tender-matching.service.ts | tenderMatch | beides | Sofortmeldung: Treffer über alle betroffenen Mandanten gelesen, Versand je Treffer mandantengebunden — siehe Abschnitt "Der Hintergrunddienst als Falle". |
|
||||
| apps/api/src/tenders/tender-matching.service.ts | tenderSavedSearch | beides | Dieselbe Begründung — gespeicherte Suchprofile aller Mandanten werden gegen neue Treffer geprüft, der Versand ist je Profil mandantengebunden. |
|
||||
| apps/api/src/tenders/tender-matching.service.ts | user | beides | Dieselbe Begründung — E-Mail-Adressen für die Sofortmeldung. |
|
||||
| apps/api/src/tenders/tender-notification-pref.service.ts | tenderNotificationPref | muss-mandantengebunden | Nutzer-CRUD für die eigenen Benachrichtigungseinstellungen — Mandant aus der Anfrage bekannt. |
|
||||
| apps/api/src/tenders/tender-rss-feed.service.ts | tenderRssFeedSource | muss-mandantengebunden | Nutzer-CRUD für die eigenen RSS-Quellen (anders als der Fan-out in `adapters/rss.adapter.ts`) — Mandant aus der Anfrage bekannt. WINDOWS #19 betrifft die nullbaren plattformweiten Zeilen, nicht diesen CRUD-Pfad. |
|
||||
| apps/api/src/tenders/tender-saved-search.service.ts | tenderSavedSearch | muss-mandantengebunden | Nutzer-CRUD für die eigenen gespeicherten Suchprofile — Mandant aus der Anfrage bekannt. |
|
||||
| apps/api/src/tenders/tender-scheduler.service.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | Plattformweiter DÖE-Poll-Status, kein `tenantId` — im Dateikopf explizit als "genuine platform-wide singleton" begründet. |
|
||||
| apps/api/src/tenders/tenders.controller.ts | tender | keine-mandantengebundene-tabelle | Lesezugriff auf den plattformweiten Katalog (D-03). |
|
||||
| apps/api/src/tenders/tenders.controller.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | Plattformweiter Poll-Status, admin-verwaltet, kein `tenantId`. |
|
||||
| apps/api/src/tenders/tenders.module.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | Singleton-Bestückung beim Boot — im Dateikopf explizit als "global, RLS-exempt (D-03)" begründet. |
|
||||
| apps/api/src/tenders/tender-triage.service.ts | tenderTriage | muss-mandantengebunden | Favorisierungs-/Ablehnungsstatus eines Nutzers, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/user/admin-seed.service.ts | tenant | keine-mandantengebundene-tabelle | Legt beim ersten Start den Standard-Mandanten selbst an — `Tenant` hat keine `tenantId`-Spalte. |
|
||||
| apps/api/src/user/admin-seed.service.ts | user | bewusst-uebergreifend | Erstanlage des Administrators beim ersten Start: läuft einmalig beim Boot, BEVOR irgendein Mandantenkontext existiert, um den allerersten Mandanten samt Admin-Nutzer anzulegen — es gibt zu diesem Zeitpunkt strukturell keinen Mandanten, an den gebunden werden könnte. |
|
||||
| apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins. |
|
||||
| apps/api/src/user/user.service.ts | user | muss-mandantengebunden | Dieselbe Begründung. |
|
||||
| Datei | Modell | Klasse | Stand | Begründung |
|
||||
|---|---|---|---|---|
|
||||
| apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — 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/auth/auth.service.ts | user | muss-mandantengebunden | gemischt | `getMe`, `changePassword`, `adminResetPassword` suchen über die Benutzerkennung aus dem Sitzungsnachweis — der Mandant ist dort bereits bekannt (Aufgabe 2 fasst sie bewusst nicht an, siehe SUMMARY). |
|
||||
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | ungebunden | Kalenderquellen eines Nutzers, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | ungebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | ungebunden | `tenantId` nullbar (WINDOWS #19) — heutige, tatsächlich gespeicherte Zeilen sind nutzerangelegt und tragen einen Mandanten; Vorgabe-Anbieter kommen laut 05-02 aus Konstanten, nicht aus der DB. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | ungebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | ungebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | ungebunden | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. |
|
||||
| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | ungebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. |
|
||||
| apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | ungebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/groups/groups.service.ts | group | muss-mandantengebunden | ungebunden | Gruppen sind je Mandant, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/groups/groups.service.ts | groupMembership | muss-mandantengebunden | ungebunden | Kein eigenes `tenantId`, RLS über Join auf `Group` (Migration 20260618112133-Nachfolger) — braucht trotzdem `forTenant()`, damit der Join-Kontext gesetzt ist. |
|
||||
| apps/api/src/groups/groups.service.ts | moduleGrant | muss-mandantengebunden | ungebunden | Modulfreigaben je Mandant, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/groups/groups.service.ts | user | muss-mandantengebunden | ungebunden | Nutzerverwaltung innerhalb eines Mandanten. |
|
||||
| apps/api/src/groups/module-grants.service.ts | group | muss-mandantengebunden | ungebunden | Wie groups.service.ts. |
|
||||
| apps/api/src/groups/module-grants.service.ts | groupMembership | muss-mandantengebunden | ungebunden | Kein eigenes `tenantId`, RLS über Join auf `Group`. |
|
||||
| apps/api/src/groups/module-grants.service.ts | moduleGrant | muss-mandantengebunden | ungebunden | Modulfreigaben je Mandant. |
|
||||
| apps/api/src/groups/module-grants.service.ts | tenantModuleActivation | muss-mandantengebunden | ungebunden | Welche Module ein Mandant aktiviert hat, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/groups/module-grants.service.ts | user | muss-mandantengebunden | ungebunden | Zielbenutzer eines Grants innerhalb des Mandanten. |
|
||||
| apps/api/src/ldap/ldap-config.service.ts | ldapConfig | beides | gemischt | `getConfig`, `createConfig` und `updateConfig` laufen ueber `forTenant()`, gebunden an den aus der Anfrage bereits bekannten Mandanten. `getAllActiveConfigs()` (Planer-Lesezugriff ueber ALLE Mandanten) und die Start-Nachverschluesselung in `onApplicationBootstrap` bleiben bewusst uebergreifend: beide laufen, bevor bzw. unabhaengig davon, ob ein einzelner Mandantenkontext feststeht (Befund B, 260909-ipc-PLAN.md). Korrektur der Klasse von `muss-mandantengebunden`: das Paar ist tatsaechlich `beides`, keine Verhaltensaenderung. |
|
||||
| apps/api/src/ldap/ldap-config.service.ts | ldapFieldMapping | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `LdapConfig`. `addFieldMapping` und `removeFieldMapping` nehmen den Mandanten jetzt als Parameter entgegen und laufen vollstaendig ueber `forTenant()` (T-IPC-01, 260909-ipc-PLAN.md) — schliesst zugleich die Fremdzugriffsluecke beim Loeschen einer Feldzuordnung ueber ihre Kennung. |
|
||||
| apps/api/src/ldap/ldap.service.ts | group | beides | gemischt | AD-Abgleich: 4 echte `forTenant()`-Aufrufstellen decken einen Teil ab, weitere `this.prisma.group`-Zugriffe innerhalb der Sync-Methoden bleiben ungebunden, obwohl der Mandant zu diesem Zeitpunkt bekannt ist (siehe Abschnitt "Der Hintergrunddienst als Falle"). |
|
||||
| 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 | ungebunden | Dieselbe Begründung wie `group` — Konfigurationszugriffe innerhalb der Sync-Methoden. |
|
||||
| apps/api/src/ldap/ldap.service.ts | user | beides | gemischt | Dieselbe Begründung — der Löschzweig um Zeile 1559 (WINDOWS #20) ist der konkrete Risikofall. |
|
||||
| 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 | moduleGrant | muss-mandantengebunden | ungebunden | Modulfreigaben je Mandant. |
|
||||
| apps/api/src/module-registry/module-access.service.ts | tenantModuleActivation | muss-mandantengebunden | ungebunden | Aktivierung je Mandant, `tenantId`-Spalte vorhanden. |
|
||||
| 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/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. |
|
||||
| apps/api/src/tenders/adapters/email-alert.adapter.ts | tenderEmailConfig | bewusst-uebergreifend | ungebunden | `fetchTenders()` liest bewusst jede aktive `TenderEmailConfig`-Zeile über ALLE Mandanten in einer Abfrage (Plattform-Scheduler, ein Tick pro Postfach, D-13/D-01) — ausführlich im Dateikopf begründet, darf laut Kommentar niemals in `forTenant()` verpackt werden. |
|
||||
| apps/api/src/tenders/adapters/rss.adapter.ts | tenderRssFeedSource | bewusst-uebergreifend | ungebunden | Fan-out über jeden aktiven Feed, plattformweit UND persönlich, in einer Abfrage (Zeilen 55–83 im Dateikopf begründet) — dieselbe Scheduler-Ebene wie beim E-Mail-Adapter. |
|
||||
| apps/api/src/tenders/tender-dedup.service.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Explizit im Dateikopf: "platform-global, RLS-exempt tables. Never wrap these queries in forTenant()." (D-03) |
|
||||
| apps/api/src/tenders/tender-dedup.service.ts | tenderSource | keine-mandantengebundene-tabelle | ungebunden | Dieselbe Begründung. |
|
||||
| apps/api/src/tenders/tender-digest.scheduler.ts | tenderMatch | beides | ungebunden | Ein einziger globaler Cron-Job liest über ALLE Mandanten (bewusst übergreifend, Pitfall-1-Kommentar im Dateikopf), der Versand je Treffer ist an dessen Mandanten gebunden. |
|
||||
| apps/api/src/tenders/tender-digest.scheduler.ts | tenderNotificationPref | beides | ungebunden | Dieselbe Begründung — Präferenzen werden über alle Mandanten gelesen, aber je Zeile mandantenbezogen ausgewertet. |
|
||||
| apps/api/src/tenders/tender-digest.scheduler.ts | user | beides | ungebunden | E-Mail-Adressen für den Versand werden über alle Mandanten gelesen, der eigentliche Versand ist je Treffer mandantengebunden. |
|
||||
| apps/api/src/tenders/tender-email-config.service.ts | tenderEmailConfig | muss-mandantengebunden | ungebunden | Nutzer-CRUD für die eigene Postfachanbindung (Phase 17, D-01) — anders als der Fan-out-Adapter oben, hier ist der Mandant aus der Anfrage bekannt. |
|
||||
| apps/api/src/tenders/tender-fingerprint-backfill.service.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Einmaliges Backfill-Skript über den plattformweiten `Tender`-Katalog (D-03). |
|
||||
| apps/api/src/tenders/tender-ingestion.service.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Explizit im Dateikopf: "Multi-tenant safety (D-03, T-10-09): uses the plain, non-tenant-scoped ... queries ... these are platform-global". |
|
||||
| apps/api/src/tenders/tender-ingestion.service.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Plattformweiter Poll-Status, kein `tenantId` (Migration 20260909140000, Gruppe b). |
|
||||
| apps/api/src/tenders/tender-matching.service.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Liest den plattformweiten Katalog (D-03), um Treffer zu berechnen — kein `tenantId`. |
|
||||
| apps/api/src/tenders/tender-matching.service.ts | tenderMatch | beides | ungebunden | Sofortmeldung: Treffer über alle betroffenen Mandanten gelesen, Versand je Treffer mandantengebunden — siehe Abschnitt "Der Hintergrunddienst als Falle". |
|
||||
| apps/api/src/tenders/tender-matching.service.ts | tenderSavedSearch | beides | ungebunden | Dieselbe Begründung — gespeicherte Suchprofile aller Mandanten werden gegen neue Treffer geprüft, der Versand ist je Profil mandantengebunden. |
|
||||
| apps/api/src/tenders/tender-matching.service.ts | user | beides | ungebunden | Dieselbe Begründung — E-Mail-Adressen für die Sofortmeldung. |
|
||||
| apps/api/src/tenders/tender-notification-pref.service.ts | tenderNotificationPref | muss-mandantengebunden | ungebunden | Nutzer-CRUD für die eigenen Benachrichtigungseinstellungen — Mandant aus der Anfrage bekannt. |
|
||||
| apps/api/src/tenders/tender-rss-feed.service.ts | tenderRssFeedSource | muss-mandantengebunden | ungebunden | Nutzer-CRUD für die eigenen RSS-Quellen (anders als der Fan-out in `adapters/rss.adapter.ts`) — Mandant aus der Anfrage bekannt. WINDOWS #19 betrifft die nullbaren plattformweiten Zeilen, nicht diesen CRUD-Pfad. |
|
||||
| apps/api/src/tenders/tender-saved-search.service.ts | tenderSavedSearch | muss-mandantengebunden | ungebunden | Nutzer-CRUD für die eigenen gespeicherten Suchprofile — Mandant aus der Anfrage bekannt. |
|
||||
| apps/api/src/tenders/tender-scheduler.service.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Plattformweiter DÖE-Poll-Status, kein `tenantId` — im Dateikopf explizit als "genuine platform-wide singleton" begründet. |
|
||||
| apps/api/src/tenders/tender-triage.service.ts | tenderTriage | muss-mandantengebunden | ungebunden | Favorisierungs-/Ablehnungsstatus eines Nutzers, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/tenders/tenders.controller.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Lesezugriff auf den plattformweiten Katalog (D-03). |
|
||||
| apps/api/src/tenders/tenders.controller.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Plattformweiter Poll-Status, admin-verwaltet, kein `tenantId`. |
|
||||
| apps/api/src/tenders/tenders.module.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Singleton-Bestückung beim Boot — im Dateikopf explizit als "global, RLS-exempt (D-03)" begründet. |
|
||||
| apps/api/src/user/admin-seed.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Legt beim ersten Start den Standard-Mandanten selbst an — `Tenant` hat keine `tenantId`-Spalte. |
|
||||
| apps/api/src/user/admin-seed.service.ts | user | bewusst-uebergreifend | ungebunden | Erstanlage des Administrators beim ersten Start: läuft einmalig beim Boot, BEVOR irgendein Mandantenkontext existiert, um den allerersten Mandanten samt Admin-Nutzer anzulegen — es gibt zu diesem Zeitpunkt strukturell keinen Mandanten, an den gebunden werden könnte. |
|
||||
| apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | ungebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins. |
|
||||
| apps/api/src/user/user.service.ts | user | muss-mandantengebunden | ungebunden | Dieselbe Begründung. |
|
||||
|
||||
## Was diese Etappe NICHT entscheidet
|
||||
|
||||
|
||||
Reference in New Issue
Block a user