feat(16-03): add syncBoundGroupsForTenant reconciliation method
- New private LdapService.syncBoundGroupsForTenant(): rename detection (SC-3), disappearance deletion with default-marker handoff before delete (SC-4/D-05/D-06), legacy ldapDn-only binding GUID backfill (D-07), and a 32-hex-char guard before any objectGUID filter interpolation (T-16-01) - LdapSyncResult grows additively: groupsAdopted, groupsRenamed, groupsDeleted, defaultMarkerMoved - LdapService constructor takes GroupsService; LdapModule imports GroupsModule (no cycle) - 14 new test cases covering the full behavior matrix plus idempotency
This commit is contained in:
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Client, Entry } from 'ldapts';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { GroupsService } from '../groups/groups.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
|
||||
/**
|
||||
@@ -16,6 +17,19 @@ export interface LdapSyncResult {
|
||||
// sync job, no second button).
|
||||
groupMembershipsAdded: number;
|
||||
groupMembershipsRemoved: number;
|
||||
// Plan 16-03: how many bound Groups this run adopted (legacy ldapDn-only
|
||||
// binding from Plan 15-06, backfilled with ldapObjectGuid, D-07), renamed
|
||||
// to match the current AD cn/dn (SC-3), deleted because the AD group
|
||||
// disappeared (SC-4/D-05), and how many times the default-group marker
|
||||
// moved to another group before one of those deletions (D-06).
|
||||
// "groupsAdopted" NOT "groupsImported": this sync never imports a group
|
||||
// (D-02, deliberate deviation from the UI-SPEC field name — see
|
||||
// 16-03-PLAN.md decisions) — it only takes an existing legacy binding
|
||||
// under management.
|
||||
groupsAdopted: number;
|
||||
groupsRenamed: number;
|
||||
groupsDeleted: number;
|
||||
defaultMarkerMoved: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
@@ -109,6 +123,7 @@ export class LdapService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private userService: UserService,
|
||||
private groupsService: GroupsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -756,6 +771,10 @@ export class LdapService {
|
||||
deactivated: 0,
|
||||
groupMembershipsAdded: 0,
|
||||
groupMembershipsRemoved: 0,
|
||||
groupsAdopted: 0,
|
||||
groupsRenamed: 0,
|
||||
groupsDeleted: 0,
|
||||
defaultMarkerMoved: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
@@ -1119,6 +1138,214 @@ export class LdapService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* D-07/SC-3/SC-4/SC-5/D-05/D-06: reconcile every AD-bound Group against
|
||||
* the current directory state — the piece the D-21 membership sync above
|
||||
* depends on already being correct. MUST run BEFORE
|
||||
* syncGroupMembershipsForTenant() in the same pass (wired as step 5a in
|
||||
* syncUsersForTenant, Plan 16-03 Task 2): that step reads Group.ldapDn to
|
||||
* build its memberOf filter, so a rename that has not been written back
|
||||
* yet would make every LDAP membership of the renamed group look removed
|
||||
* (RESEARCH.md Pitfall 1).
|
||||
*
|
||||
* Candidates are every Group with EITHER ldapObjectGuid OR ldapDn set —
|
||||
* the OR is the D-07 hookup for legacy bindings from Plan 15-06 that
|
||||
* predate ldapObjectGuid. A Group with both columns null (never bound, or
|
||||
* a purely local group) never enters this query (SC-5) and is never
|
||||
* touched by any write in this method.
|
||||
*
|
||||
* Per candidate, in order:
|
||||
* 1. Legacy-binding backfill (ldapDn set, ldapObjectGuid still null): one
|
||||
* base-scoped lookup on the stored DN. A hit backfills
|
||||
* ldapObjectGuid (groupsAdopted++) and the candidate is reconciled
|
||||
* normally in the SAME pass below. No hit is NOT a deletion — without
|
||||
* a stable key a deletion here would be a guess, not proof (T-16-11) —
|
||||
* it is an error line and the candidate is skipped.
|
||||
* 2. Existence sweep: the stored hex ldapObjectGuid is validated as
|
||||
* exactly 32 [0-9a-f] characters BEFORE it is ever turned into a
|
||||
* filter (T-16-01) — an invalid value is an error line, never a filter
|
||||
* interpolation. A valid value is turned back into a Buffer and
|
||||
* byte-wise escaped via escapeLdapFilterBuffer() into an
|
||||
* (objectGUID=...) filter, searched across every configured base DN.
|
||||
* 3. A hit whose cn/dn differ from the stored name/ldapDn is a rename
|
||||
* (SC-3): Group.name/ldapDn are updated to the AD state,
|
||||
* groupsRenamed++. internalName is NEVER written here (D-04). A
|
||||
* resulting P2002 (the new name collides with an existing local group)
|
||||
* is caught, reported as an error line, and the group is left
|
||||
* unchanged — the run continues with the remaining candidates.
|
||||
* 4. No hit is a disappearance (SC-4/D-05): the default-marker handoff
|
||||
* (reassignDefaultBeforeDelete) runs BEFORE the delete — this ordering
|
||||
* is the actual correctness guarantee of D-06, not a style choice. A
|
||||
* resulting P2025 (already gone, e.g. a concurrent manual delete) is
|
||||
* swallowed without double-counting.
|
||||
*
|
||||
* A single group's search/DB failure never stops the run — the same
|
||||
* per-group try/catch pattern as syncGroupMembershipsForTenant above.
|
||||
*
|
||||
* After the loop, if at least one deletion happened, ensureDefaultGroup()
|
||||
* runs once (not per-deletion — it is idempotent and returns immediately
|
||||
* once any group exists) to close the RESEARCH.md Pitfall 5 window: a
|
||||
* tenant must never be left with zero groups until the next API restart.
|
||||
*/
|
||||
private async syncBoundGroupsForTenant(
|
||||
client: Client,
|
||||
config: LdapConfigData,
|
||||
tenantId: string,
|
||||
result: LdapSyncResult,
|
||||
): Promise<void> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
|
||||
const candidates: {
|
||||
id: string;
|
||||
name: string;
|
||||
ldapDn: string | null;
|
||||
ldapObjectGuid: string | null;
|
||||
isDefault: boolean;
|
||||
}[] = await tenantPrisma.group.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
OR: [{ ldapObjectGuid: { not: null } }, { ldapDn: { not: null } }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
ldapDn: true,
|
||||
ldapObjectGuid: true,
|
||||
isDefault: true,
|
||||
},
|
||||
});
|
||||
if (candidates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseDns = this.parseBaseDns(config.baseDn);
|
||||
let anyDeleted = false;
|
||||
|
||||
for (const group of candidates) {
|
||||
try {
|
||||
let ldapObjectGuid = group.ldapObjectGuid;
|
||||
|
||||
// 1. Legacy-binding backfill (D-07-Anschluss).
|
||||
if (!ldapObjectGuid && group.ldapDn) {
|
||||
const { searchEntries } = await client.search(group.ldapDn, {
|
||||
filter: '(objectClass=group)',
|
||||
attributes: ['cn', 'dn', 'objectGUID'],
|
||||
explicitBufferAttributes: ['objectGUID'],
|
||||
scope: 'base',
|
||||
});
|
||||
if (searchEntries.length === 0) {
|
||||
result.errors.push(
|
||||
`Gruppe ${group.name}: Alt-Bindung ${group.ldapDn} laesst sich nicht mehr aufloesen`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const backfillRecord = searchEntries[0] as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const backfillGuid = backfillRecord['objectGUID'];
|
||||
if (!Buffer.isBuffer(backfillGuid)) {
|
||||
result.errors.push(
|
||||
`Gruppe ${group.name}: Alt-Bindung ${group.ldapDn} ohne lesbaren objectGUID`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
ldapObjectGuid = backfillGuid.toString('hex');
|
||||
await tenantPrisma.group.update({
|
||||
where: { id: group.id },
|
||||
data: { ldapObjectGuid },
|
||||
});
|
||||
result.groupsAdopted++;
|
||||
}
|
||||
|
||||
// 2. Existence sweep — validate BEFORE any filter interpolation
|
||||
// (T-16-01).
|
||||
if (!ldapObjectGuid || !/^[0-9a-f]{32}$/.test(ldapObjectGuid)) {
|
||||
result.errors.push(
|
||||
`Gruppe ${group.name}: ungueltiger ldapObjectGuid-Wert`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const guidBuffer = Buffer.from(ldapObjectGuid, 'hex');
|
||||
const filter = `(objectGUID=${LdapService.escapeLdapFilterBuffer(guidBuffer)})`;
|
||||
|
||||
let hit: Entry | null = null;
|
||||
for (const baseDn of baseDns) {
|
||||
const { searchEntries } = await client.search(baseDn, {
|
||||
filter,
|
||||
attributes: ['cn', 'dn'],
|
||||
scope: 'sub',
|
||||
});
|
||||
if (searchEntries.length > 0) {
|
||||
hit = searchEntries[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hit) {
|
||||
// 3. Rename/DN reconciliation (SC-3).
|
||||
const hitRecord = hit as unknown as Record<string, unknown>;
|
||||
const rawName = hitRecord['cn'];
|
||||
const name = Array.isArray(rawName)
|
||||
? String(rawName[0])
|
||||
: rawName
|
||||
? String(rawName)
|
||||
: hit.dn;
|
||||
const dn = hit.dn;
|
||||
|
||||
if (name !== group.name || dn !== group.ldapDn) {
|
||||
try {
|
||||
await tenantPrisma.group.update({
|
||||
where: { id: group.id },
|
||||
data: { name, ldapDn: dn },
|
||||
});
|
||||
result.groupsRenamed++;
|
||||
} catch (updateError: any) {
|
||||
if (updateError?.code === 'P2002') {
|
||||
result.errors.push(
|
||||
`Gruppe ${group.name}: Umbenennung nach '${name}' kollidiert mit einer bestehenden Gruppe`,
|
||||
);
|
||||
} else {
|
||||
throw updateError;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 4. Disappearance (SC-4/D-05/D-06) — handoff BEFORE delete.
|
||||
const movedDefault =
|
||||
await this.groupsService.reassignDefaultBeforeDelete(
|
||||
tenantId,
|
||||
group.id,
|
||||
);
|
||||
if (movedDefault) {
|
||||
result.defaultMarkerMoved++;
|
||||
}
|
||||
try {
|
||||
await tenantPrisma.group.delete({ where: { id: group.id } });
|
||||
result.groupsDeleted++;
|
||||
anyDeleted = true;
|
||||
} catch (deleteError: any) {
|
||||
if (deleteError?.code !== 'P2025') {
|
||||
throw deleteError;
|
||||
}
|
||||
// Already gone (e.g. a concurrent manual delete) — not
|
||||
// double-counted.
|
||||
}
|
||||
}
|
||||
} catch (groupError: unknown) {
|
||||
const msg =
|
||||
groupError instanceof Error
|
||||
? groupError.message
|
||||
: 'Unknown error reconciling group';
|
||||
result.errors.push(`Gruppe ${group.name}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (anyDeleted) {
|
||||
await this.groupsService.ensureDefaultGroup(tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize LDAP search filter to prevent injection (T-02-16).
|
||||
* Escapes special characters per RFC 4515.
|
||||
|
||||
Reference in New Issue
Block a user