24 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | user_setup | estimate | must_haves | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 17-eigene-ausschreibungs-quellen-je-nutzer | 02 | execute | 2 |
|
|
true |
|
|
|
Purpose: D-02 aus 17-CONTEXT.md umsetzen — "Wieso kann eine Person nicht mehrere Portale abfragen als eine andere?" Output: Erweiterte Tabelle samt Migration, Besitzerlogik im Dienst, Schutz gegen fremdes Loeschen, angepasste Startbestueckung, Herkunftsmarkierung im Abruf.
Gegenlesen von D-02, wie in 17-CONTEXT.md gewuenscht. Der Zuschnitt "Besitzerfeld darf leer bleiben, leer heisst plattformweit" ist richtig: die Alternative haette den seit Phase 14 laufenden Standard-Feed fuer alle stillgelegt. Zwei technische Folgen muessen aber mitgeplant werden, sonst faellt die Entscheidung an anderer Stelle auf die Fuesse:
- In PostgreSQL gelten leere Werte in einer Eindeutigkeitsregel als jeweils
verschieden.
@@unique([userId, url])verhindert deshalb nicht, dass dieselbe Adresse zweimal plattformweit angelegt wird. Das ist hinnehmbar: die Zusammenfuehrung gleicher Ausschreibungen laeuft ohnehin ueber den Dedup-Schluessel, ein doppelter Feed kostet also nur einen zusaetzlichen Abruf, verdoppelt aber keine Eintraege. Absichern liesse es sich nur mit einem Teil-Index in Roh-SQL — den kennt Prisma nicht, er wuerde bei der naechsten Schema-Abgleichung als Abweichung erscheinen und stillschweigend wieder entfernt werden. Deshalb bewusst nicht. - Aus demselben Grund laesst sich die Startbestueckung nicht mehr ueber die Adresse als Schluessel schreiben. Sie wird auf "erst suchen, dann anlegen" umgestellt — siehe Task 3.
Zusaetzliche Entscheidung (D-06, ueber 17-CONTEXT.md hinaus). Persoenliche Feeds tragen zusaetzlich das Mandantenfeld ihres Besitzers, und die daraus eingelesenen Ausschreibungen bekommen dieselbe Herkunftsmarkierung, die Postfach-Ausschreibungen seit Phase 14 schon haben. Grund: ohne das wuerde ein persoenlicher Feed eines Nutzers Ausschreibungen in die Trefferliste fremder Mandanten spuelen — heute unmoeglich, weil die Feed-Liste ausschliesslich von der Administration gepflegt wird. Das waere keine Umsetzung von D-05, sondern eine Ausweitung ueber den heutigen Stand hinaus. Es wird kein neues Sichtbarkeitsmodell gebaut: benutzt wird ausschliesslich das bereits ausgelieferte Feld aus Phase 14, mit derselben Bedeutung. Innerhalb eines Mandanten bleibt weiterhin alles fuer alle sichtbar — genau wie mit dem Nutzer besprochen. Plattformweite Feeds bleiben unmarkiert und damit fuer alle sichtbar, exakt wie heute.
Harte Grenze (D-05) bleibt gewahrt: Tender bekommt kein Mandantenfeld,
keine RLS, keinen Mandanten-Wrapper. Die Trefferliste wird nicht nach Nutzern
getrennt.
<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-CONTEXT.md @.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-01-SUMMARY.md@apps/api/prisma/schema.prisma @apps/api/src/tenders/tender-rss-feed.service.ts @apps/api/src/tenders/adapters/rss.adapter.ts @apps/api/src/tenders/dto/tender-rss-feed.dto.ts
Task 1: Ein persoenlicher Feed — durchgehend von der Datenbank bis zum Endpunkt Die Migration aus Plan 17-01 ist lokal angewandt (`npx prisma migrate status` im API-Verzeichnis meldet keine offene Migration). Die Migration ergaenzt nur Spalten ohne Pflichtwert und tauscht eine Eindeutigkeitsregel; kein Datenverlust, aber ein Rueckweg braucht eine zweite Migration. Die Freigabe hierfuer wurde bereits im Entscheidungspunkt in Plan 17-01 eingeholt, der beide Umbauten ausdruecklich benennt — deshalb hier kein zweiter Halt. 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/tender-rss-feed.service.ts, apps/api/src/tenders/dto/tender-rss-feed.dto.ts, apps/api/src/tenders/tenders.controller.ts (Zeilen 228-270), apps/api/prisma/migrations/20260723111946_add_rss_feed_source_and_poll_granularity/migration.sql - Nutzer A legt einen Feed an; die Liste von A enthaelt diesen Feed plus alle plattformweiten. - Die Liste von Nutzer B enthaelt den Feed von A nicht, wohl aber dieselben plattformweiten. - Zwei verschiedene Nutzer duerfen dieselbe Adresse verfolgen; das ist kein Konflikt mehr. - Derselbe Nutzer kann dieselbe Adresse nicht zweimal anlegen. - Jeder Eintrag der Liste sagt, ob er plattformweit ist oder dem Nutzer gehoert. **Schema (`schema.prisma`, Modell `TenderRssFeedSource`).** Zwei Felder ohne Pflichtwert ergaenzen: `userId String?` (leer = plattformweiter Feed) und `tenantId String?` (der Mandant des Besitzers, denormalisiert, leer bei plattformweiten Feeds — gleiche Rolle wie das gleichnamige Feld in `TenderEmailConfig` und `TenderMatch`). Die Eindeutigkeit auf `url` faellt weg und wird `@@unique([userId, url])`; `@@index([userId])` ergaenzen. Den Kopfkommentar des Modells umschreiben: die Liste ist ab Phase 17 zweigeteilt, und leere Werte in einer Eindeutigkeitsregel gelten in PostgreSQL als jeweils verschieden — die Regel deckt daher nur persoenliche Feeds ab (Begruendung siehe Objective).Migration. Verzeichnis
apps/api/prisma/migrations/20260812110000_tender_rss_feed_owner/ anlegen,
migration.sql von Hand schreiben. Erwarteter Ablauf: beide Spalten ohne
Pflichtwert ergaenzen, den alten eindeutigen Index auf der Adresse entfernen,
neuen eindeutigen Index ueber Besitzer und Adresse anlegen, gewoehnlichen Index
auf dem Besitzer anlegen. Bestandszeilen werden nicht angefasst — sie behalten
einen leeren Besitzer und sind damit plattformweit, also genau der heutige
Zustand (17-CONTEXT.md, offener Punkt 2). Deutscher Kommentarkopf wie bei den
anderen handgeschriebenen Migrationen. Anwenden mit prisma migrate deploy
gegen die lokale Datenbank, danach prisma generate.
Achtung beim erzeugten Prisma-Client: Bei einer zusammengesetzten
Eindeutigkeitsregel mit einem Feld ohne Pflichtwert erzeugt Prisma einen
Suchtyp, in dem beide Bestandteile Pflicht sind — ein leerer Besitzer laesst
sich darueber nicht ansprechen. Nach prisma generate den erzeugten Typ
TenderRssFeedSourceUserIdUrlCompoundUniqueInput einmal aufschlagen und
bestaetigen; alles, was auf diesem Weg einen plattformweiten Feed ansprechen
will, muss ueber findFirst/deleteMany mit einer gewoehnlichen Bedingung
gehen (betrifft die Startbestueckung, Task 3).
Datenvertrag (dto/tender-rss-feed.dto.ts). Feld scope ergaenzen,
optional, erlaubt nur die beiden Werte personal und platform, Vorgabe
personal. Kein Besitzer- und kein Mandantenfeld in der DTO — beides kommt
ausschliesslich aus dem angemeldeten Konto. Kommentar ergaenzen, dass scope
nur ein Wunsch ist und die Berechtigung dafuer serverseitig geprueft wird.
Dienst (tender-rss-feed.service.ts). Die vorhandene Adresspruefung
(Schema, Sperrliste, private/lokale Adressen) bleibt unveraendert und
laeuft weiterhin auf jedem Schreibweg — sie wird jetzt sogar wichtiger, weil
nicht mehr nur Administratoren dorthin gelangen. Neu:
listForUser(userId): liefert alle Zeilen mit leerem Besitzer plus die des Nutzers, sortiert nach Anlagedatum aufsteigend.createForUser({ userId, tenantId }, dto): legt einen persoenlichen Feed an, Besitzer und Mandant aus dem Aufruf.createPlatform(dto): legt einen Feed ohne Besitzer und ohne Mandant an.- Die alte
list()/create()-Form entfaellt;remove()wird in Task 2 ersetzt. Klassenkommentar auf die neue Zweiteilung umschreiben.
Endpunkte (tenders.controller.ts). Die drei RSS-Routen behalten exakt
ihre bisherige Position im Datei-Aufbau — sie stehen vor der Parameter-Route
@Get(':id'), sonst verdeckt diese sie und die Endpunkte antworten mit 404
(dokumentierte Falle, Unit-Tests fangen sie nicht). Es werden bewusst keine
neuen Routen angelegt, damit an dieser Reihenfolge nichts zu bewegen ist; die
Unterscheidung laeuft ueber scope im Rumpf.
GET /rss-feeds:@Roles(...)entfaellt,@UseModule('tender-radar')tritt an die Stelle. RuftlistForUser(userId)und ergaenzt je Eintrag ein abgeleitetes Kennzeichen, ob der Eintrag plattformweit ist. Das Besitzerfeld selbst braucht die Oberflaeche nicht — es aus der Antwort herausnehmen.POST /rss-feeds:@Roles(...)entfaellt,@UseModule('tender-radar')tritt an die Stelle. Beiscope: 'platform'zuerst pruefen, ob die Rolle des angemeldeten Kontos ADMIN oder SUPER_ADMIN ist — sonstForbiddenException. Andernfalls persoenlich anlegen. Die Rolle aus demselben Ort lesen, aus dem sie auch die vorhandene Rollenpruefung liest (roles.guard.tsvorher aufschlagen und den Zugriffsweg uebernehmen). DazuextractTriageContextum die Rolle erweitern, damit es genau eine Stelle gibt, an der Kontodaten aus der Anfrage gelesen werden. cd /home/vicolab/projects/tessera-ctl/apps/api && npx prisma validate && npx prisma migrate status cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api type-check cd /home/vicolab/projects/tessera-ctl/apps/api && npx prisma db execute --stdin <<'SQL' SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'TenderRssFeedSource' ORDER BY indexname; SQL Die Indexliste zeigt einen eindeutigen Index ueber Besitzer und Adresse und keinen eindeutigen Index mehr allein auf der Adresse. Die Typpruefung laeuft fehlerfrei. Bestandszeilen haben einen leeren Besitzer.
Ausserdem im Dienst eine Obergrenze fuer persoenliche Feeds einfuehren
(Konstante, 20). createForUser zaehlt vorher die eigenen Eintraege des Nutzers
und lehnt darueber hinaus mit einer verstaendlichen deutschen Meldung ab
(BadRequestException). Grund: Der Anlegeweg steht ab dieser Phase jedem
Modulnutzer offen und loest bei jedem Abruf eine ausgehende Verbindung je Feed
aus — ohne Deckel waere das ein bequemer Hebel, den Plattform-Zeitplan
zuzustellen. Plattformweite Feeds sind davon nicht betroffen.
Endpunkt. DELETE /rss-feeds/:feedId: @Roles(...) entfaellt,
@UseModule('tender-radar') tritt an die Stelle; Besitzer und Rolle kommen aus
dem angemeldeten Konto und werden an den Dienst durchgereicht. Der
Parametername :feedId bleibt wie er ist (er darf nie mit der
Ausschreibungs-Kennung verwechselt werden), und die Position der Route im
Datei-Aufbau bleibt unveraendert.
Tests. In tender-rss-feed.service.spec.ts einen Prisma-Doppelgaenger
benutzen, der die uebergebene Bedingung tatsaechlich auswertet (Vorbild:
der auswertende Doppelgaenger in tenders.controller.spec.ts rund um Zeile
1012) — ein Doppelgaenger, der die Bedingung ignoriert und einfach einen Erfolg
zurueckmeldet, wuerde den Loeschschutz nur vortaeuschen. Alle sieben Faelle aus
dem Abschnitt "behavior" abdecken, dazu die bestehenden Faelle zur
Adresspruefung (Sperrliste, private Adressen, falsches Schema) unveraendert
weiterlaufen lassen und einen davon zusaetzlich ueber den persoenlichen
Anlegeweg fuehren.
In tenders.controller.spec.ts zwei Faelle ergaenzen: ein Konto mit Rolle USER
bekommt beim Anlegen mit scope: 'platform' eine Ablehnung; ein Konto mit
Rolle ADMIN darf es. Erwartungswerte von Hand hinschreiben, nicht aus derselben
Hilfsfunktion erzeugen, die auch der Produktivcode benutzt.
cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/tenders/tender-rss-feed.service.spec.ts src/tenders/tenders.controller.spec.ts
cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api type-check
Alle sieben Verhaltensfaelle bestehen. Der Loeschversuch an einem fremden Feed
laesst den Eintrag nachweislich stehen und antwortet mit "nicht gefunden". Der
21. persoenliche Feed wird abgelehnt.
Startbestueckung (tenders.module.ts). Das bisherige
"anlegen-oder-aktualisieren ueber die Adresse" funktioniert nicht mehr, weil die
Eindeutigkeit jetzt aus Besitzer und Adresse besteht und ein leerer Besitzer
ueber diesen Weg nicht ansprechbar ist (siehe Hinweis in Task 1). Umstellen auf:
erst suchen, ob bereits eine Zeile mit leerem Besitzer und dieser Adresse
existiert; nur wenn nicht, anlegen — ohne Besitzer und ohne Mandant, sonst
unveraendert (aktiv, Bezeichnung service-bund). Das bleibt bei wiederholten
Starts folgenlos. Kommentar ergaenzen, warum der Weg gewechselt hat.
Tests rss.adapter.spec.ts. Bestehende Faelle auf die neue Zeilenform
nachziehen und drei ergaenzen: ein Feed mit Mandantenangabe erzeugt ausschliesslich
markierte Datensaetze; ein Feed ohne Mandantenangabe erzeugt ausschliesslich
unmarkierte; zwei Feeds unterschiedlicher Besitzer werden in einem Durchlauf
beide abgeholt, und faellt der erste aus, kommen die Datensaetze des zweiten
trotzdem an. Erwartungswerte von Hand hinschreiben.
Neuer Test rss-feed-migration-sql.spec.ts. Nach dem Vorbild von
doe-url-migration-sql.spec.ts die handgeschriebene Migrationsdatei rein
textuell pruefen: beide neuen Spalten werden ohne Pflichtwert ergaenzt (sonst
wuerde die Migration an Bestandszeilen scheitern); der alte eindeutige Index auf
der Adresse wird entfernt; ein eindeutiger Index ueber Besitzer und Adresse
entsteht; es gibt keine Anweisung, die Bestandszeilen aendert oder loescht.
Startbestueckung testen. In der bestehenden Testdatei zur Bestueckung (oder,
falls sie dafuer nicht passt, in tender-rss-feed.service.spec.ts) einen Fall
ergaenzen, der die Bestueckung zweimal hintereinander ausfuehrt und zeigt, dass
danach genau eine Zeile existiert und diese keinen Besitzer hat.
cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/tenders/adapters/rss.adapter.spec.ts src/tenders/rss-feed-migration-sql.spec.ts
cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/tenders
Alle Tests im Bereich src/tenders laufen gruen. Der Fall "persoenlicher Feed
erzeugt markierte Datensaetze" und der Fall "plattformweiter Feed erzeugt
unmarkierte Datensaetze" bestehen beide. Die Bestueckung ist bei zweimaligem
Lauf folgenlos.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
Browser -> API (/modules/tender-radar/rss-feeds) |
Der Anlegeweg fuer Feed-Adressen steht ab dieser Phase jedem Modulnutzer offen, nicht mehr nur Administratoren. |
| API -> beliebige Internetadresse | Der Abruf holt Inhalte von Adressen, die ein Nutzer eingetragen hat. |
| Nutzer A -> Datensatz von Nutzer B | Loeschen und Lesen ueber eine Kennung aus der Anfrage. |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-17-07 | Elevation of Privilege | DELETE /rss-feeds/:feedId |
high | mitigate | Mengenloeschung mit Besitzbedingung in einer einzigen Anweisung; nichts geloescht heisst "nicht gefunden". Test mit auswertendem Prisma-Doppelgaenger. |
| T-17-08 | Elevation of Privilege | POST /rss-feeds mit scope: 'platform' |
high | mitigate | Rollenpruefung im Handler gegen dieselbe Quelle, aus der auch die vorhandene Rollenpruefung liest; Test mit Rolle USER und Rolle ADMIN. |
| T-17-09 | Tampering / SSRF | Adresspruefung im Dienst | high | mitigate | Die bestehende Pruefung (Schema, Sperrliste, private und lokale Adressen) bleibt unveraendert und laeuft auf beiden Anlegewegen; ein Test fuehrt einen Sperrfall ausdruecklich ueber den persoenlichen Weg. |
| T-17-10 | Denial of Service | Anzahl persoenlicher Feeds | medium | mitigate | Obergrenze 20 je Nutzer im Dienst, mit verstaendlicher Ablehnung. Die vorhandene Groessen- und Zeitbegrenzung je Abruf bleibt bestehen. |
| T-17-11 | Information Disclosure | Ausschreibungen aus persoenlichen Feeds | high | mitigate | Herkunftsmarkierung aus dem Mandanten des Besitzers (D-06) — verhindert, dass ein persoenlicher Feed in die Trefferliste fremder Mandanten spuelt. Zwei Tests. |
| T-17-12 | Information Disclosure | Antwort von GET /rss-feeds |
low | mitigate | Die Antwort enthaelt nur plattformweite Eintraege und die eigenen; das Besitzerfeld wird aus der Antwort herausgenommen. |
| T-17-13 | Repudiation | Doppelter plattformweiter Feed | low | accept | Leere Werte gelten in einer Eindeutigkeitsregel als verschieden; ein doppelter Feed kostet nur einen zusaetzlichen Abruf, die Zusammenfuehrung gleicher Ausschreibungen faengt Doppel ab (Begruendung im Objective). |
| T-17-SC | Tampering | Paketinstallationen | low | accept | Diese Phase installiert kein neues Paket. |
| </threat_model> |
<success_criteria>
- Zwei Nutzer koennen dieselbe Feed-Adresse unabhaengig voneinander verfolgen.
- Kein Nutzer kann einen fremden oder plattformweiten Feed loeschen.
- Kein Nutzer ohne Administratorrolle kann einen plattformweiten Feed anlegen.
- Der service.bund.de-Feed bleibt unveraendert fuer alle aktiv.
- Ausschreibungen aus persoenlichen Feeds bleiben auf den Mandanten des Besitzers begrenzt; aus plattformweiten Feeds bleiben sie fuer alle sichtbar.
Tenderbleibt unveraendert ohne Mandantenfeld und ohne RLS (D-05). </success_criteria>