--- phase: 17-eigene-ausschreibungs-quellen-je-nutzer plan: 02 type: execute wave: 2 depends_on: ["17-01"] 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/adapters/rss.adapter.ts - apps/api/src/tenders/tender-rss-feed.service.spec.ts - apps/api/src/tenders/tenders.controller.spec.ts - apps/api/src/tenders/adapters/rss.adapter.spec.ts - apps/api/src/tenders/rss-feed-migration-sql.spec.ts autonomous: true requirements: [SRC-02, SRC-03] user_setup: [] estimate: tokens: 65000 raw_tokens: 65000 tasks: 3 confidence: low must_haves: truths: - "Ein Nutzer legt eigene RSS-Feeds an und sieht in seiner Liste nur die eigenen plus die plattformweiten (D-02)." - "Der seit Phase 14 gesetzte service.bund.de-Feed bleibt fuer alle unveraendert aktiv (D-02, offener Punkt 2)." - "Ein Nutzer kann keinen fremden Feed loeschen und keinen plattformweiten Feed anlegen — beides bleibt der Administration vorbehalten." - "Die Sperrliste und der Schutz gegen interne Adressen greifen auch auf dem neuen, fuer jeden Nutzer offenen Weg." - "Ausschreibungen aus einem persoenlichen Feed bleiben auf den Mandanten des Besitzers begrenzt; plattformweite Feeds bleiben fuer alle sichtbar." artifacts: - apps/api/prisma/migrations/20260812110000_tender_rss_feed_owner/migration.sql - apps/api/src/tenders/rss-feed-migration-sql.spec.ts key_links: - "TenderRssFeedSource.userId <-> extractTriageContext(req).userId — Besitz kommt aus dem angemeldeten Konto, nie aus dem Anfragerumpf." - "TenderRssFeedSource.tenantId <-> RawTenderRecord.ownerTenantId — die vorhandene Herkunftsmarkierung aus Phase 14 traegt persoenliche Feeds, plattformweite Feeds bleiben ohne Markierung." - "TendersModule.onModuleInit() <-> Startbestueckung des service.bund.de-Feeds — muss ohne die weggefallene Eindeutigkeitsregel auf der Adresse auskommen." --- RSS-Feeds bekommen einen Besitzer. Heute gilt die Feed-Liste fuer alle, und wer einen Feed loescht, nimmt ihn allen weg. Nach diesem Plan gibt es zwei Sorten: plattformweite Feeds ohne Besitzer, von der Administration gepflegt und fuer alle aktiv (dazu gehoert der seit Phase 14 gesetzte service.bund.de-Feed), und persoenliche Feeds, die genau einem Nutzer gehoeren. 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: 1. 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. 2. 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. @$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/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. Ruft `listForUser(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. Bei `scope: 'platform'` zuerst pruefen, ob die Rolle des angemeldeten Kontos ADMIN oder SUPER_ADMIN ist — sonst `ForbiddenException`. Andernfalls persoenlich anlegen. Die Rolle aus demselben Ort lesen, aus dem sie auch die vorhandene Rollenpruefung liest (`roles.guard.ts` vorher aufschlagen und den Zugriffsweg uebernehmen). Dazu `extractTriageContext` um 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. Task 2: Niemand fasst fremde Feeds an — Loeschschutz und Mengenbegrenzung 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 apps/api/src/tenders/tender-rss-feed.service.spec.ts, apps/api/src/tenders/tenders.controller.spec.ts (Zeilen 1000-1050) - Nutzer A loescht seinen eigenen Feed: erfolgreich. - Nutzer A versucht den Feed von Nutzer B zu loeschen: der Feed bleibt bestehen, die Antwort ist "nicht gefunden" und verraet damit nicht, dass es ihn gibt. - Nutzer A versucht einen plattformweiten Feed zu loeschen: bleibt bestehen, "nicht gefunden". - Ein Administrator loescht einen plattformweiten Feed: erfolgreich. - Ein Administrator loescht **nicht** ungefragt den persoenlichen Feed eines beliebigen Nutzers ueber diesen Weg. - Ein Nutzer mit bereits 20 eigenen Feeds bekommt beim 21. eine verstaendliche Fehlermeldung statt eines Eintrags. - Eine gesperrte oder interne Adresse wird auch auf dem persoenlichen Weg abgelehnt. **Dienst.** `remove(id, { userId, isAdmin })` ersetzt die bisherige `remove(id)`-Form. Geloescht wird in einer einzigen Anweisung ueber eine Mengenloeschung mit Bedingung: die Kennung stimmt **und** entweder gehoert der Eintrag dem Aufrufer, oder der Aufrufer ist Administrator und der Eintrag hat keinen Besitzer. Kommt dabei nichts zurueck, `NotFoundException` werfen — nicht `ForbiddenException`, damit die Antwort nicht verraet, dass ein fremder Eintrag mit dieser Kennung existiert. Bewusst als eine Anweisung statt "erst lesen, dann loeschen": kein Zeitfenster zwischen Pruefung und Ausfuehrung, und der Besitzvergleich liegt in der Datenbankbedingung, nicht im Anwendungscode. 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. Task 3: Abruf und Startbestueckung ziehen nach apps/api/src/tenders/adapters/rss.adapter.ts, apps/api/src/tenders/tenders.module.ts, apps/api/src/tenders/adapters/rss.adapter.spec.ts, apps/api/src/tenders/rss-feed-migration-sql.spec.ts apps/api/src/tenders/adapters/rss.adapter.ts, apps/api/src/tenders/tenders.module.ts (Zeilen 265-305), apps/api/src/tenders/adapters/rss.adapter.spec.ts, apps/api/src/tenders/doe-url-migration-sql.spec.ts - Der Abruf holt weiterhin alle aktiven Feeds in einem Durchlauf, plattformweite und persoenliche gemeinsam. - Ein kaputter Feed wird uebersprungen und blockiert die uebrigen nicht. - Datensaetze aus einem Feed mit Mandantenangabe tragen die Herkunftsmarkierung dieses Mandanten. - Datensaetze aus einem plattformweiten Feed tragen keine Herkunftsmarkierung und bleiben damit fuer alle sichtbar — wie heute. - Ein zweiter Start der Anwendung legt den service.bund.de-Feed nicht ein zweites Mal an. - Der Feed aus der Startbestueckung hat keinen Besitzer. **Abruf (`rss.adapter.ts`).** Die Sammelabfrage ueber alle aktiven Feeds bleibt unveraendert — der Plattform-Zeitplan loest weiterhin alle Feeds in einem Durchlauf auf, und die Fehlerbehandlung je Feed bleibt bestehen. Neu ist ausschliesslich: hat die Feed-Zeile eine Mandantenangabe, bekommen die daraus erzeugten Datensaetze dieselbe Herkunftsmarkierung, die Postfach-Datensaetze seit Phase 14 tragen; hat sie keine, bleiben die Datensaetze unmarkiert. Die reine Umwandlungsfunktion `parseFeed` nicht anfassen — sie kennt keine Feed-Zeile, die Markierung gehoert in die Auffaecherung darueber. Klassenkommentar um zwei Saetze zu dieser Zweiteilung ergaenzen und darin auf D-06 aus 17-02-PLAN verweisen. **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. ## 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. | 1. `pnpm --filter @tessera/api exec vitest run src/tenders` — vollstaendig gruen. 2. `pnpm --filter @tessera/api type-check` — fehlerfrei. 3. `npx prisma migrate status` — keine offene Migration. 4. Indexliste von `TenderRssFeedSource`: eindeutiger Index ueber Besitzer und Adresse vorhanden, eindeutiger Index allein auf der Adresse verschwunden. 5. Zaehlabfrage: alle Bestandszeilen haben einen leeren Besitzer, der service.bund.de-Feed ist genau einmal vorhanden und aktiv. - 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. - `Tender` bleibt unveraendert ohne Mandantenfeld und ohne RLS (D-05). Create `.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-02-SUMMARY.md` when done.