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:
@@ -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