- TenderEmailConfigService.testConnection(userId, dto) mit Rueckfall auf
gespeicherte, entschluesselte Zugangsdaten bei leeren Feldern
- TendersController: POST email-config/test, userId aus Auth-Kontext,
deklariert vor @Get(':id')
- Beide Provider (ImapProvider/ExchangeInboxProvider) optional angehaengt,
bestehende 2-Arg-Konstruktoraufrufe bleiben typkorrekt
- Reihenfolge-Waechter und IDOR-Testfall ergaenzt
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
Die Gegenprobe im Browser hat drei Stellen gefunden, die der erste Durchgang nicht
erwischt hat — darunter zwei gut sichtbare Schaltflaechen:
Aenderungen speichern -> Änderungen speichern
Oeffnen -> Öffnen
Eine Aenderung ... -> Eine Änderung ...
Die Ursache ist dieselbe fuer alle drei und steckte im Waechter selbst: sein
Verdachtsmuster /(ae|oe|ue|ss)/ war case-sensitiv. "Aenderungen" beginnt mit "Ae",
nicht mit "ae", und ist deshalb durchgerutscht — der Waechter konnte gar nicht
anschlagen. Muster jetzt case-insensitiv; damit erfasst es auch die
grossgeschriebenen Formen.
Durch die schaerfere Pruefung melden sich neu die Abkuerzungen RSS, RSSGenerator und
SSL. Sie tragen ein doppeltes S ohne Umlaut-Bezug und stehen jetzt auf der
Positivliste.
Ausserdem zwei Meldungen des LDAP-Abgleichs korrigiert, die dem Administrator in der
Oberflaeche angezeigt werden (result.errors landet in der Fehlerliste der
LDAP-Seite): "ungueltiger ldapObjectGuid-Wert" und "Base-DN-Konfiguration pruefen.
Nicht geloescht." Drei Tests pinnen diese Texte bewusst und wurden mitgezogen.
Bewusst NICHT angefasst: die Warnung in crypto.service.ts. Sie geht ueber
logger.warn ins Protokoll und nicht an einen Nutzer.
Unabhaengig gegengeprueft: von allen Tokens in de.json, die ae/oe/ue tragen, ist
keines mehr eine Ersatzschreibung. 642 API-Tests und 225 Web-Tests gruen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5jtbGzC5Sf9npJ3JCjKhq
Korrigiert die deutschen Zeichenketten in beiden Mails aus
mail.service.ts (Betreff, Passwort-Reset-Fliesstext,
Willkommens-Mail) sowie die domaincheck-Modulbeschreibung in
domaincheck.seed.ts auf echte Umlaute. Wortlaut identisch, nur
Zeichen korrigiert; die englischen Zweige bleiben unveraendert.
seedModule() ist ein upsert mit update-Zweig und laeuft in
onModuleInit — ein API-Neustart schreibt die korrigierte
Beschreibung ueber bestehende Datenbankzeilen, ohne Migration oder
Backfill.
pnpm --filter @tessera/api run build: sauber.
pnpm --filter @tessera/api run type-check: sauber.
pnpm --filter @tessera/api run test: 46 Testdateien, 642 Tests gruen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5jtbGzC5Sf9npJ3JCjKhq
Nach der Formelaenderung (v1 -> v2) traegt jede Bestandszeile einen Hash
der alten Formel und wuerde nie wieder auf einen neuen treffen. Die
Produktionsdatenbank wird von Hand nicht angefasst, deshalb rechnet ein
neuer Dienst beim Start alle veralteten Zeilen automatisch nach — an der
Versionsmarke aus tender-fingerprint.ts erkannt, gleiche Bauform wie die
LDAP-Bind-Passwort-Nachverschluesselung.
- TenderFingerprintBackfillService: OnApplicationBootstrap, seitenweise
(500 Zeilen), pro Seite eine Transaktion, Fehler werden geloggt und
geschluckt statt geworfen
- In tenders.module.ts VOR TenderSchedulerService eingetragen, damit die
Nachrechnung vor der Cron-Registrierung laeuft
- Vier Tests: leere DB, alter+leerer Hash werden beide erfasst, zweiter
Lauf schreibt nichts mehr, Datenbankfehler wirft den Haken nicht
Gemessen an der lokalen Datenbank (16.255 Zeilen): Fingerabdruck-Gruppen
mit mehr als einer Zeile vorher=0, nachher=26, veraltete Zeilen
danach=0, davon Gruppen mit widersprechenden Wert-Groessenordnungen=2.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5jtbGzC5Sf9npJ3JCjKhq
Zwei Schaeden aus derselben Wurzel behoben (WINDOWS.md #11, beim Messen
gefunden): der Aktualisierungszweig aendert Titel/Vergabestelle/Frist
einer bestehenden Zeile, schrieb den Fingerabdruck dabei aber nie mit —
der gespeicherte Wert driftete vom Inhalt der Zeile weg und Stufe 3 fand
die Zeile nie wieder (an der Live-DB an drei Gruppen gemessen).
- fingerprint wird einmal in resolve() berechnet und sowohl im
Anlegezweig als auch im Aktualisierungszweig geschrieben
- Neue Stufe-3-Pruefung: valueBucketsContradict() als Veto auf einen
Fingerabdruck-Treffer, exakter Groessenordnungsvergleich, keine
Aehnlichkeitssuche
- toNumberOrNull() haendelt Prisma Decimal und den einfachen
Zahlenwert der Test-Fakes gleichermassen
- Vier neue Tests fuer die Wert-Faelle, bestehender Update-Test um die
Fingerabdruck-Erwartung erweitert, alle vorherigen Tests bleiben gruen
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5jtbGzC5Sf9npJ3JCjKhq
Die dritte Dedup-Stufe hashte bisher fuenf Segmente, darunter CPV-Division
und geschaetzten Wert — beide werden von Nicht-DOE-Quellen systematisch
nie geliefert, wodurch dieselbe Ausschreibung aus DOE und aus einem
Scraper nie denselben Fingerabdruck ergab (WINDOWS.md #11).
- tenderFingerprint() nimmt nur noch buyerName, title, deadlineAt entgegen
- Versionsmarke FINGERPRINT_VERSION_PREFIX ("v2:") vor dem Hash, damit
Task 3 veraltete Zeilen erkennen kann
- valueBucketsContradict() als eigene, exportierte Funktion fuer den
Wert-Veto in Task 2 — kein Hash-Bestandteil mehr
- backfill-tender-source.ts an die neue Signatur angepasst
- Testsuite auf das neue Verhalten umgeschrieben inkl. Goldwert-Test
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5jtbGzC5Sf9npJ3JCjKhq
- RssAdapter.fetchTenders tags every record from a feed with a tenantId
with the same D-13 ownerTenantId origin marking email-alert records
carry since Phase 14; platform-wide feeds (no tenantId) stay unmarked.
The pure parseFeed mapping is untouched — tagging happens in the
fan-out loop that knows which row a batch came from
- Extracted the service.bund.de seed out of TendersModule.onModuleInit
into seedServiceBundRssFeed() (tenders.seed.ts, same pattern as the
existing seedTendersModule), so the find-then-create idempotency added
in Task 1 is unit-tested directly instead of only via a Nest bootstrap
- New rss-feed-migration-sql.spec.ts: text-only check of the Task 1
migration file (nullable columns, dropped/created indexes, no
existing-row mutation, correct ordering)
- Files modified: apps/api/src/tenders/adapters/rss.adapter.ts, apps/api/src/tenders/tenders.module.ts, apps/api/src/tenders/tenders.seed.ts, apps/api/src/tenders/adapters/rss.adapter.spec.ts, apps/api/src/tenders/tenders.seed.spec.ts, apps/api/src/tenders/rss-feed-migration-sql.spec.ts
- remove(id, {userId, isAdmin}) replaces remove(id): single conditional
deleteMany (id AND (owned-by-caller OR admin-on-platform-feed)) — no
TOCTOU window, ownership check lives in the DB condition. Deletes
nothing -> NotFoundException (never Forbidden, no existence leak)
- createForUser rejects a caller's 21st personal feed with a clear
German message (T-17-10); platform-wide feeds are not counted
- DELETE /rss-feeds/:feedId moves from @Roles(ADMIN,SUPER_ADMIN) to
@UseModule('tender-radar') — ownership check does the gating now
- Tests use a Prisma double that actually evaluates the where condition
(not a double that always "succeeds") for both deleteMany and count
- Files modified: apps/api/src/tenders/tender-rss-feed.service.ts, apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tender-rss-feed.service.spec.ts, apps/api/src/tenders/tenders.controller.spec.ts
- TenderRssFeedSource.userId/tenantId (nullable): null = platform-wide
(admin-managed, includes the existing service.bund.de default),
set = personal feed owned by exactly one user
- Migration replaces url @unique with @@unique([userId, url]) — two
users can now follow the same address independently; existing rows
keep an empty owner (platform-wide, unchanged behavior)
- Service: listForUser/createForUser/createPlatform replace list/create
- Controller: GET/POST /rss-feeds move from @Roles(ADMIN,SUPER_ADMIN) to
@UseModule('tender-radar'); POST with scope:'platform' still requires
ADMIN/SUPER_ADMIN, checked inline (T-17-08)
- tenders.module.ts seed switched from upsert-on-url to find-then-create
(Rule 3, pulled forward from Task 3): the new compound unique index
requires a non-null userId in Prisma's generated type, so a
platform-wide row can no longer be addressed via upsert
- Files modified: apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260812110000_tender_rss_feed_owner/migration.sql, apps/api/src/tenders/tender-rss-feed.service.ts, apps/api/src/tenders/dto/tender-rss-feed.dto.ts, apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.module.ts, apps/api/src/tenders/tender-rss-feed.service.spec.ts, apps/api/src/tenders/tenders.controller.spec.ts
Der mandantenuebergreifende Sammelabruf in email-alert.adapter.ts bleibt
mechanisch unveraendert (findMany({isActive:true}) in einem Zug,
Fehlerbehandlung je Zeile) — geaendert wird nur die Warnmeldung (Zeilen-id
+ Besitzer statt Mandant, T-17-03) und die Klassendoku.
- Neue Tests: zwei aktive Postfaecher DESSELBEN Mandanten werden beide mit
ihren jeweils eigenen Zugangsdaten abgeholt; ein kaputtes Postfach
blockiert das andere nicht und protokolliert eine Warnung ohne
Zugangsdaten/Adresse; die Herkunftsmarkierung folgt dem Mandantenfeld
der jeweiligen Zeile (zwei Mandanten -> zwei Werte). Erwartungswerte von
Hand geschrieben, nicht ueber die Produktivfunktion erzeugt.
- tender-email-config.service.spec.ts (bereits in der Task-2-Migration
mitgeliefert) deckt zusaetzlich: tenantId wird beim Anlegen mitgeschrieben,
zwei Nutzer desselben Mandanten erzeugen zwei Zeilen statt eine zu
ueberschreiben.
- email-config-migration-sql.spec.ts (neu, Vorbild
doe-url-migration-sql.spec.ts): prueft die Reihenfolge der
Hand-Migration textuell — Zuordnung vor Loeschung, Pflicht erst nach
Befuellung, alte Eindeutigkeit runter/neue rauf, gewoehnlicher
tenantId-Index bleibt stehen.
src/tenders: 335/335 gruen. API gesamt: 603/603. Web gesamt: 192/192.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Alert-Postfach gehoert jetzt dem einzelnen Nutzer (userId @unique) statt
dem Mandanten (D-01) — ein zweiter Kollege desselben Mandanten kann sein
eigenes Postfach anbinden. tenantId bleibt denormalisiert (SMTP-Aufloesung,
Herkunftsmarkierung), wird auf create UND update mitgeschrieben.
- Handgeschriebene Migration (prisma migrate dev verweigert die
nicht-interaktive Shell): befuellt Bestandszeilen mit dem aeltesten
aktiven Administrator ihres Mandanten, entfernt verwaiste Zeilen ohne
Administrator, ersetzt die tenantId-Eindeutigkeit durch userId.
Lokal getestet (0 Bestandszeilen lokal und auf alpha — Zaehlung im
Task-1-Checkpoint), Index-Ergebnis verifiziert.
- TenderEmailConfigService.getConfigForApi/saveConfig auf userId als
Schluessel umgestellt; saveConfig nimmt {userId, tenantId}.
- TendersController: email-config-Routen von @Roles(ADMIN,SUPER_ADMIN)
auf @UseModule('tender-radar') umgestellt (Postfach ist jetzt
Nutzereinstellung); Route-Reihenfolge vor @Get(':id') unveraendert.
- Neue Seite /modules/tender-radar/my-sources ("Meine Quellen") mit dem
unveraenderten EmailAlertConfigForm; Hinweistext benennt D-05 (Tender
bleibt plattform-global — nur wer Quellen einspeist aendert sich).
- tenders.controller.spec.ts an neue Service-Signatur angepasst (Rule 3,
nicht im Plan gelistet, aber zum Kompilieren/Bestehen erforderlich).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CALENDAR_ENCRYPTION_KEY was named after the calendar module because that
module needed encryption first, in Phase 5. Every feature since has shared the
same key -- SMTP, the DKV and tender mailboxes, and as of today the LDAP bind
password -- so the name has been describing one of five users rather than the
thing itself, and each new feature inherited the confusion.
TESSERA_ENCRYPTION_KEY is the name now. The old one is still read, because
renaming outright would stop every existing installation at the next start:
their .env carries the old name, and compose was just made to fail hard on a
missing key. When only the old name is present the API logs a deprecation
warning naming both, and when both are set the new one wins -- otherwise a
half-migrated .env would encrypt with one key and decrypt with the other.
CalendarCryptoService becomes CryptoService in its own global CryptoModule.
Four modules used to import CalendarModule purely to reach the provider, which
read as a dependency on calendars where there was none; that import is gone.
Compose keeps the hard failure: without either name the stack refuses to
start. Verified in both files for all three cases -- neither name set (abort),
only the old name (starts), only the new name (starts).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The LDAP bind password was the only credential still stored in clear text.
CalendarSource, SmtpConfig, DkvModuleConfig and TenderEmailConfig have been
AES-256-GCM encrypted for a while; LDAP simply predated the encryption service
and was never brought along.
Hashing is not an option here: Tessera has to replay this password to bind
against the directory, so it must stay recoverable. Encryption at rest covers
the case a hash cannot help with either way -- a database dump or backup
leaving the host without the key, which lives in the application environment.
It does not protect against a compromised host, and does not pretend to.
Reuses CalendarCryptoService, the same provider SettingsModule, DkvModule and
TendersModule already inject, rather than introducing a second crypto path.
The name is a historical accident and is noted as such in LdapModule; renaming
it touches five modules and belongs in its own change.
Decryption sits in getConfig()/getAllActiveConfigs(), the two methods every
consumer already goes through, so callers keep reading a plain `bindPassword`
and the controller keeps masking it to '********' in responses.
The migration only renames the column -- SQL cannot encrypt, since the key is
not in the database. An idempotent bootstrap backfill encrypts rows written
before this change, and until it has run the read path passes a legacy
plaintext value through unchanged so the sync does not break in that window.
A failed decrypt throws rather than returning null: a wrong key must not read
as "no password configured" and silently turn an authenticated bind into an
anonymous one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The doe-opendata adapter stored the OCDS document's own `uri` as sourceUrl.
That is the API address of the record and serves OCDS JSON by design, so
anyone following the link from the results list, the detail view, or an alert
mail landed on raw JSON instead of the notice.
Build the human-readable page from the notice id the row already carries
instead. `/ui/de/search/details?noticeId=...` is the redirect target of
`/ui/de/notices/...`, so it needs no redirect. Verified in a browser for both
id shapes the feed uses -- numeric (25673764 -> "Feuerwehr-Geraetehaus Miehlen
Fliesenarbeiten") and UUID (7085ba12-... -> "Holzfassade"). The page is a
single-page app that answers 200 with an identical shell for any id, so this
had to be checked on rendered content; a status code proves nothing.
The adapter alone only fixes new ingests, so a backfill migration rewrites the
rows already stored -- in Tender and in TenderSource, since the detail view
lists per-source links separately. It touches only rows still pointing at
/api/notices/ and only ids of a shape that was actually verified, which makes
it idempotent and keeps an unexpected id from being pasted into a URL. Counted
read-only against the live database beforehand: 2846 DOE rows affected, none
skipped.
Closes the 2026-08-05 backlog item, which was deliberately held until Phase 16
was done.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The existence sweep in syncBoundGroupsForTenant() built its filter by
interpolating a byte-wise \xx escape of the stored objectGUID into a filter
string. ldapts parses that string before encoding it and does not turn the
escape sequences back into the bytes they stand for, so the assertion value
that reached the directory was a different value and matched nothing.
Measured read-only against a real Active Directory on 2026-08-11, probing a
group whose GUID had just been read from that same directory:
(objectGUID=\1e\4b...) 0 hits
(objectGUID=\1E\4B...) 0 hits
EqualityFilter{attribute, value: <16 bytes>} 1 hit, correct DN
(cn=Domain Admins) [control] 1 hit
Both the narrow base-DN sweep and the wider WR-03 move-detection sweep shared
that filter, so neither could ever hit: every AD-bound group looked deleted and
would have been removed together with its GroupMembership and ModuleGrant rows
on the first real sync, after handing off the default-group marker.
Build the filter as an EqualityFilter over the raw Buffer instead, and drop
escapeLdapFilterBuffer() -- it has no remaining caller and is the trap the code
walked into. escapeLdapFilterValue() is untouched: escaping STRING values into
a filter is correct and still in use.
The existing spec mocks matched on the escaped string, which is how the broken
shape passed review. They now match on the filter object's Buffer value, and
two added tests fail if a stringly-typed objectGUID filter ever comes back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
syncBoundGroupsForTenant()'s existence sweep only searched the configured
base DNs, so an AD group MOVED to an OU outside that subtree (still
present in the directory) was indistinguishable from a genuine
disappearance and got deleted along with its memberships/module grants —
a silent access loss from a non-destructive AD operation, and a bigger
blast radius than D-05 ("group genuinely gone") was accepted for.
Before concluding disappearance, a second (objectGUID=...) sweep now runs
against each base DN's own domain root (skipped when a base DN already IS
its domain root — the common case, nothing wider to search). A hit there
is reported as an error line and the group is left untouched; only when
the wide sweep also finds nothing is deletion (SC-4/D-05/D-06) actually
established — mirroring the existing conservative stance already taken
for a legacy binding whose DN no longer resolves. Deleting on uncertainty
was the failure mode; this closes it without widening it.
syncBoundGroupsForTenant() compared cn/dn for byte equality, so any
casing difference AD returns between two runs (e.g. after a
domain-controller switch) would look like a rename and re-write
name/ldapDn every single sync — violating the 'sync twice over an
unchanged AD state = no-op' idempotency guarantee. The comparison used
to DECIDE 'is this a rename' is now case-insensitive; the value written
on an actual rename is still stored byte-for-byte as the directory
reports it, per D-03.
syncBoundGroupsForTenant()'s rename write updates name and ldapDn in one
call, so a P2002 there can come from either @@unique([tenantId, name])
or @@unique([tenantId, ldapDn]). The catch previously reported every
P2002 as a name collision unconditionally; it now inspects
err.meta.target the same way importGroupsByDn() already does for its
own create() call, so a non-name unique violation is no longer
mislabelled and sent the admin down the wrong troubleshooting path.
A legacy binding from plan 15-06 has ldapDn set but ldapObjectGuid stays
null until the first syncBoundGroupsForTenant() run backfills it. Until
then, GroupsService.update() let a direct PATCH rename through even
though GroupFormModal.tsx already treats the same group as AD-bound
(isImported = ldapDn != null) — the D-03 name lock was only a UI
convention for that window, not the backend invariant the 16-02 summary
claimed.
- syncUsersForTenant() now calls syncBoundGroupsForTenant() (5a) BEFORE
syncGroupMembershipsForTenant() (5b) — the central correctness ordering
of Phase 16 (RESEARCH.md Pitfall 1): a rename detected in the same run
must be written back before the memberOf filter is built, or the
membership sync would misreport a rename as a membership wipeout
- Add observable ordering test (call-order spies), a no-op-guard test, and
a regression test proving a memberOf search never uses the stale
pre-rename DN
- Update the D-21 membership-sync test fixtures to resolve step 5a as a
deterministic no-op (DN-derived identity GUID), since the wiring now
runs 5a ahead of every syncUsersForTenant() call those tests exercise
A1 (objectGUID survives an AD rename) and A2 (binary filter escape syntax)
remain unverified against a real directory — no reachable AD in this
sandbox. Documented as an outstanding live verification in the plan
SUMMARY, not silently skipped.
- New private LdapService.syncBoundGroupsForTenant(): rename detection
(SC-3), disappearance deletion with default-marker handoff before delete
(SC-4/D-05/D-06), legacy ldapDn-only binding GUID backfill (D-07), and a
32-hex-char guard before any objectGUID filter interpolation (T-16-01)
- LdapSyncResult grows additively: groupsAdopted, groupsRenamed,
groupsDeleted, defaultMarkerMoved
- LdapService constructor takes GroupsService; LdapModule imports
GroupsModule (no cycle)
- 14 new test cases covering the full behavior matrix plus idempotency
- ModuleGrantsService.getUserAccess() now selects internalName on the
membership query's group projection (already present via `include:
{ group: true }` on the grant query)
- Both display points (viaGroups names, membership chips' name field)
use internalName ?? name; groups[] sorting now runs over the
displayed name as a result, distinct from GroupsService.listForTenant()
which still sorts by the raw name column
- 3 new test cases: fallback set/unset, sort-by-displayed-name
- No apps/web/ changes (verified via git diff --name-only)
- GroupsService.update() rejects `name` with BadRequestException when the
loaded group carries a set ldapObjectGuid (imported groups) — a real
backend invariant, not a UI-only disable
- internalName is settable/clearable on any group; empty/whitespace-only
values normalize to null instead of an empty display name
- listForTenant() now projects internalName alongside name
- UpdateGroupDto drops ldapDn (D-07: no more codepath binds a local group
to AD via this route) and gains internalName?: string | null
- 9 new test cases in groups.service.spec.ts (name lock, internalName
set/clear/idempotent/local-group/unicode, listForTenant projection);
stale ldapDn update() test removed (behavior intentionally deleted)
- DEFAULT_GROUP_NAME extracted as shared constant between
ensureDefaultGroup() and the new reassignDefaultBeforeDelete()
- reassignDefaultBeforeDelete(tenantId, groupId) moves the default
marker deterministically (DEFAULT_GROUP_NAME first, else oldest
other group by createdAt asc), never deletes, never throws
- 6 test cases covering handoff, fallback ordering, no-other-group,
non-default no-op, cross-tenant no-op, and P2002 race
- Migration 20260806133916_add_group_internal_name_and_object_guid applied
against the local Postgres container (baselined 24 prior migrations first
— _prisma_migrations was missing, unrelated to this task's DDL)
- New describe block in migration-sql.spec.ts pins internalName,
ldapObjectGuid, and the (tenantId, ldapObjectGuid) unique index
- Full API test suite green (40 files, 526 tests)
Task 1 checkpoint resolved: approve-both, granted 2026-08-06 by the
project owner (D-04 one-way schema extension: Group.internalName +
Group.ldapObjectGuid, both nullable, one versioned migration).
Adds the Phase 16 tracer slice through every layer:
- Prisma schema: Group.internalName, Group.ldapObjectGuid,
@@unique([tenantId, ldapObjectGuid]) (Prisma client regenerated;
the versioned migration itself is Task 3, separately blocking).
- LdapService: listGroups() now reads objectGUID via
explicitBufferAttributes and flags alreadyImported per tenant;
new importGroupsByDn() creates a Group per checked DN with
name/ldapDn/ldapObjectGuid, reject-with-report on name collision
(P2002 on name -> nameCollisions, P2002 on ldapObjectGuid ->
skipped), never aborts the batch on one DN's error; new static
escapeLdapFilterBuffer() for Plan 16-03's later existence sweep.
- DTO/controller: ImportGroupsDto, POST /ldap/groups/import
(ADMIN/SUPER_ADMIN), listGroups route now tenant-scoped.
- Frontend: new "AD-Gruppen importieren" section in /admin/ldap,
own discovery/import handlers with a visible error state
(Owner decision 2026-08-06 — no silent catch{} for these two
handlers), i18n keys in de.json/en.json.
- Tests: 8 new cases covering the full <behavior> list plus
listGroups sort order and alreadyImported.
Flagged assumption (RESEARCH.md A1/A2): objectGUID rename-stability
and the binary filter syntax are unverified against a real AD —
this plan only WRITES the GUID, Plan 16-03 reads it back live.
- TenantService.create ruft nach prisma.tenant.create ensureDefaultGroup
auf; Fehler werden protokolliert, nicht propagiert (Muster aus
UserService.create)
- tenant.module.ts importiert GroupsModule (keine Zirkularitaet, wie
UserModule bereits vormacht)
- AdminSeedService.onApplicationBootstrap besteht jetzt aus zwei
sequenziellen await-Schritten: seedAdmin() (bisheriger Rumpf, plus
ensureDefaultGroup nach dem Tenant-Upsert und VOR user.create), dann
ensureDefaultGroupsForAllTenants() als abschliessende Reparatur ueber
ALLE Mandanten — laeuft unabhaengig von seedAdmin()s fruehen
Rueckkehrpfaden (fehlende ENV / Admin existiert bereits) und ist je
Mandant sowie insgesamt try/catch-gekapselt, blockiert den API-Start nie
- Reparatur sitzt bewusst NICHT als eigener onApplicationBootstrap-Hook
in GroupsModule (Ordering-Falle aus tender-scheduler.service.ts)
- tenant.service.spec.ts, admin-seed.service.spec.ts (neu): Reihenfolge,
beide fruehen Rueckkehrpfade, Fehlerisolation je Mandant, Idempotenz
ueber zwei Bootstrap-Laeufe
- Neue Methode ensureDefaultGroup: legt fuer einen Mandanten ohne jede
Gruppe die Standardgruppe 'Alle Benutzer' (isDefault:true) an, nimmt
alle Bestandsbenutzer als MANUAL-Mitglieder auf und erzeugt Grants
fuer alle aktiven Module — derselbe Endzustand wie die drei
Backfill-INSERTs der Migration 20260804130130
- Waechter prueft ausschliesslich group.count === 0, niemals die
fehlende isDefault-Markierung (D-13)
- P2002 aus dem partiellen Index Group_one_default_per_tenant wird
abgefangen und liefert null statt zu werfen (Race-Sicherheit)
- groups.service.spec.ts: Fake erweitert um group.count,
tenantModuleActivation, moduleGrant.findMany/createMany,
$transaction mit Callback-Form, plus voller ensureDefaultGroup-Testblock
- New groupMembership.findMany query, tenant-scoped via group.tenantId
(GroupMembership has no own tenantId column)
- Response shape changes from an array to { groups, modules }; modules
entries stay field-identical to before
- Group without any module grant now stays visible, closing the
reproduced defect
- Regression: user in a group without any module grant stays visible
- Cross-tenant: membership in a foreign tenant's group is excluded
- Origin (MANUAL/LDAP), empty-modules case, stable alpha sort
- ModuleAccessService.getCatalogFlags(tenantId, userId, role) liefert je
aktivem Modul isActiveForTenant + hasAccess in einer Auflösung
- ModuleRegistryController.findCatalog (GET /modules/catalog), erreichbar
für jeden authentifizierten Benutzer wie GET /modules (D-08)
- ADMIN/SUPER_ADMIN: hasAccess immer wahr für aktive Module (D-03)
- 4 neue Tests für getCatalogFlags
- GET /module-grants/matrix, GET /module-grants/users/:userId,
POST /module-grants, DELETE /module-grants — alle vier rollengeschützt
(RolesGuard + Roles ADMIN/SUPER_ADMIN)
- matrix vor users/:userId deklariert (Beschattungsfehler-Vermeidung)
- GroupsModule bindet ModuleGrantsController/-Service ein; kein Import
von ModuleRegistryModule nötig, da der Service nur PrismaService braucht
- assertTargetBelongsToTenant prüft groupId/userId aus dem Request-Body
gegen tenantId aus dem JWT (T-15-01), vor jedem Grant-Insert
- grant: Entweder-oder-Regel (D-04), aktive TenantModuleActivation (D-02),
P2002 als Erfolg (Doppelklick-Schutz)
- getMatrix (D-15) und getUserAccess (D-16) für Matrix-Seite und
Benutzer-Detail, jeweils sortiert und mandantengescoped
- 20 Tests inkl. adjacency/empty/ordering/idempotency/concurrency
- LdapService.syncGroupMembershipsForTenant (neu, privat): pro AD-gebundener
Group (ldapDn gesetzt) ein memberOf-Reverse-Query je Base-DN, nie ein
Attribut-Lesen (Range-Retrieval-Pitfall). GroupMembership(source: LDAP)
wird per createMany/skipDuplicates angelegt (lässt bestehende MANUAL-Zeilen
unangetastet, D-19/D-20) und per deleteMany(source: 'LDAP', notIn: [...])
bereinigt. Jede Gruppe läuft in eigenem try/catch, ein Fehler landet als
"Gruppe <name>: <message>" in result.errors, die Schleife läuft weiter.
- Aufruf in syncUsersForTenant nach der Deaktivierungsschleife (Schritt 5)
und vor lastSyncAt (Schritt 6) — hinter dem bestehenden Base-DN-No-Op-Wächter,
kein separater Job, kein zweiter Button (D-21).
- LdapSyncResult um groupMembershipsAdded/groupMembershipsRemoved erweitert.
- ldap.service.spec.ts: neuer describe-Block mit 13 Tests (adjacency, empty,
encoding, ordering, idempotency, concurrency/backstop) plus Anpassung der
drei bestehenden Prisma-Fixtures und einer Ergebnis-Assertion an die
erweiterte LdapSyncResult-Form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- DashboardModule imports ModuleRegistryModule to inject ModuleAccessService
- getWidgets(userId, tenantId, role) runs the existing findMany unchanged
first, then calls getAccessibleModuleIds exactly once — only if a loaded
widget's type is in WIDGET_MODULE_MAP (currently always empty, so no
lookup runs today); unresolved module slugs fail closed
- DashboardController.getWidgets forwards tenantId + role from the JWT
- dashboard.service.spec.ts (8 tests, TDD-GREEN): covers every <behavior>
case incl. D-03 ADMIN bypass, adjacency/empty/ordering/idempotency, and
fail-closed on an unresolved Module slug
- pnpm --filter @tessera/api test: 457/457 green; type-check clean
- manual e2e against local API + DB container: empty WIDGET_MODULE_MAP
leaves an existing user's widget count unchanged (2/2 clock+search
survived the filter), throwaway verification user/rows removed after
- covers every <behavior> case from 15-05-PLAN.md task 2, including the
adjacency/empty/ordering/idempotency edge-probe categories and the
fail-closed unresolved-slug case
- RED confirmed: 4/8 fail against the current 1-arg getWidgets(userId)
- WIDGET_MODULE_MAP + getModuleSlugForWidgetType (apps/api/src/dashboard/widget-module-map.ts)
- table intentionally empty at end of phase: all 8 existing widget types are module-free platform widgets (D-22)
- code constant chosen over a WidgetInstance schema column — no migration for a field empty on every row
- UserService.create ruft nach der Anlage GroupsService.addUserToDefaultGroup
auf (D-11/D-12) — einziger Erzeugungspunkt für Benutzer, erbt LdapService
ohne eigene Kopie der Regel
- try/catch mit Logger: gescheiterte Gruppenzuordnung bricht weder die
Benutzeranlage noch einen LDAP-Sync-Lauf ab (T-15-14)
- UserModule importiert GroupsModule, keine Zirkularität
- 4 Tests in user.service.spec.ts; ldap.service.ts unverändert
- Second, deliberately separate migration (pure hand-SQL, no Prisma-
generated DDL): ENABLE/FORCE ROW LEVEL SECURITY plus a
tenant_isolation_policy for each of the three new tables, following
the pattern of 20260618112133_rls_policies (Auth-Kerntabellen)
rather than the RLS-exempt Tender* app-layer tables
- Group/ModuleGrant compare tenantId directly against
current_tenant_id(); GroupMembership has no own tenantId and follows
the PasswordResetToken join pattern (groupId IN (SELECT id FROM
Group WHERE tenantId = ...))
- migration-sql.spec.ts extended with a second describe block covering
both migration files (6x ROW LEVEL SECURITY, 3x CREATE POLICY, the
join vs. direct-comparison shape)
- Re-ran the Task-2 end-to-end proof after applying this migration:
identical result (USER without grant 403 + empty list, USER with
direct grant 200 + slug present, ADMIN 200) — the app's DB role
(tessera) is a Postgres superuser with rolbypassrls=true, so it
bypasses RLS as documented as an acceptable outcome by the plan;
RLS remains the defense-in-depth net for any future non-superuser
connection
- ModuleAccessService.getAccessibleModuleIds(tenantId, userId, role):
ADMIN/SUPER_ADMIN bypass (D-03) via one query, otherwise a single
Promise.all of direct + group ModuleGrant lookups intersected against
active TenantModuleActivation (D-02) — no N+1 over the user's groups
- findAccessibleModules() adds the name-asc sort for stable sidebar order
- ModuleGuard now resolves userId/role from request.user (JWT-sourced,
never body/params) and calls getAccessibleModuleIds instead of the
tenant-only isModuleActive check; caches the result on
request.moduleAccessIds for same-request reuse (D-09, no cross-request
caching)
- ModuleRegistryController.findActive delegates to
ModuleAccessService.findAccessibleModules instead of
findActiveForTenant, which stays untouched for Plan 15-03's
tenant-wide marketplace catalog
- ModuleRegistryModule exports ModuleAccessService for Plan 15-03/15-05
- module-access.service.spec.ts / module.guard.spec.ts cover every case
in the plan's <behavior> list with a hand-rolled Prisma mock
- End-to-end verified against the running local API: a USER without a
grant gets 403 on a @UseModule-protected endpoint and an empty
/modules/active list; the same USER with a direct grant gets 200 plus
the slug in the list; an ADMIN without any grant also gets 200 (D-03)
- Group/GroupMembership/ModuleGrant models plus MembershipSource enum
(D-05), placed under TenantModuleActivation with German block comment
- Hand-SQL appended to the generated migration: partial unique index for
one default group per tenant (D-13), CHECK num_nonnulls xor-constraint
plus two partial unique indexes for ModuleGrant (D-04), and the D-06
backfill (Group -> GroupMembership -> ModuleGrant, each INSERT guarded
by WHERE NOT EXISTS for idempotent re-runs on `prisma migrate deploy`)
- apps/api/src/groups/migration-sql.spec.ts verifies the hand-SQL by
reading migration.sql directly, no DB required
- Verified against the local DB: default-group count matches tenant
count, membership/grant counts match existing users/active
activations, and the XOR constraint rejects a group+user-less insert
- parseBaseDns() splits the newline-separated baseDn field into a list
- syncUsersForTenant no-op guard re-keyed on empty parsed base-DN list
(was empty groupFilterDns) — the sole condition that skips search +
the deactivation loop, preventing mass-deactivation on an
unconfigured config
- collectSearchEntries/listGroups/searchUsers loop every base DN and
merge/dedupe results by entry dn
- empty groupFilterDns is no longer a no-op: it now performs a normal
multi-base search with no memberOf restriction
- groupFilterDns ou= entries stay additional search bases; group DNs
become an optional memberOf constraint applied to every base search
- spec: replaced empty-groupFilterDns no-op test with empty-base-DN
no-op test, added multi-base merge/dedup test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- 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>
Manual "Jetzt abrufen" trigger delegates to
TenderIngestionService.pollDueSources() — the same fan-out tick the
scheduler cron runs. Gated to ADMIN/SUPER_ADMIN (T-lvg-01, DoS) and
declared before @Get(':id') per the established route-order convention.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On a fresh database the DÖE poll cron was never registered: TenderScheduler
read the doe-opendata poll config in its onModuleInit, which raced ahead of
TendersModule.onModuleInit seeding that config. The scheduler saw the config
absent → skipped registering the single global cron that drives pollDueSources
(DÖE + RSS + email-alert) → the platform ingested NOTHING until a second restart.
Observed live on a fresh prod DB (0 tenders, 'doe-opendata config inactive —
cron job not registered', lastIngestedDay null despite isActive=true).
Move the scheduler to onApplicationBootstrap, which runs after every module's
onModuleInit, so the seed is guaranteed complete before the config is read.
Adds a regression test asserting the lifecycle choice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add PORTAL_URLS map (vergabe24, aumass) in source-registry.ts, keyed off
the existing DENYLISTED_PORTALS constant so the portal set is never
re-declared
- Add GET /modules/tender-radar/denylisted-portals, declared before
@Get(':id') (route-order pitfall), mapping over DENYLISTED_PORTALS
- Extend tenders.controller.spec.ts: response shape + route-order guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
buildTenderWhere gains an optional ownerTenantId param: a resolved
requesting tenant sees global tenders (null) plus its own private ones
(OR[global, mine]); an unresolved requester fails CLOSED to global-only —
never an accidental cross-tenant leak.
TendersController: listTenders/getTender resolve the requesting tenant
leniently from the auth context (resolveRequestingTenantId, never throws)
and apply the D-13 filter; getTender 404s (not a distinct "forbidden") when
a tender's non-null ownerTenantId doesn't match the requester, so no
cross-tenant detail leak. New GET/PUT /modules/tender-radar/email-config
routes (Roles ADMIN/SUPER_ADMIN, tenantId from auth context, never the
body) delegate to TenderEmailConfigService — declared before @Get(':id')
per the project's NestJS route-order convention.
Web: EmailAlertConfig type + fetchEmailConfig/saveEmailConfig client
functions; EmailAlertConfigForm mirrors the DKV InboxConfigForm (password
blank on load, only sent when typed — T-07-12), added as a new
"E-Mail-Alerts" section on the existing tender-radar settings page.
Hardcoded German strings — i18n is Plan 14-05.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prisma: new TenderEmailConfig model (per-tenant, tenantId @unique, mirrors
DkvModuleConfig) + Tender.ownerTenantId nullable column + index (D-13:
null = global/platform-wide, unchanged for all existing rows and every
public source; set = visible only to that tenant). Migration
20260723113917_tender_email_config_owner_tenant_id applied locally.
TenderEmailConfigService: safe-select admin CRUD (GET never returns the
password, only hasPassword — T-07-12) with DkvService's encrypt-preserve-
empty semantics, via CalendarCryptoService (AES-256-GCM).
RawTenderRecord/NormalizedTenderFields gain optional ownerTenantId,
threaded through TenderNormalizerService.assemble() unchanged.
TenderDedupService's CREATE branch writes ownerTenantId (defaulting to
null); the UPDATE branch deliberately never references it, so a tender
later also seen on a public source is never retroactively hidden.
EmailAlertAdapter.fetchTenders() now does the real per-tenant fan-out:
findMany({isActive:true}) across ALL tenants (deliberate, documented
cross-tenant platform-scheduler read, never forTenant()/RLS), decrypts
each tenant's credentials, picks imap/exchange provider, and tags every
extracted candidate with ownerTenantId — catch-per-tenant so one broken
mailbox never blocks the others.
tenders.module.ts: imports CalendarModule/InboxModule, registers
EmailAlertAdapter + TenderEmailConfigService, seeds an 'email-alert'
TenderSourcePollConfig row (pollGranularity='tick', isActive=false —
no default mailbox to activate yet, D-02 framework-ready stance).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>