---
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).