--- phase: quick-260729-d3k plan: 01 subsystem: auth tags: [ldap, ldapts, active-directory, multi-tenancy, admin-ui, next-intl] requires: - phase: quick-260728-lih provides: "syncUsersForTenant early-return no-op guard + groupFilterDns-based selective sync (the model this plan replaces)" provides: - "parseBaseDns() helper turning the newline-separated baseDn String into a trimmed DN list" - "Multi-base directory search across collectSearchEntries/listGroups/searchUsers, merged/deduped by entry dn" - "syncUsersForTenant no-op guard re-keyed on an empty parsed base-DN list (was empty groupFilterDns)" - "Multi-line Base-DN admin textarea + reworked de/en scope wording" affects: [ldap, admin-ldap-page, i18n] tech-stack: added: [] patterns: - "Map keyed by entry.dn used to merge/dedupe results across multiple LDAP search-base calls" key-files: created: [] modified: - apps/api/src/ldap/ldap.service.ts - apps/api/src/ldap/ldap.service.spec.ts - "apps/web/src/app/(portal)/admin/ldap/page.tsx" - apps/web/src/messages/de.json - apps/web/src/messages/en.json key-decisions: - "The syncUsersForTenant no-op guard is keyed SOLELY on parseBaseDns(config.baseDn).length === 0 — an empty groupFilterDns is no longer a no-op condition, it now performs a normal multi-base search with no memberOf restriction" - "groupFilterDns ou= entries stay ADDITIONAL search bases (plain filter); non-ou= entries become an optional memberOf constraint ANDed onto every base-DN search" - "Base-DN stays a single String column (newline-separated); no schema change or migration — parseBaseDns() is the sole place the list is derived" patterns-established: - "parseBaseDns()-then-loop-then-Map-dedupe pattern for turning a single delimited config field into a multi-target directory search, reusable for any future multi-value LDAP config field" requirements-completed: [260729-d3k] coverage: - id: D1 description: "parseBaseDns() splits the Base-DN field into a trimmed, non-empty list; empty/whitespace-only input yields []" requirement: "260729-d3k" verification: - kind: unit ref: "apps/api/src/ldap/ldap.service.spec.ts#LdapService.syncUsersForTenant — empty base DN no-op > creates nobody and deactivates nobody when the base DN is empty (whitespace-only)" status: pass human_judgment: false - id: D2 description: "syncUsersForTenant is a total no-op (no bind, no search, no create/update, no deactivation, no ldapConfig.update) ONLY when the parsed base-DN list is empty" requirement: "260729-d3k" verification: - kind: unit ref: "apps/api/src/ldap/ldap.service.spec.ts#LdapService.syncUsersForTenant — empty base DN no-op > creates nobody and deactivates nobody when the base DN is empty (whitespace-only)" status: pass - kind: unit ref: "apps/api/src/ldap/ldap.service.spec.ts#LdapService.syncUsersForTenant — empty base DN no-op > is NOT a no-op when baseDn is set but groupFilterDns is empty (normal multi-base search)" status: pass human_judgment: false - id: D3 description: "collectSearchEntries/listGroups/searchUsers search every configured base DN and merge/dedupe results by entry dn" requirement: "260729-d3k" verification: - kind: unit ref: "apps/api/src/ldap/ldap.service.spec.ts#LdapService.syncUsersForTenant — multi base DN scope > searches every configured base DN and merges/dedupes results by dn" status: pass - kind: unit ref: "pnpm --filter @tessera/api exec vitest run src/ldap/ldap.service.spec.ts (full 18-spec file, incl. searchUsers/listGroups paths exercised by existing specs)" status: pass human_judgment: false - id: D4 description: "Admin Base-DN field is a multi-line textarea persisting one newline-separated string; de/en i18n reworded to describe the group filter as optional and the base DN(s) as the sync scope" requirement: "260729-d3k" verification: - kind: unit ref: "node -e JSON.parse(...) de.json/en.json + grep Basis-DN(s)/base DN(s)/baseDnHint/textarea" status: pass - kind: other ref: "pnpm --filter @tessera/web run type-check" status: pass human_judgment: true rationale: "Visual rendering of the new textarea and hint text in the actual admin page was not verified via a running browser session in this quick task — automated checks confirm JSON validity, wording, and TypeScript correctness only." duration: 3min completed: 2026-07-29 status: complete --- # Quick Task 260729-d3k: LDAP Multi-Base-DN & Base-DN-as-Scope Summary **Replaced the "empty group filter = sync nothing" model from Quick 260728-lih with a "Base-DN(s) = sync scope" model: the Base-DN admin field now accepts multiple newline-separated DNs, all three LDAP directory-search paths loop and merge every configured base, and the mass-deactivation safety guard is re-keyed to the parsed base-DN list instead of groupFilterDns.** ## Performance - **Duration:** ~3 min - **Started:** 2026-07-29T07:34:57Z - **Completed:** 2026-07-29T07:38:16Z - **Tasks:** 2/2 completed - **Files modified:** 5 ## Accomplishments - `parseBaseDns()` splits the single `baseDn` String column (still no schema/migration change) into a trimmed, non-empty DN list, dropping blank/whitespace-only lines. - `syncUsersForTenant()`'s early-return no-op guard is now keyed exclusively on `parseBaseDns(config.baseDn).length === 0` — the sole condition that skips bind/search and the deactivation loop. An empty `groupFilterDns` no longer triggers a no-op; it now performs a normal multi-base search with no `memberOf` restriction. - `collectSearchEntries()`, `listGroups()`, and `searchUsers()` all loop over every configured base DN and merge/dedupe results into a `Map` keyed by `entry.dn`. `groupFilterDns` remains an optional extra restriction: `ou=` entries become additional search bases (plain filter), non-`ou=` entries become an optional `memberOf` OR-clause ANDed onto every base-DN search. - Admin LDAP page's Base-DN field is now a multi-line `