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:
2026-08-04 15:50:42 +02:00
parent 2a79d4ca1f
commit 614de2815a
2 changed files with 528 additions and 1 deletions
+385 -1
View File
@@ -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' }),
);
});
});
+143
View File
@@ -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.