feat(quick-260909-ipc): ldap.service.ts an forTenant() binden, Adress-Kollisionspruefung bewusst uebergreifend belassen
Aufgabe 3 der Etappe 2: elf Abfragen in sechs Methoden (listGroups, upsertMappedUser, searchUsers, importUsersByDn, importGroupsByDn, syncUsersForTenant) laufen jetzt ueber forTenant(), teils mit einem neu erzeugten, teils mit dem in derselben Methode bereits vorhandenen gebundenen Client. resolveEmailForWrite bleibt ausdruecklich ungebunden (Befund A, T-IPC-04): email/username sind plattformweit eindeutig, eine Bindung wuerde einen fremden Halter uebersehen und eine saubere Kollisionsmeldung in einen P2002-Abbruch verwandeln. Ein neuer Testblock biegt forTenant() auf ein zweites, unterscheidbares Client-Objekt um (der bisherige Identitaets-Mock haette die Umstellung nicht bemerkt, Befund F) und belegt damit, dass die Adressabfrage weiterhin am ungebundenen und der Rest am gebundenen Client landet. Alle 67 Bestandstests bleiben unveraendert gruen. docs/mandantentrennung-zugriffsklassifikation.md ist fuer den Bereich ldap geschlossen: gemessener Stand je Fundstelle, neu gerechnete Bereichsuebersicht (gebunden getrennt von ungebunden gezaehlt) und die Uebergabe des Standardgruppen-Punkts an den Bereich groups vor Etappe 4 dokumentiert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
// Mock ldapts so no real directory connection is attempted. The single shared
|
// Mock ldapts so no real directory connection is attempted. The single shared
|
||||||
// search mock is re-programmed per test.
|
// search mock is re-programmed per test.
|
||||||
@@ -59,6 +59,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
|
|||||||
|
|
||||||
import { Client } from 'ldapts';
|
import { Client } from 'ldapts';
|
||||||
import { LdapService } from './ldap.service';
|
import { LdapService } from './ldap.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
|
||||||
describe('LdapService.syncUsersForTenant — per-user exclude list', () => {
|
describe('LdapService.syncUsersForTenant — per-user exclude list', () => {
|
||||||
let service: LdapService;
|
let service: LdapService;
|
||||||
@@ -2300,3 +2301,183 @@ describe('LdapService — AD group import (SC-1/SC-2, D-01/D-02)', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aufgabe 3 (260909-ipc, WINDOWS #20 Etappe 2, Befund F): der Identitaets-
|
||||||
|
* Mock von forTenant() weiter oben in dieser Datei bemerkt eine Umstellung
|
||||||
|
* von `this.prisma.X` auf `tenantPrisma.X` nicht, weil beide Seiten auf
|
||||||
|
* denselben Objekt zeigen. Dieser Block biegt forTenant() ausschliesslich
|
||||||
|
* fuer sich selbst auf ein ZWEITES, unterscheidbares Client-Objekt um: der
|
||||||
|
* ungebundene Ersatz (`unboundPrisma`, das an den Konstruktor uebergebene
|
||||||
|
* `this.prisma`) und der gebundene Ersatz (`boundPrisma`, das Ergebnis von
|
||||||
|
* forTenant()) sind zwei verschiedene Spione. Damit ist nachweisbar, WELCHER
|
||||||
|
* Client welchen Aufruf bekommt — mit dem Identitaets-Mock waere das nicht
|
||||||
|
* unterscheidbar.
|
||||||
|
*/
|
||||||
|
describe('LdapService — Bindungsnachweis mit unterscheidbaren Clients (260909-ipc, Befund F)', () => {
|
||||||
|
let service: LdapService;
|
||||||
|
let unboundPrisma: any;
|
||||||
|
let boundPrisma: any;
|
||||||
|
let userService: any;
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
id: 'cfg1',
|
||||||
|
tenantId: 't1',
|
||||||
|
serverUrl: 'ldap://example',
|
||||||
|
baseDn: 'dc=example,dc=com',
|
||||||
|
searchFilter: '(objectClass=person)',
|
||||||
|
groupFilterDns: [] as string[],
|
||||||
|
userExcludeList: [] as string[],
|
||||||
|
fieldMappings: [
|
||||||
|
{ ldapField: 'sAMAccountName', tesseraField: 'username' },
|
||||||
|
{ ldapField: 'mail', tesseraField: 'email' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockBind.mockResolvedValue(undefined);
|
||||||
|
mockUnbind.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// Der ungebundene Ersatz: genau das, was resolveEmailForWrite() ueber
|
||||||
|
// this.prisma.user.findUnique erreicht (Befund A, bleibt bewusst
|
||||||
|
// ungebunden).
|
||||||
|
unboundPrisma = {
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Der gebundene Ersatz: das Ergebnis von forTenant(this.prisma, tenantId)
|
||||||
|
// in jeder umgestellten Methode.
|
||||||
|
boundPrisma = {
|
||||||
|
user: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
groupMembership: {
|
||||||
|
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
},
|
||||||
|
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
|
||||||
|
};
|
||||||
|
|
||||||
|
(forTenant as any).mockImplementation(() => boundPrisma);
|
||||||
|
|
||||||
|
userService = { create: vi.fn().mockResolvedValue({}) };
|
||||||
|
service = new LdapService(unboundPrisma, userService, {} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Andere describe-Bloecke dieser Datei verlassen sich auf die
|
||||||
|
// Identitaets-Grundform (forTenant gibt denselben Client zurueck) — die
|
||||||
|
// Umbiegung bleibt auf diesen Block beschraenkt.
|
||||||
|
(forTenant as any).mockImplementation((p: unknown) => p);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncUsersForTenant: resolveEmailForWrite fragt den UNGEBUNDENEN Client, upsertMappedUser den GEBUNDENEN', async () => {
|
||||||
|
mockSearch.mockResolvedValue({
|
||||||
|
searchEntries: [
|
||||||
|
{ dn: 'cn=alice,dc=example,dc=com', sAMAccountName: 'alice', mail: 'alice@x' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.syncUsersForTenant(cfg as any, 't1');
|
||||||
|
|
||||||
|
expect(result.created).toBe(1);
|
||||||
|
expect(forTenant).toHaveBeenCalledWith(unboundPrisma, 't1');
|
||||||
|
// Die Adressabfrage aus resolveEmailForWrite() landet auf dem
|
||||||
|
// UNGEBUNDENEN Client — niemals auf dem gebundenen (Befund A, T-IPC-04).
|
||||||
|
expect(unboundPrisma.user.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { email: 'alice@x' },
|
||||||
|
});
|
||||||
|
// Die Identitaetssuche aus upsertMappedUser() landet auf dem GEBUNDENEN
|
||||||
|
// Client.
|
||||||
|
expect(boundPrisma.user.findFirst).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncUsersForTenant bindet die Deaktivierungs-Kandidatenliste und die lastSyncAt-Fortschreibung', async () => {
|
||||||
|
mockSearch.mockResolvedValue({ searchEntries: [] });
|
||||||
|
|
||||||
|
await service.syncUsersForTenant(cfg as any, 't1');
|
||||||
|
|
||||||
|
expect(boundPrisma.user.findMany).toHaveBeenCalled();
|
||||||
|
expect(boundPrisma.ldapConfig.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'cfg1' },
|
||||||
|
data: { lastSyncAt: expect.any(Date) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listGroups bindet ueber forTenant() an den uebergebenen Mandanten', async () => {
|
||||||
|
mockSearch.mockResolvedValue({
|
||||||
|
searchEntries: [
|
||||||
|
{
|
||||||
|
dn: 'cn=Sales,dc=example,dc=com',
|
||||||
|
cn: 'Sales',
|
||||||
|
objectGUID: Buffer.from('0123456789abcdef0123456789abcdef', 'hex'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.listGroups(cfg as any, 't1');
|
||||||
|
|
||||||
|
expect(forTenant).toHaveBeenCalledWith(unboundPrisma, 't1');
|
||||||
|
expect(boundPrisma.group.findMany).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searchUsers bindet ueber forTenant() an den uebergebenen Mandanten', async () => {
|
||||||
|
mockSearch.mockResolvedValue({
|
||||||
|
searchEntries: [
|
||||||
|
{ dn: 'cn=alice,dc=example,dc=com', sAMAccountName: 'alice', mail: 'alice@x' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.searchUsers(cfg as any, 't1', 'a');
|
||||||
|
|
||||||
|
expect(forTenant).toHaveBeenCalledWith(unboundPrisma, 't1');
|
||||||
|
expect(boundPrisma.user.findMany).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('importUsersByDn bindet Dedup und Update, die Adress-Kollisionspruefung bleibt ungebunden', async () => {
|
||||||
|
mockSearch.mockResolvedValue({
|
||||||
|
searchEntries: [
|
||||||
|
{ dn: 'cn=carol,dc=example,dc=com', sAMAccountName: 'carol', mail: 'carol@x' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.importUsersByDn(cfg as any, 't1', [
|
||||||
|
'cn=carol,dc=example,dc=com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.created).toBe(1);
|
||||||
|
expect(boundPrisma.user.findFirst).toHaveBeenCalled();
|
||||||
|
expect(unboundPrisma.user.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { email: 'carol@x' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('importGroupsByDn bindet die Idempotenzpruefung und die Anlage auf DEMSELBEN gebundenen Client', async () => {
|
||||||
|
mockSearch.mockResolvedValue({
|
||||||
|
searchEntries: [
|
||||||
|
{
|
||||||
|
dn: 'cn=Sales,dc=example,dc=com',
|
||||||
|
cn: 'Sales',
|
||||||
|
objectGUID: Buffer.from('0123456789abcdef0123456789abcdef', 'hex'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.importGroupsByDn(cfg as any, 't1', [
|
||||||
|
'cn=Sales,dc=example,dc=com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.imported).toBe(1);
|
||||||
|
expect(boundPrisma.group.findFirst).toHaveBeenCalled();
|
||||||
|
expect(boundPrisma.group.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -295,6 +295,10 @@ export class LdapService {
|
|||||||
const client = new Client(
|
const client = new Client(
|
||||||
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
|
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
|
||||||
);
|
);
|
||||||
|
// Mandantengescopter Lesepfad (WINDOWS #20 Etappe 2, 260909-ipc): die
|
||||||
|
// "bereits importiert"-Markierung darf nur die Gruppen DIESES Mandanten
|
||||||
|
// sehen.
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.bind(client, config.bindDn, config.bindPassword);
|
await this.bind(client, config.bindDn, config.bindPassword);
|
||||||
@@ -343,7 +347,7 @@ export class LdapService {
|
|||||||
.filter((h): h is string => !!h);
|
.filter((h): h is string => !!h);
|
||||||
let importedSet = new Set<string>();
|
let importedSet = new Set<string>();
|
||||||
if (guidHexes.length > 0) {
|
if (guidHexes.length > 0) {
|
||||||
const existing = await this.prisma.group.findMany({
|
const existing = await tenantPrisma.group.findMany({
|
||||||
where: { tenantId, ldapObjectGuid: { in: guidHexes } },
|
where: { tenantId, ldapObjectGuid: { in: guidHexes } },
|
||||||
select: { ldapObjectGuid: true },
|
select: { ldapObjectGuid: true },
|
||||||
});
|
});
|
||||||
@@ -412,6 +416,21 @@ export class LdapService {
|
|||||||
* NEVER handed from one account to another (T-Q3-01): a directory entry
|
* NEVER handed from one account to another (T-Q3-01): a directory entry
|
||||||
* could otherwise take over a real person's address and receive their
|
* could otherwise take over a real person's address and receive their
|
||||||
* password-reset mail.
|
* password-reset mail.
|
||||||
|
*
|
||||||
|
* BLEIBT bewusst UNGEBUNDEN (WINDOWS #20 Etappe 2, 260909-ipc, Befund A,
|
||||||
|
* T-IPC-04): `email` und `username` sind in `prisma/schema.prisma`
|
||||||
|
* plattformweit eindeutig (`@unique`), nicht je Mandant. Wuerde diese
|
||||||
|
* Abfrage mit `forTenant()` an den eigenen Mandanten gebunden, saehe sie
|
||||||
|
* einen fremden Halter der Adresse nicht mehr, meldete "Adresse frei", und
|
||||||
|
* der anschliessende Schreibvorgang liefe in die plattformweite
|
||||||
|
* Eindeutigkeitsbedingung der Datenbank — aus einer sauber berichteten
|
||||||
|
* Kollision (WINDOWS #15/T-Q3-01) wuerde ein P2002-Abbruch des gesamten
|
||||||
|
* Sync-Laufs. Nach dem Scharfschalten (Etappe 4) liefert diese Abfrage
|
||||||
|
* fuer jeden Mandanten AUSSER dem der Adresse selbst 0 Zeilen und meldet
|
||||||
|
* damit IMMER "frei" — ein bekannter, hier bewusst offen gelassener Punkt.
|
||||||
|
* Die Loesung gehoert nach Etappe 3, vermutlich als vierte
|
||||||
|
* SECURITY-DEFINER-Funktion nach dem Muster des Anmeldewegs
|
||||||
|
* (siehe auth.service.ts).
|
||||||
*/
|
*/
|
||||||
private async resolveEmailForWrite(
|
private async resolveEmailForWrite(
|
||||||
desiredEmail: string,
|
desiredEmail: string,
|
||||||
@@ -449,12 +468,16 @@ export class LdapService {
|
|||||||
status: 'created' | 'updated';
|
status: 'created' | 'updated';
|
||||||
emailConflict?: LdapEmailConflict;
|
emailConflict?: LdapEmailConflict;
|
||||||
}> {
|
}> {
|
||||||
const existingByDn = await this.prisma.user.findFirst({
|
// Mandantengescopter Identitaets-/Schreibpfad (WINDOWS #20 Etappe 2,
|
||||||
|
// 260909-ipc). Nicht zu verwechseln mit resolveEmailForWrite() oben, die
|
||||||
|
// bewusst ungebunden bleibt.
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
const existingByDn = await tenantPrisma.user.findFirst({
|
||||||
where: { ldapDn: dn, tenantId },
|
where: { ldapDn: dn, tenantId },
|
||||||
});
|
});
|
||||||
const existingByUsername = existingByDn
|
const existingByUsername = existingByDn
|
||||||
? null
|
? null
|
||||||
: await this.prisma.user.findFirst({ where: { username, tenantId } });
|
: await tenantPrisma.user.findFirst({ where: { username, tenantId } });
|
||||||
const existing = existingByDn || existingByUsername;
|
const existing = existingByDn || existingByUsername;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -472,7 +495,7 @@ export class LdapService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.user.update({
|
await tenantPrisma.user.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: {
|
data: {
|
||||||
...(mappedData['displayName'] && {
|
...(mappedData['displayName'] && {
|
||||||
@@ -540,6 +563,10 @@ export class LdapService {
|
|||||||
const client = new Client(
|
const client = new Client(
|
||||||
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
|
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
|
||||||
);
|
);
|
||||||
|
// Mandantengescopter Lesepfad (WINDOWS #20 Etappe 2, 260909-ipc): die
|
||||||
|
// "bereits importiert"-Markierung darf nur die Konten DIESES Mandanten
|
||||||
|
// sehen.
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
const first = (v: unknown): string =>
|
const first = (v: unknown): string =>
|
||||||
Array.isArray(v) ? String(v[0] ?? '') : v != null ? String(v) : '';
|
Array.isArray(v) ? String(v[0] ?? '') : v != null ? String(v) : '';
|
||||||
|
|
||||||
@@ -576,7 +603,7 @@ export class LdapService {
|
|||||||
const usernames = entries
|
const usernames = entries
|
||||||
.map((e) => e.username.toLowerCase())
|
.map((e) => e.username.toLowerCase())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const existing = await this.prisma.user.findMany({
|
const existing = await tenantPrisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
tenantId,
|
tenantId,
|
||||||
OR: [{ ldapDn: { in: dns } }, { username: { in: usernames } }],
|
OR: [{ ldapDn: { in: dns } }, { username: { in: usernames } }],
|
||||||
@@ -584,10 +611,12 @@ export class LdapService {
|
|||||||
select: { ldapDn: true, username: true },
|
select: { ldapDn: true, username: true },
|
||||||
});
|
});
|
||||||
const dnSet = new Set(
|
const dnSet = new Set(
|
||||||
existing.map((u) => u.ldapDn).filter((d): d is string => !!d),
|
existing
|
||||||
|
.map((u: { ldapDn: string | null }) => u.ldapDn)
|
||||||
|
.filter((d: string | null): d is string => !!d),
|
||||||
);
|
);
|
||||||
const usernameSet = new Set(
|
const usernameSet = new Set(
|
||||||
existing.map((u) => u.username.toLowerCase()),
|
existing.map((u: { username: string }) => u.username.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
return entries.map((e) => ({
|
return entries.map((e) => ({
|
||||||
@@ -632,6 +661,9 @@ export class LdapService {
|
|||||||
.map((u) => u.trim().toLowerCase())
|
.map((u) => u.trim().toLowerCase())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
);
|
);
|
||||||
|
// Mandantengescopter Dedup-/Schreibpfad (WINDOWS #20 Etappe 2,
|
||||||
|
// 260909-ipc).
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.bind(client, config.bindDn, config.bindPassword);
|
await this.bind(client, config.bindDn, config.bindPassword);
|
||||||
@@ -672,12 +704,12 @@ export class LdapService {
|
|||||||
// Dedup: if a user already exists (by ldapDn or username) skip it,
|
// Dedup: if a user already exists (by ldapDn or username) skip it,
|
||||||
// but link the ldapDn so a later group/OU sync matches it and never
|
// but link the ldapDn so a later group/OU sync matches it and never
|
||||||
// duplicates.
|
// duplicates.
|
||||||
const existing = await this.prisma.user.findFirst({
|
const existing = await tenantPrisma.user.findFirst({
|
||||||
where: { tenantId, OR: [{ ldapDn: dn }, { username }] },
|
where: { tenantId, OR: [{ ldapDn: dn }, { username }] },
|
||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.ldapDn !== dn) {
|
if (existing.ldapDn !== dn) {
|
||||||
await this.prisma.user.update({
|
await tenantPrisma.user.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: { ldapDn: dn },
|
data: { ldapDn: dn },
|
||||||
});
|
});
|
||||||
@@ -797,7 +829,7 @@ export class LdapService {
|
|||||||
|
|
||||||
// Idempotency: a second import of the same AD group is a skip, not
|
// Idempotency: a second import of the same AD group is a skip, not
|
||||||
// a duplicate row.
|
// a duplicate row.
|
||||||
const existingByGuid = await this.prisma.group.findFirst({
|
const existingByGuid = await tenantPrisma.group.findFirst({
|
||||||
where: { tenantId, ldapObjectGuid },
|
where: { tenantId, ldapObjectGuid },
|
||||||
});
|
});
|
||||||
if (existingByGuid) {
|
if (existingByGuid) {
|
||||||
@@ -1000,7 +1032,7 @@ export class LdapService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Deactivation per D-15: Deactivate users removed from LDAP
|
// 5. Deactivation per D-15: Deactivate users removed from LDAP
|
||||||
const localLdapUsers = await this.prisma.user.findMany({
|
const localLdapUsers = await tenantPrisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
tenantId,
|
tenantId,
|
||||||
ldapDn: { not: null },
|
ldapDn: { not: null },
|
||||||
@@ -1011,7 +1043,7 @@ export class LdapService {
|
|||||||
|
|
||||||
for (const localUser of localLdapUsers) {
|
for (const localUser of localLdapUsers) {
|
||||||
if (localUser.ldapDn && !syncedDns.includes(localUser.ldapDn)) {
|
if (localUser.ldapDn && !syncedDns.includes(localUser.ldapDn)) {
|
||||||
await this.prisma.user.update({
|
await tenantPrisma.user.update({
|
||||||
where: { id: localUser.id },
|
where: { id: localUser.id },
|
||||||
data: { isActive: false },
|
data: { isActive: false },
|
||||||
});
|
});
|
||||||
@@ -1047,7 +1079,7 @@ export class LdapService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 6. Update lastSyncAt
|
// 6. Update lastSyncAt
|
||||||
await this.prisma.ldapConfig.update({
|
await tenantPrisma.ldapConfig.update({
|
||||||
where: { id: config.id },
|
where: { id: config.id },
|
||||||
data: { lastSyncAt: new Date() },
|
data: { lastSyncAt: new Date() },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,26 +63,40 @@ Fundstellen gelöst werden: die Policy braucht für den Lesezugriff
|
|||||||
`tenantId IS NULL OR tenantId = current_tenant_id()`, während Schreibzugriffe
|
`tenantId IS NULL OR tenantId = current_tenant_id()`, während Schreibzugriffe
|
||||||
weiterhin einen Mandanten verlangen.
|
weiterhin einen Mandanten verlangen.
|
||||||
|
|
||||||
## Übersicht je Bereich (Zeilentreffer, `this.prisma.*` ohne Specs)
|
## Übersicht je Bereich (Zeilentreffer je Bereich, ungebunden vs. gebunden)
|
||||||
|
|
||||||
Gemessen mit `grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src/<bereich> | grep -v spec | wc -l`
|
**Wichtig, seit 260909-ipc (Aufgabe 3):** die Spalte "Ungebunden" zählt NUR
|
||||||
am 2026-09-09, **nach** den Änderungen aus Aufgabe 1/2 dieses Plans:
|
noch `this.prisma.<Modell>`-Rohtreffer — eine unveränderte Spaltenüberschrift
|
||||||
|
über einer veränderten Bedeutung wäre die nächste stille Falle, seit ein
|
||||||
|
Bereich (`ldap`) tatsächlich gebundene Zugriffe hat, die aus dieser Zählung
|
||||||
|
verschwinden. Die neue Spalte "Gebunden" zählt daneben die
|
||||||
|
`forTenant()`-gebundenen Rohtreffer (`tenantPrisma.<Modell>`, Konvention
|
||||||
|
dieses Codes — siehe `rls-access-inventory.spec.ts` für die allgemeinere,
|
||||||
|
namensunabhängige Erkennung über die `const <Name> = forTenant(`-Zuweisungsform).
|
||||||
|
Beide Spalten sind Rohtreffer (mehrere Vorkommen desselben Modells in
|
||||||
|
derselben Datei zählen mehrfach), nicht (Datei, Modell)-Paare wie in der
|
||||||
|
Bestandsaufnahme unten.
|
||||||
|
|
||||||
| Bereich | Treffer | Hinweis |
|
Gemessen mit
|
||||||
|---|---|---|
|
`grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src/<bereich> | grep -v spec | wc -l`
|
||||||
| tenders | 62 | unverändert gegenüber measured_baseline |
|
bzw. `grep -ro "tenantPrisma\.[a-zA-Z]*\." apps/api/src/<bereich> | grep -v spec | wc -l`
|
||||||
| groups | 37 | unverändert |
|
am 2026-09-09, **nach** den Änderungen aus Aufgabe 2/3 dieses Plans (260909-ipc):
|
||||||
| ldap | 21 | unverändert |
|
|
||||||
| dkv | 21 | unverändert |
|
| Bereich | Ungebunden | Gebunden | Hinweis |
|
||||||
| user | 17 | unverändert |
|
|---|---|---|---|
|
||||||
| module-registry | 17 | unverändert |
|
| tenders | 62 | 0 | unverändert |
|
||||||
| dashboard | 13 | unverändert |
|
| groups | 37 | 0 | unverändert |
|
||||||
| auth | 8 | **war 13 in measured_baseline** — Aufgabe 2 hat 3 Lesezugriffe durch `auth_lookup_*()`-Funktionsaufrufe (`$queryRaw`, kein `this.prisma.<Modell>`) ersetzt und 5 Schreibzugriffe auf `forTenant()`-gebundene Aufrufe (`tenantPrisma.*`, ebenfalls kein `this.prisma.<Modell>`) umgestellt |
|
| ldap | 4 | 26 | **war 21/0** — Aufgabe 2/3 (260909-ipc) haben `ldap-config.service.ts` (5 Methoden) und `ldap.service.ts` (6 Methoden, 11 Abfragen) auf `forTenant()` umgestellt. Die 4 verbleibenden ungebundenen Treffer sind bewusst: `getAllActiveConfigs`/`onApplicationBootstrap` (Befund B) und `resolveEmailForWrite` (Befund A, T-IPC-04) |
|
||||||
| calendar | 12 | unverändert |
|
| dkv | 21 | 0 | unverändert |
|
||||||
| tenant | 8 | unverändert |
|
| user | 17 | 0 | unverändert |
|
||||||
| favorites | 7 | unverändert |
|
| module-registry | 17 | 0 | unverändert |
|
||||||
| settings | 4 | unverändert |
|
| dashboard | 13 | 0 | 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 |
|
| auth | 8 | 5 | unverändert gegenüber dem in Etappe 1 (260909-eor) gemessenen Stand |
|
||||||
|
| calendar | 12 | 0 | unverändert |
|
||||||
|
| tenant | 8 | 0 | unverändert |
|
||||||
|
| favorites | 7 | 0 | unverändert |
|
||||||
|
| settings | 4 | 0 | unverändert |
|
||||||
|
| **Summe** | **210** | **31** | Ungebunden: war 227 vor dieser Etappe (260909-eor-Stand), Delta = die 17 in Aufgabe 2/3 umgestellten `ldap`-Rohtreffer. Gebunden: war 5 (nur `auth`), jetzt zusätzlich 26 in `ldap` |
|
||||||
|
|
||||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 61 Paare)
|
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 61 Paare)
|
||||||
|
|
||||||
@@ -109,15 +123,27 @@ Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend —
|
|||||||
muss aber *innerhalb* der Schleife je Mandant binden. Drei Dateien sind
|
muss aber *innerhalb* der Schleife je Mandant binden. Drei Dateien sind
|
||||||
betroffen:
|
betroffen:
|
||||||
|
|
||||||
- **`ldap.service.ts`** (AD-Abgleich): iteriert nicht selbst über alle
|
- **`ldap.service.ts`** (AD-Abgleich) — **Stand 260909-ipc, Aufgaben 2/3:
|
||||||
Mandanten (der Sync läuft je Aufruf für einen übergebenen Mandanten), aber
|
geschlossen.** Iteriert nicht selbst über alle Mandanten (der Sync läuft
|
||||||
innerhalb der Sync-Methoden bleiben Lesezugriffe auf `group`, `ldapConfig`
|
je Aufruf für einen übergebenen Mandanten). Vor dieser Etappe blieben
|
||||||
und `user` teils ungebunden, obwohl der Mandant zu diesem Zeitpunkt bereits
|
Lesezugriffe auf `group`, `ldapConfig` und `user` innerhalb der
|
||||||
bekannt ist — die 4 echten `forTenant()`-Aufrufstellen (Zeilen 762, 905,
|
Sync-Methoden teils ungebunden, obwohl der Mandant zu diesem Zeitpunkt
|
||||||
1179, 1342) decken nur einen Teil der Lese-/Schreibpfade ab. Das ist der im
|
bereits bekannt war — nur 4 der inzwischen 11 `forTenant()`-Aufrufstellen
|
||||||
Plankontext benannte Kern von WINDOWS #20: genau dieser Löschzweig
|
deckten die Lese-/Schreibpfade ab. Jetzt sind `group` und `ldapConfig`
|
||||||
(~Zeile 1559) deutet Leere nach dem Scharfschalten als "Gruppe im
|
vollständig gebunden; bei `user` bleibt ausschließlich
|
||||||
Verzeichnis verschwunden".
|
`resolveEmailForWrite` bewusst ungebunden (Befund A, T-IPC-04 — siehe
|
||||||
|
Bestandsaufnahme unten). Der als gefährlichster Punkt benannte
|
||||||
|
Löschzweig (`syncBoundGroupsForTenant`, WINDOWS #20) war bereits seit
|
||||||
|
Etappe 1 gebunden. Offen bleibt eine Reihenfolgebedingung für Etappe 4,
|
||||||
|
NICHT Teil dieser Umstellung: die Übergabe unmittelbar vor der Löschung —
|
||||||
|
`this.groupsService.reassignDefaultBeforeDelete(tenantId, group.id)` und
|
||||||
|
`ensureDefaultGroup(tenantId)` — liegt in `groups.service.ts` und ist
|
||||||
|
nicht gebunden. Nach dem Scharfschalten würde `reassignDefaultBeforeDelete`
|
||||||
|
still `false` melden (kein Ersatzkandidat sichtbar), der Standard-Marker
|
||||||
|
wandert nicht mit, und der Mandant bliebe nach einer Gruppenlöschung ohne
|
||||||
|
Standardgruppe zurück — der Bereich `groups` muss deshalb vor Etappe 4
|
||||||
|
umgestellt sein (siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`,
|
||||||
|
Abschnitt (e), Befund D).
|
||||||
- **`tender-digest.scheduler.ts`** (Ausschreibungs-Digest): liest
|
- **`tender-digest.scheduler.ts`** (Ausschreibungs-Digest): liest
|
||||||
`tenderMatch`/`tenderNotificationPref`/`user` bewusst über ALLE Mandanten
|
`tenderMatch`/`tenderNotificationPref`/`user` bewusst über ALLE Mandanten
|
||||||
in einem `findMany` (ein einziger globaler Cron-Job, kein Mandant im
|
in einem `findMany` (ein einziger globaler Cron-Job, kein Mandant im
|
||||||
@@ -161,10 +187,10 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
|||||||
| apps/api/src/groups/module-grants.service.ts | user | muss-mandantengebunden | ungebunden | Zielbenutzer eines Grants innerhalb des Mandanten. |
|
| 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 | 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-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 | group | beides | gebunden | AD-Abgleich: `listGroups` (die "bereits importiert"-Markierung) und `importGroupsByDn` (die Idempotenzpruefung ueber `ldapObjectGuid`) sind mit Aufgabe 3 (260909-ipc) auf `forTenant()` umgestellt — zusammen mit den bereits vorher gebundenen Stellen (Anlage, Mitgliedschafts- und Gruppenabgleich) ist damit jeder `group`-Zugriff dieser Datei gebunden. Die Klasse bleibt `beides`, weil ein zukuenftiger uebergreifender Lesezugriff (z. B. ein neuer Planer-Pfad) hier ebenso legitim waere wie bei `ldapConfig` unten — nicht, weil heute noch ein ungebundener Zugriff bestuende. |
|
||||||
| 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 | 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 | ldapConfig | beides | gebunden | Die `lastSyncAt`-Fortschreibung am Ende von `syncUsersForTenant` ist mit Aufgabe 3 (260909-ipc) auf den in derselben Methode bereits vorhandenen `forTenant()`-Client umgestellt — es entsteht kein zweiter. Damit ist der einzige `ldapConfig`-Zugriff dieser Datei gebunden. |
|
||||||
| apps/api/src/ldap/ldap.service.ts | user | beides | gemischt | Dieselbe Begründung — der Löschzweig um Zeile 1559 (WINDOWS #20) ist der konkrete Risikofall. |
|
| apps/api/src/ldap/ldap.service.ts | user | beides | gemischt | Mit Aufgabe 3 (260909-ipc) sind `upsertMappedUser` (Identitaetssuche und Aktualisierung), `searchUsers` (die "bereits importiert"-Markierung), `importUsersByDn` (Dedup und ldapDn-Nachtrag) und die Deaktivierungsschleife in `syncUsersForTenant` auf `forTenant()` umgestellt. `resolveEmailForWrite` bleibt ausdruecklich UNGEBUNDEN (Befund A, T-IPC-04): `email`/`username` sind plattformweit eindeutig, eine mandantengebundene Suche saehe einen fremden Halter nicht mehr und meldete faelschlich "frei" — die geloeste Klasse waere `muss-mandantengebunden` gewesen, bleibt wegen dieser einen bewusst uebergreifenden Abfrage `beides`. Der Loeschzweig um `syncBoundGroupsForTenant` (WINDOWS #20) ist bereits seit Etappe 1 gebunden und war nie Teil dieses Befunds. |
|
||||||
| apps/api/src/module-registry/module-access.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId`. |
|
| apps/api/src/module-registry/module-access.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId`. |
|
||||||
| 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 | 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-access.service.ts | tenantModuleActivation | muss-mandantengebunden | ungebunden | Aktivierung je Mandant, `tenantId`-Spalte vorhanden. |
|
||||||
@@ -204,7 +230,12 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
|||||||
## Was diese Etappe NICHT entscheidet
|
## Was diese Etappe NICHT entscheidet
|
||||||
|
|
||||||
- Ob Controller künftig über `req.tenantPrisma` statt eines erneuten
|
- Ob Controller künftig über `req.tenantPrisma` statt eines erneuten
|
||||||
`forTenant()`-Aufrufs im Service gehen (offener Befund oben).
|
`forTenant()`-Aufrufs im Service gehen (offener Befund oben). Der Bereich
|
||||||
|
`ldap` (260909-ipc) hat sich für den Dienst-internen Weg entschieden —
|
||||||
|
`forTenant(this.prisma, tenantId)` wird in jeder umgestellten Methode neu
|
||||||
|
erzeugt, wie es die vier Bestandsstellen in `ldap.service.ts` und die drei
|
||||||
|
in `auth.service.ts` bereits vormachten. Die Frage bleibt für alle
|
||||||
|
übrigen Bereiche der Etappe 2 offen.
|
||||||
- Wie die WINDOWS-#19-Policy für `SearchProvider`/`TenderRssFeedSource`
|
- Wie die WINDOWS-#19-Policy für `SearchProvider`/`TenderRssFeedSource`
|
||||||
am Ende genau lautet — nur, dass sie vor dem Scharfschalten gelöst sein
|
am Ende genau lautet — nur, dass sie vor dem Scharfschalten gelöst sein
|
||||||
muss.
|
muss.
|
||||||
|
|||||||
Reference in New Issue
Block a user