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 |
|
true |
|
|
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)
isActiveMUST keep its@default(true)— it gates LDAP LOGIN (auth.service.ts:55), NOT auto-sync. OnlysyncIntervalMincontrols 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
syncUsersForTenantis the critical safety change: it must sit ABOVE the deactivation loop. ThecollectSearchEntries[]-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.mdThe 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> |
<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>