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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { LdapConfigService } from './ldap-config.service';
|
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
|
* Das Bind-Passwort ist das einzige Zugangsdatum, das nicht gehasht werden
|
||||||
* kann — Tessera muss sich damit am Domain Controller anmelden, braucht es
|
* 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();
|
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 { CryptoService } from '../crypto/crypto.service';
|
||||||
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import {
|
import {
|
||||||
CreateFieldMappingDto,
|
CreateFieldMappingDto,
|
||||||
CreateLdapConfigDto,
|
CreateLdapConfigDto,
|
||||||
@@ -51,6 +52,14 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
|||||||
* is logged and swallowed: a tenant whose bind password could not be
|
* is logged and swallowed: a tenant whose bind password could not be
|
||||||
* re-encrypted still authenticates, because the read path below tolerates a
|
* re-encrypted still authenticates, because the read path below tolerates a
|
||||||
* legacy plaintext value.
|
* 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> {
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -120,9 +129,15 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get LDAP config for a tenant, including field mappings.
|
* 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) {
|
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 },
|
where: { tenantId },
|
||||||
include: { fieldMappings: true },
|
include: { fieldMappings: true },
|
||||||
});
|
});
|
||||||
@@ -132,9 +147,17 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
|||||||
/**
|
/**
|
||||||
* Create LDAP config for a tenant with default field mappings (D-16).
|
* Create LDAP config for a tenant with default field mappings (D-16).
|
||||||
* Defaults: displayName -> displayName, mail -> email, sAMAccountName -> username
|
* 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) {
|
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: {
|
data: {
|
||||||
tenantId,
|
tenantId,
|
||||||
serverUrl: dto.serverUrl,
|
serverUrl: dto.serverUrl,
|
||||||
@@ -172,9 +195,12 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update LDAP config for a tenant.
|
* Update LDAP config for a tenant.
|
||||||
|
*
|
||||||
|
* Mandantengebunden (WINDOWS #20 Etappe 2, 260909-ipc).
|
||||||
*/
|
*/
|
||||||
async updateConfig(tenantId: string, dto: UpdateLdapConfigDto) {
|
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 },
|
where: { tenantId },
|
||||||
data: {
|
data: {
|
||||||
...(dto.serverUrl !== undefined && { serverUrl: dto.serverUrl }),
|
...(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).
|
* 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) {
|
async addFieldMapping(
|
||||||
return this.prisma.ldapFieldMapping.create({
|
tenantId: string,
|
||||||
|
configId: string,
|
||||||
|
dto: CreateFieldMappingDto,
|
||||||
|
) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
return tenantPrisma.ldapFieldMapping.create({
|
||||||
data: {
|
data: {
|
||||||
ldapConfigId: configId,
|
ldapConfigId: configId,
|
||||||
ldapField: dto.ldapField,
|
ldapField: dto.ldapField,
|
||||||
@@ -225,9 +261,20 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
|||||||
/**
|
/**
|
||||||
* Remove a field mapping. Only non-default mappings can be deleted.
|
* Remove a field mapping. Only non-default mappings can be deleted.
|
||||||
* System-provided defaults (isDefault=true) are protected.
|
* 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) {
|
async removeFieldMapping(tenantId: string, mappingId: string) {
|
||||||
const mapping = await this.prisma.ldapFieldMapping.findUnique({
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
const mapping = await tenantPrisma.ldapFieldMapping.findUnique({
|
||||||
where: { id: mappingId },
|
where: { id: mappingId },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -239,7 +286,7 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
|||||||
throw new Error('Cannot delete default field mappings');
|
throw new Error('Cannot delete default field mappings');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.ldapFieldMapping.delete({
|
return tenantPrisma.ldapFieldMapping.delete({
|
||||||
where: { id: mappingId },
|
where: { id: mappingId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -247,6 +294,16 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
|||||||
/**
|
/**
|
||||||
* Get all active LDAP configs. Used by the scheduler to determine which
|
* Get all active LDAP configs. Used by the scheduler to determine which
|
||||||
* tenants need auto-sync.
|
* 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() {
|
async getAllActiveConfigs() {
|
||||||
const configs = await this.prisma.ldapConfig.findMany({
|
const configs = await this.prisma.ldapConfig.findMany({
|
||||||
|
|||||||
@@ -337,17 +337,31 @@ export class LdapController {
|
|||||||
throw new NotFoundException('No LDAP config found for this tenant');
|
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.
|
* 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')
|
@Delete('config/mappings/:id')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
@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 {
|
try {
|
||||||
const result = await this.ldapConfigService.removeFieldMapping(id);
|
const result = await this.ldapConfigService.removeFieldMapping(
|
||||||
|
tenantId,
|
||||||
|
id,
|
||||||
|
);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new NotFoundException('Field mapping not found');
|
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
|
* ein Eintrag ohne Fundstelle. Vergleichsschluessel sind Datei UND
|
||||||
* Modellname — eine Zeilennummer traegt nicht, das ueberlebt das
|
* Modellname — eine Zeilennummer traegt nicht, das ueberlebt das
|
||||||
* Verschieben einer Zeile.
|
* 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 API_SRC_DIR = join(__dirname, '..');
|
||||||
const REPO_ROOT = join(__dirname, '../../../..');
|
const REPO_ROOT = join(__dirname, '../../../..');
|
||||||
const DOC_PATH = join(REPO_ROOT, 'docs/mandantentrennung-zugriffsklassifikation.md');
|
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;
|
file: string;
|
||||||
model: string;
|
unboundModels: Set<string>;
|
||||||
|
boundModels: Set<string>;
|
||||||
|
totalForTenantCalls: number;
|
||||||
|
assignmentFormCalls: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function listTsFiles(dir: string): string[] {
|
function listTsFiles(dir: string): string[] {
|
||||||
@@ -35,8 +65,8 @@ function listTsFiles(dir: string): string[] {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Filtert Kommentarzeilen (Zeilenkommentare und Blockkommentare) heraus,
|
* Filtert Kommentarzeilen (Zeilenkommentare und Blockkommentare) heraus,
|
||||||
* bevor nach `this.prisma.<model>` gesucht wird — sonst zaehlt eine
|
* bevor nach `this.prisma.<model>` oder `forTenant(` gesucht wird — sonst
|
||||||
* erklaerende Kopfzeile als Fundstelle mit.
|
* zaehlt eine erklaerende Kopfzeile als Fundstelle mit.
|
||||||
*/
|
*/
|
||||||
function stripComments(source: string): string {
|
function stripComments(source: string): string {
|
||||||
return source
|
return source
|
||||||
@@ -46,26 +76,79 @@ function stripComments(source: string): string {
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function findAccessSites(): AccessSite[] {
|
function analyzeFile(absPath: string, relPath: string): FileAnalysis {
|
||||||
const files = listTsFiles(API_SRC_DIR);
|
|
||||||
const sites: AccessSite[] = [];
|
|
||||||
|
|
||||||
for (const absPath of files) {
|
|
||||||
const relPath = relative(REPO_ROOT, absPath).split('\\').join('/');
|
|
||||||
const source = stripComments(readFileSync(absPath, 'utf-8'));
|
const source = stripComments(readFileSync(absPath, 'utf-8'));
|
||||||
const matches = source.matchAll(/this\.prisma\.([a-zA-Z]+)/g);
|
|
||||||
const models = new Set<string>();
|
const unboundModels = new Set<string>();
|
||||||
for (const m of matches) {
|
for (const m of source.matchAll(/this\.prisma\.([a-zA-Z]+)/g)) {
|
||||||
if (m[1]) models.add(m[1]);
|
if (m[1]) unboundModels.add(m[1]);
|
||||||
}
|
}
|
||||||
for (const model of models) {
|
|
||||||
sites.push({ file: relPath, model });
|
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));
|
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 = [
|
const CLASS_TOKENS = [
|
||||||
'muss-mandantengebunden',
|
'muss-mandantengebunden',
|
||||||
'bewusst-uebergreifend',
|
'bewusst-uebergreifend',
|
||||||
@@ -73,16 +156,25 @@ const CLASS_TOKENS = [
|
|||||||
'beides',
|
'beides',
|
||||||
];
|
];
|
||||||
|
|
||||||
function parseDocEntries(): { file: string; model: string; klasse: string }[] {
|
interface DocEntry {
|
||||||
const raw = readFileSync(DOC_PATH, 'utf-8');
|
file: string;
|
||||||
const entries: { file: string; model: string; klasse: string }[] = [];
|
model: string;
|
||||||
|
klasse: string;
|
||||||
|
stand: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Nur Zeilen aus der Bestandsaufnahme-Tabelle: `| Datei | Modell | Klasse | Begruendung |`
|
function parseDocEntries(): DocEntry[] {
|
||||||
const rowPattern = /^\|\s*(apps\/api\/src\/[^\s|]+\.ts)\s*\|\s*([a-zA-Z]+)\s*\|\s*([a-z-]+)\s*\|/gm;
|
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)) {
|
for (const match of raw.matchAll(rowPattern)) {
|
||||||
const [, file, model, klasse] = match;
|
const [, file, model, klasse, stand] = match;
|
||||||
entries.push({ file, model, klasse });
|
entries.push({ file, model, klasse, stand });
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
@@ -93,7 +185,9 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst
|
|||||||
expect(() => statSync(DOC_PATH)).not.toThrow();
|
expect(() => statSync(DOC_PATH)).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
const sourceSites = findAccessSites();
|
const analyses = analyzeAllFiles();
|
||||||
|
const sourceSites = findAccessSites(analyses);
|
||||||
|
const standByKey = computeStandByKey(analyses);
|
||||||
const docEntries = parseDocEntries();
|
const docEntries = parseDocEntries();
|
||||||
const docKeys = new Set(docEntries.map((e) => `${e.file}::${e.model}`));
|
const docKeys = new Set(docEntries.map((e) => `${e.file}::${e.model}`));
|
||||||
const sourceKeys = new Set(sourceSites.map((s) => `${s.file}::${s.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([]);
|
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));
|
const stale = [...docKeys].filter((key) => !sourceKeys.has(key));
|
||||||
expect(stale, `Eintraege im Dokument ohne Fundstelle im Quelltext:\n${stale.join('\n')}`).toEqual([]);
|
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([]);
|
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', () => {
|
it('keine doppelten (Datei, Modell)-Eintraege in der Tabelle', () => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const duplicates: string[] = [];
|
const duplicates: string[] = [];
|
||||||
@@ -129,4 +243,17 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst
|
|||||||
}
|
}
|
||||||
expect(duplicates).toEqual([]);
|
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 |
|
| 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 |
|
| **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 |
|
| Klasse | Anzahl Paare |
|
||||||
|---|---|
|
|---|---|
|
||||||
| muss-mandantengebunden | 31 |
|
| muss-mandantengebunden | 32 |
|
||||||
| keine-mandantengebundene-tabelle | 16 |
|
| keine-mandantengebundene-tabelle | 16 |
|
||||||
| beides | 9 |
|
| beides | 10 |
|
||||||
| bewusst-uebergreifend | 3 |
|
| bewusst-uebergreifend | 3 |
|
||||||
| **Summe** | **59** |
|
| **Summe** | **61** |
|
||||||
|
|
||||||
## Der Hintergrunddienst als Falle — drei `beides`-Fälle
|
## Der Hintergrunddienst als Falle — drei `beides`-Fälle
|
||||||
|
|
||||||
@@ -124,69 +133,73 @@ betroffen:
|
|||||||
|
|
||||||
Maschinell ermittelt, `rls-access-inventory.spec.ts` hält Vollständigkeit
|
Maschinell ermittelt, `rls-access-inventory.spec.ts` hält Vollständigkeit
|
||||||
nach. Spalten: Datei, Modell (Prisma-Modellname wie in `this.prisma.<Modell>`
|
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 |
|
| Datei | Modell | Klasse | Stand | 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/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/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | Kalenderquellen eines Nutzers, `tenantId`-Spalte vorhanden. |
|
| 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/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. |
|
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | ungebunden | Kalenderquellen 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 | dashboardLayout | muss-mandantengebunden | ungebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. |
|
||||||
| 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 | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). |
|
||||||
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. |
|
| 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/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. |
|
| 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 | dkvModuleConfig | muss-mandantengebunden | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. |
|
| 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 | dkvVehicleMaster | muss-mandantengebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. |
|
| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | ungebunden | Postfach-/Zugangsdaten 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/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | ungebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. |
|
||||||
| apps/api/src/groups/groups.service.ts | group | muss-mandantengebunden | Gruppen sind je Mandant, `tenantId`-Spalte vorhanden. |
|
| 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 | 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 | group | muss-mandantengebunden | ungebunden | Gruppen sind je Mandant, `tenantId`-Spalte vorhanden. |
|
||||||
| apps/api/src/groups/groups.service.ts | moduleGrant | muss-mandantengebunden | Modulfreigaben 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 | user | muss-mandantengebunden | Nutzerverwaltung innerhalb eines Mandanten. |
|
| apps/api/src/groups/groups.service.ts | moduleGrant | muss-mandantengebunden | ungebunden | Modulfreigaben je Mandant, `tenantId`-Spalte vorhanden. |
|
||||||
| apps/api/src/groups/module-grants.service.ts | group | muss-mandantengebunden | Wie groups.service.ts. |
|
| apps/api/src/groups/groups.service.ts | user | muss-mandantengebunden | ungebunden | Nutzerverwaltung innerhalb eines Mandanten. |
|
||||||
| 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 | group | muss-mandantengebunden | ungebunden | Wie groups.service.ts. |
|
||||||
| apps/api/src/groups/module-grants.service.ts | moduleGrant | muss-mandantengebunden | Modulfreigaben je Mandant. |
|
| 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 | tenantModuleActivation | muss-mandantengebunden | Welche Module ein Mandant aktiviert hat, `tenantId`-Spalte vorhanden. |
|
| apps/api/src/groups/module-grants.service.ts | moduleGrant | muss-mandantengebunden | ungebunden | Modulfreigaben je Mandant. |
|
||||||
| apps/api/src/groups/module-grants.service.ts | user | muss-mandantengebunden | Zielbenutzer eines Grants innerhalb des Mandanten. |
|
| apps/api/src/groups/module-grants.service.ts | tenantModuleActivation | muss-mandantengebunden | ungebunden | Welche Module ein Mandant aktiviert hat, `tenantId`-Spalte vorhanden. |
|
||||||
| apps/api/src/ldap/ldap-config.service.ts | ldapConfig | muss-mandantengebunden | LDAP-Konfiguration je Mandant, `tenantId`-Spalte (unique) 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 | ldapFieldMapping | muss-mandantengebunden | Kein eigenes `tenantId`, RLS über Join auf `LdapConfig`. |
|
| 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.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-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 | ldapConfig | beides | Dieselbe Begründung wie `group` — Konfigurationszugriffe innerhalb der Sync-Methoden. |
|
| 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 | user | beides | Dieselbe Begründung — der Löschzweig um Zeile 1559 (WINDOWS #20) ist der konkrete Risikofall. |
|
| 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/module-registry/module-access.service.ts | module | keine-mandantengebundene-tabelle | Modulkatalog ist plattformweit, kein `tenantId`. |
|
| apps/api/src/ldap/ldap.service.ts | ldapConfig | beides | ungebunden | Dieselbe Begründung wie `group` — Konfigurationszugriffe innerhalb der Sync-Methoden. |
|
||||||
| apps/api/src/module-registry/module-access.service.ts | moduleGrant | muss-mandantengebunden | Modulfreigaben je Mandant. |
|
| 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 | tenantModuleActivation | muss-mandantengebunden | Aktivierung je Mandant, `tenantId`-Spalte vorhanden. |
|
| 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-registry.service.ts | module | keine-mandantengebundene-tabelle | Modulkatalog ist plattformweit. |
|
| apps/api/src/module-registry/module-access.service.ts | moduleGrant | muss-mandantengebunden | ungebunden | Modulfreigaben je Mandant. |
|
||||||
| apps/api/src/module-registry/module-registry.service.ts | tenantModuleActivation | muss-mandantengebunden | Aktivierung je Mandant. |
|
| apps/api/src/module-registry/module-access.service.ts | tenantModuleActivation | muss-mandantengebunden | ungebunden | Aktivierung je Mandant, `tenantId`-Spalte vorhanden. |
|
||||||
| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | SMTP-Zugangsdaten 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/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/module-registry/module-registry.service.ts | tenantModuleActivation | muss-mandantengebunden | ungebunden | Aktivierung je Mandant. |
|
||||||
| apps/api/src/tenant/tenant.service.ts | tenant | keine-mandantengebundene-tabelle | Dieselbe Begründung. |
|
| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | ungebunden | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. |
|
||||||
| 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/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/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/tenant/tenant.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Dieselbe Begründung. |
|
||||||
| 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/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/tender-dedup.service.ts | tenderSource | keine-mandantengebundene-tabelle | Dieselbe Begründung. |
|
| 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-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-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-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-dedup.service.ts | tenderSource | keine-mandantengebundene-tabelle | ungebunden | Dieselbe Begründung. |
|
||||||
| 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-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-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-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-fingerprint-backfill.service.ts | tender | keine-mandantengebundene-tabelle | Einmaliges Backfill-Skript über den plattformweiten `Tender`-Katalog (D-03). |
|
| 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-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-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-ingestion.service.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | Plattformweiter Poll-Status, kein `tenantId` (Migration 20260909140000, Gruppe b). |
|
| 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-matching.service.ts | tender | keine-mandantengebundene-tabelle | Liest den plattformweiten Katalog (D-03), um Treffer zu berechnen — kein `tenantId`. |
|
| 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-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-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 | 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 | 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 | user | beides | Dieselbe Begründung — E-Mail-Adressen für die Sofortmeldung. |
|
| 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-notification-pref.service.ts | tenderNotificationPref | muss-mandantengebunden | Nutzer-CRUD für die eigenen Benachrichtigungseinstellungen — Mandant aus der Anfrage bekannt. |
|
| 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-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-matching.service.ts | user | beides | ungebunden | Dieselbe Begründung — E-Mail-Adressen für die Sofortmeldung. |
|
||||||
| 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-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-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/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/tenders.controller.ts | tender | keine-mandantengebundene-tabelle | Lesezugriff auf den plattformweiten Katalog (D-03). |
|
| 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/tenders.controller.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | Plattformweiter Poll-Status, admin-verwaltet, kein `tenantId`. |
|
| 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/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 | ungebunden | Favorisierungs-/Ablehnungsstatus eines Nutzers, `tenantId`-Spalte vorhanden. |
|
||||||
| apps/api/src/tenders/tender-triage.service.ts | tenderTriage | muss-mandantengebunden | 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/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/tenders/tenders.controller.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Plattformweiter Poll-Status, admin-verwaltet, kein `tenantId`. |
|
||||||
| 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/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/user.controller.ts | user | muss-mandantengebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins. |
|
| 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/user.service.ts | user | muss-mandantengebunden | Dieselbe Begründung. |
|
| 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
|
## Was diese Etappe NICHT entscheidet
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user