feat(15-04): AD-Gruppenmitgliedschafts-Abgleich im bestehenden LDAP-Sync
- LdapService.syncGroupMembershipsForTenant (neu, privat): pro AD-gebundener Group (ldapDn gesetzt) ein memberOf-Reverse-Query je Base-DN, nie ein Attribut-Lesen (Range-Retrieval-Pitfall). GroupMembership(source: LDAP) wird per createMany/skipDuplicates angelegt (lässt bestehende MANUAL-Zeilen unangetastet, D-19/D-20) und per deleteMany(source: 'LDAP', notIn: [...]) bereinigt. Jede Gruppe läuft in eigenem try/catch, ein Fehler landet als "Gruppe <name>: <message>" in result.errors, die Schleife läuft weiter. - Aufruf in syncUsersForTenant nach der Deaktivierungsschleife (Schritt 5) und vor lastSyncAt (Schritt 6) — hinter dem bestehenden Base-DN-No-Op-Wächter, kein separater Job, kein zweiter Button (D-21). - LdapSyncResult um groupMembershipsAdded/groupMembershipsRemoved erweitert. - ldap.service.spec.ts: neuer describe-Block mit 13 Tests (adjacency, empty, encoding, ordering, idempotency, concurrency/backstop) plus Anpassung der drei bestehenden Prisma-Fixtures und einer Ergebnis-Assertion an die erweiterte LdapSyncResult-Form. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,6 +55,13 @@ describe('LdapService.syncUsersForTenant — per-user exclude list', () => {
|
|||||||
findMany: vi.fn().mockResolvedValue([]),
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
update: 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({}) },
|
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
userService = { create: 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' }]),
|
findMany: vi.fn().mockResolvedValue([{ id: 'u-existing', ldapDn: 'cn=existing' }]),
|
||||||
update: 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({}) },
|
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
userService = { create: vi.fn().mockResolvedValue({}) };
|
userService = { create: vi.fn().mockResolvedValue({}) };
|
||||||
@@ -159,7 +171,14 @@ describe('LdapService.syncUsersForTenant — empty base DN no-op', () => {
|
|||||||
't1',
|
'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(mockSearch).not.toHaveBeenCalled();
|
||||||
expect(mockBind).not.toHaveBeenCalled();
|
expect(mockBind).not.toHaveBeenCalled();
|
||||||
expect(userService.create).not.toHaveBeenCalled();
|
expect(userService.create).not.toHaveBeenCalled();
|
||||||
@@ -212,6 +231,11 @@ describe('LdapService.syncUsersForTenant — multi base DN scope', () => {
|
|||||||
findMany: vi.fn().mockResolvedValue([]),
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
update: 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({}) },
|
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
userService = { create: vi.fn().mockResolvedValue({}) };
|
userService = { create: vi.fn().mockResolvedValue({}) };
|
||||||
@@ -490,3 +514,363 @@ describe('LdapService.verifyUserCredentials — LDAP login bind', () => {
|
|||||||
expect(mockBind).not.toHaveBeenCalled();
|
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<string, unknown>[];
|
||||||
|
|
||||||
|
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<string>(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' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export interface LdapSyncResult {
|
|||||||
created: number;
|
created: number;
|
||||||
updated: number;
|
updated: number;
|
||||||
deactivated: 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[];
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,6 +569,8 @@ export class LdapService {
|
|||||||
created: 0,
|
created: 0,
|
||||||
updated: 0,
|
updated: 0,
|
||||||
deactivated: 0,
|
deactivated: 0,
|
||||||
|
groupMembershipsAdded: 0,
|
||||||
|
groupMembershipsRemoved: 0,
|
||||||
errors: [],
|
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
|
// 6. Update lastSyncAt
|
||||||
await this.prisma.ldapConfig.update({
|
await this.prisma.ldapConfig.update({
|
||||||
where: { id: config.id },
|
where: { id: config.id },
|
||||||
@@ -791,6 +810,130 @@ export class LdapService {
|
|||||||
return Array.from(entriesByDn.values());
|
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=<groupDn>)` 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=<dn>) 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 <name>: <message>` 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<void> {
|
||||||
|
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<username> so the AD hit ORDER never affects the outcome — the
|
||||||
|
// reconciliation below is a pure set operation over usernames.
|
||||||
|
const usernames = new Set<string>();
|
||||||
|
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<string, unknown>,
|
||||||
|
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).
|
* Sanitize LDAP search filter to prevent injection (T-02-16).
|
||||||
* Escapes special characters per RFC 4515.
|
* Escapes special characters per RFC 4515.
|
||||||
|
|||||||
Reference in New Issue
Block a user