docs(260707-csw): pre-dispatch plan for LDAP AD config + import filter
This commit is contained in:
+189
@@ -0,0 +1,189 @@
|
||||
---
|
||||
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."
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Persist groupFilterDns on LdapConfig (schema + migration + DTO + config service)</name>
|
||||
<files>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</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Group/OU discovery endpoint + filtered sync (backend logic)</name>
|
||||
<files>apps/api/src/ldap/ldap.service.ts, apps/api/src/ldap/ldap.controller.ts, apps/api/src/ldap/ldap-sync.scheduler.ts</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: LDAP admin page — AD prefill + group-filter UI + i18n</name>
|
||||
<files>apps/web/src/app/(portal)/admin/ldap/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<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>
|
||||
|
||||
<verification>
|
||||
- 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.
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260707-csw-ldap-ad-anbindung-zugangsdaten-aus-xwiki/260707-csw-SUMMARY.md` when done.
|
||||
</output>
|
||||
Reference in New Issue
Block a user