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
+143
View File
@@ -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.