239 lines
16 KiB
Markdown
239 lines
16 KiB
Markdown
---
|
|
phase: quick-260728-lih
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- 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
|
|
autonomous: true
|
|
requirements: [quick-260728-lih]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "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"
|
|
artifacts:
|
|
- "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'"
|
|
key_links:
|
|
- "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"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</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
|
|
|
|
# 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
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Schema default 60→0 + local migration (applied)</name>
|
|
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/<generated>_ldap_sync_interval_default_off/migration.sql</files>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>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"</automated>
|
|
</verify>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Selective-only sync semantics + tests</name>
|
|
<files>apps/api/src/ldap/ldap.service.ts, apps/api/src/ldap/ldap.service.spec.ts</files>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>cd apps/api && pnpm vitest run src/ldap/ldap.service.spec.ts</automated>
|
|
</verify>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Frontend form default 0 + de/en copy rewording</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>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>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"</automated>
|
|
</verify>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
- 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.
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
Create `.planning/quick/260728-lih-ldap-sync-selektiv-und-auto-sync-default/260728-lih-SUMMARY.md` when done.
|
|
</output> |