diff --git a/apps/api/src/ldap/ldap.module.ts b/apps/api/src/ldap/ldap.module.ts index e91e668..92319ef 100644 --- a/apps/api/src/ldap/ldap.module.ts +++ b/apps/api/src/ldap/ldap.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { ScheduleModule } from '@nestjs/schedule'; +import { GroupsModule } from '../groups/groups.module'; import { UserModule } from '../user/user.module'; import { LdapConfigService } from './ldap-config.service'; import { LdapSyncScheduler } from './ldap-sync.scheduler'; @@ -11,9 +12,14 @@ import { LdapService } from './ldap.service'; * * Provides per-tenant LDAP configuration (D-18), manual sync (D-14), * auto-sync scheduler (D-14), and configurable field mapping (D-16/D-17). + * + * GroupsModule is imported so LdapService can call + * GroupsService.reassignDefaultBeforeDelete()/ensureDefaultGroup() from + * syncBoundGroupsForTenant() (Plan 16-03, D-06) — no cycle: GroupsModule + * imports neither LdapModule nor UserModule. */ @Module({ - imports: [ScheduleModule.forRoot(), UserModule], + imports: [ScheduleModule.forRoot(), UserModule, GroupsModule], controllers: [LdapController], providers: [LdapService, LdapConfigService, LdapSyncScheduler], exports: [LdapService, LdapConfigService], diff --git a/apps/api/src/ldap/ldap.service.spec.ts b/apps/api/src/ldap/ldap.service.spec.ts index 7bfb4f4..253d9d3 100644 --- a/apps/api/src/ldap/ldap.service.spec.ts +++ b/apps/api/src/ldap/ldap.service.spec.ts @@ -65,7 +65,7 @@ describe('LdapService.syncUsersForTenant — per-user exclude list', () => { ldapConfig: { update: vi.fn().mockResolvedValue({}) }, }; userService = { create: vi.fn().mockResolvedValue({}) }; - service = new LdapService(prisma, userService); + service = new LdapService(prisma, userService, {} as any); }); it('imports every user when the exclude list is empty', async () => { @@ -162,7 +162,7 @@ describe('LdapService.syncUsersForTenant — empty base DN no-op', () => { ldapConfig: { update: vi.fn().mockResolvedValue({}) }, }; userService = { create: vi.fn().mockResolvedValue({}) }; - service = new LdapService(prisma, userService); + service = new LdapService(prisma, userService, {} as any); }); it('creates nobody and deactivates nobody when the base DN is empty (whitespace-only)', async () => { @@ -177,6 +177,10 @@ describe('LdapService.syncUsersForTenant — empty base DN no-op', () => { deactivated: 0, groupMembershipsAdded: 0, groupMembershipsRemoved: 0, + groupsAdopted: 0, + groupsRenamed: 0, + groupsDeleted: 0, + defaultMarkerMoved: 0, errors: [], }); expect(mockSearch).not.toHaveBeenCalled(); @@ -239,7 +243,7 @@ describe('LdapService.syncUsersForTenant — multi base DN scope', () => { ldapConfig: { update: vi.fn().mockResolvedValue({}) }, }; userService = { create: vi.fn().mockResolvedValue({}) }; - service = new LdapService(prisma, userService); + service = new LdapService(prisma, userService, {} as any); }); it('searches every configured base DN and merges/dedupes results by dn', async () => { @@ -313,7 +317,7 @@ describe('LdapService — individual user search & import (dedup)', () => { ldapConfig: { update: vi.fn().mockResolvedValue({}) }, }; userService = { create: vi.fn().mockResolvedValue({}) }; - service = new LdapService(prisma, userService); + service = new LdapService(prisma, userService, {} as any); }); it('searchUsers flags results already present by username or ldapDn', async () => { @@ -439,7 +443,7 @@ describe('LdapService.testConnection — TLS verification opt-out (ldaps)', () = vi.clearAllMocks(); mockBind.mockResolvedValue(undefined); mockUnbind.mockResolvedValue(undefined); - service = new LdapService({} as any, {} as any); + service = new LdapService({} as any, {} as any, {} as any); }); it('passes tlsOptions.rejectUnauthorized=false for ldaps when opted out', async () => { @@ -480,7 +484,7 @@ describe('LdapService.verifyUserCredentials — LDAP login bind', () => { beforeEach(() => { vi.clearAllMocks(); mockUnbind.mockResolvedValue(undefined); - service = new LdapService({} as any, {} as any); + service = new LdapService({} as any, {} as any, {} as any); }); it('returns true when the user bind succeeds', async () => { @@ -639,7 +643,7 @@ describe('LdapService.syncUsersForTenant — AD-bound group membership sync (D-1 ldapConfig: { update: vi.fn().mockResolvedValue({}) }, }; userService = { create: vi.fn().mockResolvedValue({}) }; - service = new LdapService(prisma, userService); + service = new LdapService(prisma, userService, {} as any); }); it('runs no additional LDAP search for a tenant without AD-bound groups', async () => { @@ -875,6 +879,511 @@ describe('LdapService.syncUsersForTenant — AD-bound group membership sync (D-1 }); }); +describe('LdapService.syncBoundGroupsForTenant — Rekonziliation gegen das Verzeichnis (SC-3/SC-4/SC-5, D-05/D-06)', () => { + let service: LdapService; + let prisma: any; + let userService: any; + let groupsService: any; + let client: any; + + // Hand-rolled in-memory fake for Group (project pattern — see the D-21 + // block above), so update()/delete() and the OR-candidate query behave + // exactly like the real forTenant()-scoped Prisma calls this method + // issues, across multiple sequential runs (idempotency test below). + let groups: { + id: string; + tenantId: string; + name: string; + ldapDn: string | null; + ldapObjectGuid: string | null; + isDefault: boolean; + }[]; + + 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' }], + }; + + // 16 raw bytes, hex-decodable to a stable 32-char lowercase string — + // stands in for a real AD objectGUID. NOTE: deliberately its own literal, + // not shared with the group-import block below — that block's fixture + // string is actually 31 hex characters (an existing off-by-one from Plan + // 16-01 that never mattered there because importGroupsByDn() never + // length-validates), which would fail this method's 32-char guard. + const guidBuffer = Buffer.from('0123456789abcdef'.repeat(2), 'hex'); + const guidHex = guidBuffer.toString('hex'); + + const makeResult = (): any => ({ + created: 0, + updated: 0, + deactivated: 0, + groupMembershipsAdded: 0, + groupMembershipsRemoved: 0, + groupsAdopted: 0, + groupsRenamed: 0, + groupsDeleted: 0, + defaultMarkerMoved: 0, + errors: [] as string[], + }); + + // Direct invocation of the private method (not yet wired into + // syncUsersForTenant — that wiring is Plan 16-03 Task 2, tested + // separately below via a dedicated ordering test). + const run = (result: any) => + (service as any).syncBoundGroupsForTenant(client, cfg, 't1', result); + + beforeEach(() => { + vi.clearAllMocks(); + groups = []; + client = new Client({} as any); + + prisma = { + group: { + findMany: vi.fn((args: any) => + Promise.resolve( + groups.filter( + (g) => + g.tenantId === args.where.tenantId && + (g.ldapObjectGuid !== null || g.ldapDn !== null), + ), + ), + ), + update: vi.fn((args: any) => { + const g = groups.find((x) => x.id === args.where.id); + if (g) { + Object.assign(g, args.data); + } + return Promise.resolve(g); + }), + delete: vi.fn((args: any) => { + const idx = groups.findIndex((x) => x.id === args.where.id); + if (idx === -1) { + const err: any = new Error('Record to delete does not exist.'); + err.code = 'P2025'; + return Promise.reject(err); + } + const [removed] = groups.splice(idx, 1); + return Promise.resolve(removed); + }), + }, + }; + userService = { create: vi.fn().mockResolvedValue({}) }; + groupsService = { + reassignDefaultBeforeDelete: vi.fn().mockResolvedValue(false), + ensureDefaultGroup: vi.fn().mockResolvedValue(null), + }; + service = new LdapService(prisma, userService, groupsService); + }); + + it('returns immediately with no client.search call when the tenant has no candidate group (SC-5)', async () => { + const result = makeResult(); + await run(result); + + expect(mockSearch).not.toHaveBeenCalled(); + expect(result.errors).toEqual([]); + }); + + it('a purely local group (ldapObjectGuid: null, ldapDn: null) is excluded by the candidate query and never touched', async () => { + groups = [ + { id: 'g-local', tenantId: 't1', name: 'Local', ldapDn: null, ldapObjectGuid: null, isDefault: false }, + ]; + const result = makeResult(); + await run(result); + + expect(mockSearch).not.toHaveBeenCalled(); + expect(prisma.group.update).not.toHaveBeenCalled(); + expect(prisma.group.delete).not.toHaveBeenCalled(); + expect(groups).toEqual([ + { id: 'g-local', tenantId: 't1', name: 'Local', ldapDn: null, ldapObjectGuid: null, isDefault: false }, + ]); + }); + + it('a hit with unchanged cn/dn makes no write and increments no counter', async () => { + groups = [ + { + id: 'g1', + tenantId: 't1', + name: 'Sales', + ldapDn: 'cn=Sales,dc=example,dc=com', + ldapObjectGuid: guidHex, + isDefault: false, + }, + ]; + mockSearch.mockResolvedValue({ + searchEntries: [{ dn: 'cn=Sales,dc=example,dc=com', cn: 'Sales' }], + }); + + const result = makeResult(); + await run(result); + + expect(prisma.group.update).not.toHaveBeenCalled(); + expect(result.groupsRenamed).toBe(0); + expect(result.groupsDeleted).toBe(0); + expect(result.errors).toEqual([]); + }); + + it('a hit with a changed cn/dn updates name and ldapDn and increments groupsRenamed, never writing internalName (SC-3, D-04)', async () => { + groups = [ + { + id: 'g1', + tenantId: 't1', + name: 'Sales', + ldapDn: 'cn=Sales,dc=example,dc=com', + ldapObjectGuid: guidHex, + isDefault: false, + }, + ]; + mockSearch.mockResolvedValue({ + searchEntries: [{ dn: 'cn=Vertrieb,dc=example,dc=com', cn: 'Vertrieb' }], + }); + + const result = makeResult(); + await run(result); + + expect(prisma.group.update).toHaveBeenCalledWith({ + where: { id: 'g1' }, + data: { name: 'Vertrieb', ldapDn: 'cn=Vertrieb,dc=example,dc=com' }, + }); + expect(result.groupsRenamed).toBe(1); + expect(groups[0].name).toBe('Vertrieb'); + expect(groups[0].ldapDn).toBe('cn=Vertrieb,dc=example,dc=com'); + }); + + it('a rename colliding with an existing local name (P2002) is reported and the group is left unchanged, run continues', async () => { + groups = [ + { + id: 'g1', + tenantId: 't1', + name: 'Sales', + ldapDn: 'cn=Sales,dc=example,dc=com', + ldapObjectGuid: guidHex, + isDefault: false, + }, + { + id: 'g2', + tenantId: 't1', + name: 'IT', + ldapDn: 'cn=IT,dc=example,dc=com', + ldapObjectGuid: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + isDefault: false, + }, + ]; + prisma.group.update = vi.fn((args: any) => { + if (args.where.id === 'g1') { + const err: any = new Error('Unique constraint'); + err.code = 'P2002'; + return Promise.reject(err); + } + const g = groups.find((x) => x.id === args.where.id); + if (g) { + Object.assign(g, args.data); + } + return Promise.resolve(g); + }); + // g1's AD search hit renames it to "IT" (collides with g2's stored + // name); g2's own AD search hit renames it away to "IT-Extern" — both + // candidates genuinely change, so both reach the update() call and the + // "run continues" claim is observable (2 update attempts, not 1). + mockSearch + .mockImplementationOnce(() => + Promise.resolve({ + searchEntries: [{ dn: 'cn=IT,dc=example,dc=com', cn: 'IT' }], + }), + ) + .mockImplementationOnce(() => + Promise.resolve({ + searchEntries: [ + { dn: 'cn=IT-Extern,dc=example,dc=com', cn: 'IT-Extern' }, + ], + }), + ); + + const result = makeResult(); + await run(result); + + expect(result.errors).toEqual([ + "Gruppe Sales: Umbenennung nach 'IT' kollidiert mit einer bestehenden Gruppe", + ]); + expect(result.groupsRenamed).toBe(1); + expect(groups[0].name).toBe('Sales'); + expect(groups[1].name).toBe('IT-Extern'); + // The second candidate was still processed (run continues). + expect(prisma.group.update).toHaveBeenCalledTimes(2); + }); + + it('no hit, not the default group, deletes it and increments groupsDeleted without moving the marker', async () => { + groups = [ + { + id: 'g1', + tenantId: 't1', + name: 'Sales', + ldapDn: 'cn=Sales,dc=example,dc=com', + ldapObjectGuid: guidHex, + isDefault: false, + }, + { + id: 'g-other', + tenantId: 't1', + name: 'Alle Benutzer', + ldapDn: null, + ldapObjectGuid: null, + isDefault: true, + }, + ]; + mockSearch.mockResolvedValue({ searchEntries: [] }); + + const result = makeResult(); + await run(result); + + expect(groupsService.reassignDefaultBeforeDelete).toHaveBeenCalledWith('t1', 'g1'); + expect(prisma.group.delete).toHaveBeenCalledWith({ where: { id: 'g1' } }); + expect(result.groupsDeleted).toBe(1); + expect(result.defaultMarkerMoved).toBe(0); + expect(groups.find((g) => g.id === 'g1')).toBeUndefined(); + // A deletion happened this run — ensureDefaultGroup runs once as the + // Pitfall-5 fallback, even though a target already existed. + expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith('t1'); + }); + + it('no hit, IS the default group, another group exists — handoff runs BEFORE the delete and increments defaultMarkerMoved (D-06)', async () => { + groups = [ + { + id: 'g1', + tenantId: 't1', + name: 'Sales', + ldapDn: 'cn=Sales,dc=example,dc=com', + ldapObjectGuid: guidHex, + isDefault: true, + }, + ]; + groupsService.reassignDefaultBeforeDelete.mockResolvedValue(true); + mockSearch.mockResolvedValue({ searchEntries: [] }); + const callOrder: string[] = []; + groupsService.reassignDefaultBeforeDelete.mockImplementation(async () => { + callOrder.push('handoff'); + return true; + }); + prisma.group.delete = vi.fn((args: any) => { + callOrder.push('delete'); + const idx = groups.findIndex((x) => x.id === args.where.id); + const [removed] = groups.splice(idx, 1); + return Promise.resolve(removed); + }); + + const result = makeResult(); + await run(result); + + expect(callOrder).toEqual(['handoff', 'delete']); + expect(result.defaultMarkerMoved).toBe(1); + expect(result.groupsDeleted).toBe(1); + }); + + it('no hit, is the ONLY group of the tenant — after the delete, ensureDefaultGroup rebuilds the default group', async () => { + groups = [ + { + id: 'g1', + tenantId: 't1', + name: 'Sales', + ldapDn: 'cn=Sales,dc=example,dc=com', + ldapObjectGuid: guidHex, + isDefault: true, + }, + ]; + groupsService.reassignDefaultBeforeDelete.mockResolvedValue(false); + mockSearch.mockResolvedValue({ searchEntries: [] }); + + const result = makeResult(); + await run(result); + + expect(result.groupsDeleted).toBe(1); + expect(groups).toEqual([]); + expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith('t1'); + }); + + it('a P2025 on the delete (already gone, concurrent manual delete) is swallowed and not double-counted', async () => { + groups = [ + { + id: 'g1', + tenantId: 't1', + name: 'Sales', + ldapDn: 'cn=Sales,dc=example,dc=com', + ldapObjectGuid: guidHex, + isDefault: false, + }, + ]; + mockSearch.mockResolvedValue({ searchEntries: [] }); + prisma.group.delete = vi.fn(() => { + const err: any = new Error('Record to delete does not exist.'); + err.code = 'P2025'; + return Promise.reject(err); + }); + + const result = makeResult(); + await run(result); + + expect(result.groupsDeleted).toBe(0); + expect(result.errors).toEqual([]); + }); + + it('a legacy binding (ldapDn set, no ldapObjectGuid) whose DN still resolves is backfilled, groupsAdopted increments, and it is reconciled in the same pass (D-07)', async () => { + groups = [ + { + id: 'g1', + tenantId: 't1', + name: 'Sales', + ldapDn: 'cn=Sales,dc=example,dc=com', + ldapObjectGuid: null, + isDefault: false, + }, + ]; + mockSearch.mockImplementation((_dn: string, opts: any) => { + if (opts.scope === 'base') { + return Promise.resolve({ + searchEntries: [ + { dn: 'cn=Sales,dc=example,dc=com', cn: 'Sales', objectGUID: guidBuffer }, + ], + }); + } + // Existence sweep: same, unchanged group — no rename. + return Promise.resolve({ + searchEntries: [{ dn: 'cn=Sales,dc=example,dc=com', cn: 'Sales' }], + }); + }); + + const result = makeResult(); + await run(result); + + expect(result.groupsAdopted).toBe(1); + expect(groups[0].ldapObjectGuid).toBe(guidHex); + // Only the adoption write happened — cn/dn were already current, no + // rename update on top of it. + expect(prisma.group.update).toHaveBeenCalledTimes(1); + expect(prisma.group.update).toHaveBeenCalledWith({ + where: { id: 'g1' }, + data: { ldapObjectGuid: guidHex }, + }); + expect(result.errors).toEqual([]); + }); + + it('a legacy binding whose DN no longer resolves is NOT deleted — error line only (D-07/T-16-11)', async () => { + groups = [ + { + id: 'g1', + tenantId: 't1', + name: 'Sales', + ldapDn: 'cn=Sales,dc=example,dc=com', + ldapObjectGuid: null, + isDefault: false, + }, + ]; + mockSearch.mockResolvedValue({ searchEntries: [] }); + + const result = makeResult(); + await run(result); + + expect(prisma.group.delete).not.toHaveBeenCalled(); + expect(prisma.group.update).not.toHaveBeenCalled(); + expect(result.groupsDeleted).toBe(0); + expect(result.groupsAdopted).toBe(0); + expect(result.errors).toEqual([ + 'Gruppe Sales: Alt-Bindung cn=Sales,dc=example,dc=com laesst sich nicht mehr aufloesen', + ]); + expect(groups).toHaveLength(1); + }); + + it('an invalid stored ldapObjectGuid (not 32 [0-9a-f] chars) never reaches a filter — error line only', async () => { + groups = [ + { + id: 'g1', + tenantId: 't1', + name: 'Sales', + ldapDn: 'cn=Sales,dc=example,dc=com', + ldapObjectGuid: 'not-a-valid-hex-guid', + isDefault: false, + }, + ]; + + const result = makeResult(); + await run(result); + + expect(mockSearch).not.toHaveBeenCalled(); + expect(result.errors).toEqual([ + 'Gruppe Sales: ungueltiger ldapObjectGuid-Wert', + ]); + expect(prisma.group.delete).not.toHaveBeenCalled(); + }); + + it('a client.search exception for one group is recorded with the group name in result.errors, remaining groups still processed', async () => { + groups = [ + { + id: 'g-broken', + tenantId: 't1', + name: 'Broken', + ldapDn: 'cn=Broken,dc=example,dc=com', + ldapObjectGuid: guidHex, + isDefault: false, + }, + { + id: 'g-ok', + tenantId: 't1', + name: 'OK', + ldapDn: 'cn=OK,dc=example,dc=com', + ldapObjectGuid: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + isDefault: false, + }, + ]; + mockSearch.mockImplementation((_baseDn: string, opts: any) => { + if (opts.filter.includes(LdapService.escapeLdapFilterBuffer(guidBuffer))) { + return Promise.reject(new Error('directory unavailable')); + } + return Promise.resolve({ + searchEntries: [{ dn: 'cn=OK,dc=example,dc=com', cn: 'OK' }], + }); + }); + + const result = makeResult(); + await run(result); + + expect(result.errors).toEqual(['Gruppe Broken: directory unavailable']); + // The healthy group was still processed (no write needed — unchanged). + expect(groups.find((g) => g.id === 'g-ok')).toBeDefined(); + }); + + it('is idempotent: a second run over an unchanged AD state issues no group.update or group.delete call', async () => { + groups = [ + { + id: 'g1', + tenantId: 't1', + name: 'Sales', + ldapDn: 'cn=Sales,dc=example,dc=com', + ldapObjectGuid: guidHex, + isDefault: false, + }, + ]; + mockSearch.mockResolvedValue({ + searchEntries: [{ dn: 'cn=Sales,dc=example,dc=com', cn: 'Sales' }], + }); + + await run(makeResult()); + prisma.group.update.mockClear(); + prisma.group.delete.mockClear(); + + const second = makeResult(); + await run(second); + + expect(prisma.group.update).not.toHaveBeenCalled(); + expect(prisma.group.delete).not.toHaveBeenCalled(); + expect(second.groupsRenamed).toBe(0); + expect(second.groupsDeleted).toBe(0); + }); +}); + describe('LdapService — AD group import (SC-1/SC-2, D-01/D-02)', () => { let service: LdapService; let prisma: any; @@ -908,7 +1417,7 @@ describe('LdapService — AD group import (SC-1/SC-2, D-01/D-02)', () => { }, }; userService = { create: vi.fn().mockResolvedValue({}) }; - service = new LdapService(prisma, userService); + service = new LdapService(prisma, userService, {} as any); }); it('importGroupsByDn creates a Group with name/ldapDn/ldapObjectGuid and counts imported', async () => { diff --git a/apps/api/src/ldap/ldap.service.ts b/apps/api/src/ldap/ldap.service.ts index 6171c43..ea14e80 100644 --- a/apps/api/src/ldap/ldap.service.ts +++ b/apps/api/src/ldap/ldap.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { Client, Entry } from 'ldapts'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; +import { GroupsService } from '../groups/groups.service'; import { UserService } from '../user/user.service'; /** @@ -16,6 +17,19 @@ export interface LdapSyncResult { // sync job, no second button). groupMembershipsAdded: number; groupMembershipsRemoved: number; + // Plan 16-03: how many bound Groups this run adopted (legacy ldapDn-only + // binding from Plan 15-06, backfilled with ldapObjectGuid, D-07), renamed + // to match the current AD cn/dn (SC-3), deleted because the AD group + // disappeared (SC-4/D-05), and how many times the default-group marker + // moved to another group before one of those deletions (D-06). + // "groupsAdopted" NOT "groupsImported": this sync never imports a group + // (D-02, deliberate deviation from the UI-SPEC field name — see + // 16-03-PLAN.md decisions) — it only takes an existing legacy binding + // under management. + groupsAdopted: number; + groupsRenamed: number; + groupsDeleted: number; + defaultMarkerMoved: number; errors: string[]; } @@ -109,6 +123,7 @@ export class LdapService { constructor( private prisma: PrismaService, private userService: UserService, + private groupsService: GroupsService, ) {} /** @@ -756,6 +771,10 @@ export class LdapService { deactivated: 0, groupMembershipsAdded: 0, groupMembershipsRemoved: 0, + groupsAdopted: 0, + groupsRenamed: 0, + groupsDeleted: 0, + defaultMarkerMoved: 0, errors: [], }; @@ -1119,6 +1138,214 @@ export class LdapService { } } + /** + * D-07/SC-3/SC-4/SC-5/D-05/D-06: reconcile every AD-bound Group against + * the current directory state — the piece the D-21 membership sync above + * depends on already being correct. MUST run BEFORE + * syncGroupMembershipsForTenant() in the same pass (wired as step 5a in + * syncUsersForTenant, Plan 16-03 Task 2): that step reads Group.ldapDn to + * build its memberOf filter, so a rename that has not been written back + * yet would make every LDAP membership of the renamed group look removed + * (RESEARCH.md Pitfall 1). + * + * Candidates are every Group with EITHER ldapObjectGuid OR ldapDn set — + * the OR is the D-07 hookup for legacy bindings from Plan 15-06 that + * predate ldapObjectGuid. A Group with both columns null (never bound, or + * a purely local group) never enters this query (SC-5) and is never + * touched by any write in this method. + * + * Per candidate, in order: + * 1. Legacy-binding backfill (ldapDn set, ldapObjectGuid still null): one + * base-scoped lookup on the stored DN. A hit backfills + * ldapObjectGuid (groupsAdopted++) and the candidate is reconciled + * normally in the SAME pass below. No hit is NOT a deletion — without + * a stable key a deletion here would be a guess, not proof (T-16-11) — + * it is an error line and the candidate is skipped. + * 2. Existence sweep: the stored hex ldapObjectGuid is validated as + * exactly 32 [0-9a-f] characters BEFORE it is ever turned into a + * filter (T-16-01) — an invalid value is an error line, never a filter + * interpolation. A valid value is turned back into a Buffer and + * byte-wise escaped via escapeLdapFilterBuffer() into an + * (objectGUID=...) filter, searched across every configured base DN. + * 3. A hit whose cn/dn differ from the stored name/ldapDn is a rename + * (SC-3): Group.name/ldapDn are updated to the AD state, + * groupsRenamed++. internalName is NEVER written here (D-04). A + * resulting P2002 (the new name collides with an existing local group) + * is caught, reported as an error line, and the group is left + * unchanged — the run continues with the remaining candidates. + * 4. No hit is a disappearance (SC-4/D-05): the default-marker handoff + * (reassignDefaultBeforeDelete) runs BEFORE the delete — this ordering + * is the actual correctness guarantee of D-06, not a style choice. A + * resulting P2025 (already gone, e.g. a concurrent manual delete) is + * swallowed without double-counting. + * + * A single group's search/DB failure never stops the run — the same + * per-group try/catch pattern as syncGroupMembershipsForTenant above. + * + * After the loop, if at least one deletion happened, ensureDefaultGroup() + * runs once (not per-deletion — it is idempotent and returns immediately + * once any group exists) to close the RESEARCH.md Pitfall 5 window: a + * tenant must never be left with zero groups until the next API restart. + */ + private async syncBoundGroupsForTenant( + client: Client, + config: LdapConfigData, + tenantId: string, + result: LdapSyncResult, + ): Promise { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + + const candidates: { + id: string; + name: string; + ldapDn: string | null; + ldapObjectGuid: string | null; + isDefault: boolean; + }[] = await tenantPrisma.group.findMany({ + where: { + tenantId, + OR: [{ ldapObjectGuid: { not: null } }, { ldapDn: { not: null } }], + }, + select: { + id: true, + name: true, + ldapDn: true, + ldapObjectGuid: true, + isDefault: true, + }, + }); + if (candidates.length === 0) { + return; + } + + const baseDns = this.parseBaseDns(config.baseDn); + let anyDeleted = false; + + for (const group of candidates) { + try { + let ldapObjectGuid = group.ldapObjectGuid; + + // 1. Legacy-binding backfill (D-07-Anschluss). + if (!ldapObjectGuid && group.ldapDn) { + const { searchEntries } = await client.search(group.ldapDn, { + filter: '(objectClass=group)', + attributes: ['cn', 'dn', 'objectGUID'], + explicitBufferAttributes: ['objectGUID'], + scope: 'base', + }); + if (searchEntries.length === 0) { + result.errors.push( + `Gruppe ${group.name}: Alt-Bindung ${group.ldapDn} laesst sich nicht mehr aufloesen`, + ); + continue; + } + const backfillRecord = searchEntries[0] as unknown as Record< + string, + unknown + >; + const backfillGuid = backfillRecord['objectGUID']; + if (!Buffer.isBuffer(backfillGuid)) { + result.errors.push( + `Gruppe ${group.name}: Alt-Bindung ${group.ldapDn} ohne lesbaren objectGUID`, + ); + continue; + } + ldapObjectGuid = backfillGuid.toString('hex'); + await tenantPrisma.group.update({ + where: { id: group.id }, + data: { ldapObjectGuid }, + }); + result.groupsAdopted++; + } + + // 2. Existence sweep — validate BEFORE any filter interpolation + // (T-16-01). + if (!ldapObjectGuid || !/^[0-9a-f]{32}$/.test(ldapObjectGuid)) { + result.errors.push( + `Gruppe ${group.name}: ungueltiger ldapObjectGuid-Wert`, + ); + continue; + } + const guidBuffer = Buffer.from(ldapObjectGuid, 'hex'); + const filter = `(objectGUID=${LdapService.escapeLdapFilterBuffer(guidBuffer)})`; + + let hit: Entry | null = null; + for (const baseDn of baseDns) { + const { searchEntries } = await client.search(baseDn, { + filter, + attributes: ['cn', 'dn'], + scope: 'sub', + }); + if (searchEntries.length > 0) { + hit = searchEntries[0]; + break; + } + } + + if (hit) { + // 3. Rename/DN reconciliation (SC-3). + const hitRecord = hit as unknown as Record; + const rawName = hitRecord['cn']; + const name = Array.isArray(rawName) + ? String(rawName[0]) + : rawName + ? String(rawName) + : hit.dn; + const dn = hit.dn; + + if (name !== group.name || dn !== group.ldapDn) { + try { + await tenantPrisma.group.update({ + where: { id: group.id }, + data: { name, ldapDn: dn }, + }); + result.groupsRenamed++; + } catch (updateError: any) { + if (updateError?.code === 'P2002') { + result.errors.push( + `Gruppe ${group.name}: Umbenennung nach '${name}' kollidiert mit einer bestehenden Gruppe`, + ); + } else { + throw updateError; + } + } + } + } else { + // 4. Disappearance (SC-4/D-05/D-06) — handoff BEFORE delete. + const movedDefault = + await this.groupsService.reassignDefaultBeforeDelete( + tenantId, + group.id, + ); + if (movedDefault) { + result.defaultMarkerMoved++; + } + try { + await tenantPrisma.group.delete({ where: { id: group.id } }); + result.groupsDeleted++; + anyDeleted = true; + } catch (deleteError: any) { + if (deleteError?.code !== 'P2025') { + throw deleteError; + } + // Already gone (e.g. a concurrent manual delete) — not + // double-counted. + } + } + } catch (groupError: unknown) { + const msg = + groupError instanceof Error + ? groupError.message + : 'Unknown error reconciling group'; + result.errors.push(`Gruppe ${group.name}: ${msg}`); + } + } + + if (anyDeleted) { + await this.groupsService.ensureDefaultGroup(tenantId); + } + } + /** * Sanitize LDAP search filter to prevent injection (T-02-16). * Escapes special characters per RFC 4515.