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([]),
|
||||
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<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;
|
||||
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=<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).
|
||||
* Escapes special characters per RFC 4515.
|
||||
|
||||
Reference in New Issue
Block a user