--- phase: quick-260707-csw plan: 01 type: execute wave: 1 depends_on: [] files_modified: - apps/api/prisma/schema.prisma - apps/api/prisma/migrations/20260707090000_add_ldap_group_filter/migration.sql - apps/api/src/ldap/dto/ldap-config.dto.ts - apps/api/src/ldap/ldap-config.service.ts - apps/api/src/ldap/ldap.service.ts - apps/api/src/ldap/ldap.controller.ts - apps/api/src/ldap/ldap-sync.scheduler.ts - apps/web/src/app/(portal)/admin/ldap/page.tsx - apps/web/src/messages/de.json - apps/web/src/messages/en.json autonomous: true requirements: [AUTH-06] must_haves: truths: - "Opening LDAP config with NO saved config pre-fills the form with the CTL Active Directory defaults (server balios.ctl.local:3268, base dc=ctl,dc=local, ctl-backslash down-level bind hint, AD person filter); password field stays empty." - "Admin can list available AD groups and OUs from the connected directory and select which ones restrict the sync." - "The selected group/OU filter persists on LdapConfig per tenant and is editable after initial setup." - "When a filter is set, sync only imports users who are members of a selected group or located under a selected OU; when no filter is set, sync imports every user under base DN exactly as before." artifacts: - "groupFilterDns String[] column on LdapConfig (additive, idempotent migration)." - "GET /ldap/groups endpoint returning discovered groups/OUs." - "Group-filter UI section on the LDAP admin page." key_links: - "ldap-config.service create/update persist groupFilterDns; controller sync + scheduler pass it into syncUsersForTenant." - "syncUsersForTenant splits selected DNs into OU search-bases and group memberOf filters, escaping DN values via escapeLdapFilterValue." --- Adapt Tessera's per-tenant LDAP directory sync to the CTL Active Directory and add a selective group/OU import filter. Two parts, both real production requirements sourced from the working XWiki config in `user-files/xwiki.cfg`: 1. AD connection prefill — when an admin opens LDAP config and NO config is saved yet, seed the form with the known-good CTL AD defaults so they only need to enter the service-account credentials. 2. Selective import filter — discover AD groups/OUs, let the admin pick which restrict the sync, persist the selection on `LdapConfig`, and apply it during sync while preserving today's "import everyone under base DN" behavior when no filter is set. Purpose: make LDAP usable against the CTL AD without hand-editing DB rows, and give admins control over WHO gets imported instead of the whole directory. Output: schema field + migration, DTO/service/controller/scheduler wiring, a group-discovery endpoint, filtered sync logic, and the admin-page UI + i18n. Scope note (read before executing): This is genuinely full-phase-sized (schema migration + 5 backend files + frontend + i18n). It is delivered as one quick-task plan with 3 sequential tasks because the pieces are tightly coupled and low-ambiguity. If execution reveals the AD filter behaviour needs real integration testing against a live directory, flag it for promotion to a full phase — do not fake a green sync. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/STATE.md @./CLAUDE.md # LDAP subsystem (patterns to follow): @apps/api/prisma/schema.prisma @apps/api/src/ldap/dto/ldap-config.dto.ts @apps/api/src/ldap/ldap-config.service.ts @apps/api/src/ldap/ldap.service.ts @apps/api/src/ldap/ldap.controller.ts @apps/api/src/ldap/ldap-sync.scheduler.ts @apps/web/src/app/(portal)/admin/ldap/page.tsx # Reference migration style (idempotent, additive): @apps/api/prisma/migrations/20260702000000_add_user_accent_color/migration.sql ## Key facts established during planning - `LdapConfig` is tenant-scoped (`tenantId @unique`) and protected by RLS via `forTenant`. A new column on `LdapConfig` inherits per-tenant scoping automatically — no extra tenancy work needed. - Sync uses a SINGLE service-account bind (`client.bind(config.bindDn, config.bindPassword)`), NOT per-user auth. The XWiki `ctl\{0}` value is a per-user auth pattern; for Tessera's SYNC bind we only need the down-level format hint. Never hardcode a password. - Default field mappings created server-side are ALREADY AD-correct: displayName to displayName, mail to email, sAMAccountName to username. The CTL `sn`/`givenName` attributes have no matching Tessera User field (User has only username/email/displayName) — do NOT invent new User columns. So Part 1 is purely a FRONTEND prefill of the connection fields; no field-mapping changes needed. - `LdapService.escapeLdapFilterValue(value)` (static, RFC 4515) already exists — reuse it for `memberOf` DN values. - The admin LDAP page already references `useTranslations('admin.ldap')`, but the `admin.ldap` message block does NOT currently exist in de.json/en.json (pre-existing gap). Task 3 creates the `admin.ldap` block containing at minimum the NEW keys it introduces; do not attempt to backfill every pre-existing key — just keep the new UI consistent with the existing `t()` convention and do not make it worse. - API package name: `@tessera/api` (scripts: `type-check`, `build`, `test`). Existing migration timestamps run through 20260702; use `20260707090000` for the new one. Task 1: Persist groupFilterDns on LdapConfig (schema + migration + DTO + config service) apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260707090000_add_ldap_group_filter/migration.sql, apps/api/src/ldap/dto/ldap-config.dto.ts, apps/api/src/ldap/ldap-config.service.ts Add a per-tenant selective-import filter field to the data layer. 1. schema.prisma — in `model LdapConfig`, add a scalar string-array column after `isActive`: a `groupFilterDns` field of type `String[]` with `@default([])`. Maps to a Postgres text array. Selected group/OU DNs are stored here; empty array means "no filter, import everyone under base DN" (backward compatible). 2. Create migration `apps/api/prisma/migrations/20260707090000_add_ldap_group_filter/migration.sql` following the repo's idempotent additive style (see the accentColor reference migration). Add the column with a safe default so existing rows are unaffected, using an `ADD COLUMN IF NOT EXISTS` alter on the `LdapConfig` table that sets the type to a text array, NOT NULL, defaulting to an empty text array. 3. dto/ldap-config.dto.ts — add to `CreateLdapConfigDto` an optional string-array field `groupFilterDns` validated with `@IsArray()` plus `@IsString({ each: true })` plus `@IsOptional()`, defaulting to an empty array. It flows to `UpdateLdapConfigDto` automatically via `PartialType`. Add `IsArray` to the `class-validator` import. 4. ldap-config.service.ts — in `createConfig`, persist `groupFilterDns` using the dto value falling back to an empty array. In `updateConfig`, add a conditional spread that sets `groupFilterDns` only when the dto value is defined, matching the existing conditional-field pattern. Do not change field-mapping defaults. Run prisma generate so the client picks up the field before type-check. pnpm --filter @tessera/api exec prisma validate && pnpm --filter @tessera/api exec prisma generate && grep -q groupFilterDns apps/api/prisma/schema.prisma && grep -q 'IF NOT EXISTS' apps/api/prisma/migrations/20260707090000_add_ldap_group_filter/migration.sql && pnpm --filter @tessera/api type-check Schema has groupFilterDns String[] default []; idempotent migration file exists; DTO validates an optional string array; create/update config persist the field; prisma validate and API type-check pass. Task 2: Group/OU discovery endpoint + filtered sync (backend logic) apps/api/src/ldap/ldap.service.ts, apps/api/src/ldap/ldap.controller.ts, apps/api/src/ldap/ldap-sync.scheduler.ts Add directory discovery and apply the persisted filter during sync. Do NOT refactor the existing sync loop (user mapping, upsert, deactivation, lastSyncAt) beyond isolating the SEARCH step. 1. ldap.service.ts: a. Extend the `LdapConfigData` interface with `groupFilterDns: string[]`. b. Add a `listGroups(config)` method that binds with the service account and searches `config.baseDn` (scope sub) for groups and OUs using the filter matching objectClass group OR objectClass organizationalUnit. Request attributes `cn`, `ou`, `dn`. Return an array of `{ dn, name, type }` where type is `'group' | 'ou'` (derive type from the leftmost RDN: `ou=` prefix means ou, otherwise group). Unbind in a finally block, mirroring `testConnection`'s error handling. c. Refactor ONLY the search step of `syncUsersForTenant` into a private helper `collectSearchEntries(client, config, sanitizedFilter)` that returns the deduped `searchEntries` array: - If `config.groupFilterDns` is empty → single search of `config.baseDn` with `sanitizedFilter` (current behavior, backward compatible). - Otherwise split `groupFilterDns` into OU bases (DN whose leftmost RDN starts with `ou=`, case-insensitive) and group DNs (the rest, typically `cn=`). For each OU base, search that base DN with `sanitizedFilter`. If any group DNs exist, run one additional search of `config.baseDn` with a combined AND filter of the sanitized base filter and an OR of `memberOf` equality clauses, one per group DN, each DN passed through `LdapService.escapeLdapFilterValue`. Merge all entry sets and dedupe by `entry.dn`. Keep the rest of the sync method (mapping loop, upsert, deactivation, lastSyncAt) exactly as-is, consuming the entries returned by the helper. 2. ldap.controller.ts: a. Add `GET /ldap/groups` (Roles ADMIN, SUPER_ADMIN) that resolves `req.tenantId`, loads the tenant config (404 if none), and returns `ldapService.listGroups(...)` built from the stored config fields. Follow the existing tenant-guard pattern used by the other endpoints. b. In the existing `POST /ldap/sync` handler, add `groupFilterDns: config.groupFilterDns` to the config object passed into `syncUsersForTenant`. 3. ldap-sync.scheduler.ts: add `groupFilterDns: config.groupFilterDns` to the config object passed into `syncUsersForTenant` so scheduled syncs honor the filter too. grep -q "listGroups" apps/api/src/ldap/ldap.service.ts && grep -q "groupFilterDns" apps/api/src/ldap/ldap-sync.scheduler.ts && grep -q "ldap/groups\|'groups'\|Get('groups')" apps/api/src/ldap/ldap.controller.ts && grep -q "escapeLdapFilterValue" apps/api/src/ldap/ldap.service.ts && pnpm --filter @tessera/api type-check && pnpm --filter @tessera/api build listGroups returns groups+OUs with type; GET /ldap/groups is admin-guarded; syncUsersForTenant filters via OU search-bases and escaped memberOf clauses when groupFilterDns is set and is unchanged when empty; controller and scheduler both pass groupFilterDns; API type-check and build pass. Task 3: LDAP admin page — AD prefill + group-filter UI + i18n apps/web/src/app/(portal)/admin/ldap/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json Part 1 (AD prefill): When there is NO saved config, initialize the connection form with the CTL AD defaults instead of blanks. Set the initial `formData` connection values to: serverUrl `ldap://balios.ctl.local:3268`, baseDn `dc=ctl,dc=local`, bindDn empty with a placeholder hint showing the AD down-level format (domain-backslash-serviceaccount, e.g. the bindDn input placeholder becomes an illustrative `ctl\serviceaccount`), bindPassword empty (never prefilled), searchFilter `(&(objectClass=user)(objectCategory=person))`. Keep the existing behavior where `fetchConfig` OVERWRITES formData from the saved config when one exists (so editing an existing config still shows its real values, and the prefill only applies to brand-new setups). Add a short helper caption under the bindDn field explaining the down-level format. Part 2 (group filter UI): Add a new section (render only when `config` exists, matching the existing pattern for the field-mapping and sync sections) that lets the admin manage the import filter: - Local state `groupFilterDns: string[]` seeded from the loaded config, plus `discovered` state for the list from the endpoint. - A "Discover groups/OUs" button calling `GET ${API_URL}/ldap/groups` (credentials include). Render results as a checkbox list showing name, type badge (group/OU), and DN; checking/unchecking toggles the DN in `groupFilterDns`. - Allow manual DN entry: a text input plus add button that appends a typed DN to `groupFilterDns` (for DNs not surfaced by discovery). Show currently selected DNs as a removable list/chips. - A save button that PATCHes `${API_URL}/ldap/config` with `{ groupFilterDns }` then re-fetches config. - Make clear in copy that an empty selection imports everyone under base DN (backward-compatible default). Keep the plain-Tailwind styling and `t()` convention already used on the page; no new component libraries. i18n: Add the new keys used by this section (and the bindDn hint) under an `admin.ldap` block in BOTH `apps/web/src/messages/de.json` and `apps/web/src/messages/en.json`. Create the `admin.ldap` block if absent (it currently is). Use German values for de.json and English for en.json. Only add keys the new UI actually references — do not backfill the entire pre-existing key set. grep -q "balios.ctl.local:3268" apps/web/src/app/\(portal\)/admin/ldap/page.tsx && grep -q "groupFilterDns" apps/web/src/app/\(portal\)/admin/ldap/page.tsx && grep -q "ldap/groups" apps/web/src/app/\(portal\)/admin/ldap/page.tsx && node -e "JSON.parse(require('fs').readFileSync('apps/web/src/messages/de.json','utf8'));JSON.parse(require('fs').readFileSync('apps/web/src/messages/en.json','utf8'))" && pnpm --filter @tessera/web type-check New-config form pre-fills CTL AD connection defaults with empty password; a group-filter section lets the admin discover, select (and manually add) group/OU DNs and save them; both message files remain valid JSON with the new admin.ldap keys; web type-check passes. ## Trust Boundaries | Boundary | Description | |----------|-------------| | admin browser to API | Admin-supplied config values (server URL, base DN, bind DN, group/OU DNs) cross into LDAP queries | | API to AD directory | Bind + search executed against the external Active Directory | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| | T-quick-01 | Tampering / Injection | memberOf filter built from selected group DNs in syncUsersForTenant | high | mitigate | Every DN interpolated into the memberOf OR clause is passed through the existing `LdapService.escapeLdapFilterValue` (RFC 4515); base filter still runs through `sanitizeSearchFilter`. | | T-quick-02 | Information Disclosure | GET /ldap/groups exposes directory structure | medium | mitigate | Endpoint restricted to ADMIN/SUPER_ADMIN via `@Roles`, tenant-scoped via `req.tenantId`; returns only cn/ou/dn, no credentials. | | T-quick-03 | Information Disclosure | bindPassword | high | mitigate | Password never prefilled in the form and never returned by the API (masked to `********`, existing behavior); no change. | | T-quick-04 | Tampering | groupFilterDns persisted per tenant | low | accept | Field lives on tenant-scoped LdapConfig under RLS; only same-tenant admins can write it. | - API: `pnpm --filter @tessera/api exec prisma validate`, `pnpm --filter @tessera/api type-check`, `pnpm --filter @tessera/api build` all pass. - Migration is additive and idempotent (`ADD COLUMN IF NOT EXISTS`), safe to re-run. - Web: both message JSON files parse; `pnpm --filter @tessera/web type-check` passes. - Backward compatibility: with `groupFilterDns` empty, sync performs the identical single base-DN search as before. - Opening LDAP config on a tenant with no saved config shows the CTL AD connection defaults pre-filled, password blank. - Admin can discover AD groups/OUs, select/add DNs, save them, and re-open to see them persisted (editable post-setup). - A configured filter restricts sync to members of selected groups / users under selected OUs; an empty filter imports everyone under base DN. - All automated verify gates green; no unrelated LDAP behavior changed. Create `.planning/quick/260707-csw-ldap-ad-anbindung-zugangsdaten-aus-xwiki/260707-csw-SUMMARY.md` when done.