85d2d772b6
Die Gegenprobe im Browser hat drei Stellen gefunden, die der erste Durchgang nicht erwischt hat — darunter zwei gut sichtbare Schaltflaechen: Aenderungen speichern -> Änderungen speichern Oeffnen -> Öffnen Eine Aenderung ... -> Eine Änderung ... Die Ursache ist dieselbe fuer alle drei und steckte im Waechter selbst: sein Verdachtsmuster /(ae|oe|ue|ss)/ war case-sensitiv. "Aenderungen" beginnt mit "Ae", nicht mit "ae", und ist deshalb durchgerutscht — der Waechter konnte gar nicht anschlagen. Muster jetzt case-insensitiv; damit erfasst es auch die grossgeschriebenen Formen. Durch die schaerfere Pruefung melden sich neu die Abkuerzungen RSS, RSSGenerator und SSL. Sie tragen ein doppeltes S ohne Umlaut-Bezug und stehen jetzt auf der Positivliste. Ausserdem zwei Meldungen des LDAP-Abgleichs korrigiert, die dem Administrator in der Oberflaeche angezeigt werden (result.errors landet in der Fehlerliste der LDAP-Seite): "ungueltiger ldapObjectGuid-Wert" und "Base-DN-Konfiguration pruefen. Nicht geloescht." Drei Tests pinnen diese Texte bewusst und wurden mitgezogen. Bewusst NICHT angefasst: die Warnung in crypto.service.ts. Sie geht ueber logger.warn ins Protokoll und nicht an einen Nutzer. Unabhaengig gegengeprueft: von allen Tokens in de.json, die ae/oe/ue tragen, ist keines mehr eine Ersatzschreibung. 642 API-Tests und 225 Web-Tests gruen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5jtbGzC5Sf9npJ3JCjKhq
1580 lines
58 KiB
TypeScript
1580 lines
58 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;
|
|
errors: 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),
|
|
);
|
|
|
|
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 this.prisma.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 };
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
private async upsertMappedUser(
|
|
dn: string,
|
|
username: string,
|
|
mappedData: Record<string, string>,
|
|
tenantId: string,
|
|
): Promise<'created' | 'updated'> {
|
|
const existingByDn = await this.prisma.user.findFirst({
|
|
where: { ldapDn: dn, tenantId },
|
|
});
|
|
const existingByUsername = existingByDn
|
|
? null
|
|
: await this.prisma.user.findFirst({ where: { username, tenantId } });
|
|
const existing = existingByDn || existingByUsername;
|
|
|
|
if (existing) {
|
|
await this.prisma.user.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
...(mappedData['displayName'] && {
|
|
displayName: mappedData['displayName'],
|
|
}),
|
|
...(mappedData['email'] && { email: mappedData['email'] }),
|
|
...(mappedData['username'] && { username }),
|
|
ldapDn: dn,
|
|
isActive: true,
|
|
},
|
|
});
|
|
return 'updated';
|
|
}
|
|
|
|
await this.userService.create({
|
|
username,
|
|
email: mappedData['email'] || `${username}@ldap.local`,
|
|
displayName: mappedData['displayName'],
|
|
role: 'USER',
|
|
tenantId,
|
|
ldapDn: dn,
|
|
});
|
|
return 'created';
|
|
}
|
|
|
|
/**
|
|
* 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),
|
|
);
|
|
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 this.prisma.user.findMany({
|
|
where: {
|
|
tenantId,
|
|
OR: [{ ldapDn: { in: dns } }, { username: { in: usernames } }],
|
|
},
|
|
select: { ldapDn: true, username: true },
|
|
});
|
|
const dnSet = new Set(
|
|
existing.map((u) => u.ldapDn).filter((d): d is string => !!d),
|
|
);
|
|
const usernameSet = new Set(
|
|
existing.map((u) => 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),
|
|
);
|
|
|
|
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 this.prisma.user.findFirst({
|
|
where: { tenantId, OR: [{ ldapDn: dn }, { username }] },
|
|
});
|
|
if (existing) {
|
|
if (existing.ldapDn !== dn) {
|
|
await this.prisma.user.update({
|
|
where: { id: existing.id },
|
|
data: { ldapDn: dn },
|
|
});
|
|
}
|
|
result.skipped++;
|
|
continue;
|
|
}
|
|
|
|
await this.userService.create({
|
|
username,
|
|
email: mappedData['email'] || `${username}@ldap.local`,
|
|
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 this.prisma.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,
|
|
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) {
|
|
result.errors.push(
|
|
`Entry ${dn}: no username mapped (check sAMAccountName mapping)`,
|
|
);
|
|
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 = await this.upsertMappedUser(
|
|
dn,
|
|
username,
|
|
mappedData,
|
|
tenantId,
|
|
);
|
|
if (status === 'created') {
|
|
result.created++;
|
|
} else {
|
|
result.updated++;
|
|
}
|
|
} catch (entryError: unknown) {
|
|
const msg =
|
|
entryError instanceof Error
|
|
? entryError.message
|
|
: 'Unknown error processing entry';
|
|
result.errors.push(`Entry ${entry.dn}: ${msg}`);
|
|
}
|
|
}
|
|
|
|
// 5. Deactivation per D-15: Deactivate users removed from LDAP
|
|
const localLdapUsers = await this.prisma.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 this.prisma.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 this.prisma.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;
|
|
}
|
|
}
|