fix(quick-260909-ab3): kollidierende AD-Konten werden angelegt, nur ohne Adresse
- User.email auf optional gestellt (Migration geschrieben, NICHT ausgefuehrt); Eindeutigkeitsindex unangetastet, NULL bleibt in Postgres je verschieden - Neuer Kollisionsentscheider (resolveEmailForWrite) in ldap.service.ts: eine bereits vergebene Adresse wird nie umgehaengt (T-Q3-01) — das zuerst angelegte Konto behaelt sie, jedes weitere Konto entsteht ohne Adresse (gesperrte Nutzerentscheidung 2026-09-09, WINDOWS #15) - Entscheider in upsertMappedUser (Sync) UND importUsersByDn (Handimport) verdrahtet, damit der zweite Anlageweg nicht als Luecke bestehen bleibt - LdapSyncResult um emailConflicts/skippedNoLogin/entryFailures erweitert; rohe ORM-Ausnahmetexte gehen nur noch an logger.error, nie in den Bericht (T-Q3-02) - UserService.create nimmt die Adresse optional entgegen; Tender-Digest und Instant-Alert ueberspringen Empfaenger ohne Adresse (continue) - Fuenf neue Testfaelle vorab gegen den unveraenderten Bestand rot gelaufen (erwartete Ursachen bestaetigt); 651/651 API-Tests gruen, prisma validate und type-check sauber Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
@@ -30,9 +30,31 @@ export interface LdapSyncResult {
|
||||
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.
|
||||
*/
|
||||
@@ -378,6 +400,32 @@ export class LdapService {
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@@ -385,13 +433,22 @@ export class LdapService {
|
||||
* 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<'created' | 'updated'> {
|
||||
): Promise<{
|
||||
status: 'created' | 'updated';
|
||||
emailConflict?: LdapEmailConflict;
|
||||
}> {
|
||||
const existingByDn = await this.prisma.user.findFirst({
|
||||
where: { ldapDn: dn, tenantId },
|
||||
});
|
||||
@@ -401,30 +458,60 @@ export class LdapService {
|
||||
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 this.prisma.user.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
...(mappedData['displayName'] && {
|
||||
displayName: mappedData['displayName'],
|
||||
}),
|
||||
...(mappedData['email'] && { email: mappedData['email'] }),
|
||||
...(emailToWrite && { email: emailToWrite }),
|
||||
...(mappedData['username'] && { username }),
|
||||
ldapDn: dn,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
return 'updated';
|
||||
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,
|
||||
email: mappedData['email'] || `${username}@ldap.local`,
|
||||
...(createEmail && { email: createEmail }),
|
||||
displayName: mappedData['displayName'],
|
||||
role: 'USER',
|
||||
tenantId,
|
||||
ldapDn: dn,
|
||||
});
|
||||
return 'created';
|
||||
return { status: 'created', emailConflict };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -599,9 +686,24 @@ export class LdapService {
|
||||
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,
|
||||
email: mappedData['email'] || `${username}@ldap.local`,
|
||||
...(createEmail && { email: createEmail }),
|
||||
displayName: mappedData['displayName'],
|
||||
role: 'USER',
|
||||
tenantId,
|
||||
@@ -775,6 +877,9 @@ export class LdapService {
|
||||
groupsRenamed: 0,
|
||||
groupsDeleted: 0,
|
||||
defaultMarkerMoved: 0,
|
||||
emailConflicts: [],
|
||||
skippedNoLogin: [],
|
||||
entryFailures: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
@@ -858,15 +963,15 @@ export class LdapService {
|
||||
syncedDns.push(dn);
|
||||
|
||||
if (!username) {
|
||||
result.errors.push(
|
||||
`Entry ${dn}: no username mapped (check sAMAccountName mapping)`,
|
||||
);
|
||||
// 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 = await this.upsertMappedUser(
|
||||
const { status, emailConflict } = await this.upsertMappedUser(
|
||||
dn,
|
||||
username,
|
||||
mappedData,
|
||||
@@ -877,12 +982,20 @@ export class LdapService {
|
||||
} else {
|
||||
result.updated++;
|
||||
}
|
||||
if (emailConflict) {
|
||||
result.emailConflicts.push(emailConflict);
|
||||
}
|
||||
} catch (entryError: unknown) {
|
||||
const msg =
|
||||
entryError instanceof Error
|
||||
? entryError.message
|
||||
: 'Unknown error processing entry';
|
||||
result.errors.push(`Entry ${entry.dn}: ${msg}`);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user