fix(16): WR-03 do not delete a bound group merely unobserved under a narrower base DN

syncBoundGroupsForTenant()'s existence sweep only searched the configured
base DNs, so an AD group MOVED to an OU outside that subtree (still
present in the directory) was indistinguishable from a genuine
disappearance and got deleted along with its memberships/module grants —
a silent access loss from a non-destructive AD operation, and a bigger
blast radius than D-05 ("group genuinely gone") was accepted for.

Before concluding disappearance, a second (objectGUID=...) sweep now runs
against each base DN's own domain root (skipped when a base DN already IS
its domain root — the common case, nothing wider to search). A hit there
is reported as an error line and the group is left untouched; only when
the wide sweep also finds nothing is deletion (SC-4/D-05/D-06) actually
established — mirroring the existing conservative stance already taken
for a legacy binding whose DN no longer resolves. Deleting on uncertainty
was the failure mode; this closes it without widening it.
This commit is contained in:
2026-08-06 17:00:54 +02:00
parent 2779d42e6c
commit 00de6a6f9c
2 changed files with 227 additions and 2 deletions
+113
View File
@@ -1657,6 +1657,119 @@ describe('LdapService.syncBoundGroupsForTenant — Rekonziliation gegen das Verz
expect(groups[0].name).toBe('Sales');
expect(groups[0].ldapDn).toBe('CN=Sales,DC=example,DC=com');
});
it('a bound group not found under the configured (narrower) base DN, but found under the wider domain root, is NOT deleted — reported instead (WR-03, 16-REVIEW.md)', async () => {
// The configured base DN is an OU beneath the domain root — an AD
// group moved OUT of that OU (into ou=Archive, still under the same
// domain) is a directory-internal move, not a deletion.
const narrowCfg = { ...cfg, baseDn: 'ou=Sales,dc=example,dc=com' };
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,ou=Sales,dc=example,dc=com',
ldapObjectGuid: guidHex,
isDefault: false,
},
];
mockSearch.mockImplementation((baseDn: string) => {
if (baseDn === 'ou=Sales,dc=example,dc=com') {
// Nothing found under the configured (narrow) base DN.
return Promise.resolve({ searchEntries: [] });
}
if (baseDn === 'dc=example,dc=com') {
// But the group is still there, just moved to a different OU under
// the same domain — the WR-03 wide sweep must catch this.
return Promise.resolve({
searchEntries: [
{ dn: 'cn=Sales,ou=Archive,dc=example,dc=com', cn: 'Sales' },
],
});
}
throw new Error(`unexpected base DN in test: ${baseDn}`);
});
const result = makeResult();
await (service as any).syncBoundGroupsForTenant(
client,
narrowCfg,
't1',
result,
);
expect(prisma.group.delete).not.toHaveBeenCalled();
expect(prisma.group.update).not.toHaveBeenCalled();
expect(result.groupsDeleted).toBe(0);
expect(result.errors).toEqual([
"Gruppe Sales: nicht mehr unter den konfigurierten Base-DNs gefunden, existiert aber weiterhin unter 'cn=Sales,ou=Archive,dc=example,dc=com' — vermutlich im Verzeichnis verschoben, Base-DN-Konfiguration pruefen. Nicht geloescht.",
]);
expect(groups).toHaveLength(1);
});
it('a bound group not found under the configured base DN AND not found under the wider domain root either is genuinely deleted (WR-03, 16-REVIEW.md)', async () => {
const narrowCfg = { ...cfg, baseDn: 'ou=Sales,dc=example,dc=com' };
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,ou=Sales,dc=example,dc=com',
ldapObjectGuid: guidHex,
isDefault: false,
},
{
id: 'g-other',
tenantId: 't1',
name: 'Alle Benutzer',
ldapDn: null,
ldapObjectGuid: null,
isDefault: true,
},
];
// Empty everywhere: under the configured OU AND under the domain root.
mockSearch.mockResolvedValue({ searchEntries: [] });
const result = makeResult();
await (service as any).syncBoundGroupsForTenant(
client,
narrowCfg,
't1',
result,
);
expect(prisma.group.delete).toHaveBeenCalledWith({ where: { id: 'g1' } });
expect(result.groupsDeleted).toBe(1);
expect(result.errors).toEqual([]);
});
it('skips the wide fallback sweep entirely when the configured base DN already IS the domain root — no redundant client.search call (WR-03, 16-REVIEW.md)', async () => {
groups = [
{
id: 'g1',
tenantId: 't1',
name: 'Sales',
ldapDn: 'cn=Sales,dc=example,dc=com',
ldapObjectGuid: guidHex,
isDefault: false,
},
{
id: 'g-other',
tenantId: 't1',
name: 'Alle Benutzer',
ldapDn: null,
ldapObjectGuid: null,
isDefault: true,
},
];
mockSearch.mockResolvedValue({ searchEntries: [] });
const result = makeResult();
await run(result); // cfg.baseDn === 'dc=example,dc=com', already the domain root
expect(mockSearch).toHaveBeenCalledTimes(1);
expect(result.groupsDeleted).toBe(1);
});
});
describe('LdapService — AD group import (SC-1/SC-2, D-01/D-02)', () => {