diff --git a/apps/api/src/ldap/ldap.service.spec.ts b/apps/api/src/ldap/ldap.service.spec.ts index 5303994..389160f 100644 --- a/apps/api/src/ldap/ldap.service.spec.ts +++ b/apps/api/src/ldap/ldap.service.spec.ts @@ -55,6 +55,13 @@ describe('LdapService.syncUsersForTenant — per-user exclude list', () => { findMany: vi.fn().mockResolvedValue([]), update: vi.fn().mockResolvedValue({}), }, + // No AD-bound groups in this describe block — the group-membership + // reconciliation (D-21) has its own dedicated describe block below. + group: { findMany: vi.fn().mockResolvedValue([]) }, + groupMembership: { + createMany: vi.fn().mockResolvedValue({ count: 0 }), + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, ldapConfig: { update: vi.fn().mockResolvedValue({}) }, }; userService = { create: vi.fn().mockResolvedValue({}) }; @@ -147,6 +154,11 @@ describe('LdapService.syncUsersForTenant — empty base DN no-op', () => { findMany: vi.fn().mockResolvedValue([{ id: 'u-existing', ldapDn: 'cn=existing' }]), update: vi.fn().mockResolvedValue({}), }, + group: { findMany: vi.fn().mockResolvedValue([]) }, + groupMembership: { + createMany: vi.fn().mockResolvedValue({ count: 0 }), + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, ldapConfig: { update: vi.fn().mockResolvedValue({}) }, }; userService = { create: vi.fn().mockResolvedValue({}) }; @@ -159,7 +171,14 @@ describe('LdapService.syncUsersForTenant — empty base DN no-op', () => { 't1', ); - expect(result).toEqual({ created: 0, updated: 0, deactivated: 0, errors: [] }); + expect(result).toEqual({ + created: 0, + updated: 0, + deactivated: 0, + groupMembershipsAdded: 0, + groupMembershipsRemoved: 0, + errors: [], + }); expect(mockSearch).not.toHaveBeenCalled(); expect(mockBind).not.toHaveBeenCalled(); expect(userService.create).not.toHaveBeenCalled(); @@ -212,6 +231,11 @@ describe('LdapService.syncUsersForTenant — multi base DN scope', () => { findMany: vi.fn().mockResolvedValue([]), update: vi.fn().mockResolvedValue({}), }, + group: { findMany: vi.fn().mockResolvedValue([]) }, + groupMembership: { + createMany: vi.fn().mockResolvedValue({ count: 0 }), + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, ldapConfig: { update: vi.fn().mockResolvedValue({}) }, }; userService = { create: vi.fn().mockResolvedValue({}) }; @@ -490,3 +514,363 @@ describe('LdapService.verifyUserCredentials — LDAP login bind', () => { expect(mockBind).not.toHaveBeenCalled(); }); }); + +describe('LdapService.syncUsersForTenant — AD-bound group membership sync (D-19/D-20/D-21, PERM-02)', () => { + let service: LdapService; + let prisma: any; + let userService: any; + + // Hand-rolled in-memory fake for Group/GroupMembership/User (project pattern + // — see groups.service.spec.ts), so createMany/skipDuplicates and deleteMany + // behave like the real @@unique([groupId, userId]) constraint from 15-01: + // a pre-existing MANUAL row is left untouched by an LDAP createMany, never + // upgraded/duplicated (D-19/D-20). + let groups: { id: string; tenantId: string; name: string; ldapDn: string | null }[]; + let memberships: { id: string; groupId: string; userId: string; source: 'MANUAL' | 'LDAP' }[]; + let users: { id: string; tenantId: string; username: string }[]; + let seq: number; + + // The plain user-sync search (no memberOf clause) returns nothing in this + // block by default — every test here focuses purely on the group-membership + // reconciliation step, not on user creation/deactivation (covered above). + let groupSearchEntries: Record[]; + + 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' }], + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockBind.mockResolvedValue(undefined); + mockUnbind.mockResolvedValue(undefined); + + seq = 0; + groups = []; + memberships = []; + users = [ + { id: 'u-alice', tenantId: 't1', username: 'alice' }, + { id: 'u-bob', tenantId: 't1', username: 'bob' }, + ]; + groupSearchEntries = []; + + mockSearch.mockImplementation((_baseDn: string, opts: any) => { + if (typeof opts.filter === 'string' && opts.filter.includes('memberOf=')) { + return Promise.resolve({ searchEntries: groupSearchEntries }); + } + // Plain user-sync search: no entries in this describe block. + return Promise.resolve({ searchEntries: [] }); + }); + + prisma = { + user: { + findFirst: vi.fn().mockResolvedValue(null), + findMany: vi.fn((args: any) => { + if (args?.where?.username?.in) { + const wanted = new Set(args.where.username.in); + return Promise.resolve( + users + .filter( + (u) => u.tenantId === args.where.tenantId && wanted.has(u.username), + ) + .map((u) => ({ id: u.id })), + ); + } + // Deactivation-loop query (ldapDn: { not: null }) — not under test here. + return Promise.resolve([]); + }), + update: vi.fn().mockResolvedValue({}), + }, + group: { + findMany: vi.fn((args: any) => + Promise.resolve( + groups.filter( + (g) => g.tenantId === args.where.tenantId && g.ldapDn !== null, + ), + ), + ), + }, + groupMembership: { + createMany: vi.fn((args: any) => { + let count = 0; + for (const row of args.data as { + groupId: string; + userId: string; + source: string; + }[]) { + const exists = memberships.some( + (m) => m.groupId === row.groupId && m.userId === row.userId, + ); + if (exists) { + // skipDuplicates: pre-existing row (e.g. MANUAL) stays untouched, + // never upgraded/counted — exactly the @@unique([groupId, userId]) + // constraint from 15-01. + continue; + } + memberships.push({ + id: `auto${seq++}`, + groupId: row.groupId, + userId: row.userId, + source: row.source as 'MANUAL' | 'LDAP', + }); + count++; + } + return Promise.resolve({ count }); + }), + deleteMany: vi.fn((args: any) => { + const notIn: string[] = args.where.userId?.notIn ?? []; + const before = memberships.length; + memberships = memberships.filter((m) => { + const matchesDeleteTarget = + m.groupId === args.where.groupId && + m.source === args.where.source && + !notIn.includes(m.userId); + return !matchesDeleteTarget; + }); + return Promise.resolve({ count: before - memberships.length }); + }), + }, + ldapConfig: { update: vi.fn().mockResolvedValue({}) }, + }; + userService = { create: vi.fn().mockResolvedValue({}) }; + service = new LdapService(prisma, userService); + }); + + it('runs no additional LDAP search for a tenant without AD-bound groups', async () => { + // groups stays [] — group.findMany() has nothing to return. + const result = await service.syncUsersForTenant(cfg as any, 't1'); + + expect(prisma.group.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { tenantId: 't1', ldapDn: { not: null } }, + }), + ); + // Only the plain user-sync search ran (one call, one base DN) — no + // memberOf-filtered search was issued. + expect(mockSearch).toHaveBeenCalledTimes(1); + expect(mockSearch.mock.calls[0][1].filter).not.toContain('memberOf='); + expect(result.groupMembershipsAdded).toBe(0); + expect(result.groupMembershipsRemoved).toBe(0); + }); + + it('ignores a Tessera group with no ldapDn set (never queried)', async () => { + groups = [{ id: 'g-unbound', tenantId: 't1', name: 'Unbound', ldapDn: null }]; + + await service.syncUsersForTenant(cfg as any, 't1'); + + // The in-memory fake's group.findMany already filters ldapDn !== null, + // mirroring the real Prisma where-clause — so no group search happens. + expect(mockSearch).toHaveBeenCalledTimes(1); + expect(mockSearch.mock.calls[0][1].filter).not.toContain('memberOf='); + }); + + it('searches every configured base DN once per bound group, filter = sanitized filter AND escaped memberOf', async () => { + groups = [ + { id: 'g1', tenantId: 't1', name: 'Sales', ldapDn: 'CN=Sales,DC=ctl,DC=local' }, + ]; + groupSearchEntries = []; + + const result = await service.syncUsersForTenant( + { ...cfg, baseDn: 'dc=a,dc=com\ndc=b,dc=com' } as any, + 't1', + ); + + const memberOfCalls = mockSearch.mock.calls.filter(([, opts]) => + (opts.filter as string).includes('memberOf='), + ); + expect(memberOfCalls).toHaveLength(2); + expect(memberOfCalls[0][0]).toBe('dc=a,dc=com'); + expect(memberOfCalls[1][0]).toBe('dc=b,dc=com'); + for (const [, opts] of memberOfCalls) { + expect(opts.filter).toBe( + '(&(objectClass=person)(memberOf=CN=Sales,DC=ctl,DC=local))', + ); + expect(opts.scope).toBe('sub'); + } + expect(result.errors).toEqual([]); + }); + + it('passes the IDENTICAL attribute list to the group search as to the user sync (memberOf is a filter, never a return attribute)', async () => { + groups = [ + { id: 'g1', tenantId: 't1', name: 'Sales', ldapDn: 'CN=Sales,DC=ctl,DC=local' }, + ]; + groupSearchEntries = []; + + await service.syncUsersForTenant(cfg as any, 't1'); + + const userSyncCall = mockSearch.mock.calls.find( + ([, opts]) => !(opts.filter as string).includes('memberOf='), + ); + const groupSyncCall = mockSearch.mock.calls.find(([, opts]) => + (opts.filter as string).includes('memberOf='), + ); + expect(userSyncCall).toBeDefined(); + expect(groupSyncCall).toBeDefined(); + expect(groupSyncCall![1].attributes).toEqual(userSyncCall![1].attributes); + // Never a return attribute: memberOf itself is not in the requested list. + expect(groupSyncCall![1].attributes).not.toContain('memberOf'); + }); + + it('escapes special characters in the group DN before interpolating into the filter (RFC 4515)', async () => { + groups = [ + { + id: 'g1', + tenantId: 't1', + name: 'Sales EMEA', + ldapDn: 'CN=Sales (EMEA)*\\,DC=ctl,DC=local', + }, + ]; + groupSearchEntries = []; + + await service.syncUsersForTenant(cfg as any, 't1'); + + const groupSyncCall = mockSearch.mock.calls.find(([, opts]) => + (opts.filter as string).includes('memberOf='), + ); + expect(groupSyncCall![1].filter).toBe( + '(&(objectClass=person)(memberOf=CN=Sales \\28EMEA\\29\\2a\\5c,DC=ctl,DC=local))', + ); + }); + + it('creates a GroupMembership(source: LDAP) for an AD hit whose username exists locally', async () => { + groups = [{ id: 'g1', tenantId: 't1', name: 'Sales', ldapDn: 'CN=Sales,DC=ctl,DC=local' }]; + groupSearchEntries = [{ dn: 'cn=alice,dc=example,dc=com', sAMAccountName: 'alice' }]; + + const result = await service.syncUsersForTenant(cfg as any, 't1'); + + expect(memberships).toEqual([ + { id: 'auto0', groupId: 'g1', userId: 'u-alice', source: 'LDAP' }, + ]); + expect(result.groupMembershipsAdded).toBe(1); + expect(result.groupMembershipsRemoved).toBe(0); + expect(result.errors).toEqual([]); + }); + + it('creates no membership and no error for an AD hit with no matching local user', async () => { + groups = [{ id: 'g1', tenantId: 't1', name: 'Sales', ldapDn: 'CN=Sales,DC=ctl,DC=local' }]; + groupSearchEntries = [{ dn: 'cn=ghost,dc=example,dc=com', sAMAccountName: 'ghost' }]; + + const result = await service.syncUsersForTenant(cfg as any, 't1'); + + expect(memberships).toEqual([]); + expect(result.groupMembershipsAdded).toBe(0); + expect(result.errors).toEqual([]); + }); + + it('empty AD result removes every LDAP membership of the group but keeps every MANUAL one (D-19/D-20)', async () => { + groups = [{ id: 'g1', tenantId: 't1', name: 'Sales', ldapDn: 'CN=Sales,DC=ctl,DC=local' }]; + memberships = [ + { id: 'm0', groupId: 'g1', userId: 'u-alice', source: 'LDAP' }, + { id: 'm1', groupId: 'g1', userId: 'u-bob', source: 'MANUAL' }, + ]; + groupSearchEntries = []; // zero AD hits + + const result = await service.syncUsersForTenant(cfg as any, 't1'); + + expect(memberships).toEqual([ + { id: 'm1', groupId: 'g1', userId: 'u-bob', source: 'MANUAL' }, + ]); + expect(result.groupMembershipsRemoved).toBe(1); + }); + + it('never removes a MANUAL membership even when its user is absent from the AD result, and never upgrades it to LDAP when re-found (D-19/D-20 mixed membership)', async () => { + groups = [{ id: 'g1', tenantId: 't1', name: 'Sales', ldapDn: 'CN=Sales,DC=ctl,DC=local' }]; + memberships = [ + { id: 'm0', groupId: 'g1', userId: 'u-alice', source: 'MANUAL' }, + ]; + // alice IS present in the AD result too — mixed membership (D-20). + groupSearchEntries = [{ dn: 'cn=alice,dc=example,dc=com', sAMAccountName: 'alice' }]; + + const result = await service.syncUsersForTenant(cfg as any, 't1'); + + // Exactly one row, still MANUAL — createMany's skipDuplicates left it + // untouched, it was not upgraded to LDAP nor duplicated. + expect(memberships).toEqual([ + { id: 'm0', groupId: 'g1', userId: 'u-alice', source: 'MANUAL' }, + ]); + expect(result.groupMembershipsAdded).toBe(0); + expect(result.groupMembershipsRemoved).toBe(0); + }); + + it('is unaffected by AD result ORDER — the outcome is a pure set operation over usernames', async () => { + groups = [{ id: 'g1', tenantId: 't1', name: 'Sales', ldapDn: 'CN=Sales,DC=ctl,DC=local' }]; + groupSearchEntries = [ + { dn: 'cn=alice,dc=example,dc=com', sAMAccountName: 'alice' }, + { dn: 'cn=bob,dc=example,dc=com', sAMAccountName: 'bob' }, + ]; + await service.syncUsersForTenant(cfg as any, 't1'); + const forward = memberships.map((m) => m.userId).sort(); + + // Reset and re-run with the reversed hit order. + seq = 0; + memberships = []; + groupSearchEntries = [ + { dn: 'cn=bob,dc=example,dc=com', sAMAccountName: 'bob' }, + { dn: 'cn=alice,dc=example,dc=com', sAMAccountName: 'alice' }, + ]; + await service.syncUsersForTenant(cfg as any, 't1'); + const reversed = memberships.map((m) => m.userId).sort(); + + expect(reversed).toEqual(forward); + expect(forward).toEqual(['u-alice', 'u-bob']); + }); + + it('is idempotent: a second run with an unchanged AD result adds and removes nothing', async () => { + groups = [{ id: 'g1', tenantId: 't1', name: 'Sales', ldapDn: 'CN=Sales,DC=ctl,DC=local' }]; + groupSearchEntries = [{ dn: 'cn=alice,dc=example,dc=com', sAMAccountName: 'alice' }]; + + const first = await service.syncUsersForTenant(cfg as any, 't1'); + expect(first.groupMembershipsAdded).toBe(1); + expect(first.groupMembershipsRemoved).toBe(0); + + const second = await service.syncUsersForTenant(cfg as any, 't1'); + expect(second.groupMembershipsAdded).toBe(0); + expect(second.groupMembershipsRemoved).toBe(0); + expect(memberships).toHaveLength(1); + }); + + it('records a search failure for one group in result.errors (with the group name) and keeps processing the remaining groups without losing MANUAL rows (concurrency/backstop)', async () => { + groups = [ + { id: 'g-broken', tenantId: 't1', name: 'Broken Group', ldapDn: 'CN=Broken,DC=ctl,DC=local' }, + { id: 'g-ok', tenantId: 't1', name: 'OK Group', ldapDn: 'CN=OK,DC=ctl,DC=local' }, + ]; + memberships = [ + { id: 'm0', groupId: 'g-broken', userId: 'u-bob', source: 'MANUAL' }, + ]; + + mockSearch.mockImplementation((_baseDn: string, opts: any) => { + if (typeof opts.filter === 'string' && opts.filter.includes('CN=Broken')) { + return Promise.reject(new Error('directory unavailable')); + } + if (typeof opts.filter === 'string' && opts.filter.includes('memberOf=')) { + return Promise.resolve({ + searchEntries: [{ dn: 'cn=alice,dc=example,dc=com', sAMAccountName: 'alice' }], + }); + } + return Promise.resolve({ searchEntries: [] }); + }); + + const result = await service.syncUsersForTenant(cfg as any, 't1'); + + expect(result.errors).toEqual([ + 'Gruppe Broken Group: directory unavailable', + ]); + // The broken group's pre-existing MANUAL row survives untouched. + expect(memberships).toContainEqual({ + id: 'm0', + groupId: 'g-broken', + userId: 'u-bob', + source: 'MANUAL', + }); + // The second, healthy group was still processed. + expect(memberships).toContainEqual( + expect.objectContaining({ groupId: 'g-ok', userId: 'u-alice', source: 'LDAP' }), + ); + }); +}); diff --git a/apps/api/src/ldap/ldap.service.ts b/apps/api/src/ldap/ldap.service.ts index 461f16c..2eba74a 100644 --- a/apps/api/src/ldap/ldap.service.ts +++ b/apps/api/src/ldap/ldap.service.ts @@ -11,6 +11,11 @@ export interface LdapSyncResult { created: number; updated: number; deactivated: number; + // D-21: how many GroupMembership(source: LDAP) rows this run added/removed + // while reconciling every AD-bound Group in the same pass (no separate + // sync job, no second button). + groupMembershipsAdded: number; + groupMembershipsRemoved: number; errors: string[]; } @@ -564,6 +569,8 @@ export class LdapService { created: 0, updated: 0, deactivated: 0, + groupMembershipsAdded: 0, + groupMembershipsRemoved: 0, errors: [], }; @@ -695,6 +702,18 @@ export class LdapService { } } + // 5b. D-21: reconcile GroupMembership rows for every AD-bound Group in + // this same run — no separate sync job, no second button. Runs behind + // the Base-DN no-op guard above, exactly like the rest of this method. + await this.syncGroupMembershipsForTenant( + client, + config, + sanitizedFilter, + attributes, + tenantId, + result, + ); + // 6. Update lastSyncAt await this.prisma.ldapConfig.update({ where: { id: config.id }, @@ -791,6 +810,130 @@ export class LdapService { return Array.from(entriesByDn.values()); } + /** + * D-21: reconcile GroupMembership rows for every Tessera Group bound to an + * AD group (Group.ldapDn set), called from syncUsersForTenant's existing + * run instead of a separate job/button. For each bound group, members are + * found via a memberOf REVERSE-QUERY — `(memberOf=)` as a search + * FILTER, exactly the collectSearchEntries pattern above — never by reading + * memberOf/member off an entry as a return attribute, which AD silently + * truncates to `attribute;range=0-1499` for large groups (Pitfall 1, + * 15-RESEARCH.md): a bound group would then permanently show ~1500 members + * with no error anywhere. Only source: 'LDAP' rows are ever added or + * removed here; source: 'MANUAL' rows (D-20 mixed membership) are never + * touched — that filter is the sole protection for hand-added members + * (D-19). + * + * Nested AD groups are DELIBERATELY not resolved: only direct memberOf + * membership is considered via the plain (memberOf=) filter, not the + * AD-specific LDAP_MATCHING_RULE_IN_CHAIN extension. This mirrors the + * existing groupFilterDns behavior from Phase 2, is not required by D-19, + * and a vendor-specific matching rule would silently return zero hits + * against a non-AD directory instead of failing loudly. + * + * A tenant with no AD-bound groups runs no additional LDAP search at all. + * Each group is reconciled in its own try/catch so one failing group's + * search error (recorded as `Gruppe : ` in result.errors) + * never stops the remaining groups from being processed. + */ + private async syncGroupMembershipsForTenant( + client: Client, + config: LdapConfigData, + sanitizedFilter: string, + attributes: string[], + tenantId: string, + result: LdapSyncResult, + ): Promise { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + + const boundGroups: { id: string; name: string; ldapDn: string | null }[] = + await tenantPrisma.group.findMany({ + where: { tenantId, ldapDn: { not: null } }, + select: { id: true, name: true, ldapDn: true }, + }); + if (boundGroups.length === 0) { + return; + } + + const baseDns = this.parseBaseDns(config.baseDn); + + for (const group of boundGroups) { + try { + // The group DN is admin-selected input (D-18 discovery picker), but + // is always escaped before interpolation, never trusted raw (T-15-07). + const filter = `(&${sanitizedFilter}(memberOf=${LdapService.escapeLdapFilterValue(group.ldapDn as string)}))`; + + // Set so the AD hit ORDER never affects the outcome — the + // reconciliation below is a pure set operation over usernames. + const usernames = new Set(); + for (const baseDn of baseDns) { + const { searchEntries } = await client.search(baseDn, { + filter, + attributes, + scope: 'sub', + }); + for (const entry of searchEntries) { + const { username } = this.mapEntry( + entry as Record, + config.fieldMappings, + ); + if (username) { + usernames.add(username); + } + } + } + + // Resolve AD hits to local users in ONE query (tenantId-scoped, never + // a cross-tenant match — T-15-15). A username with no matching local + // user is neither an error nor a membership: it was excluded or lies + // outside the sync base. + let matchedUserIds: string[] = []; + if (usernames.size > 0) { + const localUsers: { id: string }[] = + await tenantPrisma.user.findMany({ + where: { tenantId, username: { in: [...usernames] } }, + select: { id: true }, + }); + matchedUserIds = localUsers.map((u) => u.id); + } + + // createMany + skipDuplicates against @@unique([groupId, userId]) is + // exactly what leaves a pre-existing MANUAL row untouched instead of + // upgrading it to LDAP (D-19/D-20 mixed membership). + if (matchedUserIds.length > 0) { + const created = await tenantPrisma.groupMembership.createMany({ + data: matchedUserIds.map((userId) => ({ + groupId: group.id, + userId, + source: 'LDAP', + })), + skipDuplicates: true, + }); + result.groupMembershipsAdded += created.count; + } + + // The source: 'LDAP' filter here is the ONLY thing protecting MANUAL + // memberships from this delete — an empty matchedUserIds list (zero + // AD hits) correctly removes every LDAP membership of this group and + // leaves every MANUAL one behind (notIn: [] matches all rows). + const removed = await tenantPrisma.groupMembership.deleteMany({ + where: { + groupId: group.id, + source: 'LDAP', + userId: { notIn: matchedUserIds }, + }, + }); + result.groupMembershipsRemoved += removed.count; + } catch (groupError: unknown) { + const msg = + groupError instanceof Error + ? groupError.message + : 'Unknown error syncing group'; + result.errors.push(`Gruppe ${group.name}: ${msg}`); + } + } + } + /** * Sanitize LDAP search filter to prevent injection (T-02-16). * Escapes special characters per RFC 4515.