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:
2026-09-09 14:01:28 +02:00
parent a0c9ef070f
commit 9a57fa79f5
5 changed files with 447 additions and 103 deletions
+65 -8
View File
@@ -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({