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 |
|
true |
|
|
Two parts, both real production requirements sourced from the working XWiki config in user-files/xwiki.cfg:
- 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.
- 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.mdLDAP 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
LdapConfigis tenant-scoped (tenantId @unique) and protected by RLS viaforTenant. A new column onLdapConfiginherits 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 XWikictl\{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/givenNameattributes 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 formemberOfDN values.- The admin LDAP page already references
useTranslations('admin.ldap'), but theadmin.ldapmessage block does NOT currently exist in de.json/en.json (pre-existing gap). Task 3 creates theadmin.ldapblock 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 existingt()convention and do not make it worse. - API package name:
@tessera/api(scripts:type-check,build,test). Existing migration timestamps run through 20260702; use20260707090000for the new one.
-
schema.prisma — in
model LdapConfig, add a scalar string-array column afterisActive: agroupFilterDnsfield of typeString[]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). -
Create migration
apps/api/prisma/migrations/20260707090000_add_ldap_group_filter/migration.sqlfollowing the repo's idempotent additive style (see the accentColor reference migration). Add the column with a safe default so existing rows are unaffected, using anADD COLUMN IF NOT EXISTSalter on theLdapConfigtable that sets the type to a text array, NOT NULL, defaulting to an empty text array. -
dto/ldap-config.dto.ts — add to
CreateLdapConfigDtoan optional string-array fieldgroupFilterDnsvalidated with@IsArray()plus@IsString({ each: true })plus@IsOptional(), defaulting to an empty array. It flows toUpdateLdapConfigDtoautomatically viaPartialType. AddIsArrayto theclass-validatorimport. -
ldap-config.service.ts — in
createConfig, persistgroupFilterDnsusing the dto value falling back to an empty array. InupdateConfig, add a conditional spread that setsgroupFilterDnsonly 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.-
ldap.service.ts: a. Extend the
LdapConfigDatainterface withgroupFilterDns: string[]. b. Add alistGroups(config)method that binds with the service account and searchesconfig.baseDn(scope sub) for groups and OUs using the filter matching objectClass group OR objectClass organizationalUnit. Request attributescn,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, mirroringtestConnection's error handling. c. Refactor ONLY the search step ofsyncUsersForTenantinto a private helpercollectSearchEntries(client, config, sanitizedFilter)that returns the dedupedsearchEntriesarray:- If
config.groupFilterDnsis empty → single search ofconfig.baseDnwithsanitizedFilter(current behavior, backward compatible). - Otherwise split
groupFilterDnsinto OU bases (DN whose leftmost RDN starts withou=, case-insensitive) and group DNs (the rest, typicallycn=). For each OU base, search that base DN withsanitizedFilter. If any group DNs exist, run one additional search ofconfig.baseDnwith a combined AND filter of the sanitized base filter and an OR ofmemberOfequality clauses, one per group DN, each DN passed throughLdapService.escapeLdapFilterValue. Merge all entry sets and dedupe byentry.dn. Keep the rest of the sync method (mapping loop, upsert, deactivation, lastSyncAt) exactly as-is, consuming the entries returned by the helper.
- If
-
ldap.controller.ts: a. Add
GET /ldap/groups(Roles ADMIN, SUPER_ADMIN) that resolvesreq.tenantId, loads the tenant config (404 if none), and returnsldapService.listGroups(...)built from the stored config fields. Follow the existing tenant-guard pattern used by the other endpoints. b. In the existingPOST /ldap/synchandler, addgroupFilterDns: config.groupFilterDnsto the config object passed intosyncUsersForTenant. -
ldap-sync.scheduler.ts: add
groupFilterDns: config.groupFilterDnsto the config object passed intosyncUsersForTenantso 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.
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, plusdiscoveredstate 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 ingroupFilterDns. - 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/configwith{ 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> |
<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>