Files

17 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
quick-260707-csw 01 execute 1
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
true
AUTH-06
truths artifacts key_links
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.
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.
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.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.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.

<threat_model>

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.
</threat_model>
- 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.

<success_criteria>

  • 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. </success_criteria>
Create `.planning/quick/260707-csw-ldap-ad-anbindung-zugangsdaten-aus-xwiki/260707-csw-SUMMARY.md` when done.