Files
tessera-ctl/apps/api/src/ldap/ldap.service.ts
T
schalli e1586a41dd feat(quick-260909-ipc): ldap.service.ts an forTenant() binden, Adress-Kollisionspruefung bewusst uebergreifend belassen
Aufgabe 3 der Etappe 2: elf Abfragen in sechs Methoden (listGroups,
upsertMappedUser, searchUsers, importUsersByDn, importGroupsByDn,
syncUsersForTenant) laufen jetzt ueber forTenant(), teils mit einem neu
erzeugten, teils mit dem in derselben Methode bereits vorhandenen gebundenen
Client. resolveEmailForWrite bleibt ausdruecklich ungebunden (Befund A,
T-IPC-04): email/username sind plattformweit eindeutig, eine Bindung wuerde
einen fremden Halter uebersehen und eine saubere Kollisionsmeldung in einen
P2002-Abbruch verwandeln.

Ein neuer Testblock biegt forTenant() auf ein zweites, unterscheidbares
Client-Objekt um (der bisherige Identitaets-Mock haette die Umstellung nicht
bemerkt, Befund F) und belegt damit, dass die Adressabfrage weiterhin am
ungebundenen und der Rest am gebundenen Client landet. Alle 67 Bestandstests
bleiben unveraendert gruen.

docs/mandantentrennung-zugriffsklassifikation.md ist fuer den Bereich ldap
geschlossen: gemessener Stand je Fundstelle, neu gerechnete Bereichsuebersicht
(gebunden getrennt von ungebunden gezaehlt) und die Uebergabe des
Standardgruppen-Punkts an den Bereich groups vor Etappe 4 dokumentiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-09 14:10:51 +02:00

1725 lines
65 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { Client, EqualityFilter, 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';
/**
* LDAP sync result returned after each sync operation.
*/
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;
// 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;
// WINDOWS #15, gesperrte Nutzerentscheidung 2026-09-09: ein AD-Konto,
// dessen mail-Adresse bereits einem ANDEREN Konto gehoert, wird trotzdem
// angelegt/aktualisiert -- nur eben OHNE diese Adresse. Wer die Adresse
// zuerst hatte, behaelt sie unveraendert (T-Q3-01). Diese Liste macht die
// Kollision im Bericht sichtbar statt sie still zu verwerfen.
emailConflicts: LdapEmailConflict[];
// Verzeichniseintraege ohne sAMAccountName (Kontakte, Verteiler,
// Ressourcen) -- ein normaler, erwarteter Vorgang, kein Fehler. Traegt die
// Kennung (dn) des uebersprungenen Eintrags.
skippedNoLogin: string[];
// Ein unerwarteter Fehler bei genau diesem Eintrag. Nur die Kennung (dn)
// steht hier -- der technische Wortlaut (z. B. eine rohe Prisma/ORM-
// Ausnahme) geht AUSSCHLIESSLICH ueber this.logger.error ins
// Serverprotokoll und erreicht diesen Bericht nie (T-Q3-02).
entryFailures: string[];
errors: string[];
}
/** Ein Konto, das wegen einer bereits vergebenen Adresse ohne diese Adresse
* angelegt oder aktualisiert wurde (WINDOWS #15). */
export interface LdapEmailConflict {
account: string;
email: string;
}
/**
* LDAP configuration shape as stored in the database.
*/
interface LdapConfigData {
id: string;
tenantId: string;
serverUrl: string;
baseDn: string;
bindDn?: string | null;
bindPassword?: string | null;
searchFilter: string;
// When false, skip TLS certificate verification for ldaps:// (internal CA
// / self-signed AD certs). Ignored for plain ldap://. Default true.
tlsRejectUnauthorized?: boolean | null;
groupFilterDns: string[];
userExcludeList: string[];
fieldMappings: Array<{
ldapField: string;
tesseraField: string;
}>;
}
/**
* A discovered AD group or organizational unit, returned by listGroups()
* for the admin to pick from when building a selective import filter (D-18)
* or, filtered to type: 'group', when picking AD groups to import as
* Tessera groups (SC-1/SC-2, D-01/D-02). `alreadyImported` is only ever
* true for type: 'group' entries whose objectGUID already matches a
* Group.ldapObjectGuid of the requesting tenant — OUs are never importable
* and always report false.
*/
export interface LdapDirectoryEntry {
dn: string;
name: string;
type: 'group' | 'ou';
alreadyImported?: boolean;
}
/**
* Result of importGroupsByDn() — a manual, selective AD-group-to-Tessera-group
* import (SC-1/SC-2, D-01/D-02). Name collisions are reported separately from
* generic errors so the frontend can translate the collision message
* (admin.ldap.groupImport.nameCollisionError) instead of rendering a raw
* German backend string.
*/
export interface LdapGroupImportResult {
imported: number;
skipped: number;
nameCollisions: string[];
errors: string[];
}
/**
* A single AD user matched by searchUsers() for the admin to import
* individually. `alreadyImported` is true when a Tessera user for this
* tenant already exists with the same ldapDn or username — so the UI can
* show it as already present and importUsersByDn() skips it (no duplicates).
*/
export interface LdapUserSearchResult {
dn: string;
username: string;
displayName: string;
email: string;
alreadyImported: boolean;
}
/** Result of importUsersByDn() — a manual, non-deactivating single-user import. */
export interface LdapUserImportResult {
created: number;
updated: number;
skipped: number;
errors: string[];
}
/**
* LDAP Service - DIRECTORY SYNC ONLY.
*
* CRITICAL ANTI-PATTERN AVOIDANCE: This service is used exclusively for
* importing/syncing user directories from LDAP/AD into Tessera. It is NEVER
* used for authentication. Users authenticate against local password hashes.
*
* See: RESEARCH.md anti-pattern guidance, T-02-18.
*/
@Injectable()
export class LdapService {
private readonly logger = new Logger(LdapService.name);
constructor(
private prisma: PrismaService,
private userService: UserService,
private groupsService: GroupsService,
) {}
/**
* Bind a client, falling back to an anonymous bind (empty DN/password,
* per RFC 4513) when no bindDn/bindPassword is configured. Lets tenants
* connect to directories that allow anonymous read access without
* requiring a service account.
*/
private async bind(
client: Client,
bindDn?: string | null,
bindPassword?: string | null,
): Promise<void> {
await client.bind(bindDn || '', bindPassword || '');
}
/**
* Build ldapts Client options. For ldaps:// connections, honor an opt-in
* "skip TLS verification" flag (tlsRejectUnauthorized === false) so admins
* can connect to an AD whose certificate is signed by an internal/private
* CA that Node doesn't trust ("unable to verify the first certificate").
* Ignored for plain ldap:// (no TLS). Default is full verification.
*/
private buildClientOptions(
serverUrl: string,
tlsRejectUnauthorized?: boolean | null,
): ConstructorParameters<typeof Client>[0] {
const options: ConstructorParameters<typeof Client>[0] = { url: serverUrl };
if (
serverUrl.toLowerCase().startsWith('ldaps') &&
tlsRejectUnauthorized === false
) {
options.tlsOptions = { rejectUnauthorized: false };
}
return options;
}
/**
* Test LDAP connection with given configuration.
* Returns success/failure with optional error message.
*/
async testConnection(config: {
serverUrl: string;
bindDn?: string | null;
bindPassword?: string | null;
tlsRejectUnauthorized?: boolean | null;
}): Promise<{ success: boolean; error?: string }> {
const client = new Client(
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
);
try {
await this.bind(client, config.bindDn, config.bindPassword);
return { success: true };
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : 'Unknown LDAP error';
this.logger.warn(`LDAP connection test failed: ${message}`);
return { success: false, error: message };
} finally {
try {
await client.unbind();
} catch {
// Ignore unbind errors
}
}
}
/**
* Authenticate a user for LOGIN by binding as their OWN DN with the password
* they entered (distinct from the service-account bind used for sync/search).
* Returns true only on a successful authenticated bind.
*
* SECURITY: an empty password is rejected up front — many AD servers treat a
* bind with a DN and empty password as an unauthenticated/anonymous bind that
* "succeeds", which would let anyone log in as any LDAP user. Never allow it.
*/
async verifyUserCredentials(
config: { serverUrl: string; tlsRejectUnauthorized?: boolean | null },
userDn: string,
password: string,
): Promise<boolean> {
if (!userDn || !password) {
return false;
}
const client = new Client(
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
);
try {
await client.bind(userDn, password);
return true;
} catch {
return false;
} finally {
try {
await client.unbind();
} catch {
// Ignore unbind errors
}
}
}
/**
* Split a `\n`-separated Base-DN admin field into a trimmed, non-empty DN
* list. The baseDn column stays a single String (no schema change) — this
* is the sole place that turns it into the list every search path loops
* over. Blank/whitespace-only lines are dropped; undefined/empty input
* yields [].
*/
private parseBaseDns(baseDn?: string | null): string[] {
if (!baseDn) {
return [];
}
return baseDn
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
/**
* Discover groups and organizational units under EVERY configured base DN.
* Used by the admin UI both to build a selective import filter
* (groupFilterDns) and — filtered client-side to type: 'group' — to pick
* AD groups to import as Tessera groups (D-01: one shared discovery
* endpoint, no second one). Read-only directory query using the
* service-account bind. Results from all base DNs are merged and deduped
* by entry dn.
*
* objectGUID is requested via explicitBufferAttributes so ldapts returns
* it as a Buffer instead of attempting a lossy UTF-8 decode of the raw
* 16-byte value (Pitfall 2, RESEARCH.md). It is used ONLY to compute
* alreadyImported for group entries against this tenant's
* Group.ldapObjectGuid rows — the hex value itself is never returned to
* the client (T-16-04, information disclosure).
*/
async listGroups(
config: {
serverUrl: string;
baseDn: string;
bindDn?: string | null;
bindPassword?: string | null;
tlsRejectUnauthorized?: boolean | null;
},
tenantId: string,
): Promise<LdapDirectoryEntry[]> {
const client = new Client(
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
);
// Mandantengescopter Lesepfad (WINDOWS #20 Etappe 2, 260909-ipc): die
// "bereits importiert"-Markierung darf nur die Gruppen DIESES Mandanten
// sehen.
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
try {
await this.bind(client, config.bindDn, config.bindPassword);
const baseDns = this.parseBaseDns(config.baseDn);
const entriesByDn = new Map<string, Entry>();
for (const baseDn of baseDns) {
const { searchEntries } = await client.search(baseDn, {
filter: '(|(objectClass=group)(objectClass=organizationalUnit))',
attributes: ['cn', 'ou', 'dn', 'objectGUID'],
explicitBufferAttributes: ['objectGUID'],
scope: 'sub',
});
for (const entry of searchEntries) {
entriesByDn.set(entry.dn, entry);
}
}
const mapped = Array.from(entriesByDn.values()).map((entry) => {
const dn = entry.dn;
const isOu = /^ou=/i.test(dn);
const record = entry as unknown as Record<string, unknown>;
const rawName = isOu ? record['ou'] : record['cn'];
const name = Array.isArray(rawName)
? String(rawName[0])
: rawName
? String(rawName)
: dn;
const guidValue = record['objectGUID'];
const guidHex =
!isOu && Buffer.isBuffer(guidValue)
? guidValue.toString('hex')
: null;
return {
dn,
name,
type: isOu ? ('ou' as const) : ('group' as const),
guidHex,
};
});
const guidHexes = mapped
.map((e) => e.guidHex)
.filter((h): h is string => !!h);
let importedSet = new Set<string>();
if (guidHexes.length > 0) {
const existing = await tenantPrisma.group.findMany({
where: { tenantId, ldapObjectGuid: { in: guidHexes } },
select: { ldapObjectGuid: true },
});
importedSet = new Set(
existing
.map((g: { ldapObjectGuid: string | null }) => g.ldapObjectGuid)
.filter((g: string | null): g is string => !!g),
);
}
return mapped
.map(({ guidHex, ...rest }) => ({
...rest,
alreadyImported: !!guidHex && importedSet.has(guidHex),
}))
.sort(
(a, b) => a.name.localeCompare(b.name) || a.dn.localeCompare(b.dn),
);
} finally {
try {
await client.unbind();
} catch {
// Ignore unbind errors
}
}
}
/**
* Map one LDAP entry to Tessera fields via the configured fieldMappings.
*
* ldapts represents a missing/absent attribute as an empty array ([]),
* not undefined -- naively doing String(value[0]) on that produces the
* literal string "undefined", identical across every entry lacking the
* attribute (e.g. no `mail` set), which then collides on unique constraints
* like email. Resolve to the first array element (or the raw value) and skip
* when it's actually missing/empty. Username is lowercased so logins stay
* case-insensitive regardless of AD casing.
*
* Shared by syncUsersForTenant() and importUsersByDn() so both derive the
* same identity from an entry.
*/
private mapEntry(
entry: Record<string, unknown>,
fieldMappings: { ldapField: string; tesseraField: string }[],
): { username?: string; mappedData: Record<string, string> } {
const mappedData: Record<string, string> = {};
for (const mapping of fieldMappings) {
const value = entry[mapping.ldapField];
const resolved = Array.isArray(value) ? value[0] : value;
if (resolved !== undefined && resolved !== null && resolved !== '') {
mappedData[mapping.tesseraField] = String(resolved);
}
}
return { username: mappedData['username']?.toLowerCase(), mappedData };
}
/**
* Decide whether `desiredEmail` may be written to the account identified by
* `ownRecordId` (null when the account does not exist yet, i.e. a create).
* Queries the unique `email` column via findUnique (deliberately NOT
* findFirst — the column is unique, and this keeps the call unambiguously
* distinguishable in tests from the identity findFirst lookups in
* upsertMappedUser/importUsersByDn) and returns the address only when
* nobody holds it yet, or the holder IS this same account. Otherwise the
* address is withheld and reported as a collision instead — an address is
* NEVER handed from one account to another (T-Q3-01): a directory entry
* could otherwise take over a real person's address and receive their
* password-reset mail.
*
* BLEIBT bewusst UNGEBUNDEN (WINDOWS #20 Etappe 2, 260909-ipc, Befund A,
* T-IPC-04): `email` und `username` sind in `prisma/schema.prisma`
* plattformweit eindeutig (`@unique`), nicht je Mandant. Wuerde diese
* Abfrage mit `forTenant()` an den eigenen Mandanten gebunden, saehe sie
* einen fremden Halter der Adresse nicht mehr, meldete "Adresse frei", und
* der anschliessende Schreibvorgang liefe in die plattformweite
* Eindeutigkeitsbedingung der Datenbank — aus einer sauber berichteten
* Kollision (WINDOWS #15/T-Q3-01) wuerde ein P2002-Abbruch des gesamten
* Sync-Laufs. Nach dem Scharfschalten (Etappe 4) liefert diese Abfrage
* fuer jeden Mandanten AUSSER dem der Adresse selbst 0 Zeilen und meldet
* damit IMMER "frei" — ein bekannter, hier bewusst offen gelassener Punkt.
* Die Loesung gehoert nach Etappe 3, vermutlich als vierte
* SECURITY-DEFINER-Funktion nach dem Muster des Anmeldewegs
* (siehe auth.service.ts).
*/
private async resolveEmailForWrite(
desiredEmail: string,
ownRecordId: string | null,
): Promise<{ email?: string; collides: boolean }> {
const holder = await this.prisma.user.findUnique({
where: { email: desiredEmail },
});
if (!holder || holder.id === ownRecordId) {
return { email: desiredEmail, collides: false };
}
return { collides: true };
}
/**
* Find-or-upsert one LDAP user by (ldapDn, then username) within a tenant.
*
* Shared by syncUsersForTenant() and importUsersByDn() so both use IDENTICAL
* identity resolution: a user imported one way is NEVER duplicated by the
* other. A manually-imported user (ldapDn set) is matched by ldapDn on a
* later department/group sync and updated in place, not re-created.
*
* WINDOWS #15: an account whose mapped `mail` address is already held by a
* DIFFERENT account is still created/updated, just without that address
* (locked user decision, 2026-09-09) — see resolveEmailForWrite() above.
* The `${username}@ldap.local` fallback for entries with NO mail attribute
* at all is untouched: it is not part of this defect.
*/
private async upsertMappedUser(
dn: string,
username: string,
mappedData: Record<string, string>,
tenantId: string,
): Promise<{
status: 'created' | 'updated';
emailConflict?: LdapEmailConflict;
}> {
// Mandantengescopter Identitaets-/Schreibpfad (WINDOWS #20 Etappe 2,
// 260909-ipc). Nicht zu verwechseln mit resolveEmailForWrite() oben, die
// bewusst ungebunden bleibt.
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const existingByDn = await tenantPrisma.user.findFirst({
where: { ldapDn: dn, tenantId },
});
const existingByUsername = existingByDn
? null
: await tenantPrisma.user.findFirst({ where: { username, tenantId } });
const existing = existingByDn || existingByUsername;
if (existing) {
let emailToWrite: string | undefined;
let emailConflict: LdapEmailConflict | undefined;
if (mappedData['email']) {
const decision = await this.resolveEmailForWrite(
mappedData['email'],
existing.id,
);
if (decision.collides) {
emailConflict = { account: username, email: mappedData['email'] };
} else {
emailToWrite = decision.email;
}
}
await tenantPrisma.user.update({
where: { id: existing.id },
data: {
...(mappedData['displayName'] && {
displayName: mappedData['displayName'],
}),
...(emailToWrite && { email: emailToWrite }),
...(mappedData['username'] && { username }),
ldapDn: dn,
isActive: true,
},
});
return { status: 'updated', emailConflict };
}
let createEmail: string | undefined =
mappedData['email'] || `${username}@ldap.local`;
let emailConflict: LdapEmailConflict | undefined;
if (mappedData['email']) {
const decision = await this.resolveEmailForWrite(
mappedData['email'],
null,
);
if (decision.collides) {
createEmail = undefined;
emailConflict = { account: username, email: mappedData['email'] };
} else {
createEmail = decision.email;
}
}
await this.userService.create({
username,
...(createEmail && { email: createEmail }),
displayName: mappedData['displayName'],
role: 'USER',
tenantId,
ldapDn: dn,
});
return { status: 'created', emailConflict };
}
/**
* Search AD for individual users by a free-text query (substring match on
* cn, sAMAccountName, displayName, mail). Read-only, service-account bind.
* Each result is flagged `alreadyImported` so the admin sees who is already
* present and cannot import a duplicate. The query is RFC-4515-escaped
* before interpolation (T-02-16, LDAP injection prevention).
*/
async searchUsers(
config: {
serverUrl: string;
baseDn: string;
bindDn?: string | null;
bindPassword?: string | null;
tlsRejectUnauthorized?: boolean | null;
},
tenantId: string,
query: string,
): Promise<LdapUserSearchResult[]> {
const trimmed = (query ?? '').trim();
if (trimmed.length === 0) {
return [];
}
const client = new Client(
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
);
// Mandantengescopter Lesepfad (WINDOWS #20 Etappe 2, 260909-ipc): die
// "bereits importiert"-Markierung darf nur die Konten DIESES Mandanten
// sehen.
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const first = (v: unknown): string =>
Array.isArray(v) ? String(v[0] ?? '') : v != null ? String(v) : '';
try {
await this.bind(client, config.bindDn, config.bindPassword);
const q = LdapService.escapeLdapFilterValue(trimmed);
const filter = `(&(objectClass=person)(|(cn=*${q}*)(sAMAccountName=*${q}*)(displayName=*${q}*)(mail=*${q}*)))`;
const baseDns = this.parseBaseDns(config.baseDn);
const entriesByDn = new Map<string, Entry>();
for (const baseDn of baseDns) {
const { searchEntries } = await client.search(baseDn, {
filter,
attributes: ['cn', 'displayName', 'sAMAccountName', 'mail', 'dn'],
scope: 'sub',
sizeLimit: 50,
});
for (const entry of searchEntries) {
entriesByDn.set(entry.dn, entry);
}
}
const entries = Array.from(entriesByDn.values()).map((entry) => ({
dn: entry.dn,
username: first(entry['sAMAccountName']),
displayName: first(entry['displayName']) || first(entry['cn']),
email: first(entry['mail']),
}));
// Flag entries already present for this tenant (by ldapDn or username) in
// a single query, so the UI marks them and import stays idempotent.
const dns = entries.map((e) => e.dn);
const usernames = entries
.map((e) => e.username.toLowerCase())
.filter(Boolean);
const existing = await tenantPrisma.user.findMany({
where: {
tenantId,
OR: [{ ldapDn: { in: dns } }, { username: { in: usernames } }],
},
select: { ldapDn: true, username: true },
});
const dnSet = new Set(
existing
.map((u: { ldapDn: string | null }) => u.ldapDn)
.filter((d: string | null): d is string => !!d),
);
const usernameSet = new Set(
existing.map((u: { username: string }) => u.username.toLowerCase()),
);
return entries.map((e) => ({
...e,
alreadyImported:
dnSet.has(e.dn) ||
(!!e.username && usernameSet.has(e.username.toLowerCase())),
}));
} finally {
try {
await client.unbind();
} catch {
// Ignore unbind errors
}
}
}
/**
* Import specific AD users by DN (from searchUsers results). Idempotent and
* NON-deactivating: unlike syncUsersForTenant this never deactivates other
* users. A user that already exists (by ldapDn or username) is SKIPPED — its
* ldapDn is linked if missing so a later department/group sync recognizes it
* and never creates a duplicate. Respects the userExcludeList denylist.
*/
async importUsersByDn(
config: LdapConfigData,
tenantId: string,
dns: string[],
): Promise<LdapUserImportResult> {
const result: LdapUserImportResult = {
created: 0,
updated: 0,
skipped: 0,
errors: [],
};
const client = new Client(
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
);
const excludeSet = new Set(
(config.userExcludeList ?? [])
.map((u) => u.trim().toLowerCase())
.filter(Boolean),
);
// Mandantengescopter Dedup-/Schreibpfad (WINDOWS #20 Etappe 2,
// 260909-ipc).
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
try {
await this.bind(client, config.bindDn, config.bindPassword);
const attributes = config.fieldMappings.map((m) => m.ldapField);
if (!attributes.includes('dn')) {
attributes.push('dn');
}
for (const dn of dns) {
try {
// Base-scoped lookup of exactly this DN.
const { searchEntries } = await client.search(dn, {
filter: '(objectClass=person)',
attributes,
scope: 'base',
});
if (searchEntries.length === 0) {
result.errors.push(`${dn}: not found`);
continue;
}
const { username, mappedData } = this.mapEntry(
searchEntries[0] as Record<string, unknown>,
config.fieldMappings,
);
if (!username) {
result.errors.push(
`${dn}: no username mapped (check sAMAccountName mapping)`,
);
continue;
}
if (excludeSet.has(username)) {
result.skipped++;
continue;
}
// Dedup: if a user already exists (by ldapDn or username) skip it,
// but link the ldapDn so a later group/OU sync matches it and never
// duplicates.
const existing = await tenantPrisma.user.findFirst({
where: { tenantId, OR: [{ ldapDn: dn }, { username }] },
});
if (existing) {
if (existing.ldapDn !== dn) {
await tenantPrisma.user.update({
where: { id: existing.id },
data: { ldapDn: dn },
});
}
result.skipped++;
continue;
}
// WINDOWS #15: same collision decider as upsertMappedUser — an
// address already held by a DIFFERENT account is withheld, never
// reassigned (T-Q3-01). No new LdapUserImportResult field: the
// account is still created and counted, this manual-import path's
// display stays as-is.
let createEmail: string | undefined =
mappedData['email'] || `${username}@ldap.local`;
if (mappedData['email']) {
const decision = await this.resolveEmailForWrite(
mappedData['email'],
null,
);
createEmail = decision.collides ? undefined : decision.email;
}
await this.userService.create({
username,
...(createEmail && { email: createEmail }),
displayName: mappedData['displayName'],
role: 'USER',
tenantId,
ldapDn: dn,
});
result.created++;
} catch (entryError: unknown) {
const msg =
entryError instanceof Error
? entryError.message
: 'Unknown error importing entry';
result.errors.push(`${dn}: ${msg}`);
}
}
} finally {
try {
await client.unbind();
} catch {
// Ignore unbind errors
}
}
return result;
}
/**
* Import specific AD groups by DN (from listGroups results, filtered to
* type: 'group') as Tessera groups (SC-1/SC-2, D-01/D-02). Every DN is a
* base-scoped lookup — the admin never bulk-imports an OU or a search
* result, only the exact groups they checked. objectGUID is read via
* explicitBufferAttributes (same Pitfall-2 requirement as listGroups) and
* hex-encoded into Group.ldapObjectGuid, the rename-stable identity key
* Plan 16-03's reconciliation depends on. `GroupsService.create()` is
* deliberately NOT used here: its ConflictException is built for a single
* interactive HTTP request and would abort the whole batch on the first
* name collision (Pitfall 4, RESEARCH.md) — this loop instead collects a
* per-DN outcome and never stops on one group's error.
*/
async importGroupsByDn(
config: LdapConfigData,
tenantId: string,
dns: string[],
): Promise<LdapGroupImportResult> {
const result: LdapGroupImportResult = {
imported: 0,
skipped: 0,
nameCollisions: [],
errors: [],
};
const client = new Client(
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
);
// Mandantengescopter Schreibpfad (T-16-02): app.current_tenant wird vor
// jedem group.create() gesetzt, RLS ist das zweite Netz.
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
try {
await this.bind(client, config.bindDn, config.bindPassword);
for (const dn of dns) {
try {
// Base-scoped lookup of exactly this DN — the admin-selected DN is
// the search BASE, never interpolated into a filter (T-16-01).
const { searchEntries } = await client.search(dn, {
filter: '(objectClass=group)',
attributes: ['cn', 'dn', 'objectGUID'],
explicitBufferAttributes: ['objectGUID'],
scope: 'base',
});
if (searchEntries.length === 0) {
result.errors.push(`${dn}: not found`);
continue;
}
const entry = searchEntries[0];
const record = entry as unknown as Record<string, unknown>;
const guidValue = record['objectGUID'];
if (!Buffer.isBuffer(guidValue)) {
result.errors.push(`${dn}: objectGUID not readable`);
continue;
}
const ldapObjectGuid = guidValue.toString('hex');
const rawName = record['cn'];
const name = Array.isArray(rawName)
? String(rawName[0])
: rawName
? String(rawName)
: dn;
// Idempotency: a second import of the same AD group is a skip, not
// a duplicate row.
const existingByGuid = await tenantPrisma.group.findFirst({
where: { tenantId, ldapObjectGuid },
});
if (existingByGuid) {
result.skipped++;
continue;
}
try {
await tenantPrisma.group.create({
data: { tenantId, name, ldapDn: entry.dn, ldapObjectGuid },
});
result.imported++;
} catch (createError: any) {
if (createError?.code === 'P2002') {
const target = createError?.meta?.target;
const targetsGuid = Array.isArray(target)
? target.includes('ldapObjectGuid')
: String(target ?? '').includes('ldapObjectGuid');
if (targetsGuid) {
// Lost a race against a concurrent import of the same AD
// group — treat identically to the pre-check skip above.
result.skipped++;
} else {
// @@unique([tenantId, name]) violation: reject-with-report,
// never abort the remaining DNs (Pitfall 4).
result.nameCollisions.push(name);
}
} else {
throw createError;
}
}
} catch (entryError: unknown) {
const msg =
entryError instanceof Error
? entryError.message
: 'Unknown error importing group';
result.errors.push(`${dn}: ${msg}`);
}
}
} finally {
try {
await client.unbind();
} catch {
// Ignore unbind errors
}
}
return result;
}
/**
* Sync users from LDAP directory for a specific tenant.
*
* CRITICAL per Pitfall 2: This method receives tenantId explicitly.
* When called from the scheduler, the caller sets the tenant context
* BEFORE any DB operations using forTenant.
*
* Flow:
* 1. Connect and bind to LDAP server
* 2. Search for users with configured filter
* 3. Map LDAP fields to Tessera fields using config.fieldMappings
* 4. Upsert users: create new, update existing
* 5. Deactivate users removed from LDAP (D-15)
* 6. Update lastSyncAt timestamp
*/
async syncUsersForTenant(
config: LdapConfigData,
tenantId: string,
): Promise<LdapSyncResult> {
const result: LdapSyncResult = {
created: 0,
updated: 0,
deactivated: 0,
groupMembershipsAdded: 0,
groupMembershipsRemoved: 0,
groupsAdopted: 0,
groupsRenamed: 0,
groupsDeleted: 0,
defaultMarkerMoved: 0,
emailConflicts: [],
skippedNoLogin: [],
entryFailures: [],
errors: [],
};
// CRITICAL SAFETY GUARD: the Base-DN(s) are now the sync scope. Returning
// here BEFORE any LDAP bind/search and BEFORE the deactivation loop below
// is what prevents an unconfigured/blank config from mass-deactivating
// every existing LDAP user (there would be no synced DNs to compare
// against, so every local LDAP user would look "removed"). This is the
// SOLE no-op condition: an empty groupFilterDns no longer short-circuits
// here — with >=1 base DN configured, a normal multi-base search runs
// (see collectSearchEntries), it just applies no memberOf restriction.
// lastSyncAt is intentionally left untouched on this no-op path.
const baseDns = this.parseBaseDns(config.baseDn);
if (baseDns.length === 0) {
return result;
}
const client = new Client(
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
);
// Create tenant-scoped Prisma client per Pitfall 2
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
try {
// 1. Bind with service account (anonymous when not configured)
await this.bind(client, config.bindDn, config.bindPassword);
// 2. Build attributes list from field mappings + dn
const attributes = config.fieldMappings.map((m) => m.ldapField);
// Always include dn for tracking
if (!attributes.includes('dn')) {
attributes.push('dn');
}
// Sanitize search filter (T-02-16: LDAP injection prevention)
const sanitizedFilter = this.sanitizeSearchFilter(config.searchFilter);
// 3. Search LDAP directory, applying the group/OU filter if configured
const searchEntries = await this.collectSearchEntries(
client,
config,
sanitizedFilter,
attributes,
);
// Track all DNs found in this sync for deactivation logic
const syncedDns: string[] = [];
// Per-user exclude/denylist: individual usernames (sAMAccountName) the
// admin never wants imported, e.g. service accounts like administrator,
// krbtgt, guest, ldap$. Distinct from groupFilterDns, which only limits
// which OUs/groups are searched. Normalized to lowercase to match the
// case-insensitive username handling below.
const excludeSet = new Set(
(config.userExcludeList ?? [])
.map((u) => u.trim().toLowerCase())
.filter(Boolean),
);
// 4. Process each LDAP entry
for (const entry of searchEntries) {
try {
const dn = entry.dn;
// Map LDAP fields to Tessera fields (shared mapEntry helper);
// username is lowercased for case-insensitive logins.
const { username, mappedData } = this.mapEntry(
entry as Record<string, unknown>,
config.fieldMappings,
);
// Skip excluded users before recording the DN as synced. Leaving an
// excluded entry out of syncedDns means that if the admin adds an
// already-imported user to the exclude list, the deactivation pass
// below will deactivate them on the next sync.
if (username && excludeSet.has(username)) {
continue;
}
syncedDns.push(dn);
if (!username) {
// Normal for contacts/resources/distribution entries without a
// sAMAccountName — a neutral hint, not an error (WINDOWS #15).
result.skippedNoLogin.push(dn);
continue;
}
// Find-or-upsert by (ldapDn, then username) via the shared helper so
// sync and manual import dedupe identically (never a duplicate row).
const { status, emailConflict } = await this.upsertMappedUser(
dn,
username,
mappedData,
tenantId,
);
if (status === 'created') {
result.created++;
} else {
result.updated++;
}
if (emailConflict) {
result.emailConflicts.push(emailConflict);
}
} catch (entryError: unknown) {
const msg =
entryError instanceof Error
? entryError.message
: 'Unknown error processing entry';
// T-Q3-02: the raw exception text (ORM table/column/call names)
// goes to the server log only — never to the report an
// administrator reads. Only the entry's identifier (dn) is
// reported.
this.logger.error(`LDAP sync entry ${entry.dn} failed: ${msg}`);
result.entryFailures.push(entry.dn);
}
}
// 5. Deactivation per D-15: Deactivate users removed from LDAP
const localLdapUsers = await tenantPrisma.user.findMany({
where: {
tenantId,
ldapDn: { not: null },
isActive: true,
},
select: { id: true, ldapDn: true },
});
for (const localUser of localLdapUsers) {
if (localUser.ldapDn && !syncedDns.includes(localUser.ldapDn)) {
await tenantPrisma.user.update({
where: { id: localUser.id },
data: { isActive: false },
});
result.deactivated++;
}
}
// 5a. Plan 16-03 (SC-3/SC-4/SC-5, D-05/D-06/D-07): reconcile every
// AD-bound Group's name/ldapDn/existence BEFORE the membership
// reconciliation below. This ordering is the central correctness
// condition of Phase 16, not a style choice (RESEARCH.md Pitfall 1):
// step 5b reads Group.ldapDn from the DB to build its memberOf
// filter. If a rename detected in THIS SAME run hadn't already been
// written back by the time 5b runs, the memberOf filter would still
// use the stale pre-rename DN, AD would return zero hits for it, and
// every LDAP membership of the renamed group would be misreported as
// removed — a rename would look like a membership wipeout that never
// happened in the directory.
await this.syncBoundGroupsForTenant(client, config, tenantId, result);
// 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.
// Relies on 5a above having already written back any rename in this
// run (see 5a's comment).
await this.syncGroupMembershipsForTenant(
client,
config,
sanitizedFilter,
attributes,
tenantId,
result,
);
// 6. Update lastSyncAt
await tenantPrisma.ldapConfig.update({
where: { id: config.id },
data: { lastSyncAt: new Date() },
});
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : 'Unknown LDAP sync error';
this.logger.error(`LDAP sync failed for tenant ${tenantId}: ${message}`);
result.errors.push(`Sync failed: ${message}`);
} finally {
try {
await client.unbind();
} catch {
// Ignore unbind errors
}
}
return result;
}
/**
* Run the directory search for syncUsersForTenant across EVERY configured
* Base DN (the primary sync scope — see syncUsersForTenant's early-return
* guard, which is the sole no-op path and is keyed on the parsed base-DN
* list, not on groupFilterDns). groupFilterDns is an OPTIONAL extra
* restriction:
*
* - Empty groupFilterDns: each base DN is searched with the plain
* sanitizedFilter — no memberOf restriction, every user under the base
* DN(s) is synced.
* - Non-empty groupFilterDns: split into OU DNs (searched as ADDITIONAL
* extra bases with the plain filter) and group DNs (turned into a
* memberOf OR-clause ANDed with sanitizedFilter and applied to every
* base DN search).
*
* All results across every base DN (and any extra OU bases) are merged
* and deduped by entry dn.
*/
private async collectSearchEntries(
client: Client,
config: LdapConfigData,
sanitizedFilter: string,
attributes: string[],
) {
const baseDns = this.parseBaseDns(config.baseDn);
const ouBases = (config.groupFilterDns ?? []).filter((dn) =>
/^ou=/i.test(dn),
);
const groupDns = (config.groupFilterDns ?? []).filter(
(dn) => !/^ou=/i.test(dn),
);
const entriesByDn = new Map<string, Entry>();
// Build the base-search filter: plain sanitizedFilter, optionally ANDed
// with a memberOf OR-clause when group DNs are configured.
let baseFilter = sanitizedFilter;
if (groupDns.length > 0) {
const memberOfClauses = groupDns
.map(
(dn) => `(memberOf=${LdapService.escapeLdapFilterValue(dn)})`,
)
.join('');
baseFilter = `(&${sanitizedFilter}(|${memberOfClauses}))`;
}
// Search every configured base DN with the (possibly memberOf-restricted)
// base filter.
for (const baseDn of baseDns) {
const { searchEntries } = await client.search(baseDn, {
filter: baseFilter,
attributes,
scope: 'sub',
});
for (const entry of searchEntries) {
entriesByDn.set(entry.dn, entry);
}
}
// ou= group-filter entries are ADDITIONAL search bases, searched with
// the plain sanitizedFilter (no memberOf restriction).
for (const ouBase of ouBases) {
const { searchEntries } = await client.search(ouBase, {
filter: sanitizedFilter,
attributes,
scope: 'sub',
});
for (const entry of searchEntries) {
entriesByDn.set(entry.dn, entry);
}
}
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}`);
}
}
}
/**
* 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 handed
* to an EqualityFilter over objectGUID — raw bytes, never an escaped
* filter string — 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 under any configured base DN is NOT automatically treated as a
* disappearance (WR-03, 16-REVIEW.md). The base-DN sweep only proves
* "not found under these specific base DNs" — an AD group that was
* MOVED to an OU outside the configured subtree (still present in the
* directory) would otherwise be misread as gone and deleted along with
* its memberships/module grants, which is a bigger blast radius than
* D-05 ("group genuinely disappeared") was accepted for. Before
* concluding disappearance, a second, wider (objectGUID=...) sweep runs
* against each base DN's own domain root (the trailing DC=... RDN
* chain, e.g. "ou=Sales,dc=example,dc=com" -> "dc=example,dc=com"),
* skipping any root already covered by a configured base DN (the
* common case where baseDn already IS the domain root — nothing wider
* to search). A hit there means the group still exists, just outside
* the configured subtree: reported as an error line, NOT deleted — the
* same conservative "absence must be established, never merely
* unobserved" stance already taken for a legacy binding whose DN no
* longer resolves (case 1 above). Only when the wide sweep ALSO finds
* nothing (or there is no wider root left to search) is disappearance
* (SC-4/D-05) actually established: 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;
// WR-03 (16-REVIEW.md): domain root(s) NOT already covered by a
// configured base DN — the fallback anchor for the wide existence sweep
// below, so a group moved outside every configured base DN is not
// misread as deleted. Computed once per run (identical for every
// candidate), not per-group.
const domainRoots = Array.from(
new Set(
baseDns
.map((dn) => LdapService.domainRootOf(dn))
.filter(
(dn): dn is string => dn !== null && !baseDns.includes(dn),
),
),
);
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}: ungültiger ldapObjectGuid-Wert`,
);
continue;
}
const guidBuffer = Buffer.from(ldapObjectGuid, 'hex');
// The GUID goes onto the wire as raw bytes via an EqualityFilter, NOT
// as a `\xx`-escaped filter string. A string filter is parsed by ldapts
// before it is encoded, and the parser does not turn `\1e\4b...` back
// into the 16 bytes it stands for — the assertion value that reaches the
// directory is then a different value entirely and matches nothing.
// Measured read-only against a real AD on 2026-08-11 (see the quick task
// 260811-f9i): the escaped string returned 0 hits for an object whose
// GUID had just been read from that same directory, while this
// EqualityFilter returned exactly that object. Both sweeps below share
// this value, so the WR-03 move-detection cannot silently inherit a
// broken filter again.
const filter = new EqualityFilter({
attribute: 'objectGUID',
value: 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;
// WR-04 (16-REVIEW.md): the CHANGE CHECK is case-insensitive —
// only the DECISION "is this a rename" is normalized, never the
// value written below. AD returning the same cn/dn with different
// casing between two runs (e.g. after a domain-controller switch)
// must not look like a rename: that would break the idempotency
// guarantee (Priority Check 7 — sync twice over an unchanged AD
// state = no-op) and increment groupsRenamed / issue an update on
// every subsequent run. This is a plain lowercase compare, not
// full RFC 4514 DN canonicalization (per-attribute-type
// case-sensitivity rules, escaped-character normalization, etc.)
// — a pragmatic simplification, same uncertainty class as A1/A2 in
// RESEARCH.md, not verified against a real AD.
const nameChanged = name.toLowerCase() !== (group.name ?? '').toLowerCase();
const dnChanged =
dn.toLowerCase() !== (group.ldapDn ?? '').toLowerCase();
if (nameChanged || dnChanged) {
try {
// The write below stores name/dn EXACTLY as the directory
// reports them, byte-for-byte — never the lowercased
// comparison values above. Group.name stays AD's, per D-03.
await tenantPrisma.group.update({
where: { id: group.id },
data: { name, ldapDn: dn },
});
result.groupsRenamed++;
} catch (updateError: any) {
if (updateError?.code === 'P2002') {
// WR-02 (16-REVIEW.md): this update() writes BOTH name and
// ldapDn in one call — @@unique([tenantId, name]) AND
// @@unique([tenantId, ldapDn]) are both potential triggers
// of a P2002 here, so the collision is only actually a name
// collision when updateError.meta.target says so. Mirrors
// the discrimination importGroupsByDn() already does above
// for its own create() call.
const target = updateError?.meta?.target;
const targetsName = Array.isArray(target)
? target.includes('name')
: String(target ?? '').includes('name');
result.errors.push(
targetsName
? `Gruppe ${group.name}: Umbenennung nach '${name}' kollidiert mit einer bestehenden Gruppe`
: `Gruppe ${group.name}: Aktualisierung kollidiert mit einer bestehenden Bindung (${JSON.stringify(target)})`,
);
} else {
throw updateError;
}
}
}
} else {
// WR-03 (16-REVIEW.md): before concluding disappearance, rule out
// a directory-internal move by widening the SAME (objectGUID=...)
// filter to each base DN's own domain root — outside the
// configured base-DN restriction. A hit means the group still
// exists; report and move on WITHOUT deleting or writing anything
// (deliberately no auto-rename to the found location either — a
// move outside the configured scope is an admin configuration
// signal, not something this sync silently absorbs).
let wideHit: Entry | null = null;
for (const root of domainRoots) {
const { searchEntries } = await client.search(root, {
filter,
attributes: ['cn', 'dn'],
scope: 'sub',
});
if (searchEntries.length > 0) {
wideHit = searchEntries[0];
break;
}
}
if (wideHit) {
result.errors.push(
`Gruppe ${group.name}: nicht mehr unter den konfigurierten Base-DNs gefunden, existiert aber weiterhin unter '${wideHit.dn}' — vermutlich im Verzeichnis verschoben, Base-DN-Konfiguration prüfen. Nicht gelöscht.`,
);
continue;
}
// 4. Disappearance (SC-4/D-05/D-06) established — 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.
*
* Note: We validate the overall filter structure but escape any
* user-injectable portions. The base filter itself is admin-configured.
*/
private sanitizeSearchFilter(filter: string): string {
// The filter is configured by admins, not end-users.
// We still validate it contains balanced parentheses as a sanity check.
if (!filter || filter.trim().length === 0) {
return '(objectClass=person)';
}
// Count parentheses - they must be balanced
let depth = 0;
for (const char of filter) {
if (char === '(') depth++;
if (char === ')') depth--;
if (depth < 0) {
this.logger.warn(
`Invalid LDAP filter (unbalanced parentheses): ${filter}`,
);
return '(objectClass=person)';
}
}
if (depth !== 0) {
this.logger.warn(
`Invalid LDAP filter (unbalanced parentheses): ${filter}`,
);
return '(objectClass=person)';
}
return filter;
}
/**
* Escape a value for use in an LDAP search filter per RFC 4515.
* Used when constructing filters with user-provided attribute values.
*/
static escapeLdapFilterValue(value: string): string {
return value
.replace(/\\/g, '\\5c')
.replace(/\*/g, '\\2a')
.replace(/\(/g, '\\28')
.replace(/\)/g, '\\29')
.replace(/\x00/g, '\\00');
}
// NOTE: escapeLdapFilterBuffer() used to live here — a byte-wise `\XX` hex
// escape for binary values, written under the assumption (RESEARCH.md A2)
// that ldapts would pass such a string through to the directory unchanged.
// It does not, and the resulting filter matched nothing; the existence sweep
// in syncBoundGroupsForTenant() now builds an EqualityFilter over the raw
// Buffer instead. Do not reintroduce it: escapeLdapFilterValue() below is for
// STRING values and stays correct, but binary values belong in a filter
// object, never in an interpolated filter string.
/**
* Split a DN into its individual RDN components, respecting a
* backslash-escaped comma inside an RDN value (RFC 4514) so a value like
* "cn=Sales\, Inc.,dc=example,dc=com" is not split in the wrong place.
* Used only by domainRootOf() below — never for LDAP filter construction
* (T-16-01 stays in force there; this is pure DN string bookkeeping).
*/
private static splitDnComponents(dn: string): string[] {
const parts: string[] = [];
let current = '';
let escaped = false;
for (const ch of dn) {
if (escaped) {
current += ch;
escaped = false;
continue;
}
if (ch === '\\') {
current += ch;
escaped = true;
continue;
}
if (ch === ',') {
parts.push(current);
current = '';
continue;
}
current += ch;
}
parts.push(current);
return parts.map((p) => p.trim()).filter((p) => p.length > 0);
}
/**
* Derive the domain root DN — the trailing DC=... component chain — from a
* configured base DN, e.g. "ou=Sales,dc=example,dc=com" ->
* "dc=example,dc=com". WR-03 (16-REVIEW.md) fallback search anchor for
* syncBoundGroupsForTenant()'s existence sweep: widening a "not found"
* result to the domain root before concluding a bound group has actually
* disappeared, rather than merely moved outside a narrower configured
* base DN. Returns null when the DN carries no DC= component at all
* (nothing to widen the search to — e.g. a non-AD directory using a
* different root naming scheme).
*/
private static domainRootOf(dn: string): string | null {
const dcParts = LdapService.splitDnComponents(dn).filter((rdn) =>
/^dc=/i.test(rdn),
);
return dcParts.length > 0 ? dcParts.join(',') : null;
}
}