Files
tessera-ctl/.planning/quick/260728-lih-ldap-sync-selektiv-und-auto-sync-default/260728-lih-PLAN.md
T
schalli 5cdd48d864
Tessera CI/CD / Lint & Type Check (push) Successful in 44s
Tessera CI/CD / Tests (push) Successful in 46s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m44s
docs(260728-lih): add plan + verification (passed 6/6) for LDAP selective sync
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 15:47:22 +02:00

16 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-260728-lih 01 execute 1
apps/api/prisma/schema.prisma
apps/api/prisma/migrations/<generated>_ldap_sync_interval_default_off/migration.sql
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
true
quick-260728-lih
truths artifacts key_links
New LdapConfig rows default to syncIntervalMin=0 (auto-sync OFF); existing rows keep their stored value untouched
isActive still defaults true — the LDAP login gate (auth.service.ts:55 `if (!config || !config.isActive) return null`) is unchanged
A sync with empty/undefined groupFilterDns is a COMPLETE no-op: no LDAP search, created=0/updated=0/deactivated=0, and — critically — ZERO existing LDAP users deactivated
A sync with a non-empty groupFilterDns still imports/updates only the selected groups/OUs + manually added DNs, and still deactivates LDAP users no longer in that selection (existing selective behavior preserved)
DELIBERATE back-compat break: empty groupFilterDns previously meant 'import every user under baseDn'; it now means 'sync nothing' — both code semantics and admin UI copy (de+en) reflect this
The scheduler is not touched — it already treats syncIntervalMin<=0 as disabled (ldap-sync.scheduler.ts:36), so the new default 0 means new configs never auto-sync
apps/api/prisma/schema.prisma: `syncIntervalMin Int @default(0)`
New migration dir apps/api/prisma/migrations/*_ldap_sync_interval_default_off/migration.sql with an ALTER COLUMN ... SET DEFAULT 0, APPLIED to the local tessera DB
apps/api/src/ldap/ldap.service.ts: collectSearchEntries() returns [] on empty groupFilterDns; syncUsersForTenant() early-returns an empty successful result on empty groupFilterDns BEFORE any search/deactivation
apps/api/src/ldap/ldap.service.spec.ts: updated exclude-list tests (now use a non-empty groupFilterDns) + a NEW no-op test proving empty selection = no search, no deactivation
apps/web/src/app/(portal)/admin/ldap/page.tsx: formData default syncIntervalMin: 0
apps/web/src/messages/de.json + en.json: groupFilter.description + groupFilter.emptyMeansAll reworded to 'no selection = nothing synced'
syncUsersForTenant early-return guard MUST run before the deactivation loop (ldap.service.ts ~631-649) — if placed after the search, an empty syncedDns deactivates every LDAP user (the exact bug this task prevents)
schema @default(0) -> new migration SET DEFAULT 0 -> scheduler line 36 (syncIntervalMin<=0 skip): the chain that makes auto-sync off-by-default for new configs
isActive @default(true) stays -> auth.service.ts:55 login gate keeps working for LDAP users
Make LDAP directory sync strictly selective and turn auto-sync OFF by default. Five coordinated changes: (1) Prisma `LdapConfig.syncIntervalMin` default 60→0 + migration applied locally + matching frontend form default; (2) `collectSearchEntries()` returns nothing when no group/OU is selected (no longer scans the whole baseDn subtree); (3) `syncUsersForTenant()` short-circuits to a clean empty result on an empty selection so NOBODY is created AND NOBODY is deactivated; (4) de+en admin copy reworded from "no selection imports all" to "no selection syncs nothing"; (5) tests updated to the new semantics plus a new no-op proof test.

Purpose: Prevent the whole directory from being pulled in (and, worse, prevent an empty selection from mass-deactivating every existing LDAP user via the D-15 deactivation pass), and stop new tenants from silently auto-syncing on an hourly cron they never opted into.

Output: schema + migration, two focused edits in ldap.service.ts, updated + extended ldap.service.spec.ts, one frontend default, two reworded i18n strings.

Semantic honesty (READ FIRST)

  • isActive MUST keep its @default(true) — it gates LDAP LOGIN (auth.service.ts:55), NOT auto-sync. Only syncIntervalMin controls auto-sync.
  • Do NOT touch ldap-sync.scheduler.ts — it already skips syncIntervalMin <= 0 (line 36).
  • The migration changes only the column DEFAULT. It MUST NOT rewrite existing rows.
  • The early-return in syncUsersForTenant is the critical safety change: it must sit ABOVE the deactivation loop. The collectSearchEntries []-return (change 2) is a defensive belt-and-suspenders — with the early return in place the sync path never reaches it for an empty selection, but the method's contract is corrected regardless.
  • Chosen lastSyncAt policy on empty selection: LEAVE IT UNTOUCHED. A no-op leaves no trace (the early return happens before any DB write). Documented here per the task brief.
  • Scope guard: NO new Prisma fields, NO UI restructure, NO new endpoints. Only the behavior changes above.

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

@.planning/STATE.md @CLAUDE.md

The sync + selective-filter logic being changed

@apps/api/src/ldap/ldap.service.ts

Scheduler — DO NOT EDIT, understand only (line 36 already skips interval<=0)

@apps/api/src/ldap/ldap-sync.scheduler.ts

Why isActive must stay default true (login gate at line ~55)

@apps/api/src/auth/auth.service.ts

Existing tests to update + extend

@apps/api/src/ldap/ldap.service.spec.ts

Schema (LdapConfig model ~line 61)

@apps/api/prisma/schema.prisma

Frontend form default + i18n usage

@apps/web/src/app/(portal)/admin/ldap/page.tsx

Task 1: Schema default 60→0 + local migration (applied) apps/api/prisma/schema.prisma, apps/api/prisma/migrations/<generated>_ldap_sync_interval_default_off/migration.sql In apps/api/prisma/schema.prisma, LdapConfig model (~line 70), change `syncIntervalMin Int @default(60)` to `syncIntervalMin Int @default(0)`. Leave the `isActive Boolean @default(true)` line exactly as-is — it gates LDAP login, not sync.
Generate AND apply the migration against the LOCAL tessera Postgres. The db container
has NO host port; Prisma must reach it over the container network (project memory:
"Lokale DB-Migrationen"). The container is `tessera-ctl-db-1` (image postgres:16-alpine),
db name/user/password = tessera/tessera/tessera_dev. Steps the executor runs:
(a) `docker start tessera-ctl-db-1` (it is currently Exited);
(b) resolve its IP into a var, e.g. `DB_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1)` (fallback `docker exec tessera-ctl-db-1 hostname -i`);
(c) from apps/api run `DATABASE_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" pnpm exec prisma migrate dev --name ldap_sync_interval_default_off`.
Prisma is v6 (`pnpm exec prisma`), and `migrate dev` also regenerates the client.
The emitted migration.sql must be a single `ALTER TABLE "LdapConfig" ALTER COLUMN
"syncIntervalMin" SET DEFAULT 0;` — it must NOT contain any UPDATE/data rewrite of
existing rows. If Prisma proposes anything beyond the SET DEFAULT (e.g. a drift-driven
reset), stop and report drift rather than applying.
grep -Eq 'syncIntervalMin:space:+Int:space:+@default\(0\)' apps/api/prisma/schema.prisma && test "$(docker exec tessera-ctl-db-1 psql -U tessera -d tessera -tAc "SELECT column_default FROM information_schema.columns WHERE table_name='LdapConfig' AND column_name='syncIntervalMin'" | tr -d '[:space:]')" = "0" Schema shows @default(0); a new *_ldap_sync_interval_default_off migration exists and is applied — the live LdapConfig.syncIntervalMin column_default is 0; existing rows unchanged; isActive default still true. Task 2: Selective-only sync semantics + tests apps/api/src/ldap/ldap.service.ts, apps/api/src/ldap/ldap.service.spec.ts Two edits in ldap.service.ts:
(A) `collectSearchEntries()` (~line 682): in the branch handling empty/undefined
`config.groupFilterDns`, STOP doing the full `client.search(config.baseDn, ...)` and
instead return an empty array (`return [];`). Update the method's doc comment so the
"Empty groupFilterDns" bullet says an empty selection yields no search and returns []
(no longer "identical to pre-filter behavior / backward compatible"). Keep the
non-empty OU-base and group-memberOf branches exactly as they are.

(B) `syncUsersForTenant()` (~line 526): add a guard as the VERY FIRST thing in the
method body, after constructing the empty `result` object and BEFORE `new Client(...)`
and BEFORE the deactivation loop. When `config.groupFilterDns` is missing or empty,
return the empty successful `result` immediately (created=0/updated=0/deactivated=0,
errors=[]). Add a short comment stating this returns before any search or deactivation
so an empty selection can never deactivate existing LDAP users, and that lastSyncAt is
intentionally left untouched. Do NOT write to ldapConfig.lastSyncAt on this path.

Then update ldap.service.spec.ts to the new semantics:

(1) In the `describe('LdapService.syncUsersForTenant — per-user exclude list', ...)`
block, change `baseConfig.groupFilterDns` from `[]` to a non-empty selection, e.g.
`['ou=people,dc=example,dc=com']`, so the search actually runs and the existing three
tests ('imports every user when the exclude list is empty', 'skips excluded usernames',
'deactivates a previously-imported user once they are excluded') still exercise the
import/exclude/deactivation logic and keep their current expectations. (The shared
mockSearch returns the mocked entries for the OU-base search, so those tests pass
unchanged apart from the config field.)

(2) ADD a new `describe('LdapService.syncUsersForTenant — empty selection no-op', ...)`
with a test that: sets `prisma.user.findMany` to return an existing LDAP user
(e.g. `[{ id: 'u-existing', ldapDn: 'cn=existing' }]`); calls
`service.syncUsersForTenant({ ...baseConfig, groupFilterDns: [] }, 't1')`; and asserts
ALL of: result equals `{ created: 0, updated: 0, deactivated: 0, errors: [] }`;
`mockSearch` was NOT called; `mockBind` was NOT called; `userService.create` was NOT
called; `prisma.user.update` was NOT called (no deactivation of the existing user);
and `prisma.ldapConfig.update` was NOT called (lastSyncAt untouched). This is the proof
that an empty selection creates nobody AND deactivates nobody.

Do NOT change the importUsersByDn / searchUsers / testConnection / verifyUserCredentials
describe blocks — those paths are unaffected by the groupFilterDns semantics.
cd apps/api && pnpm vitest run src/ldap/ldap.service.spec.ts ldap.service spec green: exclude-list tests run against a non-empty selection; new no-op test proves empty groupFilterDns performs no search and deactivates zero existing users; created/updated/deactivated all 0 on empty selection. Task 3: Frontend form default 0 + de/en copy rewording apps/web/src/app/(portal)/admin/ldap/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json In apps/web/src/app/(portal)/admin/ldap/page.tsx, the `useState` `formData` initializer (~line 87): change `syncIntervalMin: 60,` to `syncIntervalMin: 0,`. This governs the default for a brand-new config (create form). Leave the `data.syncIntervalMin ?? 60` fallback inside `fetchConfig` (~line 146) as-is: it only applies when editing an already saved config, whose value comes from the non-null DB column, so the fallback never fires for real data — changing it is out of scope for this task.
In both apps/web/src/messages/de.json and en.json, under `admin.ldap.groupFilter`,
reword the two strings that currently claim an empty selection imports the whole
directory, so they instead say an empty selection syncs nothing. Write EXACTLY:
- de.json `description`: "Beschraenkt den Sync auf ausgewaehlte AD-Gruppen oder Organisationseinheiten. Ohne Auswahl wird nichts synchronisiert."
- de.json `emptyMeansAll`: "Keine Auswahl - es wird nichts synchronisiert."
- en.json `description`: "Restrict sync to selected AD groups or organizational units. With no selection, nothing is synced."
- en.json `emptyMeansAll`: "No selection - nothing is synced."
Keep the JSON keys (`description`, `emptyMeansAll`) unchanged so key-parity between the
two locales is preserved — only the values change. Do not touch any other key.
grep -Eq 'syncIntervalMin: 0,' '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'))" && test "$(grep -c 'nichts synchronisiert' apps/web/src/messages/de.json)" = "2" && test "$(grep -c 'nothing is synced' apps/web/src/messages/en.json)" = "2" Create-form default is 0; both message files are valid JSON with matching keys; the empty-selection copy in de+en says nothing is synced (2 occurrences each); the old "imports all under base DN" wording is gone.

<threat_model>

Trust Boundaries

Boundary Description
admin UI/scheduler → LdapService.syncUsersForTenant trusted admin config drives which directory entries are pulled and which local users get deactivated
Prisma migrate → local tessera DB schema default change applied to the live LdapConfig table

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-lih-01 Denial of Service (availability) syncUsersForTenant deactivation pass (~631-649) high mitigate Early-return guard on empty groupFilterDns runs BEFORE the deactivation loop — an empty selection can no longer mass-deactivate every LDAP user (would otherwise lock all directory users out of login). Proven by the new no-op unit test.
T-lih-02 Tampering *_ldap_sync_interval_default_off migration medium mitigate Migration is SET DEFAULT only — no UPDATE of existing rows; executor halts and reports if Prisma proposes any data rewrite/drift. Verified via live column_default = 0 and existing rows untouched.
T-lih-03 Elevation of Privilege isActive login gate (auth.service.ts:55) medium accept isActive @default(true) is deliberately left unchanged; only syncIntervalMin default changes. No auth path is modified.
</threat_model>
- API: `cd apps/api && pnpm vitest run src/ldap/ldap.service.spec.ts` green (updated exclude tests + new no-op test). - Migration applied: live `information_schema.columns` shows LdapConfig.syncIntervalMin column_default = 0; schema shows @default(0). - Web: page.tsx create-form default is 0; de.json + en.json parse and state "no selection = nothing synced" in both locales. - Scheduler (ldap-sync.scheduler.ts) and auth.service.ts left untouched. - No new Prisma fields, no new endpoints, no UI restructure.

<success_criteria>

  • New LdapConfig rows default syncIntervalMin=0 (auto-sync off); isActive stays true.
  • Empty groupFilterDns = full no-op: no search, no create, no deactivation (unit-proven).
  • Non-empty groupFilterDns still selectively imports + deactivates as before.
  • de+en admin copy reworded to "no selection syncs nothing".
  • Three atomic commits (Task 1 schema+migration, Task 2 service+tests, Task 3 frontend+i18n). No push, no docker deploy beyond starting the local db for the migration. </success_criteria>
Create `.planning/quick/260728-lih-ldap-sync-selektiv-und-auto-sync-default/260728-lih-SUMMARY.md` when done.