--- phase: 02-authentication-multi-tenancy plan: 04 type: execute wave: 3 depends_on: - 02-02 - 02-03 files_modified: - apps/api/src/ldap/ldap.module.ts - apps/api/src/ldap/ldap.service.ts - apps/api/src/ldap/ldap-config.service.ts - apps/api/src/ldap/ldap-sync.scheduler.ts - apps/api/src/ldap/dto/ldap-config.dto.ts - apps/api/src/ldap/ldap.controller.ts - apps/api/src/app.module.ts - apps/web/src/app/(portal)/admin/ldap/page.tsx - apps/web/src/messages/de.json - apps/web/src/messages/en.json - apps/web/src/components/layout/sidebar.tsx - docker-compose.dev.yml autonomous: true requirements: - AUTH-06 - TNNT-01 - TNNT-02 - TNNT-03 must_haves: truths: - "Admin can configure LDAP connection per tenant (server URL, base DN, bind user, filter, field mapping) per D-18" - "Admin can click 'LDAP synchronisieren' to manually trigger user sync per D-14" - "Auto-sync runs on configurable interval per D-14" - "LDAP sync creates new local users with passwordHash=null (LDAP-only users)" - "Users removed from LDAP are deactivated (not deleted) in Tessera per D-15" - "Field mapping is configurable with defaults (displayName->Name, mail->Email, sAMAccountName->Username) per D-16" - "Custom field mappings can be added via extensible mapping table per D-17" - "LDAP sync respects tenant context -- each tenant syncs independently per D-18" artifacts: - path: "apps/api/src/ldap/ldap.service.ts" provides: "LDAP client connection, search, and user sync logic" exports: ["LdapService"] - path: "apps/api/src/ldap/ldap-config.service.ts" provides: "Per-tenant LDAP configuration CRUD" exports: ["LdapConfigService"] - path: "apps/api/src/ldap/ldap-sync.scheduler.ts" provides: "Cron-based auto-sync per tenant" exports: ["LdapSyncScheduler"] - path: "apps/api/src/ldap/ldap.controller.ts" provides: "LDAP config and sync REST endpoints" exports: ["LdapController"] - path: "apps/web/src/app/(portal)/admin/ldap/page.tsx" provides: "LDAP configuration UI with test connection and sync button" min_lines: 80 key_links: - from: "apps/api/src/ldap/ldap.service.ts" to: "apps/api/src/user/user.service.ts" via: "Creates/updates/deactivates users during sync" pattern: "userService\\.create|userService\\.update|userService\\.deactivate" - from: "apps/api/src/ldap/ldap-sync.scheduler.ts" to: "apps/api/src/ldap/ldap.service.ts" via: "Cron triggers syncUsersForTenant per active config" pattern: "ldapService.*syncUsersForTenant" - from: "apps/api/src/ldap/ldap-sync.scheduler.ts" to: "apps/api/src/prisma/prisma-tenant.extension.ts" via: "Sets tenant context explicitly before each sync per Pitfall 2" pattern: "forTenant" --- LDAP user import and synchronization: LdapModule with ldapts client for directory sync, per-tenant LDAP configuration (D-18), manual sync button (D-14), auto-sync scheduler (D-14), configurable field mapping with defaults (D-16/D-17), deactivation of removed users (D-15), and admin UI for LDAP settings. This plan delivers AUTH-06 (LDAP/AD user import) and reinforces TNNT-01/TNNT-02/TNNT-03 by ensuring LDAP operations respect tenant boundaries. Purpose: Enable organizations using Active Directory or LDAP to import their user directory into Tessera without manual user creation, with per-tenant isolation. Output: Working LDAP sync that imports users from configured LDAP server, admin UI for LDAP configuration with test-connection and sync-now buttons. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02-authentication-multi-tenancy/02-CONTEXT.md @.planning/phases/02-authentication-multi-tenancy/02-RESEARCH.md @.planning/phases/02-authentication-multi-tenancy/02-01-SUMMARY.md @.planning/phases/02-authentication-multi-tenancy/02-02-SUMMARY.md @.planning/phases/02-authentication-multi-tenancy/02-03-SUMMARY.md @apps/api/src/user/user.service.ts @apps/api/src/tenant/tenant.service.ts @apps/api/prisma/schema.prisma @apps/api/src/app.module.ts ## Artifacts this phase produces See Plan 02-01 for the full artifacts table. Task 1: LdapModule with sync service, config service, scheduler, and controller - apps/api/prisma/schema.prisma (LdapConfig, LdapFieldMapping models) - apps/api/src/user/user.service.ts (user create/update/deactivate methods) - apps/api/src/prisma/prisma-tenant.extension.ts (forTenant for background jobs per Pitfall 2) - apps/api/src/prisma/prisma.service.ts (PrismaService) - .planning/phases/02-authentication-multi-tenancy/02-RESEARCH.md (LDAP anti-pattern: use LDAP for sync only not auth; Pitfall 2: tenant context in async; Pitfall 6: deactivated user JWT) apps/api/src/ldap/ldap.module.ts apps/api/src/ldap/ldap.service.ts apps/api/src/ldap/ldap-config.service.ts apps/api/src/ldap/ldap-sync.scheduler.ts apps/api/src/ldap/dto/ldap-config.dto.ts apps/api/src/ldap/ldap.controller.ts apps/api/src/app.module.ts docker-compose.dev.yml Install @nestjs/schedule for cron-based auto-sync: cd apps/api && pnpm add @nestjs/schedule Create apps/api/src/ldap/dto/ldap-config.dto.ts: - CreateLdapConfigDto: serverUrl (@IsUrl), baseDn (@IsString, @IsNotEmpty), bindDn (@IsString, @IsNotEmpty), bindPassword (@IsString, @IsNotEmpty), searchFilter (@IsString, @IsOptional, default "(objectClass=person)"), syncIntervalMin (@IsInt, @IsOptional, @Min(0), default 60), isActive (@IsBoolean, @IsOptional, default true) - UpdateLdapConfigDto: PartialType of CreateLdapConfigDto - CreateFieldMappingDto: ldapField (@IsString, @IsNotEmpty), tesseraField (@IsString, @IsNotEmpty), isDefault (@IsBoolean, @IsOptional) Create apps/api/src/ldap/ldap-config.service.ts: - Injectable service. Dependency: PrismaService. - getConfig(tenantId): Find LdapConfig by tenantId, include fieldMappings. - createConfig(tenantId, dto): Create LdapConfig for tenant. Also create default field mappings: displayName->displayName, mail->email, sAMAccountName->username (with isDefault=true). Per D-16. - updateConfig(tenantId, dto): Update LdapConfig. - addFieldMapping(configId, dto): Create LdapFieldMapping. Per D-17. - removeFieldMapping(mappingId): Delete LdapFieldMapping (only if isDefault=false -- do not allow deleting system defaults). - getAllActiveConfigs(): Find all LdapConfigs where isActive=true, include tenant and fieldMappings. Used by scheduler. Create apps/api/src/ldap/ldap.service.ts: - Injectable service. Dependencies: PrismaService, UserService. - CRITICAL ANTI-PATTERN AVOIDANCE: LDAP is used for DIRECTORY SYNC ONLY, never for authentication. Users authenticate against local password hashes. Per RESEARCH.md anti-pattern guidance. - testConnection(config): Create ldapts.Client with config.serverUrl, attempt bind with config.bindDn and config.bindPassword. Return success/failure. Close client after test. - syncUsersForTenant(config, tenantId): 1. Create ldapts.Client, bind with service account credentials. 2. Search baseDn with searchFilter, request attributes from field mappings. 3. For each LDAP entry, map fields to Tessera user fields using config.fieldMappings. 4. For each mapped user: upsert into local DB. If user exists by ldapDn or username, update fields. If new, create with role USER, passwordHash null (LDAP-only user per A6), tenantId, ldapDn set. 5. DEACTIVATION per D-15: Find all local users with tenantId and ldapDn not null. Any user whose ldapDn is NOT in the sync result set gets isActive set to false. Do NOT delete. 6. IMPORTANT per Pitfall 2: This method receives tenantId explicitly. When called from scheduler, the tenant context must be set BEFORE any DB operations. Use forTenant(prisma, tenantId) to create scoped client for all DB operations within this sync. 7. Update LdapConfig.lastSyncAt to now. 8. Return sync result: { created: number, updated: number, deactivated: number, errors: string[] }. - Close client connection in finally block. - LDAP search filter: validate that user-provided searchFilter does not contain LDAP injection characters. Sanitize by escaping special characters per RFC 4515. Create apps/api/src/ldap/ldap-sync.scheduler.ts: - Injectable service using @nestjs/schedule Cron decorator. - Dependencies: LdapService, LdapConfigService, PrismaService. - Runs every minute (Cron '* * * * *') but checks each config's syncIntervalMin to determine if sync is due. - In handleCron(): Get all active configs via ldapConfigService.getAllActiveConfigs(). For each config where syncIntervalMin > 0 and lastSyncAt is older than syncIntervalMin minutes ago (or lastSyncAt is null): call ldapService.syncUsersForTenant(config, config.tenantId). Per D-14 auto-sync. - CRITICAL per Pitfall 2: Each tenant sync is an independent operation with its own tenant context. Do NOT share database connections across tenant syncs. Create a new forTenant client per tenant. - Log sync results (created/updated/deactivated count). - Wrap each sync in try/catch -- one tenant's failure must not block others. Create apps/api/src/ldap/ldap.controller.ts: - Prefix 'ldap' - GET /ldap/config: @Roles(Role.ADMIN, Role.SUPER_ADMIN) @UseGuards(RolesGuard). Get LDAP config for current tenant (from req.tenantId). Per D-18, config is per-tenant. - POST /ldap/config: @Roles(Role.ADMIN, Role.SUPER_ADMIN). Body: CreateLdapConfigDto. Create LDAP config for current tenant. - PATCH /ldap/config: @Roles(Role.ADMIN, Role.SUPER_ADMIN). Body: UpdateLdapConfigDto. Update LDAP config. - POST /ldap/test-connection: @Roles(Role.ADMIN, Role.SUPER_ADMIN). Test LDAP connection with current config. Return success/failure with error message. - POST /ldap/sync: @Roles(Role.ADMIN, Role.SUPER_ADMIN). Trigger manual sync per D-14 "LDAP synchronisieren" button. Return sync results. - POST /ldap/config/mappings: @Roles(Role.ADMIN, Role.SUPER_ADMIN). Body: CreateFieldMappingDto. Add field mapping per D-17. - DELETE /ldap/config/mappings/:id: @Roles(Role.ADMIN, Role.SUPER_ADMIN). Remove non-default field mapping. Create apps/api/src/ldap/ldap.module.ts: - Import ScheduleModule.forRoot() from @nestjs/schedule - Providers: LdapService, LdapConfigService, LdapSyncScheduler - Controllers: LdapController - Imports: UserModule (for UserService) - Exports: LdapService Update app.module.ts: Import LdapModule and ScheduleModule.forRoot(). Add test LDAP server to docker-compose.dev.yml for development testing: - openldap service using osixia/openldap:1.5.0 image - Environment: LDAP_ORGANISATION=Tessera, LDAP_DOMAIN=tessera.local, LDAP_ADMIN_PASSWORD=admin - Ports: 389:389, 636:636 - Network: data-net (internal, same as PostgreSQL) - Add phpldapadmin service using osixia/phpldapadmin:0.9.0 for visual LDAP management in dev - phpldapadmin environment: PHPLDAPADMIN_LDAP_HOSTS=openldap - phpldapadmin ports: 6443:443 - phpldapadmin network: backend-net cd /home/vicolab/projects/tessera-ctl && pnpm turbo type-check --filter=@tessera/api - LdapService uses ldapts for DIRECTORY SYNC ONLY (never for authentication per anti-pattern) - LdapConfigService creates default field mappings (displayName->displayName, mail->email, sAMAccountName->username) per D-16 - Custom field mappings can be added and removed per D-17 - LDAP config is per-tenant per D-18 - syncUsersForTenant deactivates (not deletes) users removed from LDAP per D-15 - LdapSyncScheduler sets tenant context explicitly per sync per Pitfall 2 - Manual sync endpoint POST /ldap/sync exists per D-14 - Auto-sync cron checks syncIntervalMin per D-14 - Test connection endpoint exists for LDAP config validation - OpenLDAP and phpLDAPadmin added to docker-compose.dev.yml - Type-check passes LDAP sync creates/updates/deactivates users from configured LDAP directory per tenant with configurable field mapping and auto-sync interval. Task 2: LDAP admin configuration UI page - apps/api/src/ldap/ldap.controller.ts (API endpoints from Task 1) - apps/api/src/ldap/dto/ldap-config.dto.ts (DTO fields) - apps/web/src/app/(portal)/admin/users/page.tsx (existing admin page pattern from Plan 02-02) - apps/web/src/components/layout/sidebar.tsx (admin section navigation) - apps/web/src/messages/de.json (existing i18n structure) apps/web/src/app/(portal)/admin/ldap/page.tsx apps/web/src/messages/de.json apps/web/src/messages/en.json apps/web/src/components/layout/sidebar.tsx Create apps/web/src/app/(portal)/admin/ldap/page.tsx: - 'use client' component - Only visible to ADMIN and SUPER_ADMIN roles (check auth store) - Fetches current LDAP config from GET /ldap/config on mount - Layout in sections: Section 1 - Connection Settings: - Server URL input (e.g., ldap://ldap.example.com or ldaps://) - Base DN input (e.g., dc=example,dc=com) - Bind DN input (e.g., cn=admin,dc=example,dc=com) - Bind Password input (password type) - Search Filter input with default "(objectClass=person)" - "Verbindung testen" (Test Connection) button -- calls POST /ldap/test-connection, shows success/error toast - Save button to POST/PATCH /ldap/config Section 2 - Field Mapping (per D-16, D-17): - Table showing current mappings: LDAP Field | Tessera Field | Default | Actions - Default mappings (isDefault=true) shown with lock icon, cannot be deleted - Custom mappings have a delete button - "Mapping hinzufuegen" (Add Mapping) button opens form row with ldapField and tesseraField inputs - Per D-17: extensible mapping table -- users can map any LDAP attribute to any Tessera field Section 3 - Sync Settings: - Auto-sync interval input (minutes, 0 = disabled) per D-14 - Last sync timestamp display (or "Noch nie synchronisiert" if null) - "LDAP synchronisieren" button per D-14 -- calls POST /ldap/sync - Sync result display: created/updated/deactivated counts, error list if any - Enable/disable toggle for the entire LDAP configuration All strings via useTranslations('admin') with ldap namespace. Update sidebar.tsx to add "LDAP" link under Verwaltung section: - Add "LDAP" navigation item linking to /admin/ldap - Only visible to ADMIN and SUPER_ADMIN roles - Add i18n key: sidebar.ldap Add i18n keys to de.json and en.json: - admin.ldap.title ("LDAP-Konfiguration" / "LDAP Configuration") - admin.ldap.serverUrl, admin.ldap.baseDn, admin.ldap.bindDn, admin.ldap.bindPassword, admin.ldap.searchFilter - admin.ldap.testConnection, admin.ldap.testSuccess, admin.ldap.testFailed - admin.ldap.save, admin.ldap.saved - admin.ldap.fieldMapping.title, admin.ldap.fieldMapping.ldapField, admin.ldap.fieldMapping.tesseraField, admin.ldap.fieldMapping.default, admin.ldap.fieldMapping.add, admin.ldap.fieldMapping.remove - admin.ldap.sync.title, admin.ldap.sync.interval, admin.ldap.sync.intervalDisabled, admin.ldap.sync.lastSync, admin.ldap.sync.neverSynced, admin.ldap.sync.trigger, admin.ldap.sync.syncing, admin.ldap.sync.result (with created/updated/deactivated placeholders) - admin.ldap.enable, admin.ldap.disable - sidebar.ldap ("LDAP" for both languages) cd /home/vicolab/projects/tessera-ctl && pnpm turbo type-check --filter=@tessera/web - LDAP configuration page exists at /admin/ldap - Connection settings section has all required fields per D-18 - Test connection button calls API and shows result - Field mapping table shows defaults per D-16 and allows adding custom mappings per D-17 - Sync section has interval config and manual sync button per D-14 - Sidebar shows LDAP link under Verwaltung for ADMIN/SUPER_ADMIN - All strings internationalized (de + en) - Type-check passes LDAP admin page allows per-tenant LDAP configuration (D-18), field mapping (D-16/D-17), test connection, and manual sync trigger (D-14). All AUTH-06 requirements are user-accessible. ## Trust Boundaries | Boundary | Description | |----------|-------------| | NestJS API -> LDAP server | Service account binds to external LDAP; credentials must not leak | | LDAP directory -> Tessera DB | Untrusted directory data mapped to local users | | Scheduler -> DB | Background jobs must set tenant context explicitly | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-02-16 | Tampering | LDAP search filter injection | mitigate | Sanitize user-provided searchFilter by escaping special characters per RFC 4515 before ldapts search | | T-02-17 | Information Disclosure | LDAP bind credentials | mitigate | Store bindPassword in DB (encrypted at rest via PostgreSQL); never log or return in API responses | | T-02-18 | Spoofing | LDAP as auth backend | mitigate | LDAP used for DIRECTORY SYNC ONLY per anti-pattern; users authenticate against local hashes | | T-02-19 | Information Disclosure | Cross-tenant LDAP data | mitigate | Each tenant has independent LdapConfig; sync sets tenant context per operation per Pitfall 2 | | T-02-20 | Denial of Service | LDAP sync overload | accept | Sync runs sequentially per tenant; large directories may take time but won't crash the system | After both tasks complete: 1. docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d (includes OpenLDAP) 2. Add test users to OpenLDAP via phpLDAPadmin at https://localhost:6443 3. Login as admin, navigate to /admin/ldap 4. Configure LDAP: serverUrl=ldap://openldap:389, baseDn=dc=tessera,dc=local, bindDn=cn=admin,dc=tessera,dc=local, bindPassword=admin 5. Click "Verbindung testen" -- should show success 6. Click "LDAP synchronisieren" -- should import test users 7. Navigate to /admin/users -- should show imported LDAP users with role USER 8. Remove a user from OpenLDAP, sync again -- user should be deactivated in Tessera 9. pnpm turbo type-check passes - LDAP sync imports users from configured directory (AUTH-06) - Per-tenant LDAP configuration works (D-18) - Manual sync trigger works (D-14) - Auto-sync scheduler runs on configured interval (D-14) - Field mapping configurable with defaults (D-16/D-17) - Removed LDAP users are deactivated not deleted (D-15) - Admin UI provides full LDAP management - Tenant isolation maintained during background sync (TNNT-01/TNNT-03) Create `.planning/phases/02-authentication-multi-tenancy/02-04-SUMMARY.md` when done