Commit Graph

13 Commits

Author SHA1 Message Date
schalli 57bc7f96b3 feat(260728-lih): make LDAP sync strictly selective (empty selection = no-op)
- collectSearchEntries() returns [] on empty/undefined groupFilterDns
  instead of scanning the whole baseDn subtree
- syncUsersForTenant() early-returns an empty successful result before
  any LDAP search or the deactivation loop when groupFilterDns is empty,
  so an empty selection can never mass-deactivate existing LDAP users
- Updated exclude-list tests to use a non-empty groupFilterDns; added a
  dedicated no-op test proving empty selection performs zero search/
  create/update/deactivate operations

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 15:41:33 +02:00
schalli a1cf05404c feat(auth): LDAP login — authenticate imported users against the directory
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 47s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m21s
LDAP-imported users have no local passwordHash, and validateUser only checked
the local password, so they could never log in. Now a passwordless user with
an ldapDn is authenticated by binding as their OWN DN with the entered
password against the tenant's active LDAP config (reusing the ldaps TLS-skip
option). Empty passwords are rejected before binding to avoid AD's
unauthenticated-bind bypass. Local-password users are unchanged.

LdapService.verifyUserCredentials added; LdapModule now exports
LdapConfigService; AuthModule imports LdapModule (no circular dep). 8 new
specs (bind success/fail, empty-password guard, login via bind, wrong pw, no
config, no ldapDn, inactive). API 226 green, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 14:51:23 +02:00
schalli af9e968c6f feat(ldap): opt-in skip TLS verification for ldaps (internal CA)
Tessera CI/CD / Lint & Type Check (push) Successful in 45s
Tessera CI/CD / Tests (push) Successful in 49s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m45s
Add a per-tenant "Skip TLS certificate verification" toggle to the LDAP
admin page so admins can connect to an AD whose ldaps:// certificate is
signed by an internal/self-signed CA (Node error: "unable to verify the
first certificate"). When enabled, ldapts is given
tlsOptions.rejectUnauthorized=false; the flag is ignored for plain ldap://
(no TLS). Defaults to full verification.

New Boolean column LdapConfig.tlsRejectUnauthorized (@default(true)) +
migration; wired through DTOs, config service, all Client creations
(test/groups/user-search/import/sync) and the test-connection endpoint. UI
checkbox with an insecure-network warning (de/en). 3 new service specs;
API 218 green, web 131 green, both apps tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 14:18:55 +02:00
schalli 38face43b4 feat(ldap): individual user search + selective import with dedup
Tessera CI/CD / Lint & Type Check (push) Successful in 49s
Tessera CI/CD / Tests (push) Successful in 47s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m45s
Add an AD single-user search (by cn/sAMAccountName/displayName/mail) and a
selective import to the LDAP admin page, alongside the existing group/OU
filter. Imported users are deduped against existing ones by (ldapDn, then
username): a manually-imported user carries its ldapDn, so a later
department/group sync matches and updates it in place instead of creating a
duplicate. Search results flag alreadyImported; import skips existing users
and links a missing ldapDn. Extracted shared mapEntry/upsertMappedUser
helpers so sync and manual import resolve identity identically.

Backend: GET /ldap/users/search, POST /ldap/users/import (RFC-4515 escaped
query, ADMIN-guarded). 6 new service specs (search flags, create, skip,
ldapDn-link, denylist). Full API suite 215 green, both apps tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:52:50 +02:00
schalli 9d1323fe97 feat(ldap): per-user exclude/denylist filter for sync
Tessera CI/CD / Lint & Type Check (push) Successful in 43s
Tessera CI/CD / Tests (push) Successful in 43s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m46s
Add a per-username denylist so individual accounts (service accounts like
administrator, krbtgt, guest, ldap$) can be excluded from LDAP sync,
independent of the group/OU include-filter which only scopes the search.

- schema: LdapConfig.userExcludeList String[] (+ migration)
- sync: skip excluded usernames (case-insensitive) before recording the DN,
  so an already-imported user added to the list gets deactivated next sync
- DTO / config service / controller / scheduler: thread userExcludeList through
- web: exclude-list admin UI section (add/remove/save) + de/en translations
- tests: 3 specs covering empty list, case-insensitive skip, deactivation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:34:01 +02:00
schalli 246dc89a98 fix(ldap): treat ldapts empty-array attributes as absent, not "undefined"
Tessera CI/CD / Lint & Type Check (push) Successful in 45s
Tessera CI/CD / Tests (push) Successful in 39s
Tessera CI/CD / Build & Publish Images (push) Successful in 25s
ldapts represents a missing/absent LDAP attribute as an empty array
([]), not undefined -- entry['mail'] is [] when an account has no mail
set. The field-mapping loop did Array.isArray(value) ? String(value[0])
: ..., and String(undefined) is the literal string "undefined". Every
synced entry without that attribute got mappedData['email'] = "undefined"
(a truthy string, so the `|| fallback` never kicked in), and the second
such entry onward crashed with a unique constraint violation on email
since they all shared the exact same literal string.

Found live: syncing against a real Zentyal/Samba AD directory failed
on every entry after the first (Kevin Schaller, krbtgt, Guest, the DC
computer object, etc.) with "Unique constraint failed on the fields:
(email)".

Fix: resolve array values to their first element (or use the raw
value for non-arrays) and only keep it when actually present and
non-empty, so a genuinely missing attribute falls through to the
`${username}@ldap.local` fallback instead of the string "undefined".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 15:28:48 +02:00
schalli baff7ce4db fix(auth): make usernames case-insensitive
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 42s
Tessera CI/CD / Build & Publish Images (push) Successful in 24s
Username lookups (login, admin seed, LDAP sync) compared case-sensitively
against a stored value with whatever casing it was created with, so
"Admin" and "admin" were treated as different accounts.

Normalizes at every write and read path: UserService.create/update
lowercase the username before persisting, findByUsername lowercases
the lookup input, AuthService.validateUser lowercases before the login
query, AdminSeedService lowercases the configured admin username, and
the LDAP sync loop lowercases the mapped sAMAccountName before using it
for lookup/create/update -- so AD casing differences don't create
duplicate accounts either.

Added a data migration to lowercase any existing mixed-case usernames.
It relies on the User.username unique constraint to fail loudly if two
existing accounts would collide after normalizing, rather than silently
merging them.

Verified locally: logged in with "ADMIN" (uppercase) against the
existing lowercase "admin" account after rebuilding the API image.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 15:20:49 +02:00
schalli 010aceb1ac feat(ldap): support anonymous bind (no bind DN/password required)
Tessera CI/CD / Lint & Type Check (push) Successful in 41s
Tessera CI/CD / Tests (push) Successful in 39s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m40s
bindDn and bindPassword are now optional on LdapConfig (nullable
migration) and throughout the DTOs/service/client -- an admin can
leave both blank to connect to directories that permit anonymous
read access. LdapService.bind() falls back to an RFC 4513 anonymous
bind (empty DN + empty password) whenever either field is missing,
shared across testConnection, listGroups, and syncUsersForTenant.

Frontend: removed the required attribute from Bind-DN/Bind-Passwort,
added a placeholder hint ("leer = anonymous bind"), and the
"Verbindung testen" button now only needs a Server-URL to enable
(not bindDn+bindPassword). Config responses now return bindPassword
as null (not a misleading "********") when no password is set.

Verified locally: submitted only a Server-URL with both bind fields
empty and confirmed the request reached the anonymous-bind code path
(DNS failure for the unreachable test host, not a validation error).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 12:57:26 +02:00
schalli 39aa4bff2a feat(ldap): allow testing connection before saving a config
Tessera CI/CD / Lint & Type Check (push) Successful in 43s
Tessera CI/CD / Tests (push) Successful in 42s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m35s
"Verbindung testen" only appeared after a config was already saved,
so a fresh/never-configured tenant had no way to validate connection
details at all. Now the button is always visible; POST /ldap/test-connection
accepts optional ad-hoc serverUrl/bindDn/bindPassword and falls back
field-by-field to the saved config for anything omitted (bindPassword
in particular, since the form never re-sends the masked saved password).

Verified locally: tested with typed-but-unsaved values (base DN left
blank, nothing saved) and got a real connection attempt/error back,
not a 404 "no config" response.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 10:55:46 +02:00
schalli a5c500dbb3 fix(ldap): remove DTO default initializers that clobbered partial updates
CreateLdapConfigDto's optional fields (searchFilter, syncIntervalMin,
isActive, groupFilterDns) had class-field default initializers.
NestJS's ValidationPipe instantiates DTOs via plainToInstance, which
applies those defaults even when the field is absent from the request
body -- so any partial PATCH not including a given field silently
reset it to the hardcoded default instead of leaving it untouched.

Caught by testing the new groupFilterDns-only PATCH: saving the group
filter alone reset searchFilter back to "(objectClass=person)",
clobbering the configured Active Directory filter. The service layer
already has its own `?? default` fallback for create, so the DTO
initializers were redundant and unsafe. Removing them makes updateConfig's
existing "only set if dto.field !== undefined" pattern behave correctly
for every optional field, not just the ones sent together in one request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 09:57:24 +02:00
schalli 04fc33fc2a feat(ldap): group/OU discovery endpoint + selective sync filter
Adds listGroups() to browse AD groups/OUs under base DN, and
collectSearchEntries() to restrict syncUsersForTenant to members of
selected groups or users under selected OUs. Group DNs are matched
via escaped memberOf clauses (RFC 4515); OU DNs become extra search
bases. Empty groupFilterDns keeps the original single-base-DN search
unchanged. Controller sync endpoint and the sync scheduler both pass
groupFilterDns through so manual and scheduled syncs honor it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 09:36:58 +02:00
schalli 3c057f863d feat(ldap): add groupFilterDns column for selective import filter
Persists per-tenant AD group/OU DNs to restrict which directory
entries get synced. Empty array (default) preserves current
behavior — import everyone under base DN.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 09:34:27 +02:00
schalli f928cd7713 feat(02-04): LdapModule with sync service, config service, scheduler, and controller
- LdapService uses ldapts for DIRECTORY SYNC ONLY (anti-pattern avoidance)
- LdapConfigService creates default field mappings per D-16 (displayName, mail, sAMAccountName)
- Custom field mappings can be added/removed per D-17
- Per-tenant LDAP config per D-18
- syncUsersForTenant deactivates users removed from LDAP per D-15
- LdapSyncScheduler sets tenant context explicitly per Pitfall 2
- Manual sync endpoint POST /ldap/sync per D-14
- Auto-sync cron checks syncIntervalMin per D-14
- Test connection endpoint for LDAP config validation
- OpenLDAP + phpLDAPadmin added to docker-compose.dev.yml
- LDAP search filter sanitization per T-02-16
- bindPassword never returned in API responses per T-02-17
2026-06-19 08:38:07 +02:00