From 1285a495fdbcdc45964d7a6e0336b70b9b4c7ccb Mon Sep 17 00:00:00 2001 From: Enrico Wilhelm Date: Thu, 9 Jul 2026 19:04:13 +0200 Subject: [PATCH] Add supplier product security governance --- CHANGELOG.md | 9 + docs/GUI_SCREENSHOTS.md | 4 + docs/ISCY_Handbuch.md | 32 +- docs/ISCY_Handbuch.pdf | Bin 89355 -> 93595 bytes docs/ISCY_STRATEGIC_ROADMAP.md | 23 +- .../assets/iscy-supplier-product-security.png | Bin 0 -> 78659 bytes rust/iscy-backend/src/db_admin.rs | 325 ++ rust/iscy-backend/src/lib.rs | 2074 ++++++++++- rust/iscy-backend/src/main.rs | 8 +- rust/iscy-backend/src/report_store.rs | 173 +- .../src/supplier_product_security_store.rs | 3076 +++++++++++++++++ rust/iscy-backend/tests/http_tests.rs | 453 ++- 12 files changed, 6154 insertions(+), 23 deletions(-) create mode 100644 docs/assets/iscy-supplier-product-security.png create mode 100644 rust/iscy-backend/src/supplier_product_security_store.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 591d225..bc060cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ The project uses release tags for immutable release points. Changes under **Unre ### Added +- ergaenzt Supplier/Product-Security-Deepening mit tenantgebundenem Governance-Modell fuer Lieferant, Produkt/Service, lokale Advisory-/PSIRT-/CVE-Metadaten, SBOM-/VEX-Bezuege, Review-Status, offene Massnahmen und Management-/Regulatory-Review-Bezug +- ergaenzt additive Migration `0034_rust_supplier_product_security_governance` fuer Supplier/Product-Security-Datensaetze, Evidence-Links, Ereignis-/Audit-Historie und Vertrags-/Exit-Plan-Historie ohne destruktive Datenbankoperationen +- ergaenzt API-Pfade fuer Supplier/Product Security unter `/api/v1/suppliers/product-security`, Detail-, Status-, Evidence-, Ereignis- und Supplier-bezogene Contract-/Exit-History-Abfragen +- ergaenzt die deutsche Weboberflaeche `/suppliers/product-security/` mit Filtern nach Supplier, Produkt/Service, Status, Schweregrad, ueberfaelligen Reviews sowie DORA-, NIS2-, DSGVO- und kritischer-Service-Relevanz +- erweitert Regulatory Review-Pakete fuer NIS2, DORA, DSGVO und generische Security Governance um Supplier/Product-Security-Gaps, offene Advisorys, kritische Lieferantenabhaengigkeiten, fehlende Evidence, fehlende Owner, offene Massnahmen sowie Vertrags-/Exit-Plan-Hinweise - ergaenzt Review-Pack-Filter fuer Pack-Typ, Status, Zeitraum, offene/kritische Luecken und sichere Limits - ergaenzt Owner-/Verantwortlichen-Hinweise in Regulatory Review Pack Previews, ohne fehlende Verantwortliche zu erfinden oder Kontaktdaten unnoetig auszugeben - ergaenzt pack-spezifische Lueckengruppierung fuer NIS2, DORA, DSGVO/GDPR und generische Security Governance @@ -43,6 +48,10 @@ The project uses release tags for immutable release points. Changes under **Unre ### Security +- haelt Supplier/Product-Security-Datensaetze, Evidence-Links, Events und Vertrags-/Exit-Historie tenantgebunden; Admin-/Editor-Rollen duerfen schreiben, Read-only-Rollen sehen nur sichere Metadaten +- validiert Status-, Severity-, Datums-, CVE-, Score-, Owner-, Supplier-, Evidence- und Referenzfelder mit sicheren 4xx-Fehlern ohne SQL-/Store-Details, Secrets, Rohpayloads oder absolute Dateipfade +- speichert Advisory-/PSIRT-/CVE-Referenzen nur lokal als Text/URL und fuehrt keine externen Live-Abfragen aus; URL-Ausgabe in API und Web UI bleibt gegen unsichere Schemes und HTML-Injection abgesichert +- auditierbare Supplier/Product-Security-Erstellung, Aenderung, Statuswechsel, Evidence-Verknuepfung und Vertrags-/Exit-Plan-Aenderung ohne vertrauliche Datei-Inhalte, Tokens, Authorization-Header oder fremde Tenant-IDs - haelt Regulatory-Review-Pack-Filter, Previews, Snapshots und Exporte tenantgebunden; Read-only-Rollen duerfen sichere Inhalte lesen, Snapshot-Erzeugung bleibt schreibenden Rollen vorbehalten - validiert Pack-Typ-, Status-, Zeitraum-, Gap- und Limit-Filter mit sicheren 4xx-Fehlern ohne SQL-/Store-Details - nutzt die bestehende Management-Review-Snapshot- und Audit-Infrastruktur fuer Regulatory Review-Pakete, ohne zweiten Compliance-Store und ohne Evidence-Pfade, SQL-Details, Secrets oder rohe Evidence-Payloads offenzulegen diff --git a/docs/GUI_SCREENSHOTS.md b/docs/GUI_SCREENSHOTS.md index e0422e3..cc54aff 100644 --- a/docs/GUI_SCREENSHOTS.md +++ b/docs/GUI_SCREENSHOTS.md @@ -76,6 +76,10 @@ Diese Screenshots dokumentieren die aktuelle serverseitige ISCY-Weboberflaeche f ![Supplier Review](assets/iscy-supplier-review.png) +## Supplier/Product Security + +![Supplier/Product Security](assets/iscy-supplier-product-security.png) + ## Imports ![Imports](assets/iscy-imports.png) diff --git a/docs/ISCY_Handbuch.md b/docs/ISCY_Handbuch.md index 268042d..c88b97a 100644 --- a/docs/ISCY_Handbuch.md +++ b/docs/ISCY_Handbuch.md @@ -96,6 +96,7 @@ Die wichtigsten Bereiche sind: - Evidence unter `/evidence/` - Assets unter `/assets/` - Suppliers unter `/suppliers/` +- Supplier/Product Security unter `/suppliers/product-security/` - Imports unter `/imports/` - Processes unter `/processes/` - AI Governance unter `/ai-governance/` @@ -492,7 +493,7 @@ Management-Review-Pakete: - werden aus aktuellen ISCY-Daten fuer einen Zeitraum erzeugt - koennen als generisches Security-Governance-Paket oder ueber Templates fuer ISO 27001, NIS2, DORA, DSGVO und KRITIS vorbereitet werden -- speichern Top-Risiken, ISCY-27-Control-Gaps, Evidence-Luecken, Evidence-Integrity-/Storage-Aggregate, Incident-Entscheidungen, Roadmap-Fokus, Product-Security-Lage, Supplier-Review-Summary, Agent-Posture, AI-Governance, Quellenzaehlung, Gap-Summary, Management-Hinweise und regulatorischen Kontext als Snapshot +- speichern Top-Risiken, ISCY-27-Control-Gaps, Evidence-Luecken, Evidence-Integrity-/Storage-Aggregate, Incident-Entscheidungen, Roadmap-Fokus, Product-Security-Lage, Supplier-Review-Summary, Supplier/Product-Security-Lage, Agent-Posture, AI-Governance, Quellenzaehlung, Gap-Summary, Management-Hinweise und regulatorischen Kontext als Snapshot - verlinken Snapshot-Zeilen zurueck zu Risiko, Control, Evidence, Incident und Roadmap - koennen von Draft ueber In Review bis Approved oder Archived gefuehrt werden - dokumentieren Entscheidung, naechste Massnahmen, freigebenden User und Freigabezeitpunkt @@ -502,11 +503,12 @@ Regulatory Review-Pakete: - nutzen dieselbe eingefrorene Snapshot-Schicht wie Management Reviews und erzeugen kein separates Compliance-Silo - bieten fokussierte Pack-Typen fuer NIS2, DORA und DSGVO sowie ein generisches Security-Governance-Pack -- beruecksichtigen Organisationsprofil, Risiken, ISCY-27-Controls, Evidence-Qualitaet, Evidence-Integritaet, Storage-/Restore-Drill-Signale, Incidents, Supplier Review, Product Security, AI Governance, Roadmap und Agent Posture soweit vorhanden +- beruecksichtigen Organisationsprofil, Risiken, ISCY-27-Controls, Evidence-Qualitaet, Evidence-Integritaet, Storage-/Restore-Drill-Signale, Incidents, Supplier Review, Supplier/Product Security, Product Security, AI Governance, Roadmap und Agent Posture soweit vorhanden - unterscheiden Preview und Snapshot-Erzeugung: Read-only-Rollen koennen Vorschauen lesen, aber keine eingefrorenen Snapshots erstellen - bieten sichere Filter fuer Pack-Typ, Status, Zeitraum, offene Luecken, kritische Luecken und Limit - zeigen Owner-/Verantwortlichen-Hinweise aus vorhandenen Rollen-/Owner-Daten; fehlende Verantwortliche werden als `Nicht erfasst` markiert und nicht geraten - gruppieren Luecken pack-spezifisch, z. B. NIS2 nach Incident-/Meldeentscheidungen, TOMs, Evidence und Supplier; DORA nach ICT-Risk, Incident, ICT-Third-Party und Storage; DSGVO nach Datenschutzrollen, Data-Breach, Legal Hold / Aufbewahrungssperre, Disposition und Supplier-Datenbezug +- nehmen Supplier/Product-Security-Gaps auf: offene Advisorys, kritische Lieferantenabhaengigkeiten, fehlende Evidence, fehlende Owner, offene Massnahmen, ueberfaellige Reviews sowie Vertrags-/Exit-Plan-Hinweise fuer DORA, NIS2 und DSGVO - enthalten Hinweise, dass ISCY Governance- und Evidence-Unterstuetzung liefert, jedoch keine Rechtsberatung, Zertifizierung, automatische Meldung oder formale Einreichung ersetzt API- und Web-Pfade: @@ -537,7 +539,7 @@ Fachlicher Nutzen: GUI-Nachweis: -- Die aktuelle Screenshot-Uebersicht fuer Dashboard, Evidence, Regulatory Review-Pakete, Supplier Review, Product Security, AI Governance und Operations liegt in [docs/GUI_SCREENSHOTS.md](GUI_SCREENSHOTS.md). +- Die aktuelle Screenshot-Uebersicht fuer Dashboard, Evidence, Regulatory Review-Pakete, Supplier Review, Supplier/Product Security, Product Security, AI Governance und Operations liegt in [docs/GUI_SCREENSHOTS.md](GUI_SCREENSHOTS.md). Fuer Nicht-Sicherheitsleute: Reports sind die offizielle Zusammenfassung des Stands. @@ -612,6 +614,7 @@ Typische Objekte: - VEX-Entscheidung - PSIRT Case - CSAF-/SBOM-Importhistorie +- lokale Supplier-Advisory-/PSIRT-/CVE-Metadaten - SBOM-Diff - CVE-Asset-Korrelation - CVE-Risiko-Review-Queue @@ -630,6 +633,7 @@ Fachlicher Nutzen: - automatische Ableitung von Risiko- und Roadmap-Arbeit aus akzeptierten CVE-Korrelationen - Nachweissteuerung ueber Evidence-Keys fuer CVE, Import, Risiko und Roadmap - dokumentierte Release-/PSIRT-Entscheidungen mit eingefrorenem Nachweisstand und Blocker-Gates +- Verbindung zu Supplier/Product Security, damit Hersteller-, Lieferanten- und Produktbezuege gemeinsam mit Evidence, Review-Status und offenen Massnahmen sichtbar werden Fuer Nicht-Sicherheitsleute: Dieser Bereich ist fuer Unternehmen wichtig, die Software, digitale Produkte oder vernetzte Systeme bereitstellen. @@ -654,6 +658,9 @@ Aktueller Rust-Funktionsumfang: - vorbehaltlose Freigabe nur ohne Blocker; bedingte Freigabe nur mit dokumentierter Review-Notiz - Markdown-, HTML-, PDF- und JSON-Export je Paketversion - Betriebsstatus fuer Paket-Backlog und offene Blocker +- Supplier/Product-Security-Register unter `/suppliers/product-security/` fuer lokale Advisory-/PSIRT-/CVE-Metadaten, Lieferantenprodukt, betroffene und behobene Versionen, SBOM-/VEX-Bezug, Review-Status, Owner, Faelligkeit und Evidence +- API-Pfade unter `/api/v1/suppliers/product-security` fuer Liste, Anlage, Detail, Aenderung, Statuswechsel, Evidence-Verknuepfung und Ereignishistorie +- Keine externen Live-Abfragen gegen NVD, Herstellerportale, GitHub Advisories oder andere Feeds; Referenzen werden nur sicher gespeichert und ausgegeben ### 5.16 AI Governance @@ -726,16 +733,22 @@ Aktueller Rust-Funktionsumfang: - API `GET` und `POST /api/v1/suppliers/{id}/reviews` fuer Review-/Approval-Ereignisse - API `GET` und `POST /api/v1/suppliers/{id}/subprocessors` sowie `PATCH /api/v1/suppliers/{id}/subprocessors/{subprocessor_id}` fuer Unterauftragnehmer im Supplier-Kontext - API `GET /api/v1/suppliers/{id}/evidence`, `POST /api/v1/suppliers/{id}/evidence-links`, `POST /api/v1/suppliers/{id}/control-links` und `POST /api/v1/suppliers/{id}/risk-links` fuer explizite Nachweis-, Control- und Risiko-Bezuege +- Webansicht `/suppliers/product-security/` fuer Supplier/Product Security mit lokaler Advisory-/PSIRT-/CVE-Erfassung, Review-Status, Evidence, Vertragsstatus und Exit-Plan-Status +- API `GET` und `POST /api/v1/suppliers/product-security`, `GET` und `PATCH /api/v1/suppliers/product-security/{record_id}`, `POST /api/v1/suppliers/product-security/{record_id}/status`, `POST /api/v1/suppliers/product-security/{record_id}/evidence`, `GET /api/v1/suppliers/product-security/{record_id}/events`, `GET /api/v1/suppliers/{supplier_id}/product-security` und `GET /api/v1/suppliers/{supplier_id}/contract-exit-history` - Datenfelder fuer Vertrags-/Security-Annex-Bezug, Security-Kontakt, Datenarten, Regionen, Exit-Abhaengigkeit, regulatorischen Scope, Review-Status, Review-Faelligkeit und Notes - additive Migration `0031_rust_supplier_review_workflow` fuer Review-Freigaben, Subprocessors, Vertragslaufzeiten, Exit-Test-Nachweise und Link-/Audit-Tabellen +- additive Migration `0034_rust_supplier_product_security_governance` fuer Supplier/Product-Security-Datensaetze, Evidence-Links, Ereignisse und Vertrags-/Exit-Plan-Historie - automatische Signale aus Produktkomponenten, offenen Product-Security-Schwachstellen, Supplier-bezogenen Risiken und Supplier-Evidence - direkte Evidence-Vorbefuellung je Supplier mit stabilem Linked Requirement `SUPPLIER:{id}` - Score- und Issue-Logik fuer kritische CVEs, ueberfaellige Reviews, fehlende Evidence, fehlende Exit-Strategie, fehlenden Security-Kontakt und fehlende Risikodokumentation - Review-Statusmodell: draft, in_review, approved, approved_with_conditions, rejected, expired und archived +- Supplier/Product-Security-Review-Statusmodell: draft, needs_review, in_review, accepted_risk, remediation_required, mitigated, closed und not_applicable - Exit-Test-Statusmodell: not_required, required, planned, passed, failed und overdue +- Vertrags-/Exit-Plan-Historie mit Version, Akteurreferenz, Aenderungszeitpunkt, Grund, vorherigem/neuem Status, Summary und Evidence-Referenzen - Begruendungspflicht fuer `approved_with_conditions` und `rejected` - tenantgebundene Validierung fuer Supplier, Subprocessors, Owner, Evidence, Controls und Risiken -- auditierbare Erstellung, Aenderung, Statusentscheidung, Subprocessor-Aenderung und Link-Verwaltung ohne Secrets, SQL-Details oder vertrauliche Payloads +- tenantgebundene Filter fuer Lieferant, Produkt/Service, Review-Status, Schweregrad, offene Massnahmen, ueberfaellige Reviews sowie DORA-, NIS2-, DSGVO- und kritische-Service-Relevanz +- auditierbare Erstellung, Aenderung, Statusentscheidung, Subprocessor-Aenderung, Product-Security-Statuswechsel, Evidence-Verknuepfung, Vertrags-/Exit-Plan-Aenderung und Link-Verwaltung ohne Secrets, SQL-Details oder vertrauliche Payloads Fachlicher Nutzen: @@ -744,12 +757,14 @@ Fachlicher Nutzen: - Evidence, Risiken, Product Security und Roadmap-Arbeit bekommen einen gemeinsamen Lieferantenbezug. - Review-Entscheidungen werden nicht nur als aktueller Status gespeichert, sondern als nachvollziehbare Freigabehistorie. - Vertragsende, automatischer Renew, Kuendigungsfrist und Exit-Test-Status helfen dabei, Abhaengigkeiten vor Ablauf, Eskalation oder Lieferantenwechsel zu steuern. +- Lokale Advisory-/PSIRT-/CVE-Metadaten machen sichtbar, welche Lieferantenprodukte, Versionen, Nachweise und Massnahmen fuer NIS2, DORA, DSGVO, CRA und Auditvorbereitung relevant sind. Rollen- und Sicherheitsmodell: -- Admin- und Editor-Rollen duerfen Supplier, Reviews, Subprocessors und Links schreiben. +- Admin- und Editor-Rollen duerfen Supplier, Reviews, Subprocessors, Supplier/Product-Security-Datensaetze, Status, Evidence-Links und Links schreiben. - Read-only-Rollen sehen sichere tenantgebundene Metadaten. - Fremde Tenant-Objekte, manipulierte Supplier-IDs sowie fremde Evidence-, Risk- und Owner-IDs werden nicht aufgeloest. +- Advisory-Referenzen werden nur als lokale Text-/URL-Metadaten gespeichert; ISCY ruft keine externen Feeds automatisch ab und rendert unsichere URL-Schemes nicht als Link. - Interne Store- oder SQL-Fehler werden in API und Web UI nicht als technische Details ausgegeben. Bewusst nicht Teil dieses Moduls: @@ -757,6 +772,7 @@ Bewusst nicht Teil dieses Moduls: - keine neue Evidence-Engine - keine neue Risiko-Engine - keine neue Control-Bibliothek +- keine externe NVD-, Hersteller-, GitHub-Advisory- oder sonstige Live-Feed-Integration - kein Legal-Hold-, Disposition-, Loesch-, Re-Hash- oder Objektspeichersystem Fuer Nicht-Sicherheitsleute: @@ -830,6 +846,8 @@ Hier wird aus einer technischen Schwachstellenmeldung eine geschaeftlich nutzbar 9. CVE-Risiko-Review-Queue abarbeiten 10. Evidence direkt aus Queue, Risiko oder Roadmap-Task hochladen 11. CRA-Readiness je Produkt pruefen und Massnahmen ueber Roadmap oder Risiko-Behandlung steuern +12. Supplier/Product-Security-Datensaetze fuer relevante Lieferantenprodukte pflegen, Advisory-/PSIRT-/CVE-Bezuege lokal dokumentieren und Evidence verknuepfen +13. Vertrags-/Exit-Plan-Status fuer kritische Lieferantenprodukte pruefen und Review-Pakete fuer NIS2, DORA oder DSGVO vorbereiten ### 6.5 Incident- und NIS2-Meldeworkflow @@ -1359,11 +1377,11 @@ ISCY strukturiert, dokumentiert, priorisiert und verbindet. Entscheidungen muess ## 10. Strategische Weiterentwicklung -Die Rust-Migration ist abgeschlossen. Mit V23.7.19 ist das regulatorische Organisationsprofil als erster strategischer Baustein umgesetzt; V23.7.20 ergaenzt Management-Review- und Audit-Pakete als steuerbaren Review-Workflow; V23.7.21 liefert Exporte, Snapshot-Ruecklinks und Evidence-Qualitaet; V23.7.22 setzt Third-Party-/Supplier-Risk als eigenes Rust-Web-/API-Modul um; V23.7.23 baut Product Security um VEX, SBOM-Diff und CRA-Readiness aus; V23.7.24 fuegt AI Governance hinzu; V23.7.25 schliesst Agent-Policy-Profile, erwartete Flottenabdeckung und aktive Policy-Webhooks an; V23.7.26 ergaenzt versionierte Product-Security-Evidence-Pakete. Migration `0027_rust_ai_governance_links` verbindet AI-Systeme tenantgebunden mit Risiken, Roadmap-Tasks, Incidents und Changes. Migration `0028_rust_guided_agent_onboarding` ergaenzt den gefuehrten, tenantgebundenen Agent-Rollout mit Token-Lifecycle, Policy-Zuordnung und Auditspur. Migration `0029_rust_cross_domain_notifications` fuehrt Evidence-, CVE-, Incident- und Roadmap-Signale in denselben sicheren Kanalbetrieb. Migration `0031_rust_supplier_review_workflow` ergaenzt Supplier-Reviews mit Freigabehistorie, Subprocessors, Vertragslaufzeiten, Exit-Test-Nachweisen und tenantgesicherten Evidence-/Control-/Risk-Links. Migration `0032_rust_management_regulatory_templates` ergaenzt Management-/Regulatory-Templates fuer ISO 27001, NIS2, DORA, DSGVO, KRITIS und generische Security-Governance-Reviews. Kontextsensitive Regulatory Review Packs fuer NIS2, DORA und DSGVO nutzen diese bestehende Snapshot-Schicht und nehmen Evidence-Integrity-/Storage-Aggregate auf, ohne ein neues Compliance-Silo oder ein zweites Evidence-System anzulegen. Migration `0033_rust_evidence_integrity_disposition` ergaenzt Evidence Integrity & Disposition Phase 1 mit manueller und begrenzter Batch-Re-Hash-Pruefung, Legal-Hold-Metadaten, metadata-only Disposition und auditierbaren Integritaetsereignissen. Evidence Object Storage & Restore Drill Phase 2 nutzt diese bestehenden Metadaten fuer eine interne lokale Storage-Abstraktion, sichere Artefaktreferenzen und tenantgebundene Restore-/Integritaetsdrills ohne neues Speichersystem. Die weitere ISCY-Agenda konzentriert sich deshalb nicht mehr auf Abloesung alter Python-/Django-Pfade, sondern auf fachliche Produktreife. +Die Rust-Migration ist abgeschlossen. Mit V23.7.19 ist das regulatorische Organisationsprofil als erster strategischer Baustein umgesetzt; V23.7.20 ergaenzt Management-Review- und Audit-Pakete als steuerbaren Review-Workflow; V23.7.21 liefert Exporte, Snapshot-Ruecklinks und Evidence-Qualitaet; V23.7.22 setzt Third-Party-/Supplier-Risk als eigenes Rust-Web-/API-Modul um; V23.7.23 baut Product Security um VEX, SBOM-Diff und CRA-Readiness aus; V23.7.24 fuegt AI Governance hinzu; V23.7.25 schliesst Agent-Policy-Profile, erwartete Flottenabdeckung und aktive Policy-Webhooks an; V23.7.26 ergaenzt versionierte Product-Security-Evidence-Pakete. Migration `0027_rust_ai_governance_links` verbindet AI-Systeme tenantgebunden mit Risiken, Roadmap-Tasks, Incidents und Changes. Migration `0028_rust_guided_agent_onboarding` ergaenzt den gefuehrten, tenantgebundenen Agent-Rollout mit Token-Lifecycle, Policy-Zuordnung und Auditspur. Migration `0029_rust_cross_domain_notifications` fuehrt Evidence-, CVE-, Incident- und Roadmap-Signale in denselben sicheren Kanalbetrieb. Migration `0031_rust_supplier_review_workflow` ergaenzt Supplier-Reviews mit Freigabehistorie, Subprocessors, Vertragslaufzeiten, Exit-Test-Nachweisen und tenantgesicherten Evidence-/Control-/Risk-Links. Migration `0032_rust_management_regulatory_templates` ergaenzt Management-/Regulatory-Templates fuer ISO 27001, NIS2, DORA, DSGVO, KRITIS und generische Security-Governance-Reviews. Kontextsensitive Regulatory Review Packs fuer NIS2, DORA und DSGVO nutzen diese bestehende Snapshot-Schicht und nehmen Evidence-Integrity-/Storage-Aggregate auf, ohne ein neues Compliance-Silo oder ein zweites Evidence-System anzulegen. Migration `0033_rust_evidence_integrity_disposition` ergaenzt Evidence Integrity & Disposition Phase 1 mit manueller und begrenzter Batch-Re-Hash-Pruefung, Legal-Hold-Metadaten, metadata-only Disposition und auditierbaren Integritaetsereignissen. Evidence Object Storage & Restore Drill Phase 2 nutzt diese bestehenden Metadaten fuer eine interne lokale Storage-Abstraktion, sichere Artefaktreferenzen und tenantgebundene Restore-/Integritaetsdrills ohne neues Speichersystem. Migration `0034_rust_supplier_product_security_governance` verbindet Lieferanten, Produkte/Services, lokale Advisory-/PSIRT-/CVE-Metadaten, Evidence, Review-Status, Vertrags-/Exit-Plan-Historie und Regulatory Review Packs tenantgebunden, ohne externe Live-Feeds einzufuehren. Die weitere ISCY-Agenda konzentriert sich deshalb nicht mehr auf Abloesung alter Python-/Django-Pfade, sondern auf fachliche Produktreife. Die priorisierte Roadmap liegt in `docs/ISCY_STRATEGIC_ROADMAP.md` und umfasst: -1. Review-Pack-Bedienung weiter polishen, z. B. zusaetzliche Filter, Pack-spezifische Gap-Gruppierung und Review-Owner-Hinweise +1. Supplier/Product-Security-Workflow fachlich weiter polishen, z. B. feinere Import-Vorbereitung fuer Hersteller-Advisorys, Contract-/Exit-Reifegrade und Review-Pack-Gliederung 2. Evidence-Integrity-Worker, kontrollierte physische Disposition und spaeteres produktives Objektspeicher-Backend 3. Signierte Agent-Pakete sowie eine spaetere getrennte CA-/PKI-Stufe 4. Performance-, HA- und visuelle Regressionstests diff --git a/docs/ISCY_Handbuch.pdf b/docs/ISCY_Handbuch.pdf index f71ced00eaaafe76a5276d5125c190c7566cd6e4..dcf4f3023ad517898bd670a015b89af59e8fa370 100644 GIT binary patch delta 5609 zcmb7HX>1%<9oN{7k4;jyshhJQuPIKPcE>w2J9|vihB&d~IG0_oYZAoK+1hj z=CB(>SS3OT2~{ngII9w+O}MI5NFf@juJ`~H`hiO62cQT<5JD9a{Xn506{$!d{_oAT z9y=B!AJ#kj-kbmNJO1zMKU{b9?z$KMxe28>#G%R0p~PX$QcWa?4#WKEzP5C_IW)iw zIKB7Z>Uu0^)$26{>xl_nD_R8;<#55$74tlTEm%hh!&_k3A8g&}{AkM?ZO!gZrq8~* z-EK>|C-t(ht7>ZcJ~VYUHuab@y<=z0UG-xB6>k;qOzdgzvJdq3*_YTWZP^SB!v59l z9_QpBUhgh{SGv+B2=tc?Gjd@(=byOn#9w>q-do46v~lhVx_AEg^$p&uPA|3zeDmM{ zv(MgfT6V_I%y(s?>9Kx#> zKX1fQ6*{6Ag$fSde?q|}tjo|NRnAvrtd^B>6)W&Hj!L*v!)g(uky)jP)dJ*MYWUB_ z=Txl6{db&{4MUYlWMT3a&SSkK<62EAV>F3p6+CAkLz`1Dn#Q`R%VmR2j6A16;cBwV z9#zyitQZ)TEC9^#_~cL=of^vtfS@88&W%owL)6XoPG+0kuUzg(@{;HR@T2m>jQzE0 zuYLaP`UR!O={nopwJlnp*1~d|g8TuqWfLhXa>mcS(QaQo)@Q$9Ew-ga0szr|(5g76 zW-qo6*0icz!$H|uzo=|tA~!ZU#U>6=rVVLdDF>I-0|J(Y`$>*^4)O?dxCu5@pFxE?#aI`79-av$OF+o71>7 zOcIM$H-z6LE-R3PzJm9Ox~mMV;VDDHZM0k2pdo7)mX@XABbJ_6HRC9cP2EE~0ZSfN zv^;@6)Zb7vwH1}QL*pme#PrB%_7D`Z9QT0fl_`!qTpw|9OQ=9g=hv0QY5(C;S7T^m zjGZXSzz$E~fs$x{&Y)w}}Y_RA!U;`uk9CQTA0HB)1>`=Z0+=Fjrz>$hh zO%KOgpjX#)6QCYPql$Ud%KP%6U;}9oB9cj(O(i@)Nw)6i%RiA98HYprtDvQbH~6Ms}kM0<3E9=&(% z)nH)Y5boNCKWTV&4Z*DhGQpg;@R8Pq1x*E2FPJPQqVA&cyuJ5Y)ori&oy#3!Dl56F zZC~5S*dJYcs3Gc>Vfx2-G5Q)&)@A{?GI$SVPXM}T*F*goCi-j0*qjFl`~722C~6h5 z^N9i`139>-ed4j$08nZbJECN=`3u{e@4wvNw*B7kv`=4u7>pTsphMO1TpSF=|4S|u zaNWd3T`{U5w{;Nhq9U6hPC7nkDLO9331F2ngb#lfYMOzIlq;%c%4D`G$oX0;vnl$f zuwlB=W&iQ|sm2<4V9EzrKm-x=rfOnK*FDm<=#McEl2^A>BCDeyNX0PdSxpB9gPG4H zRBYiH*vuHDn=ge*iaGdM{m4kTQ}}+98qe-8mPes{swp^&QLWIIjqkr zWXy1fg+P;Yg$ig2F6%Os#?|=%389z8(OMG+nI{rpt7M{p4?0h{z8VZg03!N+h`fep zWp&>E{f#sB3(xM5+(hpo#hwy_Myy1U^Z2(?ZBFvLXBaTe6YdPLss;t41A=+A0_y1- za~$Nf22Wb&0ZI~RVAvY3uV8fO6zq5BDAJ9fJe?{ndy}7F`W`<#IYhBFWEB+?+6sdj zbe-6V?xt=A^|haWbc?25FD*`A=eD#OHPBcu5}Gw7+hn{YALwOC%<#6A_dh; zHDdN5VuDVz4zOe-f@QP^jT$OyRSQOX6Yh0@$X0TrSu%tW~!1l`9CkVOG(e94&2?a!RD6MIlJHA z?hYvnA1}72(%#Qc{q0td^W%?qcaW_&+ml)MM(Mow?>*g1cRQI6=?@Pz4{l}_nG~ID zmhP-)z7Sjb&t?YE7e3T{Z7*}VU68Ug(xri&%uLKazA4u1*~e^YemKDNEG-N$F>mpk zSy-IpXj%hH*9Mv1So1dn%(muWmg!o0MPe>^3-vL^?hw-=Pm^5Qd5}5SW53k5erf+P z=JjrxDwDFm(XVbw@#K1s4jhO9EwudESjv82Pao94-P-)eB(u=`!AYjCk^CR68o)uW zJ=kn*av<~A*Lg7H|7-Z)9~$}!i&#Z_;X>(-!vwkZoB8ceqY1fCH4wS$swRw|@SG^7 zRtZPskgWy+S(�ZxulQAR2~iE9fftR|v-Di#cX1gPON<%+RiY2f%!g7nNMXQ3=!- zyc_hp`M?y@Xxv*tKdtH2Qcar+ws>ztb*%=YJqdJv5FHwXYdOeWOAuo-WMpTjTVAn^x>L&X$(CiX zi03n0BnqC-Mx)@xY`XP)vQdLv!MQB zhD%4v5B-)R<6sbjuOAC=&q*79}VyQG2tyoIR_ziFPxJyY=wBu<};+6%=L7-eR z5=A--F3_41tjcDxk@92&fsZ7a5t3rEv3zGH4R44(%48zA$nw0f;*pSw^esy=Uw&3L zl}RpNwT*)uqac=mNtx!`pJjIJ6;NVQ(@X^ExcUzFN8Nl5hW?l&q(m;(-#;>bB=%oN C`59LL delta 2019 zcmZWoU2GIp7|q???sk7{(G;Yu(hFwkQo8Njxp)4s5(`KHBgH}tC6X}dPRp=mw$AQS zti}a}DDoijwu}TrA`yrf0%6oCKJX-n(I~{wh!`X2gJ2UMOfV*DoSj)qd%G{2eBXDz zbIy0q9KBg}@YAY8_u^Y&`^sF=C_o(!0OIKj<0*UIoU;)-F@HP|O{L*REnCQ$ z)`JP~2>q^s0^S0xV2ORQu`x#9NiU!i$+3VyJlwIx!3h1~(MGF20qW?->B9l3+(7n` zwt;v!wNvfu-smr?TXr~r1rKz4*<|f~(Cu|D2z#K*PajVNQfaGg4S1Mt>Rsz^eR=a( zkQ06AvECOWwVeZ+QAlsdZ+7w*^H|>aRYKf(&p>_ah+iE6g1r~yOdc&-x zTeL-PZNC-|+WL+L;O{-9j|O<yvH#|VUUROu2?-~m#1bLpKyY~cX_O+<}d|)T+HiV<+LukQ&@#Ga z6peuj6CBD7>iO-O(F#Y};EFalQp~GbVZ_@5SL=hWnpWsR^F!K*-mmAU6LhIV=`OQ4 zG~{Y4FD)7wSfT44HZ(K6MmMTlPkGIGMFZgw;H>>RSWyyX$d!N4h$S zU8O1GNa4wJWv&XI7bOPC^Rj!Zvcf4KFDi%yN*obpPGQ&PtP;Rvs2EFF z!V04TO9EnUL3qS!CA@23I-`4kB4EtC5)p{t-)G7tQDmNRNfbq3EC~e5vq}PDObQB* zEAmVK=T1Q?_wP}9u8_MTj>0NrPRJ#hK*SiYc|h25ss RACqPGdyFNMPj#=3{ReIsH6Q>0 diff --git a/docs/ISCY_STRATEGIC_ROADMAP.md b/docs/ISCY_STRATEGIC_ROADMAP.md index 2a3a56b..f0d887e 100644 --- a/docs/ISCY_STRATEGIC_ROADMAP.md +++ b/docs/ISCY_STRATEGIC_ROADMAP.md @@ -45,7 +45,7 @@ Erfolgskriterium: Ziel: ISCY soll aus vorhandenen Daten automatisch ein Management-Review- und Audit-Paket erzeugen. -Status: In V23.7.20 als Rust-Web-/API-Pfad und persistierter Audit-Snapshot umgesetzt; V23.7.21 ergaenzt Exporte und Snapshot-Ruecklinks. Im Unreleased-Stand kommen Management-/Regulatory-Templates fuer ISO 27001, NIS2, DORA, DSGVO, KRITIS und generische Security-Governance-Reviews sowie kontextsensitive Regulatory Review-Pakete fuer NIS2, DORA und DSGVO hinzu. Der Review-Pack-Polish ergaenzt Filter, Owner-Hinweise, pack-spezifische Lueckengruppen und deutsch konsistente UI-Beschriftungen. +Status: In V23.7.20 als Rust-Web-/API-Pfad und persistierter Audit-Snapshot umgesetzt; V23.7.21 ergaenzt Exporte und Snapshot-Ruecklinks. Im Unreleased-Stand kommen Management-/Regulatory-Templates fuer ISO 27001, NIS2, DORA, DSGVO, KRITIS und generische Security-Governance-Reviews sowie kontextsensitive Regulatory Review-Pakete fuer NIS2, DORA und DSGVO hinzu. Der Review-Pack-Polish ergaenzt Filter, Owner-Hinweise, pack-spezifische Lueckengruppen und deutsch konsistente UI-Beschriftungen; Supplier/Product-Security-Gaps werden nun in NIS2-, DORA-, DSGVO- und generischen Review-Paketen beruecksichtigt. Umgesetzt: @@ -64,6 +64,7 @@ Umgesetzt: - Weboberflaeche mit Template-Auswahl, Preview vor Snapshot-Erzeugung und Detailansicht fuer Quellen, Gaps, Management-Hinweise, Supplier Review und regulatorischen Kontext. - Weboberflaeche `/regulatory-review-packs/` mit NIS2-, DORA- und DSGVO-Auswahl, Filterbereich, Preview, Owner-/Verantwortlichen-Hinweisen, pack-spezifischer Lueckenuebersicht, Snapshot-Erzeugung und Snapshot-Liste. - Preview erzeugt keinen Review-Snapshot; Snapshot-Erzeugung bleibt schreibenden Rollen vorbehalten. +- Supplier/Product-Security-Daten aus Migration `0034_rust_supplier_product_security_governance` fliessen in Review-Pakete als offene Advisorys, kritische Lieferantenabhaengigkeiten, fehlende Evidence, fehlende Owner, offene Massnahmen, Vertrags-/Exit-Plan-Hinweise und relevante DORA-/NIS2-/DSGVO-Bezuege ein. - Regulatory Review-Pakete liefern Governance- und Evidence-Unterstuetzung, aber keine Rechtsberatung, Zertifizierung, automatische Meldung oder formale Einreichung. Naechste Vertiefung: @@ -120,7 +121,8 @@ Erfolgskriterium: Ziel: Lieferanten, Cloud-, SaaS-, IKT- und Produktzulieferer sollen als eigener Risikobereich sichtbar werden. Status: In V23.7.22 als Supplier-Risk-API und Webansicht umgesetzt. Im -Unreleased-Stand kommt der tenantgebundene Supplier-Review-Workflow hinzu. +Unreleased-Stand kommen der tenantgebundene Supplier-Review-Workflow und das +Supplier/Product-Security-Deepening hinzu. Umgesetzt: @@ -138,18 +140,23 @@ Umgesetzt: - Supplier koennen explizit mit bestehenden Evidence-, ISCY-27-Control- und Risiko-Objekten verknuepft werden, ohne eine parallele Evidence-, Risiko- oder Control-Engine einzufuehren. - Vertragsbeginn, Vertragsende, Kuendigungsfrist, automatische Verlaengerung, naechster Vertragsreview, Vertrags-Evidence sowie Exit-Test-Status und Exit-Test-Evidence sind als Metadaten abbildbar. - Web-Detailansicht `/suppliers/{id}/` zeigt Reviewstand, Historie, Subprocessors, Vertrags-/Exit-Daten, Links und Auditspur; schreibende Aktionen bleiben Admin-/Editor-Rollen vorbehalten. +- Migration `0034_rust_supplier_product_security_governance` verbindet Supplier, Produkt/Service, lokale Advisory-/PSIRT-/CVE-Metadaten, SBOM-/VEX-Bezuege, Evidence, Review-Status, offene Massnahmen und Management-/Regulatory-Review-Bezug. +- Weboberflaeche `/suppliers/product-security/` zeigt Lieferant, Produkt/Service, Kritikalitaet, Advisory-/CVE-/PSIRT-Bezug, betroffene und behobene Versionen, Status, Owner, Faelligkeit, Evidence, Vertragsstatus, Exit-Plan-Status und DORA-/NIS2-/DSGVO-Relevanz. +- API-Pfade `GET`/`POST /api/v1/suppliers/product-security`, `GET`/`PATCH /api/v1/suppliers/product-security/{record_id}`, Status-, Evidence- und Event-Endpunkte sowie Supplier-bezogene Contract-/Exit-History-Abfragen. +- Vertrags-/Exit-Plan-Aenderungen werden als Historie mit Version, Akteurreferenz, Zeitpunkt, Grund, vorherigem/neuem Status, Summary und Evidence-Referenzen gefuehrt. +- Advisory-/PSIRT-/CVE-Referenzen bleiben lokale Metadaten und Import-Vorbereitung. ISCY ruft keine externen Hersteller-, NVD-, GitHub-Advisory- oder sonstigen Live-Feeds ab. Naechste Vertiefung: -- Datenuebermittlungen und komplexere Vertrags-/Exit-Versionierung spaeter bewusst als eigenen Reifegrad betrachten. -- Supplier-Advisory- und Herstellerfeeds aus dem Product-Security-Bereich fachlich anbinden. -- Management-/Regulatory-Templates nehmen Supplier-Review-Daten bereits als eingefrorene Summary in wiederholbare Pruefpakete auf. +- Datenuebermittlungen und noch feinere Vertrags-/Exit-Dokumentenmodelle spaeter bewusst als eigenen Reifegrad betrachten. +- Externe Supplier-Advisory- und Herstellerfeeds erst als getrennten, sicherheitsgeprueften Import-Meilenstein anbinden. +- Management-/Regulatory-Templates nehmen Supplier-Review- und Supplier/Product-Security-Daten als eingefrorene Summary in wiederholbare Pruefpakete auf. Erfolgskriterium: - ISCY kann zeigen, welche externen Abhaengigkeiten kritisch sind, welche Nachweise fehlen und welche Risiken daraus entstehen. -## Prioritaet 5: Product-Security-Reife (umgesetzt in V23.7.23 und V23.7.26) +## Prioritaet 5: Product-Security-Reife (umgesetzt in V23.7.23 und V23.7.26, vertieft im Unreleased-Stand) Ziel: Der bestehende Product-Security-Bereich soll von Import/Korrelation zu einem echten PSIRT-/CRA-Arbeitsplatz wachsen. @@ -163,11 +170,12 @@ Umgesetzt: - Blocker-Gate fuer vorbehaltlose Freigaben sowie dokumentierte bedingte Freigaben. - Paketversionierung mit Vorgaengerbezug und Export als Markdown, HTML, PDF und JSON. - Betriebs- und Prometheus-Signal fuer offene Paketreviews und Blocker. +- Supplier/Product-Security-Deepening verknuepft lokale Supplier-Advisory-, PSIRT- und CVE-Metadaten mit Lieferanten, Produkt/Service, Evidence, Review-Status, offenen Massnahmen, Vertrags-/Exit-Plan-Historie und Regulatory Review Packs. Noch ausbaufähig: - Security-Update- und Support-Ende je Produkt/Version pflegen. -- Supplier-Advisory- und Herstellerfeeds je Produkt verknuepfen. +- Externe Supplier-Advisory- und Herstellerfeeds je Produkt als separaten Import-/Validierungsmeilenstein verknuepfen. Erfolgskriterium: @@ -254,6 +262,7 @@ Erfolgskriterium: | Erledigt | Gefuehrtes Agent-Onboarding | Enrollment-Tokens, Deployment-Artefakte und Flottenstatus sind ueber einen sicheren Admin-Assistenten bedienbar. | | Unreleased | Fachuebergreifende Notifications | Evidence-Ablauf, CVE-Review, Incident-Entscheidung und Roadmap-Faelligkeit nutzen denselben sicheren Kanalbetrieb. | | Unreleased | Supplier-Review-Workflow | Kritische Lieferanten erhalten Freigabehistorie, Unterauftragnehmer, Vertragsfristen, Exit-Test-Nachweise und tenantgesicherte Evidence-/Control-/Risk-Links. | +| Unreleased | Supplier/Product-Security-Deepening | Lieferanten, Produkte/Services, lokale Advisory-/PSIRT-/CVE-Metadaten, Evidence, Review-Status, Vertrags-/Exit-Plan-Historie und Regulatory Review Packs sind tenantgebunden verbunden. | | Umgesetzt / Vertiefung | Management-/Regulatory-Templates | Wiederholbare ISO-27001-, NIS2-, DORA-, KRITIS- und Governance-Pakete werden aus bestehenden Snapshots erzeugt; feinere Varianten koennen spaeter folgen. | | Unreleased | Evidence Integrity & Disposition Phase 1 | Manuelle und begrenzte Batch-Re-Hash-Pruefung, Legal Hold, metadata-only Disposition und auditierbare Integritaetsereignisse sind tenantgebunden verfuegbar. | | Unreleased | Evidence Object Storage & Restore Drill Phase 2 | Eine interne Storage-Abstraktion mit lokalem Filesystem-Backend prueft referenzierte Artefakte sicher auf Vorhandensein, Lesbarkeit und Hash-Konsistenz. | diff --git a/docs/assets/iscy-supplier-product-security.png b/docs/assets/iscy-supplier-product-security.png new file mode 100644 index 0000000000000000000000000000000000000000..507f452fc0c79a754eaa7d954bbe9e4c71e6347c GIT binary patch literal 78659 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 literal 0 HcmV?d00001 diff --git a/rust/iscy-backend/src/db_admin.rs b/rust/iscy-backend/src/db_admin.rs index fd7a535..18e108c 100644 --- a/rust/iscy-backend/src/db_admin.rs +++ b/rust/iscy-backend/src/db_admin.rs @@ -227,6 +227,11 @@ const MIGRATIONS: &[Migration] = &[ sqlite_sql: SQLITE_EVIDENCE_INTEGRITY_DISPOSITION_SCHEMA, postgres_sql: POSTGRES_EVIDENCE_INTEGRITY_DISPOSITION_SCHEMA, }, + Migration { + version: "0034_rust_supplier_product_security_governance", + sqlite_sql: SQLITE_SUPPLIER_PRODUCT_SECURITY_GOVERNANCE_SCHEMA, + postgres_sql: POSTGRES_SUPPLIER_PRODUCT_SECURITY_GOVERNANCE_SCHEMA, + }, ]; const SQLITE_CATALOG_REQUIREMENTS_SEED: &str = @@ -1048,6 +1053,208 @@ CREATE INDEX IF NOT EXISTS idx_evidence_disposition ON evidence_evidenceitem(tenant_id, disposition_status, disposition_due_at); "#; +const SQLITE_SUPPLIER_PRODUCT_SECURITY_GOVERNANCE_SCHEMA: &str = r#" +CREATE TABLE IF NOT EXISTS supplier_product_security_record ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + supplier_id INTEGER NOT NULL, + product_id INTEGER NULL, + product_or_service TEXT NOT NULL, + criticality varchar(32) NOT NULL DEFAULT 'medium', + internal_owner TEXT NOT NULL DEFAULT '', + supplier_security_contact TEXT NOT NULL DEFAULT '', + product_security_status varchar(32) NOT NULL DEFAULT 'not_assessed', + advisory_id varchar(128) NOT NULL DEFAULT '', + advisory_source_type varchar(32) NOT NULL DEFAULT 'manual', + advisory_reference TEXT NOT NULL DEFAULT '', + cve_ids_json TEXT NOT NULL DEFAULT '[]', + affected_versions TEXT NOT NULL DEFAULT '', + fixed_versions TEXT NOT NULL DEFAULT '', + severity varchar(32) NOT NULL DEFAULT 'unknown', + cvss_score REAL NULL, + epss_score REAL NULL, + exploitation_status varchar(32) NOT NULL DEFAULT 'unknown', + affected_assets_summary TEXT NOT NULL DEFAULT '', + impact_summary TEXT NOT NULL DEFAULT '', + remediation_summary TEXT NOT NULL DEFAULT '', + workaround_summary TEXT NOT NULL DEFAULT '', + review_status varchar(32) NOT NULL DEFAULT 'draft', + owner TEXT NOT NULL DEFAULT '', + due_date TEXT NULL, + evidence_ids_json TEXT NOT NULL DEFAULT '[]', + sbom_vex_reference TEXT NOT NULL DEFAULT '', + open_actions TEXT NOT NULL DEFAULT '', + management_review_reference TEXT NOT NULL DEFAULT '', + contract_status varchar(32) NOT NULL DEFAULT 'not_recorded', + contract_review_date TEXT NULL, + next_contract_review_due TEXT NULL, + exit_plan_status varchar(32) NOT NULL DEFAULT 'not_recorded', + exit_plan_version varchar(64) NOT NULL DEFAULT '', + exit_plan_review_date TEXT NULL, + exit_plan_owner TEXT NOT NULL DEFAULT '', + critical_service_dependency bool NOT NULL DEFAULT 0, + data_processing_relevance bool NOT NULL DEFAULT 0, + dora_ict_third_party_relevance bool NOT NULL DEFAULT 0, + nis2_supply_chain_relevance bool NOT NULL DEFAULT 0, + termination_risk_summary TEXT NOT NULL DEFAULT '', + alternative_supplier_summary TEXT NOT NULL DEFAULT '', + created_by_id INTEGER NULL, + updated_by_id INTEGER NULL, + reviewed_at TEXT NULL, + reviewed_by INTEGER NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS supplier_product_security_evidence_link ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + record_id INTEGER NOT NULL, + evidence_id INTEGER NOT NULL, + link_type varchar(64) NOT NULL DEFAULT 'review', + created_by_id INTEGER NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(tenant_id, record_id, evidence_id) +); +CREATE TABLE IF NOT EXISTS supplier_product_security_event ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + record_id INTEGER NOT NULL, + event_type varchar(64) NOT NULL, + actor_id INTEGER NULL, + detail_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS supplier_contract_exit_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + record_id INTEGER NOT NULL, + supplier_id INTEGER NOT NULL, + version_number INTEGER NOT NULL, + changed_by INTEGER NULL, + changed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + change_reason TEXT NOT NULL DEFAULT '', + previous_status varchar(64) NOT NULL DEFAULT '', + new_status varchar(64) NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '', + evidence_ids_json TEXT NOT NULL DEFAULT '[]' +); +CREATE INDEX IF NOT EXISTS idx_supplier_product_security_tenant_supplier + ON supplier_product_security_record(tenant_id, supplier_id, review_status); +CREATE INDEX IF NOT EXISTS idx_supplier_product_security_product + ON supplier_product_security_record(tenant_id, product_id); +CREATE INDEX IF NOT EXISTS idx_supplier_product_security_due + ON supplier_product_security_record(tenant_id, due_date, review_status); +CREATE INDEX IF NOT EXISTS idx_supplier_product_security_relevance + ON supplier_product_security_record(tenant_id, dora_ict_third_party_relevance, nis2_supply_chain_relevance, data_processing_relevance); +CREATE INDEX IF NOT EXISTS idx_supplier_product_security_evidence + ON supplier_product_security_evidence_link(tenant_id, record_id); +CREATE INDEX IF NOT EXISTS idx_supplier_product_security_event + ON supplier_product_security_event(tenant_id, record_id, created_at); +CREATE INDEX IF NOT EXISTS idx_supplier_contract_exit_history + ON supplier_contract_exit_history(tenant_id, supplier_id, record_id, version_number); +"#; + +const POSTGRES_SUPPLIER_PRODUCT_SECURITY_GOVERNANCE_SCHEMA: &str = r#" +CREATE TABLE IF NOT EXISTS supplier_product_security_record ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + supplier_id BIGINT NOT NULL, + product_id BIGINT NULL, + product_or_service TEXT NOT NULL, + criticality varchar(32) NOT NULL DEFAULT 'medium', + internal_owner TEXT NOT NULL DEFAULT '', + supplier_security_contact TEXT NOT NULL DEFAULT '', + product_security_status varchar(32) NOT NULL DEFAULT 'not_assessed', + advisory_id varchar(128) NOT NULL DEFAULT '', + advisory_source_type varchar(32) NOT NULL DEFAULT 'manual', + advisory_reference TEXT NOT NULL DEFAULT '', + cve_ids_json TEXT NOT NULL DEFAULT '[]', + affected_versions TEXT NOT NULL DEFAULT '', + fixed_versions TEXT NOT NULL DEFAULT '', + severity varchar(32) NOT NULL DEFAULT 'unknown', + cvss_score DOUBLE PRECISION NULL, + epss_score DOUBLE PRECISION NULL, + exploitation_status varchar(32) NOT NULL DEFAULT 'unknown', + affected_assets_summary TEXT NOT NULL DEFAULT '', + impact_summary TEXT NOT NULL DEFAULT '', + remediation_summary TEXT NOT NULL DEFAULT '', + workaround_summary TEXT NOT NULL DEFAULT '', + review_status varchar(32) NOT NULL DEFAULT 'draft', + owner TEXT NOT NULL DEFAULT '', + due_date TEXT NULL, + evidence_ids_json TEXT NOT NULL DEFAULT '[]', + sbom_vex_reference TEXT NOT NULL DEFAULT '', + open_actions TEXT NOT NULL DEFAULT '', + management_review_reference TEXT NOT NULL DEFAULT '', + contract_status varchar(32) NOT NULL DEFAULT 'not_recorded', + contract_review_date TEXT NULL, + next_contract_review_due TEXT NULL, + exit_plan_status varchar(32) NOT NULL DEFAULT 'not_recorded', + exit_plan_version varchar(64) NOT NULL DEFAULT '', + exit_plan_review_date TEXT NULL, + exit_plan_owner TEXT NOT NULL DEFAULT '', + critical_service_dependency BOOLEAN NOT NULL DEFAULT FALSE, + data_processing_relevance BOOLEAN NOT NULL DEFAULT FALSE, + dora_ict_third_party_relevance BOOLEAN NOT NULL DEFAULT FALSE, + nis2_supply_chain_relevance BOOLEAN NOT NULL DEFAULT FALSE, + termination_risk_summary TEXT NOT NULL DEFAULT '', + alternative_supplier_summary TEXT NOT NULL DEFAULT '', + created_by_id BIGINT NULL, + updated_by_id BIGINT NULL, + reviewed_at TEXT NULL, + reviewed_by BIGINT NULL, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)::text, + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)::text +); +CREATE TABLE IF NOT EXISTS supplier_product_security_evidence_link ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + record_id BIGINT NOT NULL, + evidence_id BIGINT NOT NULL, + link_type varchar(64) NOT NULL DEFAULT 'review', + created_by_id BIGINT NULL, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)::text, + UNIQUE(tenant_id, record_id, evidence_id) +); +CREATE TABLE IF NOT EXISTS supplier_product_security_event ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + record_id BIGINT NOT NULL, + event_type varchar(64) NOT NULL, + actor_id BIGINT NULL, + detail_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)::text +); +CREATE TABLE IF NOT EXISTS supplier_contract_exit_history ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + record_id BIGINT NOT NULL, + supplier_id BIGINT NOT NULL, + version_number BIGINT NOT NULL, + changed_by BIGINT NULL, + changed_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)::text, + change_reason TEXT NOT NULL DEFAULT '', + previous_status varchar(64) NOT NULL DEFAULT '', + new_status varchar(64) NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '', + evidence_ids_json TEXT NOT NULL DEFAULT '[]' +); +CREATE INDEX IF NOT EXISTS idx_supplier_product_security_tenant_supplier + ON supplier_product_security_record(tenant_id, supplier_id, review_status); +CREATE INDEX IF NOT EXISTS idx_supplier_product_security_product + ON supplier_product_security_record(tenant_id, product_id); +CREATE INDEX IF NOT EXISTS idx_supplier_product_security_due + ON supplier_product_security_record(tenant_id, due_date, review_status); +CREATE INDEX IF NOT EXISTS idx_supplier_product_security_relevance + ON supplier_product_security_record(tenant_id, dora_ict_third_party_relevance, nis2_supply_chain_relevance, data_processing_relevance); +CREATE INDEX IF NOT EXISTS idx_supplier_product_security_evidence + ON supplier_product_security_evidence_link(tenant_id, record_id); +CREATE INDEX IF NOT EXISTS idx_supplier_product_security_event + ON supplier_product_security_event(tenant_id, record_id, created_at); +CREATE INDEX IF NOT EXISTS idx_supplier_contract_exit_history + ON supplier_contract_exit_history(tenant_id, supplier_id, record_id, version_number); +"#; + const SQLITE_SUPPLIER_RISK_CORE_SCHEMA: &str = r#" ALTER TABLE organizations_supplier ADD COLUMN contact_email varchar(254) NOT NULL DEFAULT ''; ALTER TABLE organizations_supplier ADD COLUMN contract_reference varchar(255) NOT NULL DEFAULT ''; @@ -6450,4 +6657,122 @@ mod tests { .unwrap(); assert_eq!(index_count, 4); } + + #[tokio::test] + async fn sqlite_0034_is_restartable_and_preserves_supplier_product_security_governance() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + run_sqlite_migrations(&pool).await.unwrap(); + sqlx::query( + r#" + INSERT INTO organizations_supplier ( + id, tenant_id, name, service_description, criticality, review_status, + approval_status, risk_assessment, created_at, updated_at + ) VALUES ( + 6601, 66, 'Existing PSIRT Supplier', 'Existing product security data', + 'CRITICAL', 'approved', 'approved', 'high', + '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z' + ) + "#, + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + r#" + INSERT INTO supplier_product_security_record ( + id, tenant_id, supplier_id, product_or_service, criticality, + advisory_id, cve_ids_json, severity, review_status, owner, + evidence_ids_json, contract_status, exit_plan_status, + critical_service_dependency, data_processing_relevance, + dora_ict_third_party_relevance, nis2_supply_chain_relevance, + open_actions + ) VALUES ( + 6602, 66, 6601, 'Existing Managed Service', 'critical', + 'PSIRT-2026-001', '["CVE-2026-6601"]', 'critical', + 'needs_review', 'Supplier Security Owner', '[6603]', + 'active', 'planned', 1, 1, 1, 1, 'Patch window abstimmen' + ) + "#, + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + r#" + INSERT INTO supplier_contract_exit_history ( + tenant_id, record_id, supplier_id, version_number, changed_by, + change_reason, previous_status, new_status, summary, evidence_ids_json + ) VALUES ( + 66, 6602, 6601, 1, 6, 'Existing history', + '', 'active', 'Existing contract metadata', '[6603]' + ) + "#, + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "DELETE FROM iscy_schema_migrations WHERE version = '0034_rust_supplier_product_security_governance'", + ) + .execute(&pool) + .await + .unwrap(); + + let applied = run_sqlite_migrations(&pool).await.unwrap(); + assert_eq!( + applied, + vec!["0034_rust_supplier_product_security_governance"] + ); + assert!(run_sqlite_migrations(&pool).await.unwrap().is_empty()); + + let record = sqlx::query( + r#" + SELECT product_or_service, advisory_id, cve_ids_json, severity, + review_status, owner, evidence_ids_json, contract_status, + exit_plan_status, critical_service_dependency, + dora_ict_third_party_relevance, nis2_supply_chain_relevance + FROM supplier_product_security_record + WHERE tenant_id = 66 AND id = 6602 + "#, + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + record.get::("product_or_service"), + "Existing Managed Service" + ); + assert_eq!(record.get::("advisory_id"), "PSIRT-2026-001"); + assert_eq!( + record.get::("cve_ids_json"), + "[\"CVE-2026-6601\"]" + ); + assert_eq!(record.get::("severity"), "critical"); + assert_eq!(record.get::("review_status"), "needs_review"); + assert_eq!(record.get::("owner"), "Supplier Security Owner"); + assert_eq!(record.get::("evidence_ids_json"), "[6603]"); + assert_eq!(record.get::("contract_status"), "active"); + assert_eq!(record.get::("exit_plan_status"), "planned"); + assert_eq!(record.get::("critical_service_dependency"), 1); + assert_eq!(record.get::("dora_ict_third_party_relevance"), 1); + assert_eq!(record.get::("nis2_supply_chain_relevance"), 1); + let history_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM supplier_contract_exit_history WHERE tenant_id = 66 AND record_id = 6602", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(history_count, 1); + let index_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name IN ('idx_supplier_product_security_tenant_supplier','idx_supplier_product_security_product','idx_supplier_product_security_due','idx_supplier_product_security_relevance','idx_supplier_product_security_evidence','idx_supplier_product_security_event','idx_supplier_contract_exit_history')", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(index_count, 7); + } } diff --git a/rust/iscy-backend/src/lib.rs b/rust/iscy-backend/src/lib.rs index 29b53e1..dd8c26a 100644 --- a/rust/iscy-backend/src/lib.rs +++ b/rust/iscy-backend/src/lib.rs @@ -56,6 +56,7 @@ pub mod requirement_store; pub mod risk_store; pub mod roadmap_store; pub mod security_store; +pub mod supplier_product_security_store; pub mod supplier_store; pub mod tenant_store; pub mod wizard_store; @@ -89,6 +90,9 @@ use requirement_store::RequirementStore; use risk_store::RiskStore; use roadmap_store::RoadmapStore; use security_store::SecurityStore; +use supplier_product_security_store::{ + SupplierProductSecurityErrorKind, SupplierProductSecurityStore, +}; use supplier_store::{SupplierStore, SupplierStoreErrorKind}; use tenant_store::TenantStore; use wizard_store::WizardStore; @@ -117,6 +121,7 @@ pub struct AppState { pub risk_store: Option, pub roadmap_store: Option, pub security_store: Option, + pub supplier_product_security_store: Option, pub supplier_store: Option, pub tenant_store: Option, pub wizard_store: Option, @@ -159,6 +164,7 @@ impl AppState { risk_store: None, roadmap_store: None, security_store: None, + supplier_product_security_store: None, supplier_store: None, tenant_store: None, wizard_store: None, @@ -194,6 +200,7 @@ impl AppState { risk_store: None, roadmap_store: None, security_store: None, + supplier_product_security_store: None, supplier_store: None, tenant_store, wizard_store: None, @@ -344,6 +351,14 @@ impl AppState { self } + pub fn with_supplier_product_security_store( + mut self, + supplier_product_security_store: Option, + ) -> Self { + self.supplier_product_security_store = supplier_product_security_store; + self + } + pub fn with_wizard_store(mut self, wizard_store: Option) -> Self { self.wizard_store = wizard_store; self @@ -741,6 +756,109 @@ struct WebSupplierEntityLinkForm { entity_id: Option, } +#[derive(Debug, Deserialize)] +struct WebSupplierProductSecurityCreateForm { + supplier_id: String, + product_id: Option, + product_or_service: String, + criticality: Option, + internal_owner: Option, + supplier_security_contact: Option, + product_security_status: Option, + advisory_id: Option, + advisory_source_type: Option, + advisory_reference: Option, + cve_ids: Option, + affected_versions: Option, + fixed_versions: Option, + severity: Option, + cvss_score: Option, + epss_score: Option, + exploitation_status: Option, + affected_assets_summary: Option, + impact_summary: Option, + remediation_summary: Option, + workaround_summary: Option, + review_status: Option, + owner: Option, + due_date: Option, + evidence_ids: Option, + sbom_vex_reference: Option, + open_actions: Option, + management_review_reference: Option, + contract_status: Option, + contract_review_date: Option, + next_contract_review_due: Option, + exit_plan_status: Option, + exit_plan_version: Option, + exit_plan_review_date: Option, + exit_plan_owner: Option, + critical_service_dependency: Option, + data_processing_relevance: Option, + dora_ict_third_party_relevance: Option, + nis2_supply_chain_relevance: Option, + termination_risk_summary: Option, + alternative_supplier_summary: Option, + change_reason: Option, +} + +#[derive(Debug, Deserialize)] +struct WebSupplierProductSecurityPatchForm { + product_id: Option, + product_or_service: Option, + criticality: Option, + internal_owner: Option, + supplier_security_contact: Option, + product_security_status: Option, + advisory_id: Option, + advisory_source_type: Option, + advisory_reference: Option, + cve_ids: Option, + affected_versions: Option, + fixed_versions: Option, + severity: Option, + cvss_score: Option, + epss_score: Option, + exploitation_status: Option, + affected_assets_summary: Option, + impact_summary: Option, + remediation_summary: Option, + workaround_summary: Option, + owner: Option, + due_date: Option, + evidence_ids: Option, + sbom_vex_reference: Option, + open_actions: Option, + management_review_reference: Option, + contract_status: Option, + contract_review_date: Option, + next_contract_review_due: Option, + exit_plan_status: Option, + exit_plan_version: Option, + exit_plan_review_date: Option, + exit_plan_owner: Option, + critical_service_dependency: Option, + data_processing_relevance: Option, + dora_ict_third_party_relevance: Option, + nis2_supply_chain_relevance: Option, + termination_risk_summary: Option, + alternative_supplier_summary: Option, + change_reason: Option, +} + +#[derive(Debug, Deserialize)] +struct WebSupplierProductSecurityStatusForm { + new_status: String, + reason: Option, + review_notes: Option, +} + +#[derive(Debug, Deserialize)] +struct WebSupplierProductSecurityEvidenceForm { + evidence_id: Option, + link_type: Option, +} + #[derive(Debug, Deserialize)] struct WebTenantRegulatoryProfileForm { country: Option, @@ -1059,6 +1177,54 @@ pub struct SupplierEvidenceLinksResponse { pub evidence_links: Vec, } +#[derive(Debug, Serialize)] +pub struct SupplierProductSecurityOverviewResponse { + pub api_version: &'static str, + #[serde(flatten)] + pub overview: supplier_product_security_store::SupplierProductSecurityOverview, +} + +#[derive(Debug, Serialize)] +pub struct SupplierProductSecurityDetailResponse { + pub api_version: &'static str, + pub detail: supplier_product_security_store::SupplierProductSecurityDetail, +} + +#[derive(Debug, Serialize)] +pub struct SupplierProductSecurityWriteResponse { + pub accepted: bool, + pub api_version: &'static str, + pub detail: supplier_product_security_store::SupplierProductSecurityDetail, +} + +#[derive(Debug, Serialize)] +pub struct SupplierProductSecurityEventsResponse { + pub api_version: &'static str, + pub record_id: i64, + pub events: Vec, +} + +#[derive(Debug, Serialize)] +pub struct SupplierContractExitHistoryResponse { + pub api_version: &'static str, + pub supplier_id: i64, + pub history: Vec, +} + +#[derive(Debug, Default, Deserialize)] +pub struct SupplierProductSecurityApiQuery { + pub supplier_id: Option, + pub product: Option, + pub status: Option, + pub severity: Option, + pub dora_relevant: Option, + pub nis2_relevant: Option, + pub data_processing_relevant: Option, + pub critical_services: Option, + pub overdue: Option, + pub limit: Option, +} + #[derive(Debug, Serialize)] pub struct CatalogDomainsResponse { pub api_version: &'static str, @@ -1613,6 +1779,14 @@ pub struct WebContextQuery { pub channel_id: Option, pub pack_type: Option, pub status: Option, + pub supplier_id: Option, + pub product: Option, + pub severity: Option, + pub dora_relevant: Option, + pub nis2_relevant: Option, + pub data_processing_relevant: Option, + pub critical_services: Option, + pub overdue: Option, pub period_start: Option, pub period_end: Option, pub has_open_gaps: Option, @@ -6025,6 +6199,413 @@ async fn supplier_risk_link_create( } } +fn supplier_product_security_error_response( + err: supplier_product_security_store::SupplierProductSecurityError, +) -> Response { + let (status, error_code) = match err.kind() { + SupplierProductSecurityErrorKind::InvalidPayload => { + (StatusCode::BAD_REQUEST, "invalid_payload") + } + SupplierProductSecurityErrorKind::NotFound => (StatusCode::NOT_FOUND, "not_found"), + SupplierProductSecurityErrorKind::Database => { + (StatusCode::INTERNAL_SERVER_ERROR, "database_error") + } + }; + ( + status, + Json(ApiErrorResponse { + accepted: false, + api_version: "v1", + error_code, + message: err.safe_message().to_string(), + }), + ) + .into_response() +} + +fn supplier_product_security_not_configured_response() -> Response { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiErrorResponse { + accepted: false, + api_version: "v1", + error_code: "database_not_configured", + message: "Rust-Supplier/Product-Security-Store ist nicht konfiguriert.".to_string(), + }), + ) + .into_response() +} + +fn supplier_product_security_bad_request(message: impl Into) -> Response { + ( + StatusCode::BAD_REQUEST, + Json(ApiErrorResponse { + accepted: false, + api_version: "v1", + error_code: "invalid_filter", + message: message.into(), + }), + ) + .into_response() +} + +fn supplier_product_security_not_found_response() -> Response { + ( + StatusCode::NOT_FOUND, + Json(ApiErrorResponse { + accepted: false, + api_version: "v1", + error_code: "not_found", + message: "Supplier/Product-Security-Datensatz wurde fuer diesen Tenant nicht gefunden." + .to_string(), + }), + ) + .into_response() +} + +fn supplier_product_security_filters_from_api_query( + query: SupplierProductSecurityApiQuery, + supplier_id_override: Option, +) -> Result { + let supplier_id = supplier_id_override.or(query.supplier_id); + if supplier_id.is_some_and(|value| value <= 0) { + return Err("Supplier-ID muss positiv sein.".to_string()); + } + let product = query + .product + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + if product.as_ref().is_some_and(|value| value.len() > 120) { + return Err("Produkt-/Service-Filter ist zu lang.".to_string()); + } + let review_status = query + .status + .map(|value| value.trim().to_ascii_lowercase().replace('-', "_")) + .filter(|value| !value.is_empty()); + let severity = query + .severity + .map(|value| value.trim().to_ascii_lowercase().replace('-', "_")) + .filter(|value| !value.is_empty()); + Ok( + supplier_product_security_store::SupplierProductSecurityFilters { + supplier_id, + product, + review_status, + severity, + dora_relevant: normalized_filter_bool(query.dora_relevant.as_deref(), "DORA-Filter")?, + nis2_relevant: normalized_filter_bool(query.nis2_relevant.as_deref(), "NIS2-Filter")?, + data_processing_relevant: normalized_filter_bool( + query.data_processing_relevant.as_deref(), + "DSGVO-/Datenverarbeitungsfilter", + )?, + critical_services: normalized_filter_bool( + query.critical_services.as_deref(), + "Kritische-Services-Filter", + )?, + overdue: normalized_filter_bool(query.overdue.as_deref(), "Ueberfaellig-Filter")?, + limit: normalized_filter_limit(query.limit.as_deref())?, + }, + ) +} + +fn supplier_product_security_filters_from_web_query( + query: &WebContextQuery, +) -> Result { + supplier_product_security_filters_from_api_query( + SupplierProductSecurityApiQuery { + supplier_id: query.supplier_id, + product: query.product.clone(), + status: query.status.clone(), + severity: query.severity.clone(), + dora_relevant: query.dora_relevant.clone(), + nis2_relevant: query.nis2_relevant.clone(), + data_processing_relevant: query.data_processing_relevant.clone(), + critical_services: query.critical_services.clone(), + overdue: query.overdue.clone(), + limit: query.limit.clone(), + }, + None, + ) +} + +async fn supplier_product_security_overview( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Response { + let context = match authenticated_tenant_context(&state, &headers).await { + Ok(context) => context, + Err(err) => return api_context_error(err), + }; + let filters = match supplier_product_security_filters_from_api_query(query, None) { + Ok(filters) => filters, + Err(message) => return supplier_product_security_bad_request(message), + }; + let Some(store) = state.supplier_product_security_store else { + return supplier_product_security_not_configured_response(); + }; + match store.overview(context.tenant_id, filters).await { + Ok(overview) => ( + StatusCode::OK, + Json(SupplierProductSecurityOverviewResponse { + api_version: "v1", + overview, + }), + ) + .into_response(), + Err(_) => supplier_database_error_response( + "Supplier/Product-Security-Uebersicht konnte nicht gelesen werden.", + ), + } +} + +async fn supplier_product_security_by_supplier( + Path(supplier_id): Path, + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Response { + let context = match authenticated_tenant_context(&state, &headers).await { + Ok(context) => context, + Err(err) => return api_context_error(err), + }; + let filters = match supplier_product_security_filters_from_api_query(query, Some(supplier_id)) { + Ok(filters) => filters, + Err(message) => return supplier_product_security_bad_request(message), + }; + let Some(store) = state.supplier_product_security_store else { + return supplier_product_security_not_configured_response(); + }; + match store.overview(context.tenant_id, filters).await { + Ok(overview) => ( + StatusCode::OK, + Json(SupplierProductSecurityOverviewResponse { + api_version: "v1", + overview, + }), + ) + .into_response(), + Err(_) => supplier_database_error_response( + "Supplier/Product-Security-Uebersicht konnte nicht gelesen werden.", + ), + } +} + +async fn supplier_product_security_create( + State(state): State, + headers: HeaderMap, + Json(payload): Json, +) -> Response { + let context = match authenticated_tenant_context(&state, &headers).await { + Ok(context) => context, + Err(err) => return api_context_error(err), + }; + if let Some(response) = write_permission_error(&context) { + return response; + } + let Some(store) = state.supplier_product_security_store else { + return supplier_product_security_not_configured_response(); + }; + match store + .create_record(context.tenant_id, context.user_id, payload) + .await + { + Ok(detail) => ( + StatusCode::CREATED, + Json(SupplierProductSecurityWriteResponse { + accepted: true, + api_version: "v1", + detail, + }), + ) + .into_response(), + Err(err) => supplier_product_security_error_response(err), + } +} + +async fn supplier_product_security_detail( + Path(record_id): Path, + State(state): State, + headers: HeaderMap, +) -> Response { + let context = match authenticated_tenant_context(&state, &headers).await { + Ok(context) => context, + Err(err) => return api_context_error(err), + }; + let Some(store) = state.supplier_product_security_store else { + return supplier_product_security_not_configured_response(); + }; + match store.detail(context.tenant_id, record_id).await { + Ok(Some(detail)) => ( + StatusCode::OK, + Json(SupplierProductSecurityDetailResponse { + api_version: "v1", + detail, + }), + ) + .into_response(), + Ok(None) => supplier_product_security_not_found_response(), + Err(err) => supplier_product_security_error_response(err), + } +} + +async fn supplier_product_security_update( + Path(record_id): Path, + State(state): State, + headers: HeaderMap, + Json(payload): Json, +) -> Response { + let context = match authenticated_tenant_context(&state, &headers).await { + Ok(context) => context, + Err(err) => return api_context_error(err), + }; + if let Some(response) = write_permission_error(&context) { + return response; + } + let Some(store) = state.supplier_product_security_store else { + return supplier_product_security_not_configured_response(); + }; + match store + .update_record(context.tenant_id, record_id, context.user_id, payload) + .await + { + Ok(Some(detail)) => ( + StatusCode::OK, + Json(SupplierProductSecurityWriteResponse { + accepted: true, + api_version: "v1", + detail, + }), + ) + .into_response(), + Ok(None) => supplier_product_security_not_found_response(), + Err(err) => supplier_product_security_error_response(err), + } +} + +async fn supplier_product_security_status_update( + Path(record_id): Path, + State(state): State, + headers: HeaderMap, + Json(payload): Json, +) -> Response { + let context = match authenticated_tenant_context(&state, &headers).await { + Ok(context) => context, + Err(err) => return api_context_error(err), + }; + if let Some(response) = write_permission_error(&context) { + return response; + } + let Some(store) = state.supplier_product_security_store else { + return supplier_product_security_not_configured_response(); + }; + match store + .update_status(context.tenant_id, record_id, context.user_id, payload) + .await + { + Ok(Some(detail)) => ( + StatusCode::OK, + Json(SupplierProductSecurityWriteResponse { + accepted: true, + api_version: "v1", + detail, + }), + ) + .into_response(), + Ok(None) => supplier_product_security_not_found_response(), + Err(err) => supplier_product_security_error_response(err), + } +} + +async fn supplier_product_security_evidence_link( + Path(record_id): Path, + State(state): State, + headers: HeaderMap, + Json(payload): Json, +) -> Response { + let context = match authenticated_tenant_context(&state, &headers).await { + Ok(context) => context, + Err(err) => return api_context_error(err), + }; + if let Some(response) = write_permission_error(&context) { + return response; + } + let Some(store) = state.supplier_product_security_store else { + return supplier_product_security_not_configured_response(); + }; + match store + .link_evidence(context.tenant_id, record_id, context.user_id, payload) + .await + { + Ok(Some(detail)) => ( + StatusCode::OK, + Json(SupplierProductSecurityWriteResponse { + accepted: true, + api_version: "v1", + detail, + }), + ) + .into_response(), + Ok(None) => supplier_product_security_not_found_response(), + Err(err) => supplier_product_security_error_response(err), + } +} + +async fn supplier_product_security_events( + Path(record_id): Path, + State(state): State, + headers: HeaderMap, +) -> Response { + let context = match authenticated_tenant_context(&state, &headers).await { + Ok(context) => context, + Err(err) => return api_context_error(err), + }; + let Some(store) = state.supplier_product_security_store else { + return supplier_product_security_not_configured_response(); + }; + match store.events(context.tenant_id, record_id).await { + Ok(events) => ( + StatusCode::OK, + Json(SupplierProductSecurityEventsResponse { + api_version: "v1", + record_id, + events, + }), + ) + .into_response(), + Err(err) => supplier_product_security_error_response(err), + } +} + +async fn supplier_contract_exit_history( + Path(supplier_id): Path, + State(state): State, + headers: HeaderMap, +) -> Response { + let context = match authenticated_tenant_context(&state, &headers).await { + Ok(context) => context, + Err(err) => return api_context_error(err), + }; + let Some(store) = state.supplier_product_security_store else { + return supplier_product_security_not_configured_response(); + }; + match store + .supplier_contract_exit_history(context.tenant_id, supplier_id) + .await + { + Ok(history) => ( + StatusCode::OK, + Json(SupplierContractExitHistoryResponse { + api_version: "v1", + supplier_id, + history, + }), + ) + .into_response(), + Err(err) => supplier_product_security_error_response(err), + } +} + async fn catalog_domains(State(state): State, headers: HeaderMap) -> Response { if let Err(err) = authenticated_tenant_context(&state, &headers).await { return ( @@ -14225,7 +14806,7 @@ async fn web_risks( r#"

Risks

{} Risiken

- +
{}
TitelScoreLevelStatusOwnerEvidenceReview
@@ -18262,6 +18843,720 @@ async fn web_suppliers_submit( } } +async fn web_supplier_product_security( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Html { + let Some(context) = web_context_from_request(&query, &headers, &state).await else { + return web_missing_context("Supplier/Product Security", "/suppliers/product-security/"); + }; + let can_write = authenticated_tenant_context(&state, &headers) + .await + .is_ok_and(|auth_context| auth_context.can_write()); + let Some(store) = state.supplier_product_security_store else { + return web_store_missing( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + "Supplier/Product Security", + ); + }; + let filters = match supplier_product_security_filters_from_web_query(&query) { + Ok(filters) => filters, + Err(message) => { + return web_error_page( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + &message, + ); + } + }; + match store.overview(context.tenant_id, filters).await { + Ok(overview) => { + let rows = overview + .records + .iter() + .map(|record| { + let detail_href = web_path_with_context( + &format!("/suppliers/product-security/{}/", record.id), + Some(&context), + ); + let supplier_href = web_path_with_context( + &format!("/suppliers/{}/", record.supplier_id), + Some(&context), + ); + format!( + r#"{}

{}

{}{}{}{}{}{}{}{}{}{}{}"#, + html_escape(&detail_href), + html_escape(&record.product_or_service), + html_escape(&supplier_href), + html_escape(&record.supplier_name), + web_badge( + &record.criticality_label, + supplier_product_security_criticality_badge_class(&record.criticality), + ), + supplier_product_security_cve_badges(&record.cve_ids), + html_escape(&record.advisory_id), + severity_badge(&record.severity, &record.severity_label), + web_badge( + &record.review_status_label, + supplier_product_security_review_badge_class(&record.review_status), + ), + html_escape(if record.owner.is_empty() { "Nicht erfasst" } else { &record.owner }), + html_escape(record.due_date.as_deref().unwrap_or("Nicht erfasst")), + supplier_product_security_scope_badges(record), + html_escape(if record.open_actions.is_empty() { "Keine offenen Massnahmen" } else { &record.open_actions }), + web_badge( + &record.contract_status_label, + supplier_product_security_contract_badge_class(&record.contract_status), + ), + web_badge( + &record.exit_plan_status_label, + supplier_product_security_exit_badge_class(&record.exit_plan_status), + ), + ) + }) + .collect::>() + .join(""); + let create_form = if can_write { + supplier_product_security_create_panel(&context) + } else { + String::new() + }; + let filter_panel = supplier_product_security_filter_panel(&context, &query); + let body = format!( + r#" +

Supplier/Product Security

Lieferanten, Product Security / Produktsicherheit, Advisorys, CVEs, Evidence und Exit-Plan-Reife

+
+ {} + {} + {} + {} + {} + {} + {} + {} +
+ {} + {} +
+

Supplier/Product-Security-Register

+ + + {} +
Produkt / LieferantKritikalitaetCVEAdvisorySchweregradStatusOwner / VerantwortlicherFaelligkeitRegulatorikOffene MassnahmenVertragExit-Plan
+
+ "#, + metric_card("Datensaetze", overview.summary.total_records), + metric_card("Offene Advisorys", overview.summary.open_advisories), + metric_card("Kritisch", overview.summary.critical_records), + metric_card("Ueberfaellig", overview.summary.overdue_reviews), + metric_card("Evidence fehlt", overview.summary.missing_evidence), + metric_card("DORA", overview.summary.dora_relevant), + metric_card("NIS2", overview.summary.nis2_relevant), + metric_card("DSGVO/Daten", overview.summary.data_processing_relevant), + filter_panel, + create_form, + if rows.is_empty() { + web_empty_row(12, "Keine Supplier/Product-Security-Daten erfasst.") + } else { + rows + }, + ); + web_page( + "Supplier/Product Security", + "/suppliers/product-security/", + Some(&context), + &body, + ) + } + Err(_) => web_error_page( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + "Supplier/Product-Security-Register konnte nicht gelesen werden.", + ), + } +} + +async fn web_supplier_product_security_submit( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + let auth_context = match authenticated_tenant_context(&state, &headers).await { + Ok(context) => context, + Err(_) => { + return web_missing_context( + "Supplier/Product Security", + "/suppliers/product-security/", + ) + .into_response(); + } + }; + let context = web_context_from_auth(&auth_context); + if !auth_context.can_write() { + return web_error_page( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + "Diese Rust-Webroute benoetigt eine schreibende ISCY-Rolle.", + ) + .into_response(); + } + let Some(store) = state.supplier_product_security_store else { + return web_store_missing( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + "Supplier/Product Security", + ) + .into_response(); + }; + let payload = match web_supplier_product_security_create_payload(form) { + Ok(payload) => payload, + Err(message) => { + return web_error_page( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + &message, + ) + .into_response(); + } + }; + match store + .create_record(auth_context.tenant_id, auth_context.user_id, payload) + .await + { + Ok(detail) => { + web_supplier_product_security_redirect(&context, detail.record.id).into_response() + } + Err(err) => web_error_page( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + err.safe_message(), + ) + .into_response(), + } +} + +async fn web_supplier_product_security_detail( + Path(record_id): Path, + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Html { + let Some(context) = web_context_from_request(&query, &headers, &state).await else { + return web_missing_context("Supplier/Product Security", "/suppliers/product-security/"); + }; + let can_write = authenticated_tenant_context(&state, &headers) + .await + .is_ok_and(|auth_context| auth_context.can_write()); + let Some(store) = state.supplier_product_security_store else { + return web_store_missing( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + "Supplier/Product Security", + ); + }; + match store.detail(context.tenant_id, record_id).await { + Ok(Some(detail)) => { + let record = &detail.record; + let supplier_href = web_path_with_context( + &format!("/suppliers/{}/", record.supplier_id), + Some(&context), + ); + let evidence_rows = detail + .evidence_links + .iter() + .map(|link| { + format!( + r#"{}{}{}{}"#, + link.evidence_id, + html_escape(&link.title), + html_escape(&link.status), + html_escape(&link.link_type), + ) + }) + .collect::>() + .join(""); + let event_rows = detail + .events + .iter() + .map(|event| { + format!( + r#"{}{}{}{}"#, + html_escape(&event.created_at), + html_escape(&event.event_type), + html_escape( + &event + .actor_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "-".to_string()) + ), + html_escape(&event.detail_json.to_string()), + ) + }) + .collect::>() + .join(""); + let history_rows = detail + .contract_exit_history + .iter() + .map(|entry| { + format!( + r#"{}{}{}{}{}{}"#, + entry.version_number, + html_escape(&entry.changed_at), + html_escape(&entry.previous_status), + html_escape(&entry.new_status), + html_escape(&entry.change_reason), + html_escape(&entry.evidence_ids.iter().map(i64::to_string).collect::>().join(", ")), + ) + }) + .collect::>() + .join(""); + let forms = if can_write { + supplier_product_security_detail_forms(&context, &detail) + } else { + String::new() + }; + let body = format!( + r#" +

{}

{} · Advisory / Sicherheitshinweis, CVE, SBOM/VEX, Vertrag und Exit-Plan

+
+ {} + {} + {} + {} + {} + {} +
+
+
+

Advisory / CVE

+

Advisory-ID: {}

+

Quelle: {}

+

Referenz: {}

+

CVE: {}

+

Betroffene Versionen: {}

+

Behobene Versionen: {}

+
+
+

Review

+

Status: {}

+

Product-Security-Status: {}

+

Owner / Verantwortlicher: {}

+

Interner Owner: {}

+

Faelligkeit: {}

+

Review-Bezug: {}

+
+
+

Vertrag & Exit-Plan

+

Vertrag: {}

+

Naechste Vertragspruefung: {}

+

Exit-Plan: {} · Version {}

+

Exit-Plan-Owner: {}

+

Kritischer Service: {}

+

DORA/NIS2/DSGVO: {} / {} / {}

+
+
+
+

Auswirkung und Massnahmen

+

Assets: {}

+

Impact: {}

+

Remediation: {}

+

Workaround: {}

+

Offene Massnahmen: {}

+

SBOM/VEX: {}

+

Kuendigungsrisiko: {}

+

Alternativlieferant: {}

+
+ {} +
+

Evidence-Verknuepfungen

+ {}
Evidence-IDTitelStatusTyp
+
+
+

Contract-/Exit-Historie

+ {}
VersionZeitVorherNeuGrundEvidence
+
+
+

Audit-/Ereignis-Historie

+ {}
ZeitEreignisActorDetails
+
+ "#, + html_escape(&record.product_or_service), + html_escape(&supplier_href), + html_escape(&record.supplier_name), + metric_card("Schweregrad", 0), + severity_badge(&record.severity, &record.severity_label), + web_badge( + &record.review_status_label, + supplier_product_security_review_badge_class(&record.review_status), + ), + web_badge( + &record.criticality_label, + supplier_product_security_criticality_badge_class(&record.criticality), + ), + metric_card("Evidence", record.evidence_ids.len() as i64), + metric_card("Events", detail.events.len() as i64), + html_escape(if record.advisory_id.is_empty() { + "Nicht erfasst" + } else { + &record.advisory_id + }), + html_escape(&record.advisory_source_type_label), + supplier_product_security_reference_html(&record.advisory_reference), + supplier_product_security_cve_badges(&record.cve_ids), + html_escape(if record.affected_versions.is_empty() { + "Nicht erfasst" + } else { + &record.affected_versions + }), + html_escape(if record.fixed_versions.is_empty() { + "Nicht erfasst" + } else { + &record.fixed_versions + }), + web_badge( + &record.review_status_label, + supplier_product_security_review_badge_class(&record.review_status), + ), + html_escape(&record.product_security_status_label), + html_escape(if record.owner.is_empty() { + "Nicht erfasst" + } else { + &record.owner + }), + html_escape(if record.internal_owner.is_empty() { + "Nicht erfasst" + } else { + &record.internal_owner + }), + html_escape(record.due_date.as_deref().unwrap_or("Nicht erfasst")), + html_escape(if record.management_review_reference.is_empty() { + "Nicht erfasst" + } else { + &record.management_review_reference + }), + web_badge( + &record.contract_status_label, + supplier_product_security_contract_badge_class(&record.contract_status), + ), + html_escape( + record + .next_contract_review_due + .as_deref() + .unwrap_or("Nicht erfasst") + ), + web_badge( + &record.exit_plan_status_label, + supplier_product_security_exit_badge_class(&record.exit_plan_status), + ), + html_escape(if record.exit_plan_version.is_empty() { + "Nicht erfasst" + } else { + &record.exit_plan_version + }), + html_escape(if record.exit_plan_owner.is_empty() { + "Nicht erfasst" + } else { + &record.exit_plan_owner + }), + yes_no(record.critical_service_dependency), + yes_no(record.dora_ict_third_party_relevance), + yes_no(record.nis2_supply_chain_relevance), + yes_no(record.data_processing_relevance), + html_escape(if record.affected_assets_summary.is_empty() { + "Nicht erfasst" + } else { + &record.affected_assets_summary + }), + html_escape(if record.impact_summary.is_empty() { + "Nicht erfasst" + } else { + &record.impact_summary + }), + html_escape(if record.remediation_summary.is_empty() { + "Nicht erfasst" + } else { + &record.remediation_summary + }), + html_escape(if record.workaround_summary.is_empty() { + "Nicht erfasst" + } else { + &record.workaround_summary + }), + html_escape(if record.open_actions.is_empty() { + "Keine offenen Massnahmen" + } else { + &record.open_actions + }), + supplier_product_security_reference_html(&record.sbom_vex_reference), + html_escape(if record.termination_risk_summary.is_empty() { + "Nicht erfasst" + } else { + &record.termination_risk_summary + }), + html_escape(if record.alternative_supplier_summary.is_empty() { + "Nicht erfasst" + } else { + &record.alternative_supplier_summary + }), + forms, + if evidence_rows.is_empty() { + web_empty_row(4, "Keine Evidence verknuepft.") + } else { + evidence_rows + }, + if history_rows.is_empty() { + web_empty_row(6, "Keine Contract-/Exit-Historie erfasst.") + } else { + history_rows + }, + if event_rows.is_empty() { + web_empty_row(4, "Keine Audit-Ereignisse vorhanden.") + } else { + event_rows + }, + ); + web_page( + "Supplier/Product Security", + "/suppliers/product-security/", + Some(&context), + &body, + ) + } + Ok(None) => web_error_page( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + "Supplier/Product-Security-Datensatz wurde fuer diesen Tenant nicht gefunden.", + ), + Err(_) => web_error_page( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + "Supplier/Product-Security-Detail konnte nicht gelesen werden.", + ), + } +} + +async fn web_supplier_product_security_update_submit( + Path(record_id): Path, + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + let auth_context = match authenticated_tenant_context(&state, &headers).await { + Ok(context) => context, + Err(_) => { + return web_missing_context( + "Supplier/Product Security", + "/suppliers/product-security/", + ) + .into_response(); + } + }; + let context = web_context_from_auth(&auth_context); + if !auth_context.can_write() { + return web_supplier_product_security_permission_error(&context).into_response(); + } + let Some(store) = state.supplier_product_security_store else { + return web_store_missing( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + "Supplier/Product Security", + ) + .into_response(); + }; + let payload = match web_supplier_product_security_patch_payload(form) { + Ok(payload) => payload, + Err(message) => { + return web_error_page( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + &message, + ) + .into_response(); + } + }; + match store + .update_record( + auth_context.tenant_id, + record_id, + auth_context.user_id, + payload, + ) + .await + { + Ok(Some(_)) => web_supplier_product_security_redirect(&context, record_id).into_response(), + Ok(None) => web_error_page( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + "Supplier/Product-Security-Datensatz wurde fuer diesen Tenant nicht gefunden.", + ) + .into_response(), + Err(err) => web_error_page( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + err.safe_message(), + ) + .into_response(), + } +} + +async fn web_supplier_product_security_status_submit( + Path(record_id): Path, + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + let auth_context = match authenticated_tenant_context(&state, &headers).await { + Ok(context) => context, + Err(_) => { + return web_missing_context( + "Supplier/Product Security", + "/suppliers/product-security/", + ) + .into_response(); + } + }; + let context = web_context_from_auth(&auth_context); + if !auth_context.can_write() { + return web_supplier_product_security_permission_error(&context).into_response(); + } + let Some(store) = state.supplier_product_security_store else { + return web_store_missing( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + "Supplier/Product Security", + ) + .into_response(); + }; + let payload = supplier_product_security_store::SupplierProductSecurityStatusRequest { + new_status: form.new_status, + reason: web_form_text(form.reason), + review_notes: web_form_text(form.review_notes), + }; + match store + .update_status( + auth_context.tenant_id, + record_id, + auth_context.user_id, + payload, + ) + .await + { + Ok(Some(_)) => web_supplier_product_security_redirect(&context, record_id).into_response(), + Ok(None) => web_error_page( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + "Supplier/Product-Security-Datensatz wurde fuer diesen Tenant nicht gefunden.", + ) + .into_response(), + Err(err) => web_error_page( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + err.safe_message(), + ) + .into_response(), + } +} + +async fn web_supplier_product_security_evidence_submit( + Path(record_id): Path, + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + let auth_context = match authenticated_tenant_context(&state, &headers).await { + Ok(context) => context, + Err(_) => { + return web_missing_context( + "Supplier/Product Security", + "/suppliers/product-security/", + ) + .into_response(); + } + }; + let context = web_context_from_auth(&auth_context); + if !auth_context.can_write() { + return web_supplier_product_security_permission_error(&context).into_response(); + } + let Some(store) = state.supplier_product_security_store else { + return web_store_missing( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + "Supplier/Product Security", + ) + .into_response(); + }; + let evidence_id = match parse_optional_i64(form.evidence_id.as_deref(), "Evidence-ID") { + Ok(Some(id)) => id, + Ok(None) => { + return web_error_page( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + "Evidence-ID ist erforderlich.", + ) + .into_response(); + } + Err(message) => { + return web_error_page( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + &message, + ) + .into_response(); + } + }; + let payload = supplier_product_security_store::SupplierProductSecurityEvidenceRequest { + evidence_id, + link_type: normalized_optional_form_text(form.link_type), + }; + match store + .link_evidence( + auth_context.tenant_id, + record_id, + auth_context.user_id, + payload, + ) + .await + { + Ok(Some(_)) => web_supplier_product_security_redirect(&context, record_id).into_response(), + Ok(None) => web_error_page( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + "Supplier/Product-Security-Datensatz wurde fuer diesen Tenant nicht gefunden.", + ) + .into_response(), + Err(err) => web_error_page( + "Supplier/Product Security", + "/suppliers/product-security/", + &context, + err.safe_message(), + ) + .into_response(), + } +} + async fn web_supplier_detail( Path(supplier_id): Path, State(state): State, @@ -18860,6 +20155,678 @@ fn supplier_detail_forms(context: &WebContext, supplier_id: i64) -> String { ) } +fn supplier_product_security_filter_panel(context: &WebContext, query: &WebContextQuery) -> String { + let action = web_path_with_context("/suppliers/product-security/", Some(context)); + format!( + r#" +
+

Filter

+
+ {} +
+ + + + + + + + + + +
+ +
+
+ "#, + html_escape(&action), + web_context_hidden_inputs(context), + html_escape( + &query + .supplier_id + .map(|id| id.to_string()) + .unwrap_or_default() + ), + html_escape(query.product.as_deref().unwrap_or_default()), + supplier_product_security_select_options( + query.status.as_deref().unwrap_or_default(), + &[ + ("draft", "Draft"), + ("needs_review", "Review erforderlich"), + ("in_review", "In Review"), + ("accepted_risk", "Risiko akzeptiert"), + ("remediation_required", "Remediation erforderlich"), + ("mitigated", "Mitigiert"), + ("closed", "Geschlossen"), + ("not_applicable", "Nicht anwendbar"), + ], + ), + supplier_product_security_select_options( + query.severity.as_deref().unwrap_or_default(), + &[ + ("critical", "Kritisch"), + ("high", "Hoch"), + ("medium", "Mittel"), + ("low", "Niedrig"), + ("info", "Info"), + ("unknown", "Unbekannt"), + ], + ), + bool_filter_options_for_query(query.dora_relevant.as_deref()), + bool_filter_options_for_query(query.nis2_relevant.as_deref()), + bool_filter_options_for_query(query.data_processing_relevant.as_deref()), + bool_filter_options_for_query(query.critical_services.as_deref()), + bool_filter_options_for_query(query.overdue.as_deref()), + html_escape(query.limit.as_deref().unwrap_or("50")), + ) +} + +fn supplier_product_security_create_panel(context: &WebContext) -> String { + let action = web_path_with_context("/suppliers/product-security/", Some(context)); + format!( + r#" +
+

Supplier/Product-Security-Datensatz anlegen

+
+
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + +
+ + + + +
+ +
+
+ "#, + html_escape(&action), + ) +} + +fn supplier_product_security_detail_forms( + context: &WebContext, + detail: &supplier_product_security_store::SupplierProductSecurityDetail, +) -> String { + let record = &detail.record; + let update_action = web_path_with_context( + &format!("/suppliers/product-security/{}/update", record.id), + Some(context), + ); + let status_action = web_path_with_context( + &format!("/suppliers/product-security/{}/status", record.id), + Some(context), + ); + let evidence_action = web_path_with_context( + &format!("/suppliers/product-security/{}/evidence", record.id), + Some(context), + ); + format!( + r#" +
+
+

Status auditiert setzen

+
+ + + + +
+
+
+

Evidence verknuepfen

+
+ + + +
+
+
+
+

Datensatz bearbeiten

+
+
+ + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + +
+ + + + +
+ +
+
+ "#, + html_escape(&status_action), + supplier_product_security_select_options( + &record.review_status, + &[ + ("draft", "Draft"), + ("needs_review", "Review erforderlich"), + ("in_review", "In Review"), + ("accepted_risk", "Risiko akzeptiert"), + ("remediation_required", "Remediation erforderlich"), + ("mitigated", "Mitigiert"), + ("closed", "Geschlossen"), + ("not_applicable", "Nicht anwendbar"), + ], + ), + html_escape(&evidence_action), + html_escape(&update_action), + html_escape( + &record + .product_id + .map(|id| id.to_string()) + .unwrap_or_default() + ), + html_escape(&record.product_or_service), + supplier_product_security_select_options( + &record.criticality, + &[ + ("critical", "Kritisch"), + ("high", "Hoch"), + ("medium", "Mittel"), + ("low", "Niedrig"), + ], + ), + supplier_product_security_select_options( + &record.severity, + &[ + ("critical", "Kritisch"), + ("high", "Hoch"), + ("medium", "Mittel"), + ("low", "Niedrig"), + ("info", "Info"), + ("unknown", "Unbekannt"), + ], + ), + html_escape(&record.owner), + html_escape(&record.internal_owner), + html_escape(&record.supplier_security_contact), + html_escape(record.due_date.as_deref().unwrap_or_default()), + html_escape(&record.advisory_id), + supplier_product_security_select_options( + &record.advisory_source_type, + &[ + ("manual", "Manuell"), + ("psirt", "PSIRT"), + ("vendor", "Hersteller"), + ("customer", "Kunde"), + ("internal", "Intern"), + ("import_preparation", "Import-Vorbereitung"), + ], + ), + html_escape( + &record + .cvss_score + .map(|value| value.to_string()) + .unwrap_or_default() + ), + html_escape( + &record + .epss_score + .map(|value| value.to_string()) + .unwrap_or_default() + ), + supplier_product_security_select_options( + &record.exploitation_status, + &[ + ("unknown", "Unbekannt"), + ("not_observed", "Nicht beobachtet"), + ("proof_of_concept", "Proof of Concept"), + ("active", "Aktiv"), + ("exploited", "Ausgenutzt"), + ], + ), + supplier_product_security_select_options( + &record.contract_status, + &[ + ("not_recorded", "Nicht erfasst"), + ("draft", "Draft"), + ("active", "Aktiv"), + ("under_review", "In Pruefung"), + ("renewal_due", "Verlaengerung faellig"), + ("terminated", "Beendet"), + ("exit_required", "Exit erforderlich"), + ], + ), + html_escape(record.contract_review_date.as_deref().unwrap_or_default()), + html_escape( + record + .next_contract_review_due + .as_deref() + .unwrap_or_default() + ), + supplier_product_security_select_options( + &record.exit_plan_status, + &[ + ("not_recorded", "Nicht erfasst"), + ("draft", "Draft"), + ("planned", "Geplant"), + ("tested", "Getestet"), + ("needs_update", "Update erforderlich"), + ("not_required", "Nicht erforderlich"), + ], + ), + html_escape(&record.exit_plan_version), + html_escape(record.exit_plan_review_date.as_deref().unwrap_or_default()), + html_escape(&record.exit_plan_owner), + html_escape(&record.advisory_reference), + html_escape(&record.cve_ids.join(", ")), + html_escape(&record.affected_versions), + html_escape(&record.fixed_versions), + html_escape(&record.affected_assets_summary), + html_escape(&record.impact_summary), + html_escape(&record.remediation_summary), + html_escape(&record.workaround_summary), + html_escape(&record.open_actions), + html_escape( + &record + .evidence_ids + .iter() + .map(i64::to_string) + .collect::>() + .join(", "), + ), + html_escape(&record.sbom_vex_reference), + html_escape(&record.management_review_reference), + html_escape(&record.termination_risk_summary), + html_escape(&record.alternative_supplier_summary), + checked_attr(record.critical_service_dependency), + checked_attr(record.data_processing_relevance), + checked_attr(record.dora_ict_third_party_relevance), + checked_attr(record.nis2_supply_chain_relevance), + ) +} + +fn web_supplier_product_security_create_payload( + form: WebSupplierProductSecurityCreateForm, +) -> Result { + let supplier_id = parse_optional_i64(Some(form.supplier_id.as_str()), "Supplier-ID")? + .ok_or_else(|| "Supplier-ID ist erforderlich.".to_string())?; + Ok( + supplier_product_security_store::SupplierProductSecurityWriteRequest { + supplier_id, + product_id: parse_optional_i64(form.product_id.as_deref(), "Produkt-ID")?, + product_or_service: form.product_or_service, + criticality: normalized_optional_form_text(form.criticality), + internal_owner: web_form_text(form.internal_owner), + supplier_security_contact: web_form_text(form.supplier_security_contact), + product_security_status: normalized_optional_form_text(form.product_security_status), + advisory_id: web_form_text(form.advisory_id), + advisory_source_type: normalized_optional_form_text(form.advisory_source_type), + advisory_reference: web_form_text(form.advisory_reference), + cve_ids: parse_text_list(form.cve_ids.as_deref()), + affected_versions: web_form_text(form.affected_versions), + fixed_versions: web_form_text(form.fixed_versions), + severity: normalized_optional_form_text(form.severity), + cvss_score: parse_optional_f64(form.cvss_score.as_deref(), "CVSS")?, + epss_score: parse_optional_f64(form.epss_score.as_deref(), "EPSS")?, + exploitation_status: normalized_optional_form_text(form.exploitation_status), + affected_assets_summary: web_form_text(form.affected_assets_summary), + impact_summary: web_form_text(form.impact_summary), + remediation_summary: web_form_text(form.remediation_summary), + workaround_summary: web_form_text(form.workaround_summary), + review_status: normalized_optional_form_text(form.review_status), + owner: web_form_text(form.owner), + due_date: normalized_optional_form_text(form.due_date), + evidence_ids: parse_id_list(form.evidence_ids.as_deref(), "Evidence-IDs")?, + sbom_vex_reference: web_form_text(form.sbom_vex_reference), + open_actions: web_form_text(form.open_actions), + management_review_reference: web_form_text(form.management_review_reference), + contract_status: normalized_optional_form_text(form.contract_status), + contract_review_date: normalized_optional_form_text(form.contract_review_date), + next_contract_review_due: normalized_optional_form_text(form.next_contract_review_due), + exit_plan_status: normalized_optional_form_text(form.exit_plan_status), + exit_plan_version: web_form_text(form.exit_plan_version), + exit_plan_review_date: normalized_optional_form_text(form.exit_plan_review_date), + exit_plan_owner: web_form_text(form.exit_plan_owner), + critical_service_dependency: Some(form_checkbox_value( + form.critical_service_dependency, + )), + data_processing_relevance: Some(form_checkbox_value(form.data_processing_relevance)), + dora_ict_third_party_relevance: Some(form_checkbox_value( + form.dora_ict_third_party_relevance, + )), + nis2_supply_chain_relevance: Some(form_checkbox_value( + form.nis2_supply_chain_relevance, + )), + termination_risk_summary: web_form_text(form.termination_risk_summary), + alternative_supplier_summary: web_form_text(form.alternative_supplier_summary), + change_reason: web_form_text(form.change_reason), + }, + ) +} + +fn web_supplier_product_security_patch_payload( + form: WebSupplierProductSecurityPatchForm, +) -> Result { + Ok( + supplier_product_security_store::SupplierProductSecurityPatchRequest { + product_id: parse_optional_i64(form.product_id.as_deref(), "Produkt-ID")?, + product_or_service: normalized_optional_form_text(form.product_or_service), + criticality: normalized_optional_form_text(form.criticality), + internal_owner: normalized_optional_form_text(form.internal_owner), + supplier_security_contact: normalized_optional_form_text( + form.supplier_security_contact, + ), + product_security_status: normalized_optional_form_text(form.product_security_status), + advisory_id: normalized_optional_form_text(form.advisory_id), + advisory_source_type: normalized_optional_form_text(form.advisory_source_type), + advisory_reference: normalized_optional_form_text(form.advisory_reference), + cve_ids: normalized_optional_form_text(form.cve_ids) + .map(|value| parse_text_list(Some(&value))), + affected_versions: normalized_optional_form_text(form.affected_versions), + fixed_versions: normalized_optional_form_text(form.fixed_versions), + severity: normalized_optional_form_text(form.severity), + cvss_score: parse_optional_f64(form.cvss_score.as_deref(), "CVSS")?, + epss_score: parse_optional_f64(form.epss_score.as_deref(), "EPSS")?, + exploitation_status: normalized_optional_form_text(form.exploitation_status), + affected_assets_summary: normalized_optional_form_text(form.affected_assets_summary), + impact_summary: normalized_optional_form_text(form.impact_summary), + remediation_summary: normalized_optional_form_text(form.remediation_summary), + workaround_summary: normalized_optional_form_text(form.workaround_summary), + review_status: None, + owner: normalized_optional_form_text(form.owner), + due_date: form.due_date, + evidence_ids: Some(parse_id_list(form.evidence_ids.as_deref(), "Evidence-IDs")?), + sbom_vex_reference: normalized_optional_form_text(form.sbom_vex_reference), + open_actions: normalized_optional_form_text(form.open_actions), + management_review_reference: normalized_optional_form_text( + form.management_review_reference, + ), + contract_status: normalized_optional_form_text(form.contract_status), + contract_review_date: form.contract_review_date, + next_contract_review_due: form.next_contract_review_due, + exit_plan_status: normalized_optional_form_text(form.exit_plan_status), + exit_plan_version: normalized_optional_form_text(form.exit_plan_version), + exit_plan_review_date: form.exit_plan_review_date, + exit_plan_owner: normalized_optional_form_text(form.exit_plan_owner), + critical_service_dependency: Some(form_checkbox_value( + form.critical_service_dependency, + )), + data_processing_relevance: Some(form_checkbox_value(form.data_processing_relevance)), + dora_ict_third_party_relevance: Some(form_checkbox_value( + form.dora_ict_third_party_relevance, + )), + nis2_supply_chain_relevance: Some(form_checkbox_value( + form.nis2_supply_chain_relevance, + )), + termination_risk_summary: normalized_optional_form_text(form.termination_risk_summary), + alternative_supplier_summary: normalized_optional_form_text( + form.alternative_supplier_summary, + ), + change_reason: normalized_optional_form_text(form.change_reason), + }, + ) +} + +fn web_supplier_product_security_redirect(context: &WebContext, record_id: i64) -> Response { + let path = web_path_with_context( + &format!("/suppliers/product-security/{record_id}/"), + Some(context), + ); + Redirect::to(&path).into_response() +} + +fn web_supplier_product_security_permission_error(context: &WebContext) -> Html { + web_error_page( + "Supplier/Product Security", + "/suppliers/product-security/", + context, + "Diese Rust-Webroute benoetigt eine schreibende ISCY-Rolle.", + ) +} + +fn web_form_text(value: Option) -> String { + normalized_optional_form_text(value).unwrap_or_default() +} + +fn parse_text_list(value: Option<&str>) -> Vec { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return Vec::new(); + }; + let mut values = Vec::new(); + for item in value.split([',', ';', '\n', '\r', '\t', ' ']) { + let item = item.trim(); + if item.is_empty() { + continue; + } + let normalized = item.to_ascii_uppercase(); + if !values.iter().any(|existing| existing == &normalized) { + values.push(normalized); + } + } + values +} + +fn parse_optional_f64(value: Option<&str>, field_name: &str) -> Result, String> { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + value + .replace(',', ".") + .parse::() + .map(Some) + .map_err(|_| format!("{field_name} muss eine Zahl sein.")) +} + +fn supplier_product_security_select_options(selected: &str, options: &[(&str, &str)]) -> String { + let selected = selected.trim().replace('-', "_").to_ascii_lowercase(); + options + .iter() + .map(|(value, label)| { + let option_selected = selected == *value; + format!( + r#""#, + html_escape(value), + selected_attr(option_selected), + html_escape(label), + ) + }) + .collect::>() + .join("") +} + +fn bool_filter_options_for_query(selected: Option<&str>) -> String { + let normalized = selected + .map(|value| value.trim().to_ascii_lowercase()) + .unwrap_or_default(); + [("", "Alle"), ("true", "Ja"), ("false", "Nein")] + .iter() + .map(|(value, label)| { + let selected = normalized == *value + || (normalized == "1" && *value == "true") + || (normalized == "0" && *value == "false"); + format!( + r#""#, + value, + selected_attr(selected), + label, + ) + }) + .collect::>() + .join("") +} + +fn supplier_product_security_reference_html(reference: &str) -> String { + let reference = reference.trim(); + if reference.is_empty() { + return "Nicht erfasst".to_string(); + } + let lower = reference.to_ascii_lowercase(); + if lower.starts_with("https://") || lower.starts_with("http://") { + return format!( + r#"{}"#, + html_escape(reference), + html_escape(reference), + ); + } + html_escape(reference) +} + +fn supplier_product_security_cve_badges(cves: &[String]) -> String { + if cves.is_empty() { + return "Nicht erfasst".to_string(); + } + cves.iter() + .map(|cve| web_badge(cve, "info")) + .collect::>() + .join(" ") +} + +fn supplier_product_security_scope_badges( + record: &supplier_product_security_store::SupplierProductSecurityRecord, +) -> String { + let mut badges = Vec::new(); + if record.dora_ict_third_party_relevance { + badges.push(web_badge("DORA", "info")); + } + if record.nis2_supply_chain_relevance { + badges.push(web_badge("NIS2", "info")); + } + if record.data_processing_relevance { + badges.push(web_badge("DSGVO/Daten", "info")); + } + if record.critical_service_dependency { + badges.push(web_badge("Kritischer Service", "warn")); + } + if badges.is_empty() { + "Nicht erfasst".to_string() + } else { + badges.join(" ") + } +} + +fn supplier_product_security_criticality_badge_class(criticality: &str) -> &'static str { + match criticality + .trim() + .replace('-', "_") + .to_ascii_lowercase() + .as_str() + { + "critical" => "danger", + "high" => "high", + "medium" => "warn", + "low" => "info", + _ => "muted-badge", + } +} + +fn supplier_product_security_review_badge_class(status: &str) -> &'static str { + match status + .trim() + .replace('-', "_") + .to_ascii_lowercase() + .as_str() + { + "closed" | "mitigated" | "not_applicable" => "ok", + "in_review" | "accepted_risk" => "info", + "needs_review" | "remediation_required" => "warn", + "draft" => "muted-badge", + _ => "muted-badge", + } +} + +fn supplier_product_security_contract_badge_class(status: &str) -> &'static str { + match status + .trim() + .replace('-', "_") + .to_ascii_lowercase() + .as_str() + { + "active" => "ok", + "under_review" | "renewal_due" => "warn", + "terminated" | "exit_required" => "danger", + _ => "muted-badge", + } +} + +fn supplier_product_security_exit_badge_class(status: &str) -> &'static str { + match status + .trim() + .replace('-', "_") + .to_ascii_lowercase() + .as_str() + { + "tested" | "not_required" => "ok", + "planned" => "info", + "needs_update" => "warn", + _ => "muted-badge", + } +} + fn web_supplier_create_payload( form: WebSupplierCreateForm, ) -> Result { @@ -29134,6 +31101,7 @@ fn web_page( ("/regulatory-review-packs/", "Review-Pakete"), ("/assets/", "Assets"), ("/suppliers/", "Suppliers"), + ("/suppliers/product-security/", "Supplier/Product Security"), ("/imports/", "Imports"), ("/processes/", "Processes"), ("/ai-governance/", "AI Governance"), @@ -29231,6 +31199,14 @@ fn web_page( table {{ width:100%; min-width:720px; border-collapse:collapse; }} th, td {{ padding:10px 8px; border-bottom:1px solid var(--line); text-align:left; vertical-align:top; overflow-wrap:anywhere; }} th {{ color:var(--muted); font-size:12px; text-transform:uppercase; }} + .wide-table {{ min-width:1180px; }} + .wide-table th, .wide-table td {{ overflow-wrap:normal; word-break:normal; }} + .wide-table td {{ overflow-wrap:anywhere; }} + .supplier-product-security-table {{ min-width:1320px; }} + .supplier-product-security-table th {{ white-space:nowrap; }} + .supplier-product-security-table td:first-child {{ min-width:230px; }} + .supplier-product-security-table td:nth-child(7), + .supplier-product-security-table td:nth-child(10) {{ min-width:160px; }} td a {{ color:var(--accent); text-decoration:none; }} pre {{ white-space:pre-wrap; overflow-wrap:anywhere; }} .badge {{ display:inline-flex; align-items:center; min-height:24px; padding:3px 8px; border-radius:999px; border:1px solid transparent; background:var(--soft); color:var(--ink); font-size:12px; font-weight:800; line-height:1.2; white-space:nowrap; }} @@ -32210,6 +34186,37 @@ fn management_review_json_label(key: &str) -> String { "open_roadmap_tasks" => "Offene Roadmap-Tasks".to_string(), "critical_suppliers" => "Kritische Supplier".to_string(), "overdue_supplier_reviews" => "Ueberfaellige Supplier-Reviews".to_string(), + "supplier_product_security_records" => "Supplier/Product-Security-Datensaetze".to_string(), + "open_supplier_product_security_advisories" => { + "Offene Supplier/Product-Security-Advisorys".to_string() + } + "critical_supplier_product_security_advisories" => { + "Kritische Supplier/Product-Security-Advisorys".to_string() + } + "supplier_product_security_missing_evidence" => { + "Fehlende Supplier/Product-Security-Evidence".to_string() + } + "supplier_product_security_missing_owner" => { + "Fehlende Supplier/Product-Security-Owner".to_string() + } + "supplier_product_security_open_actions" => { + "Offene Supplier/Product-Security-Massnahmen".to_string() + } + "supplier_product_security_overdue_reviews" => { + "Ueberfaellige Supplier/Product-Security-Reviews".to_string() + } + "supplier_product_security_dora_relevant" => { + "DORA-relevante Supplier/Product-Security-Daten".to_string() + } + "supplier_product_security_nis2_relevant" => { + "NIS2-relevante Supplier/Product-Security-Daten".to_string() + } + "supplier_product_security_data_processing_relevant" => { + "Supplier/Product Security mit Datenbezug".to_string() + } + "supplier_product_security_critical_services" => { + "Kritische Supplier/Product-Security-Services".to_string() + } "evidence_integrity_not_checked" => "Evidence-Integritaet nicht geprueft".to_string(), "evidence_integrity_valid" => "Evidence-Integritaet gueltig".to_string(), "evidence_integrity_mismatch" => "Evidence-Integritaetsabweichungen".to_string(), @@ -32710,6 +34717,23 @@ fn web_path_with_context(path: &str, context: Option<&WebContext>) -> String { ) } +fn web_context_hidden_inputs(context: &WebContext) -> String { + let email = context + .user_email + .as_ref() + .map(|email| { + format!( + r#""#, + html_escape(email), + ) + }) + .unwrap_or_default(); + format!( + r#"{}"#, + context.tenant_id, context.user_id, email, + ) +} + fn evidence_prefill_href( context: &WebContext, title: &str, @@ -33634,10 +35658,38 @@ pub fn app_router_with_state(state: AppState) -> Router { get(agent_device_findings).post(agent_findings), ) .route("/api/v1/assets/information-assets", get(asset_inventory)) + .route( + "/api/v1/suppliers/product-security", + get(supplier_product_security_overview).post(supplier_product_security_create), + ) + .route( + "/api/v1/suppliers/product-security/{record_id}", + get(supplier_product_security_detail).patch(supplier_product_security_update), + ) + .route( + "/api/v1/suppliers/product-security/{record_id}/status", + post(supplier_product_security_status_update), + ) + .route( + "/api/v1/suppliers/product-security/{record_id}/evidence", + post(supplier_product_security_evidence_link), + ) + .route( + "/api/v1/suppliers/product-security/{record_id}/events", + get(supplier_product_security_events), + ) .route( "/api/v1/suppliers", get(supplier_risk_overview).post(supplier_create), ) + .route( + "/api/v1/suppliers/{supplier_id}/product-security", + get(supplier_product_security_by_supplier), + ) + .route( + "/api/v1/suppliers/{supplier_id}/contract-exit-history", + get(supplier_contract_exit_history), + ) .route( "/api/v1/suppliers/{supplier_id}", get(supplier_risk_detail).patch(supplier_update), @@ -34187,6 +36239,26 @@ pub fn app_router_with_state(state: AppState) -> Router { ) .route("/assets/", get(web_assets)) .route("/suppliers/", get(web_suppliers).post(web_suppliers_submit)) + .route( + "/suppliers/product-security/", + get(web_supplier_product_security).post(web_supplier_product_security_submit), + ) + .route( + "/suppliers/product-security/{record_id}/", + get(web_supplier_product_security_detail), + ) + .route( + "/suppliers/product-security/{record_id}/update", + post(web_supplier_product_security_update_submit), + ) + .route( + "/suppliers/product-security/{record_id}/status", + post(web_supplier_product_security_status_submit), + ) + .route( + "/suppliers/product-security/{record_id}/evidence", + post(web_supplier_product_security_evidence_submit), + ) .route("/suppliers/{supplier_id}/", get(web_supplier_detail)) .route( "/suppliers/{supplier_id}/reviews", diff --git a/rust/iscy-backend/src/main.rs b/rust/iscy-backend/src/main.rs index 979c33f..3386776 100644 --- a/rust/iscy-backend/src/main.rs +++ b/rust/iscy-backend/src/main.rs @@ -32,6 +32,7 @@ use iscy_backend::{ risk_store::RiskStore, roadmap_store::RoadmapStore, security_store::SecurityStore, + supplier_product_security_store::SupplierProductSecurityStore, supplier_store::SupplierStore, tenant_store::TenantStore, wizard_store::WizardStore, @@ -104,6 +105,7 @@ async fn main() -> anyhow::Result<()> { roadmap_store, security_store, supplier_store, + supplier_product_security_store, wizard_store, product_security_store, ai_governance_store, @@ -131,6 +133,8 @@ async fn main() -> anyhow::Result<()> { let roadmap_store = RoadmapStore::connect(database_url).await?; let security_store = SecurityStore::connect(database_url).await?; let supplier_store = SupplierStore::connect(database_url).await?; + let supplier_product_security_store = + SupplierProductSecurityStore::connect(database_url).await?; let wizard_store = WizardStore::connect(database_url).await?; let product_security_store = ProductSecurityStore::connect(database_url).await?; let ai_governance_store = AiGovernanceStore::connect(database_url).await?; @@ -157,6 +161,7 @@ async fn main() -> anyhow::Result<()> { Some(roadmap_store), Some(security_store), Some(supplier_store), + Some(supplier_product_security_store), Some(wizard_store), Some(product_security_store), Some(ai_governance_store), @@ -164,7 +169,7 @@ async fn main() -> anyhow::Result<()> { } _ => ( None, None, None, None, None, None, None, None, None, None, None, None, None, None, - None, None, None, None, None, None, None, None, None, None, None, + None, None, None, None, None, None, None, None, None, None, None, None, ), }; let notification_worker_store = agent_governance_store.clone(); @@ -190,6 +195,7 @@ async fn main() -> anyhow::Result<()> { .with_roadmap_store(roadmap_store) .with_security_store(security_store) .with_supplier_store(supplier_store) + .with_supplier_product_security_store(supplier_product_security_store) .with_wizard_store(wizard_store) .with_product_security_store(product_security_store) .with_ai_governance_store(ai_governance_store) diff --git a/rust/iscy-backend/src/report_store.rs b/rust/iscy-backend/src/report_store.rs index 9206fda..9fd9c7c 100644 --- a/rust/iscy-backend/src/report_store.rs +++ b/rust/iscy-backend/src/report_store.rs @@ -2209,7 +2209,18 @@ async fn product_security_postgres(pool: &PgPool, tenant_id: i64) -> anyhow::Res "open_vulnerabilities": count_postgres(pool, "SELECT COUNT(*)::bigint AS count_value FROM product_security_vulnerability WHERE tenant_id = $1 AND status NOT IN ('FIXED', 'CLOSED', 'RESOLVED')", tenant_id).await?, "critical_open_vulnerabilities": count_postgres(pool, "SELECT COUNT(*)::bigint AS count_value FROM product_security_vulnerability WHERE tenant_id = $1 AND severity = 'CRITICAL' AND status NOT IN ('FIXED', 'CLOSED', 'RESOLVED')", tenant_id).await?, "open_cve_correlation_reviews": count_postgres(pool, "SELECT COUNT(*)::bigint AS count_value FROM product_security_cvecorrelation WHERE tenant_id = $1 AND status = 'SUGGESTED'", tenant_id).await?, - "invalid_imports": count_postgres(pool, "SELECT COUNT(*)::bigint AS count_value FROM product_security_importartifact WHERE tenant_id = $1 AND validation_status NOT IN ('VALID', 'VALIDATED')", tenant_id).await? + "invalid_imports": count_postgres(pool, "SELECT COUNT(*)::bigint AS count_value FROM product_security_importartifact WHERE tenant_id = $1 AND validation_status NOT IN ('VALID', 'VALIDATED')", tenant_id).await?, + "supplier_product_security_records": count_postgres(pool, "SELECT COUNT(*)::bigint AS count_value FROM supplier_product_security_record WHERE tenant_id = $1", tenant_id).await?, + "open_supplier_product_security_advisories": count_postgres(pool, "SELECT COUNT(*)::bigint AS count_value FROM supplier_product_security_record WHERE tenant_id = $1 AND review_status NOT IN ('closed', 'mitigated', 'not_applicable')", tenant_id).await?, + "critical_supplier_product_security_advisories": count_postgres(pool, "SELECT COUNT(*)::bigint AS count_value FROM supplier_product_security_record WHERE tenant_id = $1 AND (severity = 'critical' OR criticality = 'critical') AND review_status NOT IN ('closed', 'mitigated', 'not_applicable')", tenant_id).await?, + "supplier_product_security_missing_evidence": count_postgres(pool, "SELECT COUNT(*)::bigint AS count_value FROM supplier_product_security_record WHERE tenant_id = $1 AND review_status NOT IN ('closed', 'mitigated', 'not_applicable') AND (BTRIM(evidence_ids_json) = '[]' OR evidence_ids_json IS NULL)", tenant_id).await?, + "supplier_product_security_missing_owner": count_postgres(pool, "SELECT COUNT(*)::bigint AS count_value FROM supplier_product_security_record WHERE tenant_id = $1 AND BTRIM(owner) = '' AND BTRIM(internal_owner) = ''", tenant_id).await?, + "supplier_product_security_open_actions": count_postgres(pool, "SELECT COUNT(*)::bigint AS count_value FROM supplier_product_security_record WHERE tenant_id = $1 AND review_status NOT IN ('closed', 'mitigated', 'not_applicable') AND BTRIM(open_actions) <> ''", tenant_id).await?, + "supplier_product_security_overdue_reviews": count_postgres(pool, "SELECT COUNT(*)::bigint AS count_value FROM supplier_product_security_record WHERE tenant_id = $1 AND due_date IS NOT NULL AND due_date::text < CURRENT_DATE::text AND review_status NOT IN ('closed', 'mitigated', 'not_applicable')", tenant_id).await?, + "supplier_product_security_dora_relevant": count_postgres(pool, "SELECT COUNT(*)::bigint AS count_value FROM supplier_product_security_record WHERE tenant_id = $1 AND dora_ict_third_party_relevance = TRUE", tenant_id).await?, + "supplier_product_security_nis2_relevant": count_postgres(pool, "SELECT COUNT(*)::bigint AS count_value FROM supplier_product_security_record WHERE tenant_id = $1 AND nis2_supply_chain_relevance = TRUE", tenant_id).await?, + "supplier_product_security_data_processing_relevant": count_postgres(pool, "SELECT COUNT(*)::bigint AS count_value FROM supplier_product_security_record WHERE tenant_id = $1 AND data_processing_relevance = TRUE", tenant_id).await?, + "supplier_product_security_critical_services": count_postgres(pool, "SELECT COUNT(*)::bigint AS count_value FROM supplier_product_security_record WHERE tenant_id = $1 AND critical_service_dependency = TRUE", tenant_id).await? })) } @@ -2219,7 +2230,18 @@ async fn product_security_sqlite(pool: &SqlitePool, tenant_id: i64) -> anyhow::R "open_vulnerabilities": count_sqlite(pool, "SELECT COUNT(*) AS count_value FROM product_security_vulnerability WHERE tenant_id = ? AND status NOT IN ('FIXED', 'CLOSED', 'RESOLVED')", tenant_id).await?, "critical_open_vulnerabilities": count_sqlite(pool, "SELECT COUNT(*) AS count_value FROM product_security_vulnerability WHERE tenant_id = ? AND severity = 'CRITICAL' AND status NOT IN ('FIXED', 'CLOSED', 'RESOLVED')", tenant_id).await?, "open_cve_correlation_reviews": count_sqlite(pool, "SELECT COUNT(*) AS count_value FROM product_security_cvecorrelation WHERE tenant_id = ? AND status = 'SUGGESTED'", tenant_id).await?, - "invalid_imports": count_sqlite(pool, "SELECT COUNT(*) AS count_value FROM product_security_importartifact WHERE tenant_id = ? AND validation_status NOT IN ('VALID', 'VALIDATED')", tenant_id).await? + "invalid_imports": count_sqlite(pool, "SELECT COUNT(*) AS count_value FROM product_security_importartifact WHERE tenant_id = ? AND validation_status NOT IN ('VALID', 'VALIDATED')", tenant_id).await?, + "supplier_product_security_records": count_sqlite(pool, "SELECT COUNT(*) AS count_value FROM supplier_product_security_record WHERE tenant_id = ?", tenant_id).await?, + "open_supplier_product_security_advisories": count_sqlite(pool, "SELECT COUNT(*) AS count_value FROM supplier_product_security_record WHERE tenant_id = ? AND review_status NOT IN ('closed', 'mitigated', 'not_applicable')", tenant_id).await?, + "critical_supplier_product_security_advisories": count_sqlite(pool, "SELECT COUNT(*) AS count_value FROM supplier_product_security_record WHERE tenant_id = ? AND (severity = 'critical' OR criticality = 'critical') AND review_status NOT IN ('closed', 'mitigated', 'not_applicable')", tenant_id).await?, + "supplier_product_security_missing_evidence": count_sqlite(pool, "SELECT COUNT(*) AS count_value FROM supplier_product_security_record WHERE tenant_id = ? AND review_status NOT IN ('closed', 'mitigated', 'not_applicable') AND (TRIM(evidence_ids_json) = '[]' OR evidence_ids_json IS NULL)", tenant_id).await?, + "supplier_product_security_missing_owner": count_sqlite(pool, "SELECT COUNT(*) AS count_value FROM supplier_product_security_record WHERE tenant_id = ? AND TRIM(owner) = '' AND TRIM(internal_owner) = ''", tenant_id).await?, + "supplier_product_security_open_actions": count_sqlite(pool, "SELECT COUNT(*) AS count_value FROM supplier_product_security_record WHERE tenant_id = ? AND review_status NOT IN ('closed', 'mitigated', 'not_applicable') AND TRIM(open_actions) <> ''", tenant_id).await?, + "supplier_product_security_overdue_reviews": count_sqlite(pool, "SELECT COUNT(*) AS count_value FROM supplier_product_security_record WHERE tenant_id = ? AND due_date IS NOT NULL AND CAST(due_date AS TEXT) < date('now') AND review_status NOT IN ('closed', 'mitigated', 'not_applicable')", tenant_id).await?, + "supplier_product_security_dora_relevant": count_sqlite(pool, "SELECT COUNT(*) AS count_value FROM supplier_product_security_record WHERE tenant_id = ? AND dora_ict_third_party_relevance = 1", tenant_id).await?, + "supplier_product_security_nis2_relevant": count_sqlite(pool, "SELECT COUNT(*) AS count_value FROM supplier_product_security_record WHERE tenant_id = ? AND nis2_supply_chain_relevance = 1", tenant_id).await?, + "supplier_product_security_data_processing_relevant": count_sqlite(pool, "SELECT COUNT(*) AS count_value FROM supplier_product_security_record WHERE tenant_id = ? AND data_processing_relevance = 1", tenant_id).await?, + "supplier_product_security_critical_services": count_sqlite(pool, "SELECT COUNT(*) AS count_value FROM supplier_product_security_record WHERE tenant_id = ? AND critical_service_dependency = 1", tenant_id).await? })) } @@ -2651,6 +2673,7 @@ fn management_review_source_counts( "roadmap_tasks": counts.roadmap, "product_security_products": json_i64(product_security, "products"), "product_security_open_vulnerabilities": json_i64(product_security, "open_vulnerabilities"), + "supplier_product_security_records": json_i64(product_security, "supplier_product_security_records"), "suppliers": counts.suppliers, "agent_devices": json_i64(agent_posture, "devices"), "ai_governance_systems": counts.ai_governance, @@ -2662,6 +2685,7 @@ fn management_review_source_counts( "incidents": counts.incidents == 0, "roadmap_tasks": counts.roadmap == 0, "suppliers": counts.suppliers == 0, + "supplier_product_security": json_i64(product_security, "supplier_product_security_records") == 0, "ai_governance": counts.ai_governance == 0 } }) @@ -2684,6 +2708,16 @@ fn management_review_gap_summary( "critical_open_vulnerabilities": json_i64(product_security, "critical_open_vulnerabilities"), "open_cve_correlation_reviews": json_i64(product_security, "open_cve_correlation_reviews"), "invalid_product_security_imports": json_i64(product_security, "invalid_imports"), + "open_supplier_product_security_advisories": json_i64(product_security, "open_supplier_product_security_advisories"), + "critical_supplier_product_security_advisories": json_i64(product_security, "critical_supplier_product_security_advisories"), + "supplier_product_security_missing_evidence": json_i64(product_security, "supplier_product_security_missing_evidence"), + "supplier_product_security_missing_owner": json_i64(product_security, "supplier_product_security_missing_owner"), + "supplier_product_security_open_actions": json_i64(product_security, "supplier_product_security_open_actions"), + "supplier_product_security_overdue_reviews": json_i64(product_security, "supplier_product_security_overdue_reviews"), + "supplier_product_security_dora_relevant": json_i64(product_security, "supplier_product_security_dora_relevant"), + "supplier_product_security_nis2_relevant": json_i64(product_security, "supplier_product_security_nis2_relevant"), + "supplier_product_security_data_processing_relevant": json_i64(product_security, "supplier_product_security_data_processing_relevant"), + "supplier_product_security_critical_services": json_i64(product_security, "supplier_product_security_critical_services"), "evidence_integrity_not_checked": json_i64(metrics, "evidence_integrity_not_checked"), "evidence_integrity_mismatch": json_i64(metrics, "evidence_integrity_mismatch"), "evidence_storage_artifact_references": json_i64(metrics, "evidence_storage_artifact_references"), @@ -2751,11 +2785,36 @@ fn review_gap_groups(template_type: &str, gap_summary: &Value) -> Value { "Supplier- und Supply-Chain-Gaps", &[ ("Kritische Supplier", "critical_suppliers", true), + ( + "Offene Supplier/Product-Security-Advisorys", + "open_supplier_product_security_advisories", + true, + ), + ( + "Kritische Lieferantenabhaengigkeiten", + "supplier_product_security_critical_services", + true, + ), + ( + "Offene Supplier/Product-Security-Massnahmen", + "supplier_product_security_open_actions", + false, + ), ( "Fehlende Supplier-Evidence", "missing_supplier_evidence", false, ), + ( + "Fehlende Supplier/Product-Security-Evidence", + "supplier_product_security_missing_evidence", + false, + ), + ( + "Fehlende Supplier/Product-Security-Owner", + "supplier_product_security_missing_owner", + false, + ), ( "Ueberfaellige oder nicht bewertete Supplier", "overdue_or_unreviewed_suppliers", @@ -2783,6 +2842,21 @@ fn review_gap_groups(template_type: &str, gap_summary: &Value) -> Value { "ICT-Third-Party-Gaps", &[ ("Kritische Supplier", "critical_suppliers", true), + ( + "DORA-relevante Supplier/Product-Security-Datensaetze", + "supplier_product_security_dora_relevant", + false, + ), + ( + "Kritische ICT-Dienstleister / Services", + "supplier_product_security_critical_services", + true, + ), + ( + "Offene Supplier/Product-Security-Advisorys", + "open_supplier_product_security_advisories", + true, + ), ( "Ueberfaellige oder nicht bewertete Supplier", "overdue_or_unreviewed_suppliers", @@ -2798,6 +2872,11 @@ fn review_gap_groups(template_type: &str, gap_summary: &Value) -> Value { ( "Exit-, Contract- und Review-Gaps", &[ + ( + "Ueberfaellige Supplier/Product-Security-Reviews", + "supplier_product_security_overdue_reviews", + false, + ), ( "Ueberfaellige oder nicht bewertete Supplier", "overdue_or_unreviewed_suppliers", @@ -2859,11 +2938,31 @@ fn review_gap_groups(template_type: &str, gap_summary: &Value) -> Value { "Supplier mit Datenbezug", &[ ("Kritische Supplier", "critical_suppliers", true), + ( + "Supplier/Product Security mit Datenbezug", + "supplier_product_security_data_processing_relevant", + false, + ), + ( + "Offene Supplier-Advisorys mit moeglichem Datenbezug", + "open_supplier_product_security_advisories", + true, + ), ( "Fehlende Supplier-Evidence", "missing_supplier_evidence", false, ), + ( + "Fehlende Supplier/Product-Security-Evidence", + "supplier_product_security_missing_evidence", + false, + ), + ( + "Fehlende Owner oder Reviews", + "supplier_product_security_missing_owner", + false, + ), ( "Ueberfaellige oder nicht bewertete Supplier", "overdue_or_unreviewed_suppliers", @@ -2925,6 +3024,31 @@ fn review_gap_groups(template_type: &str, gap_summary: &Value) -> Value { ), ], ), + ( + "Supplier/Product Security", + &[ + ( + "Offene Advisorys", + "open_supplier_product_security_advisories", + true, + ), + ( + "Offene Massnahmen", + "supplier_product_security_open_actions", + false, + ), + ( + "Fehlende Evidence", + "supplier_product_security_missing_evidence", + false, + ), + ( + "Ueberfaellige Reviews", + "supplier_product_security_overdue_reviews", + false, + ), + ], + ), ( "Product Security", &[ @@ -3059,8 +3183,10 @@ fn review_owner_hints( ), owner_hint( "Product Security / PSIRT", - if json_i64(product_security, "products") > 0 { - "PSIRT-/Product-Security-Verantwortung pruefen" + if json_i64(product_security, "products") > 0 + || json_i64(product_security, "supplier_product_security_records") > 0 + { + "PSIRT-, Supplier/Product-Security- und Remediation-Verantwortung pruefen" } else { "Keine Product-Security-Daten erfasst" }, @@ -3136,6 +3262,7 @@ fn review_data_completeness_summary(source_counts: &Value) -> Value { ("Incidents", "incidents"), ("Roadmap-Tasks", "roadmap_tasks"), ("Supplier", "suppliers"), + ("Supplier/Product Security", "supplier_product_security"), ("AI Governance", "ai_governance"), ] .iter() @@ -3146,6 +3273,7 @@ fn review_data_completeness_summary(source_counts: &Value) -> Value { "ai_governance" => "ai_governance_systems", "controls" => "controls", "suppliers" => "suppliers", + "supplier_product_security" => "supplier_product_security_records", key => key, }; let count = json_i64(source_counts, count_key); @@ -3177,6 +3305,12 @@ fn regulatory_review_has_open_gaps(gap_summary: &Value) -> bool { "critical_open_vulnerabilities", "open_cve_correlation_reviews", "invalid_product_security_imports", + "open_supplier_product_security_advisories", + "critical_supplier_product_security_advisories", + "supplier_product_security_missing_evidence", + "supplier_product_security_missing_owner", + "supplier_product_security_open_actions", + "supplier_product_security_overdue_reviews", "evidence_integrity_not_checked", "evidence_integrity_mismatch", "evidence_legal_hold_active", @@ -3198,6 +3332,8 @@ fn regulatory_review_has_critical_gaps(gap_summary: &Value) -> bool { [ "critical_open_risks", "critical_open_vulnerabilities", + "critical_supplier_product_security_advisories", + "supplier_product_security_critical_services", "evidence_integrity_mismatch", "critical_suppliers", "critical_agent_findings", @@ -3240,6 +3376,35 @@ fn management_review_decision_summary( { required_decisions.push("Product-Security-CVE-Review und Remediation-Prioritaet"); } + if json_i64( + sources.product_security, + "open_supplier_product_security_advisories", + ) > 0 + || json_i64( + sources.product_security, + "supplier_product_security_open_actions", + ) > 0 + || json_i64( + sources.product_security, + "supplier_product_security_overdue_reviews", + ) > 0 + { + required_decisions.push( + "Supplier/Product-Security-Advisorys, Vertrags-/Exit-Plan-Status und offene Massnahmen pruefen", + ); + } + if json_i64( + sources.product_security, + "supplier_product_security_missing_evidence", + ) > 0 + || json_i64( + sources.product_security, + "supplier_product_security_missing_owner", + ) > 0 + { + required_decisions + .push("Supplier/Product-Security-Evidence und Owner / Verantwortliche nachziehen"); + } if json_i64(sources.metrics, "evidence_integrity_mismatch") > 0 || json_i64(sources.metrics, "evidence_integrity_not_checked") > 0 || json_i64(sources.metrics, "evidence_disposition_due") > 0 diff --git a/rust/iscy-backend/src/supplier_product_security_store.rs b/rust/iscy-backend/src/supplier_product_security_store.rs new file mode 100644 index 0000000..ed4d4ac --- /dev/null +++ b/rust/iscy-backend/src/supplier_product_security_store.rs @@ -0,0 +1,3076 @@ +use anyhow::{bail, Context}; +use chrono::{NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sqlx::{ + postgres::{PgPool, PgPoolOptions, PgRow}, + sqlite::{SqlitePool, SqlitePoolOptions, SqliteRow}, + Postgres, QueryBuilder, Row, Sqlite, +}; +use std::{collections::BTreeSet, error::Error, fmt}; + +use crate::cve_store::normalize_database_url; + +#[derive(Clone)] +pub enum SupplierProductSecurityStore { + Postgres(PgPool), + Sqlite(SqlitePool), +} + +#[derive(Debug, Clone, Default)] +pub struct SupplierProductSecurityFilters { + pub supplier_id: Option, + pub product: Option, + pub review_status: Option, + pub severity: Option, + pub dora_relevant: Option, + pub nis2_relevant: Option, + pub data_processing_relevant: Option, + pub critical_services: Option, + pub overdue: Option, + pub limit: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SupplierProductSecurityOverview { + pub tenant_id: i64, + pub summary: SupplierProductSecuritySummary, + pub filter_summary: Value, + pub records: Vec, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct SupplierProductSecuritySummary { + pub total_records: i64, + pub open_advisories: i64, + pub critical_records: i64, + pub overdue_reviews: i64, + pub missing_evidence: i64, + pub dora_relevant: i64, + pub nis2_relevant: i64, + pub data_processing_relevant: i64, + pub critical_services: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SupplierProductSecurityDetail { + pub record: SupplierProductSecurityRecord, + pub evidence_links: Vec, + pub events: Vec, + pub contract_exit_history: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SupplierProductSecurityRecord { + pub id: i64, + pub tenant_id: i64, + pub supplier_id: i64, + pub supplier_name: String, + pub product_id: Option, + pub product_name: Option, + pub product_or_service: String, + pub criticality: String, + pub criticality_label: String, + pub internal_owner: String, + pub supplier_security_contact: String, + pub product_security_status: String, + pub product_security_status_label: String, + pub advisory_id: String, + pub advisory_source_type: String, + pub advisory_source_type_label: String, + pub advisory_reference: String, + pub cve_ids: Vec, + pub affected_versions: String, + pub fixed_versions: String, + pub severity: String, + pub severity_label: String, + pub cvss_score: Option, + pub epss_score: Option, + pub exploitation_status: String, + pub exploitation_status_label: String, + pub affected_assets_summary: String, + pub impact_summary: String, + pub remediation_summary: String, + pub workaround_summary: String, + pub review_status: String, + pub review_status_label: String, + pub owner: String, + pub due_date: Option, + pub evidence_ids: Vec, + pub sbom_vex_reference: String, + pub open_actions: String, + pub management_review_reference: String, + pub contract_status: String, + pub contract_status_label: String, + pub contract_review_date: Option, + pub next_contract_review_due: Option, + pub exit_plan_status: String, + pub exit_plan_status_label: String, + pub exit_plan_version: String, + pub exit_plan_review_date: Option, + pub exit_plan_owner: String, + pub critical_service_dependency: bool, + pub data_processing_relevance: bool, + pub dora_ict_third_party_relevance: bool, + pub nis2_supply_chain_relevance: bool, + pub termination_risk_summary: String, + pub alternative_supplier_summary: String, + pub created_by_id: Option, + pub updated_by_id: Option, + pub reviewed_at: Option, + pub reviewed_by: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SupplierProductSecurityEvidenceLink { + pub id: i64, + pub record_id: i64, + pub evidence_id: i64, + pub title: String, + pub status: String, + pub link_type: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SupplierProductSecurityEvent { + pub id: i64, + pub record_id: i64, + pub event_type: String, + pub actor_id: Option, + pub detail_json: Value, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SupplierContractExitHistoryEntry { + pub id: i64, + pub record_id: i64, + pub supplier_id: i64, + pub version_number: i64, + pub changed_by: Option, + pub changed_at: String, + pub change_reason: String, + pub previous_status: String, + pub new_status: String, + pub summary: String, + pub evidence_ids: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct SupplierProductSecurityWriteRequest { + pub supplier_id: i64, + #[serde(default)] + pub product_id: Option, + pub product_or_service: String, + #[serde(default)] + pub criticality: Option, + #[serde(default)] + pub internal_owner: String, + #[serde(default)] + pub supplier_security_contact: String, + #[serde(default)] + pub product_security_status: Option, + #[serde(default)] + pub advisory_id: String, + #[serde(default)] + pub advisory_source_type: Option, + #[serde(default)] + pub advisory_reference: String, + #[serde(default)] + pub cve_ids: Vec, + #[serde(default)] + pub affected_versions: String, + #[serde(default)] + pub fixed_versions: String, + #[serde(default)] + pub severity: Option, + #[serde(default)] + pub cvss_score: Option, + #[serde(default)] + pub epss_score: Option, + #[serde(default)] + pub exploitation_status: Option, + #[serde(default)] + pub affected_assets_summary: String, + #[serde(default)] + pub impact_summary: String, + #[serde(default)] + pub remediation_summary: String, + #[serde(default)] + pub workaround_summary: String, + #[serde(default)] + pub review_status: Option, + #[serde(default)] + pub owner: String, + #[serde(default)] + pub due_date: Option, + #[serde(default)] + pub evidence_ids: Vec, + #[serde(default)] + pub sbom_vex_reference: String, + #[serde(default)] + pub open_actions: String, + #[serde(default)] + pub management_review_reference: String, + #[serde(default)] + pub contract_status: Option, + #[serde(default)] + pub contract_review_date: Option, + #[serde(default)] + pub next_contract_review_due: Option, + #[serde(default)] + pub exit_plan_status: Option, + #[serde(default)] + pub exit_plan_version: String, + #[serde(default)] + pub exit_plan_review_date: Option, + #[serde(default)] + pub exit_plan_owner: String, + #[serde(default)] + pub critical_service_dependency: Option, + #[serde(default)] + pub data_processing_relevance: Option, + #[serde(default)] + pub dora_ict_third_party_relevance: Option, + #[serde(default)] + pub nis2_supply_chain_relevance: Option, + #[serde(default)] + pub termination_risk_summary: String, + #[serde(default)] + pub alternative_supplier_summary: String, + #[serde(default)] + pub change_reason: String, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct SupplierProductSecurityPatchRequest { + #[serde(default)] + pub product_id: Option, + #[serde(default)] + pub product_or_service: Option, + #[serde(default)] + pub criticality: Option, + #[serde(default)] + pub internal_owner: Option, + #[serde(default)] + pub supplier_security_contact: Option, + #[serde(default)] + pub product_security_status: Option, + #[serde(default)] + pub advisory_id: Option, + #[serde(default)] + pub advisory_source_type: Option, + #[serde(default)] + pub advisory_reference: Option, + #[serde(default)] + pub cve_ids: Option>, + #[serde(default)] + pub affected_versions: Option, + #[serde(default)] + pub fixed_versions: Option, + #[serde(default)] + pub severity: Option, + #[serde(default)] + pub cvss_score: Option, + #[serde(default)] + pub epss_score: Option, + #[serde(default)] + pub exploitation_status: Option, + #[serde(default)] + pub affected_assets_summary: Option, + #[serde(default)] + pub impact_summary: Option, + #[serde(default)] + pub remediation_summary: Option, + #[serde(default)] + pub workaround_summary: Option, + #[serde(default)] + pub review_status: Option, + #[serde(default)] + pub owner: Option, + #[serde(default)] + pub due_date: Option, + #[serde(default)] + pub evidence_ids: Option>, + #[serde(default)] + pub sbom_vex_reference: Option, + #[serde(default)] + pub open_actions: Option, + #[serde(default)] + pub management_review_reference: Option, + #[serde(default)] + pub contract_status: Option, + #[serde(default)] + pub contract_review_date: Option, + #[serde(default)] + pub next_contract_review_due: Option, + #[serde(default)] + pub exit_plan_status: Option, + #[serde(default)] + pub exit_plan_version: Option, + #[serde(default)] + pub exit_plan_review_date: Option, + #[serde(default)] + pub exit_plan_owner: Option, + #[serde(default)] + pub critical_service_dependency: Option, + #[serde(default)] + pub data_processing_relevance: Option, + #[serde(default)] + pub dora_ict_third_party_relevance: Option, + #[serde(default)] + pub nis2_supply_chain_relevance: Option, + #[serde(default)] + pub termination_risk_summary: Option, + #[serde(default)] + pub alternative_supplier_summary: Option, + #[serde(default)] + pub change_reason: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SupplierProductSecurityStatusRequest { + pub new_status: String, + #[serde(default)] + pub reason: String, + #[serde(default)] + pub review_notes: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SupplierProductSecurityEvidenceRequest { + pub evidence_id: i64, + #[serde(default)] + pub link_type: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SupplierProductSecurityErrorKind { + NotFound, + InvalidPayload, + Database, +} + +#[derive(Debug)] +pub struct SupplierProductSecurityError { + kind: SupplierProductSecurityErrorKind, + message: String, +} + +pub type SupplierProductSecurityResult = Result; + +impl SupplierProductSecurityError { + pub fn kind(&self) -> SupplierProductSecurityErrorKind { + self.kind + } + + pub fn safe_message(&self) -> &str { + &self.message + } + + fn not_found() -> Self { + Self { + kind: SupplierProductSecurityErrorKind::NotFound, + message: "Supplier/Product-Security-Datensatz wurde fuer diesen Tenant nicht gefunden." + .to_string(), + } + } + + fn invalid(message: impl Into) -> Self { + Self { + kind: SupplierProductSecurityErrorKind::InvalidPayload, + message: message.into(), + } + } + + fn database() -> Self { + Self { + kind: SupplierProductSecurityErrorKind::Database, + message: "Supplier/Product-Security-Daten konnten nicht verarbeitet werden." + .to_string(), + } + } +} + +impl fmt::Display for SupplierProductSecurityError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for SupplierProductSecurityError {} + +impl From for SupplierProductSecurityError { + fn from(_: sqlx::Error) -> Self { + Self::database() + } +} + +impl From for SupplierProductSecurityError { + fn from(_: serde_json::Error) -> Self { + Self::database() + } +} + +impl SupplierProductSecurityStore { + pub async fn connect(database_url: &str) -> anyhow::Result { + let normalized_url = normalize_database_url(database_url); + if normalized_url.starts_with("postgres://") || normalized_url.starts_with("postgresql://") + { + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&normalized_url) + .await + .context( + "PostgreSQL-Verbindung fuer Supplier/Product-Security-Store fehlgeschlagen", + )?; + return Ok(Self::Postgres(pool)); + } + if normalized_url.starts_with("sqlite:") { + let pool = SqlitePoolOptions::new() + .max_connections(5) + .connect(&normalized_url) + .await + .context("SQLite-Verbindung fuer Supplier/Product-Security-Store fehlgeschlagen")?; + return Ok(Self::Sqlite(pool)); + } + bail!("Nicht unterstuetztes DATABASE_URL-Schema fuer Supplier/Product-Security-Store"); + } + + pub fn from_sqlite_pool(pool: SqlitePool) -> Self { + Self::Sqlite(pool) + } + + pub async fn overview( + &self, + tenant_id: i64, + filters: SupplierProductSecurityFilters, + ) -> anyhow::Result { + let records = match self { + Self::Postgres(pool) => list_records_postgres(pool, tenant_id, &filters).await?, + Self::Sqlite(pool) => list_records_sqlite(pool, tenant_id, &filters).await?, + }; + Ok(SupplierProductSecurityOverview { + tenant_id, + summary: supplier_product_security_summary(&records), + filter_summary: supplier_product_security_filter_summary(&filters), + records, + }) + } + + pub async fn detail( + &self, + tenant_id: i64, + record_id: i64, + ) -> SupplierProductSecurityResult> { + match self { + Self::Postgres(pool) => detail_postgres(pool, tenant_id, record_id).await, + Self::Sqlite(pool) => detail_sqlite(pool, tenant_id, record_id).await, + } + } + + pub async fn create_record( + &self, + tenant_id: i64, + actor_id: i64, + payload: SupplierProductSecurityWriteRequest, + ) -> SupplierProductSecurityResult { + let record = ValidatedSupplierProductSecurityRecord::from_create(payload)?; + match self { + Self::Postgres(pool) => create_record_postgres(pool, tenant_id, actor_id, record).await, + Self::Sqlite(pool) => create_record_sqlite(pool, tenant_id, actor_id, record).await, + } + } + + pub async fn update_record( + &self, + tenant_id: i64, + record_id: i64, + actor_id: i64, + payload: SupplierProductSecurityPatchRequest, + ) -> SupplierProductSecurityResult> { + match self { + Self::Postgres(pool) => { + update_record_postgres(pool, tenant_id, record_id, actor_id, payload).await + } + Self::Sqlite(pool) => { + update_record_sqlite(pool, tenant_id, record_id, actor_id, payload).await + } + } + } + + pub async fn update_status( + &self, + tenant_id: i64, + record_id: i64, + actor_id: i64, + payload: SupplierProductSecurityStatusRequest, + ) -> SupplierProductSecurityResult> { + let status = normalize_review_status(&payload.new_status)?; + let reason = normalized_text(&payload.reason, 1_000, "Statusgrund")?; + let notes = normalized_text(&payload.review_notes, 2_000, "Review-Notiz")?; + match self { + Self::Postgres(pool) => { + update_status_postgres(pool, tenant_id, record_id, actor_id, status, reason, notes) + .await + } + Self::Sqlite(pool) => { + update_status_sqlite(pool, tenant_id, record_id, actor_id, status, reason, notes) + .await + } + } + } + + pub async fn link_evidence( + &self, + tenant_id: i64, + record_id: i64, + actor_id: i64, + payload: SupplierProductSecurityEvidenceRequest, + ) -> SupplierProductSecurityResult> { + let link_type = normalized_text( + payload.link_type.as_deref().unwrap_or("review"), + 64, + "Evidence-Linktyp", + )?; + match self { + Self::Postgres(pool) => { + link_evidence_postgres( + pool, + tenant_id, + record_id, + actor_id, + payload.evidence_id, + link_type, + ) + .await + } + Self::Sqlite(pool) => { + link_evidence_sqlite( + pool, + tenant_id, + record_id, + actor_id, + payload.evidence_id, + link_type, + ) + .await + } + } + } + + pub async fn events( + &self, + tenant_id: i64, + record_id: i64, + ) -> SupplierProductSecurityResult> { + match self { + Self::Postgres(pool) => { + ensure_record_postgres(pool, tenant_id, record_id).await?; + list_events_postgres(pool, tenant_id, record_id).await + } + Self::Sqlite(pool) => { + ensure_record_sqlite(pool, tenant_id, record_id).await?; + list_events_sqlite(pool, tenant_id, record_id).await + } + } + } + + pub async fn supplier_contract_exit_history( + &self, + tenant_id: i64, + supplier_id: i64, + ) -> SupplierProductSecurityResult> { + match self { + Self::Postgres(pool) => { + ensure_supplier_postgres(pool, tenant_id, supplier_id).await?; + list_supplier_history_postgres(pool, tenant_id, supplier_id).await + } + Self::Sqlite(pool) => { + ensure_supplier_sqlite(pool, tenant_id, supplier_id).await?; + list_supplier_history_sqlite(pool, tenant_id, supplier_id).await + } + } + } +} + +#[derive(Debug, Clone)] +struct ValidatedSupplierProductSecurityRecord { + supplier_id: i64, + product_id: Option, + product_or_service: String, + criticality: String, + internal_owner: String, + supplier_security_contact: String, + product_security_status: String, + advisory_id: String, + advisory_source_type: String, + advisory_reference: String, + cve_ids: Vec, + affected_versions: String, + fixed_versions: String, + severity: String, + cvss_score: Option, + epss_score: Option, + exploitation_status: String, + affected_assets_summary: String, + impact_summary: String, + remediation_summary: String, + workaround_summary: String, + review_status: String, + owner: String, + due_date: Option, + evidence_ids: Vec, + sbom_vex_reference: String, + open_actions: String, + management_review_reference: String, + contract_status: String, + contract_review_date: Option, + next_contract_review_due: Option, + exit_plan_status: String, + exit_plan_version: String, + exit_plan_review_date: Option, + exit_plan_owner: String, + critical_service_dependency: bool, + data_processing_relevance: bool, + dora_ict_third_party_relevance: bool, + nis2_supply_chain_relevance: bool, + termination_risk_summary: String, + alternative_supplier_summary: String, + change_reason: String, +} + +impl ValidatedSupplierProductSecurityRecord { + fn from_create( + payload: SupplierProductSecurityWriteRequest, + ) -> SupplierProductSecurityResult { + if payload.supplier_id <= 0 { + return Err(SupplierProductSecurityError::invalid( + "Supplier-ID muss positiv sein.", + )); + } + if payload.product_id.is_some_and(|value| value <= 0) { + return Err(SupplierProductSecurityError::invalid( + "Produkt-ID muss positiv sein.", + )); + } + Ok(Self { + supplier_id: payload.supplier_id, + product_id: payload.product_id, + product_or_service: normalized_required_text( + &payload.product_or_service, + 255, + "Produkt oder Service", + )?, + criticality: normalize_criticality(payload.criticality.as_deref().unwrap_or("medium"))?, + internal_owner: normalized_text(&payload.internal_owner, 255, "Interner Owner")?, + supplier_security_contact: normalized_text( + &payload.supplier_security_contact, + 255, + "Supplier Security Contact", + )?, + product_security_status: normalize_product_security_status( + payload + .product_security_status + .as_deref() + .unwrap_or("not_assessed"), + )?, + advisory_id: normalized_text(&payload.advisory_id, 128, "Advisory-ID")?, + advisory_source_type: normalize_advisory_source_type( + payload.advisory_source_type.as_deref().unwrap_or("manual"), + )?, + advisory_reference: normalized_reference(&payload.advisory_reference)?, + cve_ids: normalize_cve_ids(payload.cve_ids)?, + affected_versions: normalized_text( + &payload.affected_versions, + 1_000, + "Betroffene Versionen", + )?, + fixed_versions: normalized_text(&payload.fixed_versions, 1_000, "Behobene Versionen")?, + severity: normalize_severity(payload.severity.as_deref().unwrap_or("unknown"))?, + cvss_score: normalize_score(payload.cvss_score, 0.0, 10.0, "CVSS")?, + epss_score: normalize_score(payload.epss_score, 0.0, 1.0, "EPSS")?, + exploitation_status: normalize_exploitation_status( + payload.exploitation_status.as_deref().unwrap_or("unknown"), + )?, + affected_assets_summary: normalized_text( + &payload.affected_assets_summary, + 2_000, + "Asset-Zusammenfassung", + )?, + impact_summary: normalized_text(&payload.impact_summary, 4_000, "Impact")?, + remediation_summary: normalized_text( + &payload.remediation_summary, + 4_000, + "Remediation", + )?, + workaround_summary: normalized_text(&payload.workaround_summary, 4_000, "Workaround")?, + review_status: normalize_review_status( + payload.review_status.as_deref().unwrap_or("draft"), + )?, + owner: normalized_text(&payload.owner, 255, "Owner")?, + due_date: normalized_optional_date(payload.due_date.as_deref(), "Faelligkeit")?, + evidence_ids: normalize_positive_ids(payload.evidence_ids, "Evidence-IDs")?, + sbom_vex_reference: normalized_reference(&payload.sbom_vex_reference)?, + open_actions: normalized_text(&payload.open_actions, 4_000, "Offene Massnahmen")?, + management_review_reference: normalized_text( + &payload.management_review_reference, + 512, + "Management-Review-Bezug", + )?, + contract_status: normalize_contract_status( + payload.contract_status.as_deref().unwrap_or("not_recorded"), + )?, + contract_review_date: normalized_optional_date( + payload.contract_review_date.as_deref(), + "Vertragspruefung", + )?, + next_contract_review_due: normalized_optional_date( + payload.next_contract_review_due.as_deref(), + "Naechste Vertragspruefung", + )?, + exit_plan_status: normalize_exit_plan_status( + payload + .exit_plan_status + .as_deref() + .unwrap_or("not_recorded"), + )?, + exit_plan_version: normalized_text( + &payload.exit_plan_version, + 64, + "Exit-Plan-Version", + )?, + exit_plan_review_date: normalized_optional_date( + payload.exit_plan_review_date.as_deref(), + "Exit-Plan-Pruefung", + )?, + exit_plan_owner: normalized_text(&payload.exit_plan_owner, 255, "Exit-Plan-Owner")?, + critical_service_dependency: payload.critical_service_dependency.unwrap_or(false), + data_processing_relevance: payload.data_processing_relevance.unwrap_or(false), + dora_ict_third_party_relevance: payload.dora_ict_third_party_relevance.unwrap_or(false), + nis2_supply_chain_relevance: payload.nis2_supply_chain_relevance.unwrap_or(false), + termination_risk_summary: normalized_text( + &payload.termination_risk_summary, + 4_000, + "Kuendigungsrisiko", + )?, + alternative_supplier_summary: normalized_text( + &payload.alternative_supplier_summary, + 4_000, + "Alternativlieferant", + )?, + change_reason: normalized_text(&payload.change_reason, 1_000, "Aenderungsgrund")?, + }) + } + + fn apply_patch( + current: &SupplierProductSecurityRecord, + payload: SupplierProductSecurityPatchRequest, + ) -> SupplierProductSecurityResult { + Ok(Self { + supplier_id: current.supplier_id, + product_id: payload.product_id.or(current.product_id), + product_or_service: match payload.product_or_service { + Some(value) => normalized_required_text(&value, 255, "Produkt oder Service")?, + None => current.product_or_service.clone(), + }, + criticality: match payload.criticality { + Some(value) => normalize_criticality(&value)?, + None => current.criticality.clone(), + }, + internal_owner: match payload.internal_owner { + Some(value) => normalized_text(&value, 255, "Interner Owner")?, + None => current.internal_owner.clone(), + }, + supplier_security_contact: match payload.supplier_security_contact { + Some(value) => normalized_text(&value, 255, "Supplier Security Contact")?, + None => current.supplier_security_contact.clone(), + }, + product_security_status: match payload.product_security_status { + Some(value) => normalize_product_security_status(&value)?, + None => current.product_security_status.clone(), + }, + advisory_id: match payload.advisory_id { + Some(value) => normalized_text(&value, 128, "Advisory-ID")?, + None => current.advisory_id.clone(), + }, + advisory_source_type: match payload.advisory_source_type { + Some(value) => normalize_advisory_source_type(&value)?, + None => current.advisory_source_type.clone(), + }, + advisory_reference: match payload.advisory_reference { + Some(value) => normalized_reference(&value)?, + None => current.advisory_reference.clone(), + }, + cve_ids: match payload.cve_ids { + Some(value) => normalize_cve_ids(value)?, + None => current.cve_ids.clone(), + }, + affected_versions: match payload.affected_versions { + Some(value) => normalized_text(&value, 1_000, "Betroffene Versionen")?, + None => current.affected_versions.clone(), + }, + fixed_versions: match payload.fixed_versions { + Some(value) => normalized_text(&value, 1_000, "Behobene Versionen")?, + None => current.fixed_versions.clone(), + }, + severity: match payload.severity { + Some(value) => normalize_severity(&value)?, + None => current.severity.clone(), + }, + cvss_score: normalize_score( + payload.cvss_score.or(current.cvss_score), + 0.0, + 10.0, + "CVSS", + )?, + epss_score: normalize_score( + payload.epss_score.or(current.epss_score), + 0.0, + 1.0, + "EPSS", + )?, + exploitation_status: match payload.exploitation_status { + Some(value) => normalize_exploitation_status(&value)?, + None => current.exploitation_status.clone(), + }, + affected_assets_summary: match payload.affected_assets_summary { + Some(value) => normalized_text(&value, 2_000, "Asset-Zusammenfassung")?, + None => current.affected_assets_summary.clone(), + }, + impact_summary: match payload.impact_summary { + Some(value) => normalized_text(&value, 4_000, "Impact")?, + None => current.impact_summary.clone(), + }, + remediation_summary: match payload.remediation_summary { + Some(value) => normalized_text(&value, 4_000, "Remediation")?, + None => current.remediation_summary.clone(), + }, + workaround_summary: match payload.workaround_summary { + Some(value) => normalized_text(&value, 4_000, "Workaround")?, + None => current.workaround_summary.clone(), + }, + review_status: match payload.review_status { + Some(value) => normalize_review_status(&value)?, + None => current.review_status.clone(), + }, + owner: match payload.owner { + Some(value) => normalized_text(&value, 255, "Owner")?, + None => current.owner.clone(), + }, + due_date: match payload.due_date { + Some(value) => normalized_optional_date(Some(&value), "Faelligkeit")?, + None => current.due_date.clone(), + }, + evidence_ids: match payload.evidence_ids { + Some(value) => normalize_positive_ids(value, "Evidence-IDs")?, + None => current.evidence_ids.clone(), + }, + sbom_vex_reference: match payload.sbom_vex_reference { + Some(value) => normalized_reference(&value)?, + None => current.sbom_vex_reference.clone(), + }, + open_actions: match payload.open_actions { + Some(value) => normalized_text(&value, 4_000, "Offene Massnahmen")?, + None => current.open_actions.clone(), + }, + management_review_reference: match payload.management_review_reference { + Some(value) => normalized_text(&value, 512, "Management-Review-Bezug")?, + None => current.management_review_reference.clone(), + }, + contract_status: match payload.contract_status { + Some(value) => normalize_contract_status(&value)?, + None => current.contract_status.clone(), + }, + contract_review_date: match payload.contract_review_date { + Some(value) => normalized_optional_date(Some(&value), "Vertragspruefung")?, + None => current.contract_review_date.clone(), + }, + next_contract_review_due: match payload.next_contract_review_due { + Some(value) => normalized_optional_date(Some(&value), "Naechste Vertragspruefung")?, + None => current.next_contract_review_due.clone(), + }, + exit_plan_status: match payload.exit_plan_status { + Some(value) => normalize_exit_plan_status(&value)?, + None => current.exit_plan_status.clone(), + }, + exit_plan_version: match payload.exit_plan_version { + Some(value) => normalized_text(&value, 64, "Exit-Plan-Version")?, + None => current.exit_plan_version.clone(), + }, + exit_plan_review_date: match payload.exit_plan_review_date { + Some(value) => normalized_optional_date(Some(&value), "Exit-Plan-Pruefung")?, + None => current.exit_plan_review_date.clone(), + }, + exit_plan_owner: match payload.exit_plan_owner { + Some(value) => normalized_text(&value, 255, "Exit-Plan-Owner")?, + None => current.exit_plan_owner.clone(), + }, + critical_service_dependency: payload + .critical_service_dependency + .unwrap_or(current.critical_service_dependency), + data_processing_relevance: payload + .data_processing_relevance + .unwrap_or(current.data_processing_relevance), + dora_ict_third_party_relevance: payload + .dora_ict_third_party_relevance + .unwrap_or(current.dora_ict_third_party_relevance), + nis2_supply_chain_relevance: payload + .nis2_supply_chain_relevance + .unwrap_or(current.nis2_supply_chain_relevance), + termination_risk_summary: match payload.termination_risk_summary { + Some(value) => normalized_text(&value, 4_000, "Kuendigungsrisiko")?, + None => current.termination_risk_summary.clone(), + }, + alternative_supplier_summary: match payload.alternative_supplier_summary { + Some(value) => normalized_text(&value, 4_000, "Alternativlieferant")?, + None => current.alternative_supplier_summary.clone(), + }, + change_reason: normalized_text( + payload + .change_reason + .as_deref() + .unwrap_or("Datensatz aktualisiert."), + 1_000, + "Aenderungsgrund", + )?, + }) + } +} + +const RECORD_SELECT: &str = r#" +SELECT + record.id, + record.tenant_id, + record.supplier_id, + supplier.name AS supplier_name, + record.product_id, + product.name AS product_name, + record.product_or_service, + record.criticality, + record.internal_owner, + record.supplier_security_contact, + record.product_security_status, + record.advisory_id, + record.advisory_source_type, + record.advisory_reference, + record.cve_ids_json, + record.affected_versions, + record.fixed_versions, + record.severity, + record.cvss_score, + record.epss_score, + record.exploitation_status, + record.affected_assets_summary, + record.impact_summary, + record.remediation_summary, + record.workaround_summary, + record.review_status, + record.owner, + record.due_date, + record.evidence_ids_json, + record.sbom_vex_reference, + record.open_actions, + record.management_review_reference, + record.contract_status, + record.contract_review_date, + record.next_contract_review_due, + record.exit_plan_status, + record.exit_plan_version, + record.exit_plan_review_date, + record.exit_plan_owner, + record.critical_service_dependency, + record.data_processing_relevance, + record.dora_ict_third_party_relevance, + record.nis2_supply_chain_relevance, + record.termination_risk_summary, + record.alternative_supplier_summary, + record.created_by_id, + record.updated_by_id, + record.reviewed_at, + record.reviewed_by, + record.created_at, + record.updated_at +FROM supplier_product_security_record record +JOIN organizations_supplier supplier + ON supplier.id = record.supplier_id + AND supplier.tenant_id = record.tenant_id +LEFT JOIN product_security_product product + ON product.id = record.product_id + AND product.tenant_id = record.tenant_id +"#; + +async fn list_records_sqlite( + pool: &SqlitePool, + tenant_id: i64, + filters: &SupplierProductSecurityFilters, +) -> anyhow::Result> { + let mut builder = QueryBuilder::::new(RECORD_SELECT); + push_filters_sqlite(&mut builder, tenant_id, filters); + let rows = builder.build().fetch_all(pool).await?; + rows.into_iter() + .map(record_from_sqlite_row) + .collect::, _>>() + .map_err(Into::into) +} + +async fn list_records_postgres( + pool: &PgPool, + tenant_id: i64, + filters: &SupplierProductSecurityFilters, +) -> anyhow::Result> { + let mut builder = QueryBuilder::::new(RECORD_SELECT); + push_filters_postgres(&mut builder, tenant_id, filters); + let rows = builder.build().fetch_all(pool).await?; + rows.into_iter() + .map(record_from_pg_row) + .collect::, _>>() + .map_err(Into::into) +} + +fn push_filters_sqlite( + builder: &mut QueryBuilder<'_, Sqlite>, + tenant_id: i64, + filters: &SupplierProductSecurityFilters, +) { + builder.push(" WHERE record.tenant_id = "); + builder.push_bind(tenant_id); + push_common_filters(builder, filters); +} + +fn push_filters_postgres( + builder: &mut QueryBuilder<'_, Postgres>, + tenant_id: i64, + filters: &SupplierProductSecurityFilters, +) { + builder.push(" WHERE record.tenant_id = "); + builder.push_bind(tenant_id); + push_common_filters(builder, filters); +} + +fn push_common_filters( + builder: &mut QueryBuilder<'_, DB>, + filters: &SupplierProductSecurityFilters, +) where + DB: sqlx::Database, + for<'a> i64: sqlx::Encode<'a, DB> + sqlx::Type, + for<'a> String: sqlx::Encode<'a, DB> + sqlx::Type, + for<'a> bool: sqlx::Encode<'a, DB> + sqlx::Type, +{ + if let Some(supplier_id) = filters.supplier_id { + builder.push(" AND record.supplier_id = "); + builder.push_bind(supplier_id); + } + if let Some(product) = filters.product.as_ref() { + builder.push(" AND LOWER(record.product_or_service) LIKE "); + builder.push_bind(format!("%{}%", product.to_ascii_lowercase())); + } + if let Some(status) = filters.review_status.as_ref() { + builder.push(" AND record.review_status = "); + builder.push_bind(status.clone()); + } + if let Some(severity) = filters.severity.as_ref() { + builder.push(" AND record.severity = "); + builder.push_bind(severity.clone()); + } + if let Some(value) = filters.dora_relevant { + builder.push(" AND record.dora_ict_third_party_relevance = "); + builder.push_bind(value); + } + if let Some(value) = filters.nis2_relevant { + builder.push(" AND record.nis2_supply_chain_relevance = "); + builder.push_bind(value); + } + if let Some(value) = filters.data_processing_relevant { + builder.push(" AND record.data_processing_relevance = "); + builder.push_bind(value); + } + if let Some(value) = filters.critical_services { + builder.push(" AND record.critical_service_dependency = "); + builder.push_bind(value); + } + if filters.overdue == Some(true) { + builder.push(" AND record.due_date IS NOT NULL AND record.due_date < "); + builder.push_bind(Utc::now().date_naive().to_string()); + builder.push(" AND record.review_status NOT IN ('closed', 'mitigated', 'not_applicable')"); + } + builder.push( + " ORDER BY CASE record.severity WHEN 'critical' THEN 5 WHEN 'high' THEN 4 WHEN 'medium' THEN 3 WHEN 'low' THEN 2 ELSE 1 END DESC, record.updated_at DESC, record.id DESC LIMIT ", + ); + builder.push_bind(filters.limit.clamp(1, 100)); +} + +async fn detail_sqlite( + pool: &SqlitePool, + tenant_id: i64, + record_id: i64, +) -> SupplierProductSecurityResult> { + let Some(record) = fetch_record_sqlite(pool, tenant_id, record_id).await? else { + return Ok(None); + }; + Ok(Some(SupplierProductSecurityDetail { + evidence_links: list_evidence_links_sqlite(pool, tenant_id, record_id).await?, + events: list_events_sqlite(pool, tenant_id, record_id).await?, + contract_exit_history: list_record_history_sqlite(pool, tenant_id, record_id).await?, + record, + })) +} + +async fn detail_postgres( + pool: &PgPool, + tenant_id: i64, + record_id: i64, +) -> SupplierProductSecurityResult> { + let Some(record) = fetch_record_postgres(pool, tenant_id, record_id).await? else { + return Ok(None); + }; + Ok(Some(SupplierProductSecurityDetail { + evidence_links: list_evidence_links_postgres(pool, tenant_id, record_id).await?, + events: list_events_postgres(pool, tenant_id, record_id).await?, + contract_exit_history: list_record_history_postgres(pool, tenant_id, record_id).await?, + record, + })) +} + +async fn fetch_record_sqlite( + pool: &SqlitePool, + tenant_id: i64, + record_id: i64, +) -> Result, sqlx::Error> { + let query = format!("{RECORD_SELECT} WHERE record.tenant_id = ? AND record.id = ?"); + sqlx::query(&query) + .bind(tenant_id) + .bind(record_id) + .fetch_optional(pool) + .await? + .map(record_from_sqlite_row) + .transpose() +} + +async fn fetch_record_postgres( + pool: &PgPool, + tenant_id: i64, + record_id: i64, +) -> Result, sqlx::Error> { + let query = format!("{RECORD_SELECT} WHERE record.tenant_id = $1 AND record.id = $2"); + sqlx::query(&query) + .bind(tenant_id) + .bind(record_id) + .fetch_optional(pool) + .await? + .map(record_from_pg_row) + .transpose() +} + +async fn create_record_sqlite( + pool: &SqlitePool, + tenant_id: i64, + actor_id: i64, + record: ValidatedSupplierProductSecurityRecord, +) -> SupplierProductSecurityResult { + ensure_supplier_sqlite(pool, tenant_id, record.supplier_id).await?; + ensure_product_sqlite(pool, tenant_id, record.product_id).await?; + ensure_evidence_ids_sqlite(pool, tenant_id, &record.evidence_ids).await?; + let record_id = insert_record_sqlite(pool, tenant_id, actor_id, &record).await?; + for evidence_id in &record.evidence_ids { + insert_evidence_link_sqlite(pool, tenant_id, record_id, actor_id, *evidence_id, "review") + .await?; + } + let history = ContractHistoryInsert { + tenant_id, + record_id, + supplier_id: record.supplier_id, + actor_id, + version_number: 1, + previous_status: "", + new_status: &record.contract_status, + change_reason: &record.change_reason, + evidence_ids: &record.evidence_ids, + }; + insert_contract_history_sqlite(pool, &history).await?; + insert_event_sqlite( + pool, + tenant_id, + record_id, + "supplier_product_security_record_created", + Some(actor_id), + json!({"supplier_id": record.supplier_id, "review_status": record.review_status}), + ) + .await?; + detail_sqlite(pool, tenant_id, record_id) + .await? + .ok_or_else(SupplierProductSecurityError::not_found) +} + +async fn create_record_postgres( + pool: &PgPool, + tenant_id: i64, + actor_id: i64, + record: ValidatedSupplierProductSecurityRecord, +) -> SupplierProductSecurityResult { + ensure_supplier_postgres(pool, tenant_id, record.supplier_id).await?; + ensure_product_postgres(pool, tenant_id, record.product_id).await?; + ensure_evidence_ids_postgres(pool, tenant_id, &record.evidence_ids).await?; + let record_id = insert_record_postgres(pool, tenant_id, actor_id, &record).await?; + for evidence_id in &record.evidence_ids { + insert_evidence_link_postgres(pool, tenant_id, record_id, actor_id, *evidence_id, "review") + .await?; + } + let history = ContractHistoryInsert { + tenant_id, + record_id, + supplier_id: record.supplier_id, + actor_id, + version_number: 1, + previous_status: "", + new_status: &record.contract_status, + change_reason: &record.change_reason, + evidence_ids: &record.evidence_ids, + }; + insert_contract_history_postgres(pool, &history).await?; + insert_event_postgres( + pool, + tenant_id, + record_id, + "supplier_product_security_record_created", + Some(actor_id), + json!({"supplier_id": record.supplier_id, "review_status": record.review_status}), + ) + .await?; + detail_postgres(pool, tenant_id, record_id) + .await? + .ok_or_else(SupplierProductSecurityError::not_found) +} + +async fn insert_record_sqlite( + pool: &SqlitePool, + tenant_id: i64, + actor_id: i64, + record: &ValidatedSupplierProductSecurityRecord, +) -> Result { + let sql = insert_record_sql("?", "?"); + sqlx::query_scalar(&sql) + .bind(tenant_id) + .bind(record.supplier_id) + .bind(record.product_id) + .bind(&record.product_or_service) + .bind(&record.criticality) + .bind(&record.internal_owner) + .bind(&record.supplier_security_contact) + .bind(&record.product_security_status) + .bind(&record.advisory_id) + .bind(&record.advisory_source_type) + .bind(&record.advisory_reference) + .bind(json_ids(&record.cve_ids)) + .bind(&record.affected_versions) + .bind(&record.fixed_versions) + .bind(&record.severity) + .bind(record.cvss_score) + .bind(record.epss_score) + .bind(&record.exploitation_status) + .bind(&record.affected_assets_summary) + .bind(&record.impact_summary) + .bind(&record.remediation_summary) + .bind(&record.workaround_summary) + .bind(&record.review_status) + .bind(&record.owner) + .bind(&record.due_date) + .bind(json_ids_i64(&record.evidence_ids)) + .bind(&record.sbom_vex_reference) + .bind(&record.open_actions) + .bind(&record.management_review_reference) + .bind(&record.contract_status) + .bind(&record.contract_review_date) + .bind(&record.next_contract_review_due) + .bind(&record.exit_plan_status) + .bind(&record.exit_plan_version) + .bind(&record.exit_plan_review_date) + .bind(&record.exit_plan_owner) + .bind(record.critical_service_dependency) + .bind(record.data_processing_relevance) + .bind(record.dora_ict_third_party_relevance) + .bind(record.nis2_supply_chain_relevance) + .bind(&record.termination_risk_summary) + .bind(&record.alternative_supplier_summary) + .bind(actor_id) + .bind(actor_id) + .fetch_one(pool) + .await +} + +async fn insert_record_postgres( + pool: &PgPool, + tenant_id: i64, + actor_id: i64, + record: &ValidatedSupplierProductSecurityRecord, +) -> Result { + let sql = insert_record_sql("$", "$"); + sqlx::query_scalar(&sql) + .bind(tenant_id) + .bind(record.supplier_id) + .bind(record.product_id) + .bind(&record.product_or_service) + .bind(&record.criticality) + .bind(&record.internal_owner) + .bind(&record.supplier_security_contact) + .bind(&record.product_security_status) + .bind(&record.advisory_id) + .bind(&record.advisory_source_type) + .bind(&record.advisory_reference) + .bind(json_ids(&record.cve_ids)) + .bind(&record.affected_versions) + .bind(&record.fixed_versions) + .bind(&record.severity) + .bind(record.cvss_score) + .bind(record.epss_score) + .bind(&record.exploitation_status) + .bind(&record.affected_assets_summary) + .bind(&record.impact_summary) + .bind(&record.remediation_summary) + .bind(&record.workaround_summary) + .bind(&record.review_status) + .bind(&record.owner) + .bind(&record.due_date) + .bind(json_ids_i64(&record.evidence_ids)) + .bind(&record.sbom_vex_reference) + .bind(&record.open_actions) + .bind(&record.management_review_reference) + .bind(&record.contract_status) + .bind(&record.contract_review_date) + .bind(&record.next_contract_review_due) + .bind(&record.exit_plan_status) + .bind(&record.exit_plan_version) + .bind(&record.exit_plan_review_date) + .bind(&record.exit_plan_owner) + .bind(record.critical_service_dependency) + .bind(record.data_processing_relevance) + .bind(record.dora_ict_third_party_relevance) + .bind(record.nis2_supply_chain_relevance) + .bind(&record.termination_risk_summary) + .bind(&record.alternative_supplier_summary) + .bind(actor_id) + .bind(actor_id) + .fetch_one(pool) + .await +} + +fn insert_record_sql(bind_prefix: &str, _placeholder: &str) -> String { + let placeholders = if bind_prefix == "?" { + (0..44).map(|_| "?".to_string()).collect::>() + } else { + (1..=44) + .map(|index| format!("${index}")) + .collect::>() + }; + format!( + r#" + INSERT INTO supplier_product_security_record ( + tenant_id, supplier_id, product_id, product_or_service, criticality, + internal_owner, supplier_security_contact, product_security_status, + advisory_id, advisory_source_type, advisory_reference, cve_ids_json, + affected_versions, fixed_versions, severity, cvss_score, epss_score, + exploitation_status, affected_assets_summary, impact_summary, + remediation_summary, workaround_summary, review_status, owner, due_date, + evidence_ids_json, sbom_vex_reference, open_actions, + management_review_reference, contract_status, contract_review_date, + next_contract_review_due, exit_plan_status, exit_plan_version, + exit_plan_review_date, exit_plan_owner, critical_service_dependency, + data_processing_relevance, dora_ict_third_party_relevance, + nis2_supply_chain_relevance, termination_risk_summary, + alternative_supplier_summary, created_by_id, updated_by_id + ) VALUES ({}) + RETURNING id + "#, + placeholders.join(", ") + ) +} + +async fn update_record_sqlite( + pool: &SqlitePool, + tenant_id: i64, + record_id: i64, + actor_id: i64, + payload: SupplierProductSecurityPatchRequest, +) -> SupplierProductSecurityResult> { + let Some(current) = fetch_record_sqlite(pool, tenant_id, record_id).await? else { + return Ok(None); + }; + let record = ValidatedSupplierProductSecurityRecord::apply_patch(¤t, payload)?; + ensure_product_sqlite(pool, tenant_id, record.product_id).await?; + ensure_evidence_ids_sqlite(pool, tenant_id, &record.evidence_ids).await?; + apply_update_sqlite(pool, tenant_id, record_id, actor_id, &record).await?; + replace_evidence_links_sqlite(pool, tenant_id, record_id, actor_id, &record.evidence_ids) + .await?; + let version = next_history_version_sqlite(pool, tenant_id, record_id).await?; + let history = ContractHistoryInsert { + tenant_id, + record_id, + supplier_id: record.supplier_id, + actor_id, + version_number: version, + previous_status: ¤t.contract_status, + new_status: &record.contract_status, + change_reason: &record.change_reason, + evidence_ids: &record.evidence_ids, + }; + insert_contract_history_sqlite(pool, &history).await?; + insert_event_sqlite( + pool, + tenant_id, + record_id, + "supplier_product_security_record_updated", + Some(actor_id), + json!({"previous_status": current.review_status, "review_status": record.review_status}), + ) + .await?; + detail_sqlite(pool, tenant_id, record_id).await +} + +async fn update_record_postgres( + pool: &PgPool, + tenant_id: i64, + record_id: i64, + actor_id: i64, + payload: SupplierProductSecurityPatchRequest, +) -> SupplierProductSecurityResult> { + let Some(current) = fetch_record_postgres(pool, tenant_id, record_id).await? else { + return Ok(None); + }; + let record = ValidatedSupplierProductSecurityRecord::apply_patch(¤t, payload)?; + ensure_product_postgres(pool, tenant_id, record.product_id).await?; + ensure_evidence_ids_postgres(pool, tenant_id, &record.evidence_ids).await?; + apply_update_postgres(pool, tenant_id, record_id, actor_id, &record).await?; + replace_evidence_links_postgres(pool, tenant_id, record_id, actor_id, &record.evidence_ids) + .await?; + let version = next_history_version_postgres(pool, tenant_id, record_id).await?; + let history = ContractHistoryInsert { + tenant_id, + record_id, + supplier_id: record.supplier_id, + actor_id, + version_number: version, + previous_status: ¤t.contract_status, + new_status: &record.contract_status, + change_reason: &record.change_reason, + evidence_ids: &record.evidence_ids, + }; + insert_contract_history_postgres(pool, &history).await?; + insert_event_postgres( + pool, + tenant_id, + record_id, + "supplier_product_security_record_updated", + Some(actor_id), + json!({"previous_status": current.review_status, "review_status": record.review_status}), + ) + .await?; + detail_postgres(pool, tenant_id, record_id).await +} + +async fn apply_update_sqlite( + pool: &SqlitePool, + tenant_id: i64, + record_id: i64, + actor_id: i64, + record: &ValidatedSupplierProductSecurityRecord, +) -> Result<(), sqlx::Error> { + let sql = update_record_sql("?"); + bind_update(sqlx::query(&sql), tenant_id, record_id, actor_id, record) + .execute(pool) + .await?; + Ok(()) +} + +async fn apply_update_postgres( + pool: &PgPool, + tenant_id: i64, + record_id: i64, + actor_id: i64, + record: &ValidatedSupplierProductSecurityRecord, +) -> Result<(), sqlx::Error> { + let sql = update_record_sql("$"); + bind_update(sqlx::query(&sql), tenant_id, record_id, actor_id, record) + .execute(pool) + .await?; + Ok(()) +} + +fn update_record_sql(bind_prefix: &str) -> String { + let placeholders = if bind_prefix == "?" { + (0..41).map(|_| "?".to_string()).collect::>() + } else { + (1..=41) + .map(|index| format!("${index}")) + .collect::>() + }; + format!( + r#" + UPDATE supplier_product_security_record SET + product_id = {}, product_or_service = {}, criticality = {}, + internal_owner = {}, supplier_security_contact = {}, + product_security_status = {}, advisory_id = {}, advisory_source_type = {}, + advisory_reference = {}, cve_ids_json = {}, affected_versions = {}, + fixed_versions = {}, severity = {}, cvss_score = {}, epss_score = {}, + exploitation_status = {}, affected_assets_summary = {}, impact_summary = {}, + remediation_summary = {}, workaround_summary = {}, review_status = {}, + owner = {}, due_date = {}, evidence_ids_json = {}, sbom_vex_reference = {}, + open_actions = {}, management_review_reference = {}, contract_status = {}, + contract_review_date = {}, next_contract_review_due = {}, exit_plan_status = {}, + exit_plan_version = {}, exit_plan_review_date = {}, exit_plan_owner = {}, + critical_service_dependency = {}, data_processing_relevance = {}, + dora_ict_third_party_relevance = {}, nis2_supply_chain_relevance = {}, + termination_risk_summary = {}, alternative_supplier_summary = {}, + updated_by_id = {}, updated_at = CURRENT_TIMESTAMP + WHERE tenant_id = {} AND id = {} + "#, + placeholders[0], + placeholders[1], + placeholders[2], + placeholders[3], + placeholders[4], + placeholders[5], + placeholders[6], + placeholders[7], + placeholders[8], + placeholders[9], + placeholders[10], + placeholders[11], + placeholders[12], + placeholders[13], + placeholders[14], + placeholders[15], + placeholders[16], + placeholders[17], + placeholders[18], + placeholders[19], + placeholders[20], + placeholders[21], + placeholders[22], + placeholders[23], + placeholders[24], + placeholders[25], + placeholders[26], + placeholders[27], + placeholders[28], + placeholders[29], + placeholders[30], + placeholders[31], + placeholders[32], + placeholders[33], + placeholders[34], + placeholders[35], + placeholders[36], + placeholders[37], + placeholders[38], + placeholders[39], + placeholders[40], + if bind_prefix == "?" { "?" } else { "$42" }, + if bind_prefix == "?" { "?" } else { "$43" }, + ) +} + +fn bind_update<'q, DB>( + query: sqlx::query::Query<'q, DB, ::Arguments<'q>>, + tenant_id: i64, + record_id: i64, + actor_id: i64, + record: &ValidatedSupplierProductSecurityRecord, +) -> sqlx::query::Query<'q, DB, ::Arguments<'q>> +where + DB: sqlx::Database, + for<'a> Option: sqlx::Encode<'a, DB> + sqlx::Type, + for<'a> String: sqlx::Encode<'a, DB> + sqlx::Type, + for<'a> Option: sqlx::Encode<'a, DB> + sqlx::Type, + for<'a> Option: sqlx::Encode<'a, DB> + sqlx::Type, + for<'a> bool: sqlx::Encode<'a, DB> + sqlx::Type, + for<'a> i64: sqlx::Encode<'a, DB> + sqlx::Type, +{ + query + .bind(record.product_id) + .bind(record.product_or_service.clone()) + .bind(record.criticality.clone()) + .bind(record.internal_owner.clone()) + .bind(record.supplier_security_contact.clone()) + .bind(record.product_security_status.clone()) + .bind(record.advisory_id.clone()) + .bind(record.advisory_source_type.clone()) + .bind(record.advisory_reference.clone()) + .bind(json_ids(&record.cve_ids)) + .bind(record.affected_versions.clone()) + .bind(record.fixed_versions.clone()) + .bind(record.severity.clone()) + .bind(record.cvss_score) + .bind(record.epss_score) + .bind(record.exploitation_status.clone()) + .bind(record.affected_assets_summary.clone()) + .bind(record.impact_summary.clone()) + .bind(record.remediation_summary.clone()) + .bind(record.workaround_summary.clone()) + .bind(record.review_status.clone()) + .bind(record.owner.clone()) + .bind(record.due_date.clone()) + .bind(json_ids_i64(&record.evidence_ids)) + .bind(record.sbom_vex_reference.clone()) + .bind(record.open_actions.clone()) + .bind(record.management_review_reference.clone()) + .bind(record.contract_status.clone()) + .bind(record.contract_review_date.clone()) + .bind(record.next_contract_review_due.clone()) + .bind(record.exit_plan_status.clone()) + .bind(record.exit_plan_version.clone()) + .bind(record.exit_plan_review_date.clone()) + .bind(record.exit_plan_owner.clone()) + .bind(record.critical_service_dependency) + .bind(record.data_processing_relevance) + .bind(record.dora_ict_third_party_relevance) + .bind(record.nis2_supply_chain_relevance) + .bind(record.termination_risk_summary.clone()) + .bind(record.alternative_supplier_summary.clone()) + .bind(actor_id) + .bind(tenant_id) + .bind(record_id) +} + +async fn update_status_sqlite( + pool: &SqlitePool, + tenant_id: i64, + record_id: i64, + actor_id: i64, + status: String, + reason: String, + notes: String, +) -> SupplierProductSecurityResult> { + let Some(current) = fetch_record_sqlite(pool, tenant_id, record_id).await? else { + return Ok(None); + }; + sqlx::query( + "UPDATE supplier_product_security_record SET review_status = ?, reviewed_at = CURRENT_TIMESTAMP, reviewed_by = ?, updated_by_id = ?, updated_at = CURRENT_TIMESTAMP WHERE tenant_id = ? AND id = ?", + ) + .bind(&status) + .bind(actor_id) + .bind(actor_id) + .bind(tenant_id) + .bind(record_id) + .execute(pool) + .await?; + insert_event_sqlite( + pool, + tenant_id, + record_id, + "supplier_product_security_status_changed", + Some(actor_id), + json!({"previous_status": current.review_status, "new_status": status, "reason": reason, "review_notes": notes}), + ) + .await?; + detail_sqlite(pool, tenant_id, record_id).await +} + +async fn update_status_postgres( + pool: &PgPool, + tenant_id: i64, + record_id: i64, + actor_id: i64, + status: String, + reason: String, + notes: String, +) -> SupplierProductSecurityResult> { + let Some(current) = fetch_record_postgres(pool, tenant_id, record_id).await? else { + return Ok(None); + }; + sqlx::query( + "UPDATE supplier_product_security_record SET review_status = $1, reviewed_at = (CURRENT_TIMESTAMP)::text, reviewed_by = $2, updated_by_id = $3, updated_at = (CURRENT_TIMESTAMP)::text WHERE tenant_id = $4 AND id = $5", + ) + .bind(&status) + .bind(actor_id) + .bind(actor_id) + .bind(tenant_id) + .bind(record_id) + .execute(pool) + .await?; + insert_event_postgres( + pool, + tenant_id, + record_id, + "supplier_product_security_status_changed", + Some(actor_id), + json!({"previous_status": current.review_status, "new_status": status, "reason": reason, "review_notes": notes}), + ) + .await?; + detail_postgres(pool, tenant_id, record_id).await +} + +async fn link_evidence_sqlite( + pool: &SqlitePool, + tenant_id: i64, + record_id: i64, + actor_id: i64, + evidence_id: i64, + link_type: String, +) -> SupplierProductSecurityResult> { + let Some(current) = fetch_record_sqlite(pool, tenant_id, record_id).await? else { + return Ok(None); + }; + ensure_evidence_ids_sqlite(pool, tenant_id, &[evidence_id]).await?; + insert_evidence_link_sqlite( + pool, + tenant_id, + record_id, + actor_id, + evidence_id, + &link_type, + ) + .await?; + let mut ids = current.evidence_ids; + ids.push(evidence_id); + ids = normalize_positive_ids(ids, "Evidence-IDs")?; + sqlx::query("UPDATE supplier_product_security_record SET evidence_ids_json = ?, updated_by_id = ?, updated_at = CURRENT_TIMESTAMP WHERE tenant_id = ? AND id = ?") + .bind(json_ids_i64(&ids)) + .bind(actor_id) + .bind(tenant_id) + .bind(record_id) + .execute(pool) + .await?; + insert_event_sqlite( + pool, + tenant_id, + record_id, + "supplier_product_security_evidence_linked", + Some(actor_id), + json!({"evidence_linked": true, "link_type": link_type}), + ) + .await?; + detail_sqlite(pool, tenant_id, record_id).await +} + +async fn link_evidence_postgres( + pool: &PgPool, + tenant_id: i64, + record_id: i64, + actor_id: i64, + evidence_id: i64, + link_type: String, +) -> SupplierProductSecurityResult> { + let Some(current) = fetch_record_postgres(pool, tenant_id, record_id).await? else { + return Ok(None); + }; + ensure_evidence_ids_postgres(pool, tenant_id, &[evidence_id]).await?; + insert_evidence_link_postgres( + pool, + tenant_id, + record_id, + actor_id, + evidence_id, + &link_type, + ) + .await?; + let mut ids = current.evidence_ids; + ids.push(evidence_id); + ids = normalize_positive_ids(ids, "Evidence-IDs")?; + sqlx::query("UPDATE supplier_product_security_record SET evidence_ids_json = $1, updated_by_id = $2, updated_at = (CURRENT_TIMESTAMP)::text WHERE tenant_id = $3 AND id = $4") + .bind(json_ids_i64(&ids)) + .bind(actor_id) + .bind(tenant_id) + .bind(record_id) + .execute(pool) + .await?; + insert_event_postgres( + pool, + tenant_id, + record_id, + "supplier_product_security_evidence_linked", + Some(actor_id), + json!({"evidence_linked": true, "link_type": link_type}), + ) + .await?; + detail_postgres(pool, tenant_id, record_id).await +} + +async fn insert_evidence_link_sqlite( + pool: &SqlitePool, + tenant_id: i64, + record_id: i64, + actor_id: i64, + evidence_id: i64, + link_type: &str, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT OR IGNORE INTO supplier_product_security_evidence_link (tenant_id, record_id, evidence_id, link_type, created_by_id) VALUES (?, ?, ?, ?, ?)", + ) + .bind(tenant_id) + .bind(record_id) + .bind(evidence_id) + .bind(link_type) + .bind(actor_id) + .execute(pool) + .await?; + Ok(()) +} + +async fn insert_evidence_link_postgres( + pool: &PgPool, + tenant_id: i64, + record_id: i64, + actor_id: i64, + evidence_id: i64, + link_type: &str, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO supplier_product_security_evidence_link (tenant_id, record_id, evidence_id, link_type, created_by_id) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (tenant_id, record_id, evidence_id) DO NOTHING", + ) + .bind(tenant_id) + .bind(record_id) + .bind(evidence_id) + .bind(link_type) + .bind(actor_id) + .execute(pool) + .await?; + Ok(()) +} + +async fn replace_evidence_links_sqlite( + pool: &SqlitePool, + tenant_id: i64, + record_id: i64, + actor_id: i64, + evidence_ids: &[i64], +) -> Result<(), sqlx::Error> { + sqlx::query( + "DELETE FROM supplier_product_security_evidence_link WHERE tenant_id = ? AND record_id = ?", + ) + .bind(tenant_id) + .bind(record_id) + .execute(pool) + .await?; + for evidence_id in evidence_ids { + insert_evidence_link_sqlite(pool, tenant_id, record_id, actor_id, *evidence_id, "review") + .await?; + } + Ok(()) +} + +async fn replace_evidence_links_postgres( + pool: &PgPool, + tenant_id: i64, + record_id: i64, + actor_id: i64, + evidence_ids: &[i64], +) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM supplier_product_security_evidence_link WHERE tenant_id = $1 AND record_id = $2") + .bind(tenant_id) + .bind(record_id) + .execute(pool) + .await?; + for evidence_id in evidence_ids { + insert_evidence_link_postgres(pool, tenant_id, record_id, actor_id, *evidence_id, "review") + .await?; + } + Ok(()) +} + +async fn list_evidence_links_sqlite( + pool: &SqlitePool, + tenant_id: i64, + record_id: i64, +) -> SupplierProductSecurityResult> { + let rows = sqlx::query( + r#" + SELECT link.id, link.record_id, link.evidence_id, evidence.title, evidence.status, + link.link_type, CAST(link.created_at AS TEXT) AS created_at + FROM supplier_product_security_evidence_link link + JOIN evidence_evidenceitem evidence + ON evidence.id = link.evidence_id + AND evidence.tenant_id = link.tenant_id + WHERE link.tenant_id = ? AND link.record_id = ? + ORDER BY link.created_at DESC, link.id DESC + "#, + ) + .bind(tenant_id) + .bind(record_id) + .fetch_all(pool) + .await?; + rows.into_iter() + .map(evidence_link_from_sqlite_row) + .collect::, _>>() + .map_err(Into::into) +} + +async fn list_evidence_links_postgres( + pool: &PgPool, + tenant_id: i64, + record_id: i64, +) -> SupplierProductSecurityResult> { + let rows = sqlx::query( + r#" + SELECT link.id, link.record_id, link.evidence_id, evidence.title, evidence.status, + link.link_type, link.created_at AS created_at + FROM supplier_product_security_evidence_link link + JOIN evidence_evidenceitem evidence + ON evidence.id = link.evidence_id + AND evidence.tenant_id = link.tenant_id + WHERE link.tenant_id = $1 AND link.record_id = $2 + ORDER BY link.created_at DESC, link.id DESC + "#, + ) + .bind(tenant_id) + .bind(record_id) + .fetch_all(pool) + .await?; + rows.into_iter() + .map(evidence_link_from_pg_row) + .collect::, _>>() + .map_err(Into::into) +} + +async fn insert_event_sqlite( + pool: &SqlitePool, + tenant_id: i64, + record_id: i64, + event_type: &str, + actor_id: Option, + detail_json: Value, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO supplier_product_security_event (tenant_id, record_id, event_type, actor_id, detail_json) VALUES (?, ?, ?, ?, ?)", + ) + .bind(tenant_id) + .bind(record_id) + .bind(event_type) + .bind(actor_id) + .bind(detail_json.to_string()) + .execute(pool) + .await?; + Ok(()) +} + +async fn insert_event_postgres( + pool: &PgPool, + tenant_id: i64, + record_id: i64, + event_type: &str, + actor_id: Option, + detail_json: Value, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO supplier_product_security_event (tenant_id, record_id, event_type, actor_id, detail_json) VALUES ($1, $2, $3, $4, $5)", + ) + .bind(tenant_id) + .bind(record_id) + .bind(event_type) + .bind(actor_id) + .bind(detail_json.to_string()) + .execute(pool) + .await?; + Ok(()) +} + +async fn list_events_sqlite( + pool: &SqlitePool, + tenant_id: i64, + record_id: i64, +) -> SupplierProductSecurityResult> { + let rows = sqlx::query( + "SELECT id, record_id, event_type, actor_id, detail_json, CAST(created_at AS TEXT) AS created_at FROM supplier_product_security_event WHERE tenant_id = ? AND record_id = ? ORDER BY created_at DESC, id DESC LIMIT 100", + ) + .bind(tenant_id) + .bind(record_id) + .fetch_all(pool) + .await?; + rows.into_iter() + .map(event_from_sqlite_row) + .collect::, _>>() + .map_err(Into::into) +} + +async fn list_events_postgres( + pool: &PgPool, + tenant_id: i64, + record_id: i64, +) -> SupplierProductSecurityResult> { + let rows = sqlx::query( + "SELECT id, record_id, event_type, actor_id, detail_json, created_at FROM supplier_product_security_event WHERE tenant_id = $1 AND record_id = $2 ORDER BY created_at DESC, id DESC LIMIT 100", + ) + .bind(tenant_id) + .bind(record_id) + .fetch_all(pool) + .await?; + rows.into_iter() + .map(event_from_pg_row) + .collect::, _>>() + .map_err(Into::into) +} + +struct ContractHistoryInsert<'a> { + tenant_id: i64, + record_id: i64, + supplier_id: i64, + actor_id: i64, + version_number: i64, + previous_status: &'a str, + new_status: &'a str, + change_reason: &'a str, + evidence_ids: &'a [i64], +} + +async fn insert_contract_history_sqlite( + pool: &SqlitePool, + history: &ContractHistoryInsert<'_>, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO supplier_contract_exit_history (tenant_id, record_id, supplier_id, version_number, changed_by, change_reason, previous_status, new_status, summary, evidence_ids_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(history.tenant_id) + .bind(history.record_id) + .bind(history.supplier_id) + .bind(history.version_number) + .bind(history.actor_id) + .bind(history.change_reason) + .bind(history.previous_status) + .bind(history.new_status) + .bind(format!( + "Contract-/Exit-Plan-Version {} erfasst.", + history.version_number + )) + .bind(json_ids_i64(history.evidence_ids)) + .execute(pool) + .await?; + Ok(()) +} + +async fn insert_contract_history_postgres( + pool: &PgPool, + history: &ContractHistoryInsert<'_>, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO supplier_contract_exit_history (tenant_id, record_id, supplier_id, version_number, changed_by, change_reason, previous_status, new_status, summary, evidence_ids_json) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + ) + .bind(history.tenant_id) + .bind(history.record_id) + .bind(history.supplier_id) + .bind(history.version_number) + .bind(history.actor_id) + .bind(history.change_reason) + .bind(history.previous_status) + .bind(history.new_status) + .bind(format!( + "Contract-/Exit-Plan-Version {} erfasst.", + history.version_number + )) + .bind(json_ids_i64(history.evidence_ids)) + .execute(pool) + .await?; + Ok(()) +} + +async fn next_history_version_sqlite( + pool: &SqlitePool, + tenant_id: i64, + record_id: i64, +) -> Result { + let value = sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(MAX(version_number), 0) + 1 FROM supplier_contract_exit_history WHERE tenant_id = ? AND record_id = ?", + ) + .bind(tenant_id) + .bind(record_id) + .fetch_one(pool) + .await?; + Ok(value) +} + +async fn next_history_version_postgres( + pool: &PgPool, + tenant_id: i64, + record_id: i64, +) -> Result { + let value = sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(MAX(version_number), 0) + 1 FROM supplier_contract_exit_history WHERE tenant_id = $1 AND record_id = $2", + ) + .bind(tenant_id) + .bind(record_id) + .fetch_one(pool) + .await?; + Ok(value) +} + +async fn list_record_history_sqlite( + pool: &SqlitePool, + tenant_id: i64, + record_id: i64, +) -> SupplierProductSecurityResult> { + let rows = sqlx::query( + "SELECT * FROM supplier_contract_exit_history WHERE tenant_id = ? AND record_id = ? ORDER BY version_number DESC, id DESC", + ) + .bind(tenant_id) + .bind(record_id) + .fetch_all(pool) + .await?; + rows.into_iter() + .map(history_from_sqlite_row) + .collect::, _>>() + .map_err(Into::into) +} + +async fn list_record_history_postgres( + pool: &PgPool, + tenant_id: i64, + record_id: i64, +) -> SupplierProductSecurityResult> { + let rows = sqlx::query( + "SELECT * FROM supplier_contract_exit_history WHERE tenant_id = $1 AND record_id = $2 ORDER BY version_number DESC, id DESC", + ) + .bind(tenant_id) + .bind(record_id) + .fetch_all(pool) + .await?; + rows.into_iter() + .map(history_from_pg_row) + .collect::, _>>() + .map_err(Into::into) +} + +async fn list_supplier_history_sqlite( + pool: &SqlitePool, + tenant_id: i64, + supplier_id: i64, +) -> SupplierProductSecurityResult> { + let rows = sqlx::query( + "SELECT * FROM supplier_contract_exit_history WHERE tenant_id = ? AND supplier_id = ? ORDER BY changed_at DESC, id DESC LIMIT 100", + ) + .bind(tenant_id) + .bind(supplier_id) + .fetch_all(pool) + .await?; + rows.into_iter() + .map(history_from_sqlite_row) + .collect::, _>>() + .map_err(Into::into) +} + +async fn list_supplier_history_postgres( + pool: &PgPool, + tenant_id: i64, + supplier_id: i64, +) -> SupplierProductSecurityResult> { + let rows = sqlx::query( + "SELECT * FROM supplier_contract_exit_history WHERE tenant_id = $1 AND supplier_id = $2 ORDER BY changed_at DESC, id DESC LIMIT 100", + ) + .bind(tenant_id) + .bind(supplier_id) + .fetch_all(pool) + .await?; + rows.into_iter() + .map(history_from_pg_row) + .collect::, _>>() + .map_err(Into::into) +} + +async fn ensure_supplier_sqlite( + pool: &SqlitePool, + tenant_id: i64, + supplier_id: i64, +) -> SupplierProductSecurityResult<()> { + let exists = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM organizations_supplier WHERE tenant_id = ? AND id = ?", + ) + .bind(tenant_id) + .bind(supplier_id) + .fetch_one(pool) + .await?; + if exists == 0 { + return Err(SupplierProductSecurityError::not_found()); + } + Ok(()) +} + +async fn ensure_supplier_postgres( + pool: &PgPool, + tenant_id: i64, + supplier_id: i64, +) -> SupplierProductSecurityResult<()> { + let exists = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*)::bigint FROM organizations_supplier WHERE tenant_id = $1 AND id = $2", + ) + .bind(tenant_id) + .bind(supplier_id) + .fetch_one(pool) + .await?; + if exists == 0 { + return Err(SupplierProductSecurityError::not_found()); + } + Ok(()) +} + +async fn ensure_record_sqlite( + pool: &SqlitePool, + tenant_id: i64, + record_id: i64, +) -> SupplierProductSecurityResult<()> { + if fetch_record_sqlite(pool, tenant_id, record_id) + .await? + .is_none() + { + return Err(SupplierProductSecurityError::not_found()); + } + Ok(()) +} + +async fn ensure_record_postgres( + pool: &PgPool, + tenant_id: i64, + record_id: i64, +) -> SupplierProductSecurityResult<()> { + if fetch_record_postgres(pool, tenant_id, record_id) + .await? + .is_none() + { + return Err(SupplierProductSecurityError::not_found()); + } + Ok(()) +} + +async fn ensure_product_sqlite( + pool: &SqlitePool, + tenant_id: i64, + product_id: Option, +) -> SupplierProductSecurityResult<()> { + let Some(product_id) = product_id else { + return Ok(()); + }; + let exists = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM product_security_product WHERE tenant_id = ? AND id = ?", + ) + .bind(tenant_id) + .bind(product_id) + .fetch_one(pool) + .await?; + if exists == 0 { + return Err(SupplierProductSecurityError::invalid( + "Produkt wurde fuer diesen Tenant nicht gefunden.", + )); + } + Ok(()) +} + +async fn ensure_product_postgres( + pool: &PgPool, + tenant_id: i64, + product_id: Option, +) -> SupplierProductSecurityResult<()> { + let Some(product_id) = product_id else { + return Ok(()); + }; + let exists = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*)::bigint FROM product_security_product WHERE tenant_id = $1 AND id = $2", + ) + .bind(tenant_id) + .bind(product_id) + .fetch_one(pool) + .await?; + if exists == 0 { + return Err(SupplierProductSecurityError::invalid( + "Produkt wurde fuer diesen Tenant nicht gefunden.", + )); + } + Ok(()) +} + +async fn ensure_evidence_ids_sqlite( + pool: &SqlitePool, + tenant_id: i64, + evidence_ids: &[i64], +) -> SupplierProductSecurityResult<()> { + for evidence_id in evidence_ids { + let exists = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM evidence_evidenceitem WHERE tenant_id = ? AND id = ?", + ) + .bind(tenant_id) + .bind(*evidence_id) + .fetch_one(pool) + .await?; + if exists == 0 { + return Err(SupplierProductSecurityError::invalid( + "Evidence wurde fuer diesen Tenant nicht gefunden.", + )); + } + } + Ok(()) +} + +async fn ensure_evidence_ids_postgres( + pool: &PgPool, + tenant_id: i64, + evidence_ids: &[i64], +) -> SupplierProductSecurityResult<()> { + for evidence_id in evidence_ids { + let exists = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*)::bigint FROM evidence_evidenceitem WHERE tenant_id = $1 AND id = $2", + ) + .bind(tenant_id) + .bind(*evidence_id) + .fetch_one(pool) + .await?; + if exists == 0 { + return Err(SupplierProductSecurityError::invalid( + "Evidence wurde fuer diesen Tenant nicht gefunden.", + )); + } + } + Ok(()) +} + +fn record_from_sqlite_row(row: SqliteRow) -> Result { + record_from_raw(RawRecord { + id: row.try_get("id")?, + tenant_id: row.try_get("tenant_id")?, + supplier_id: row.try_get("supplier_id")?, + supplier_name: row.try_get("supplier_name")?, + product_id: row.try_get("product_id")?, + product_name: row.try_get("product_name")?, + product_or_service: row.try_get("product_or_service")?, + criticality: row.try_get("criticality")?, + internal_owner: row.try_get("internal_owner")?, + supplier_security_contact: row.try_get("supplier_security_contact")?, + product_security_status: row.try_get("product_security_status")?, + advisory_id: row.try_get("advisory_id")?, + advisory_source_type: row.try_get("advisory_source_type")?, + advisory_reference: row.try_get("advisory_reference")?, + cve_ids_json: row.try_get("cve_ids_json")?, + affected_versions: row.try_get("affected_versions")?, + fixed_versions: row.try_get("fixed_versions")?, + severity: row.try_get("severity")?, + cvss_score: row.try_get("cvss_score")?, + epss_score: row.try_get("epss_score")?, + exploitation_status: row.try_get("exploitation_status")?, + affected_assets_summary: row.try_get("affected_assets_summary")?, + impact_summary: row.try_get("impact_summary")?, + remediation_summary: row.try_get("remediation_summary")?, + workaround_summary: row.try_get("workaround_summary")?, + review_status: row.try_get("review_status")?, + owner: row.try_get("owner")?, + due_date: row.try_get("due_date")?, + evidence_ids_json: row.try_get("evidence_ids_json")?, + sbom_vex_reference: row.try_get("sbom_vex_reference")?, + open_actions: row.try_get("open_actions")?, + management_review_reference: row.try_get("management_review_reference")?, + contract_status: row.try_get("contract_status")?, + contract_review_date: row.try_get("contract_review_date")?, + next_contract_review_due: row.try_get("next_contract_review_due")?, + exit_plan_status: row.try_get("exit_plan_status")?, + exit_plan_version: row.try_get("exit_plan_version")?, + exit_plan_review_date: row.try_get("exit_plan_review_date")?, + exit_plan_owner: row.try_get("exit_plan_owner")?, + critical_service_dependency: row.try_get("critical_service_dependency")?, + data_processing_relevance: row.try_get("data_processing_relevance")?, + dora_ict_third_party_relevance: row.try_get("dora_ict_third_party_relevance")?, + nis2_supply_chain_relevance: row.try_get("nis2_supply_chain_relevance")?, + termination_risk_summary: row.try_get("termination_risk_summary")?, + alternative_supplier_summary: row.try_get("alternative_supplier_summary")?, + created_by_id: row.try_get("created_by_id")?, + updated_by_id: row.try_get("updated_by_id")?, + reviewed_at: row.try_get("reviewed_at")?, + reviewed_by: row.try_get("reviewed_by")?, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + }) +} + +fn record_from_pg_row(row: PgRow) -> Result { + record_from_raw(RawRecord { + id: row.try_get("id")?, + tenant_id: row.try_get("tenant_id")?, + supplier_id: row.try_get("supplier_id")?, + supplier_name: row.try_get("supplier_name")?, + product_id: row.try_get("product_id")?, + product_name: row.try_get("product_name")?, + product_or_service: row.try_get("product_or_service")?, + criticality: row.try_get("criticality")?, + internal_owner: row.try_get("internal_owner")?, + supplier_security_contact: row.try_get("supplier_security_contact")?, + product_security_status: row.try_get("product_security_status")?, + advisory_id: row.try_get("advisory_id")?, + advisory_source_type: row.try_get("advisory_source_type")?, + advisory_reference: row.try_get("advisory_reference")?, + cve_ids_json: row.try_get("cve_ids_json")?, + affected_versions: row.try_get("affected_versions")?, + fixed_versions: row.try_get("fixed_versions")?, + severity: row.try_get("severity")?, + cvss_score: row.try_get("cvss_score")?, + epss_score: row.try_get("epss_score")?, + exploitation_status: row.try_get("exploitation_status")?, + affected_assets_summary: row.try_get("affected_assets_summary")?, + impact_summary: row.try_get("impact_summary")?, + remediation_summary: row.try_get("remediation_summary")?, + workaround_summary: row.try_get("workaround_summary")?, + review_status: row.try_get("review_status")?, + owner: row.try_get("owner")?, + due_date: row.try_get("due_date")?, + evidence_ids_json: row.try_get("evidence_ids_json")?, + sbom_vex_reference: row.try_get("sbom_vex_reference")?, + open_actions: row.try_get("open_actions")?, + management_review_reference: row.try_get("management_review_reference")?, + contract_status: row.try_get("contract_status")?, + contract_review_date: row.try_get("contract_review_date")?, + next_contract_review_due: row.try_get("next_contract_review_due")?, + exit_plan_status: row.try_get("exit_plan_status")?, + exit_plan_version: row.try_get("exit_plan_version")?, + exit_plan_review_date: row.try_get("exit_plan_review_date")?, + exit_plan_owner: row.try_get("exit_plan_owner")?, + critical_service_dependency: row.try_get("critical_service_dependency")?, + data_processing_relevance: row.try_get("data_processing_relevance")?, + dora_ict_third_party_relevance: row.try_get("dora_ict_third_party_relevance")?, + nis2_supply_chain_relevance: row.try_get("nis2_supply_chain_relevance")?, + termination_risk_summary: row.try_get("termination_risk_summary")?, + alternative_supplier_summary: row.try_get("alternative_supplier_summary")?, + created_by_id: row.try_get("created_by_id")?, + updated_by_id: row.try_get("updated_by_id")?, + reviewed_at: row.try_get("reviewed_at")?, + reviewed_by: row.try_get("reviewed_by")?, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + }) +} + +struct RawRecord { + id: i64, + tenant_id: i64, + supplier_id: i64, + supplier_name: String, + product_id: Option, + product_name: Option, + product_or_service: String, + criticality: String, + internal_owner: String, + supplier_security_contact: String, + product_security_status: String, + advisory_id: String, + advisory_source_type: String, + advisory_reference: String, + cve_ids_json: String, + affected_versions: String, + fixed_versions: String, + severity: String, + cvss_score: Option, + epss_score: Option, + exploitation_status: String, + affected_assets_summary: String, + impact_summary: String, + remediation_summary: String, + workaround_summary: String, + review_status: String, + owner: String, + due_date: Option, + evidence_ids_json: String, + sbom_vex_reference: String, + open_actions: String, + management_review_reference: String, + contract_status: String, + contract_review_date: Option, + next_contract_review_due: Option, + exit_plan_status: String, + exit_plan_version: String, + exit_plan_review_date: Option, + exit_plan_owner: String, + critical_service_dependency: bool, + data_processing_relevance: bool, + dora_ict_third_party_relevance: bool, + nis2_supply_chain_relevance: bool, + termination_risk_summary: String, + alternative_supplier_summary: String, + created_by_id: Option, + updated_by_id: Option, + reviewed_at: Option, + reviewed_by: Option, + created_at: String, + updated_at: String, +} + +fn record_from_raw(raw: RawRecord) -> Result { + let criticality = normalize_lossy(&raw.criticality); + let product_security_status = normalize_lossy(&raw.product_security_status); + let advisory_source_type = normalize_lossy(&raw.advisory_source_type); + let severity = normalize_lossy(&raw.severity); + let exploitation_status = normalize_lossy(&raw.exploitation_status); + let review_status = normalize_lossy(&raw.review_status); + let contract_status = normalize_lossy(&raw.contract_status); + let exit_plan_status = normalize_lossy(&raw.exit_plan_status); + Ok(SupplierProductSecurityRecord { + id: raw.id, + tenant_id: raw.tenant_id, + supplier_id: raw.supplier_id, + supplier_name: raw.supplier_name, + product_id: raw.product_id, + product_name: raw.product_name, + product_or_service: raw.product_or_service, + criticality_label: criticality_label(&criticality).to_string(), + criticality, + internal_owner: raw.internal_owner, + supplier_security_contact: raw.supplier_security_contact, + product_security_status_label: product_security_status_label(&product_security_status) + .to_string(), + product_security_status, + advisory_id: raw.advisory_id, + advisory_source_type_label: advisory_source_type_label(&advisory_source_type).to_string(), + advisory_source_type, + advisory_reference: raw.advisory_reference, + cve_ids: parse_string_vec(&raw.cve_ids_json), + affected_versions: raw.affected_versions, + fixed_versions: raw.fixed_versions, + severity_label: severity_label(&severity).to_string(), + severity, + cvss_score: raw.cvss_score, + epss_score: raw.epss_score, + exploitation_status_label: exploitation_status_label(&exploitation_status).to_string(), + exploitation_status, + affected_assets_summary: raw.affected_assets_summary, + impact_summary: raw.impact_summary, + remediation_summary: raw.remediation_summary, + workaround_summary: raw.workaround_summary, + review_status_label: review_status_label(&review_status).to_string(), + review_status, + owner: raw.owner, + due_date: raw.due_date, + evidence_ids: parse_i64_vec(&raw.evidence_ids_json), + sbom_vex_reference: raw.sbom_vex_reference, + open_actions: raw.open_actions, + management_review_reference: raw.management_review_reference, + contract_status_label: contract_status_label(&contract_status).to_string(), + contract_status, + contract_review_date: raw.contract_review_date, + next_contract_review_due: raw.next_contract_review_due, + exit_plan_status_label: exit_plan_status_label(&exit_plan_status).to_string(), + exit_plan_status, + exit_plan_version: raw.exit_plan_version, + exit_plan_review_date: raw.exit_plan_review_date, + exit_plan_owner: raw.exit_plan_owner, + critical_service_dependency: raw.critical_service_dependency, + data_processing_relevance: raw.data_processing_relevance, + dora_ict_third_party_relevance: raw.dora_ict_third_party_relevance, + nis2_supply_chain_relevance: raw.nis2_supply_chain_relevance, + termination_risk_summary: raw.termination_risk_summary, + alternative_supplier_summary: raw.alternative_supplier_summary, + created_by_id: raw.created_by_id, + updated_by_id: raw.updated_by_id, + reviewed_at: raw.reviewed_at, + reviewed_by: raw.reviewed_by, + created_at: raw.created_at, + updated_at: raw.updated_at, + }) +} + +fn evidence_link_from_sqlite_row( + row: SqliteRow, +) -> Result { + Ok(SupplierProductSecurityEvidenceLink { + id: row.try_get("id")?, + record_id: row.try_get("record_id")?, + evidence_id: row.try_get("evidence_id")?, + title: row.try_get("title")?, + status: row.try_get("status")?, + link_type: row.try_get("link_type")?, + created_at: row.try_get("created_at")?, + }) +} + +fn evidence_link_from_pg_row( + row: PgRow, +) -> Result { + Ok(SupplierProductSecurityEvidenceLink { + id: row.try_get("id")?, + record_id: row.try_get("record_id")?, + evidence_id: row.try_get("evidence_id")?, + title: row.try_get("title")?, + status: row.try_get("status")?, + link_type: row.try_get("link_type")?, + created_at: row.try_get("created_at")?, + }) +} + +fn event_from_sqlite_row(row: SqliteRow) -> Result { + Ok(SupplierProductSecurityEvent { + id: row.try_get("id")?, + record_id: row.try_get("record_id")?, + event_type: row.try_get("event_type")?, + actor_id: row.try_get("actor_id")?, + detail_json: parse_json_object(row.try_get("detail_json")?), + created_at: row.try_get("created_at")?, + }) +} + +fn event_from_pg_row(row: PgRow) -> Result { + Ok(SupplierProductSecurityEvent { + id: row.try_get("id")?, + record_id: row.try_get("record_id")?, + event_type: row.try_get("event_type")?, + actor_id: row.try_get("actor_id")?, + detail_json: parse_json_object(row.try_get("detail_json")?), + created_at: row.try_get("created_at")?, + }) +} + +fn history_from_sqlite_row( + row: SqliteRow, +) -> Result { + Ok(SupplierContractExitHistoryEntry { + id: row.try_get("id")?, + record_id: row.try_get("record_id")?, + supplier_id: row.try_get("supplier_id")?, + version_number: row.try_get("version_number")?, + changed_by: row.try_get("changed_by")?, + changed_at: row.try_get("changed_at")?, + change_reason: row.try_get("change_reason")?, + previous_status: row.try_get("previous_status")?, + new_status: row.try_get("new_status")?, + summary: row.try_get("summary")?, + evidence_ids: parse_i64_vec(row.try_get("evidence_ids_json")?), + }) +} + +fn history_from_pg_row(row: PgRow) -> Result { + Ok(SupplierContractExitHistoryEntry { + id: row.try_get("id")?, + record_id: row.try_get("record_id")?, + supplier_id: row.try_get("supplier_id")?, + version_number: row.try_get("version_number")?, + changed_by: row.try_get("changed_by")?, + changed_at: row.try_get("changed_at")?, + change_reason: row.try_get("change_reason")?, + previous_status: row.try_get("previous_status")?, + new_status: row.try_get("new_status")?, + summary: row.try_get("summary")?, + evidence_ids: parse_i64_vec(row.try_get("evidence_ids_json")?), + }) +} + +fn supplier_product_security_summary( + records: &[SupplierProductSecurityRecord], +) -> SupplierProductSecuritySummary { + let today = Utc::now().date_naive().to_string(); + SupplierProductSecuritySummary { + total_records: records.len() as i64, + open_advisories: records + .iter() + .filter(|record| { + !matches!( + record.review_status.as_str(), + "closed" | "mitigated" | "not_applicable" + ) + }) + .count() as i64, + critical_records: records + .iter() + .filter(|record| record.severity == "critical" || record.criticality == "critical") + .count() as i64, + overdue_reviews: records + .iter() + .filter(|record| { + record + .due_date + .as_deref() + .is_some_and(|due| due < today.as_str()) + }) + .count() as i64, + missing_evidence: records + .iter() + .filter(|record| record.evidence_ids.is_empty()) + .count() as i64, + dora_relevant: records + .iter() + .filter(|record| record.dora_ict_third_party_relevance) + .count() as i64, + nis2_relevant: records + .iter() + .filter(|record| record.nis2_supply_chain_relevance) + .count() as i64, + data_processing_relevant: records + .iter() + .filter(|record| record.data_processing_relevance) + .count() as i64, + critical_services: records + .iter() + .filter(|record| record.critical_service_dependency) + .count() as i64, + } +} + +fn supplier_product_security_filter_summary(filters: &SupplierProductSecurityFilters) -> Value { + json!({ + "supplier_id": filters.supplier_id, + "product": filters.product, + "review_status": filters.review_status, + "severity": filters.severity, + "dora_relevant": filters.dora_relevant, + "nis2_relevant": filters.nis2_relevant, + "data_processing_relevant": filters.data_processing_relevant, + "critical_services": filters.critical_services, + "overdue": filters.overdue, + "limit": filters.limit + }) +} + +fn normalized_required_text( + value: &str, + max_len: usize, + field: &str, +) -> SupplierProductSecurityResult { + let value = normalized_text(value, max_len, field)?; + if value.is_empty() { + return Err(SupplierProductSecurityError::invalid(format!( + "{field} darf nicht leer sein." + ))); + } + Ok(value) +} + +fn normalized_text( + value: &str, + max_len: usize, + field: &str, +) -> SupplierProductSecurityResult { + let value = value.trim(); + if value.chars().any(char::is_control) { + return Err(SupplierProductSecurityError::invalid(format!( + "{field} enthaelt nicht erlaubte Steuerzeichen." + ))); + } + if value.chars().count() > max_len { + return Err(SupplierProductSecurityError::invalid(format!( + "{field} ist zu lang." + ))); + } + Ok(value.to_string()) +} + +fn normalized_reference(value: &str) -> SupplierProductSecurityResult { + let value = normalized_text(value, 2_000, "Referenz")?; + let lower = value.to_ascii_lowercase(); + if lower.contains("://") && !(lower.starts_with("https://") || lower.starts_with("http://")) { + return Err(SupplierProductSecurityError::invalid( + "Referenz-URLs duerfen nur http oder https verwenden.", + )); + } + if lower.starts_with("javascript:") + || lower.starts_with("data:") + || lower.starts_with("file:") + || lower.starts_with("ftp:") + { + return Err(SupplierProductSecurityError::invalid( + "Referenz enthaelt ein nicht erlaubtes URL-Schema.", + )); + } + Ok(value) +} + +fn normalized_optional_date( + value: Option<&str>, + field: &str, +) -> SupplierProductSecurityResult> { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + NaiveDate::parse_from_str(value, "%Y-%m-%d").map_err(|_| { + SupplierProductSecurityError::invalid(format!("{field} muss im Format YYYY-MM-DD sein.")) + })?; + Ok(Some(value.to_string())) +} + +fn normalize_score( + score: Option, + min: f64, + max: f64, + field: &str, +) -> SupplierProductSecurityResult> { + if let Some(score) = score { + if !score.is_finite() || score < min || score > max { + return Err(SupplierProductSecurityError::invalid(format!( + "{field}-Score ist ausserhalb des erlaubten Bereichs." + ))); + } + return Ok(Some((score * 1000.0).round() / 1000.0)); + } + Ok(None) +} + +fn normalize_positive_ids(ids: Vec, field: &str) -> SupplierProductSecurityResult> { + let mut seen = BTreeSet::new(); + for id in ids { + if id <= 0 { + return Err(SupplierProductSecurityError::invalid(format!( + "{field} muessen positiv sein." + ))); + } + seen.insert(id); + } + Ok(seen.into_iter().collect()) +} + +fn normalize_cve_ids(ids: Vec) -> SupplierProductSecurityResult> { + let mut seen = BTreeSet::new(); + for id in ids { + let id = id.trim().to_ascii_uppercase(); + if id.is_empty() { + continue; + } + let valid = id + .strip_prefix("CVE-") + .and_then(|rest| rest.split_once('-')) + .is_some_and(|(year, number)| { + year.len() == 4 + && year.chars().all(|ch| ch.is_ascii_digit()) + && number.len() >= 4 + && number.chars().all(|ch| ch.is_ascii_digit()) + }); + if !valid { + return Err(SupplierProductSecurityError::invalid( + "CVE-IDs muessen dem Format CVE-YYYY-NNNN entsprechen.", + )); + } + seen.insert(id); + } + Ok(seen.into_iter().collect()) +} + +fn normalize_criticality(value: &str) -> SupplierProductSecurityResult { + normalize_enum( + value, + &["critical", "high", "medium", "low", "unknown"], + "Kritikalitaet", + ) +} + +fn normalize_product_security_status(value: &str) -> SupplierProductSecurityResult { + normalize_enum( + value, + &[ + "not_assessed", + "in_review", + "affected", + "not_affected", + "remediation_required", + "mitigated", + "closed", + ], + "Product-Security-Status", + ) +} + +fn normalize_advisory_source_type(value: &str) -> SupplierProductSecurityResult { + normalize_enum( + value, + &[ + "manual", "vendor", "psirt", "csaf", "sbom", "vex", "internal", + ], + "Advisory-Quelle", + ) +} + +fn normalize_severity(value: &str) -> SupplierProductSecurityResult { + normalize_enum( + value, + &["critical", "high", "medium", "low", "info", "unknown"], + "Schweregrad", + ) +} + +fn normalize_exploitation_status(value: &str) -> SupplierProductSecurityResult { + normalize_enum( + value, + &[ + "unknown", + "not_known", + "proof_of_concept", + "active", + "exploited", + "not_applicable", + ], + "Exploitation-Status", + ) +} + +fn normalize_review_status(value: &str) -> SupplierProductSecurityResult { + normalize_enum( + value, + &[ + "draft", + "needs_review", + "in_review", + "accepted_risk", + "remediation_required", + "mitigated", + "closed", + "not_applicable", + ], + "Review-Status", + ) +} + +fn normalize_contract_status(value: &str) -> SupplierProductSecurityResult { + normalize_enum( + value, + &[ + "not_recorded", + "draft", + "active", + "under_review", + "renewal_due", + "expired", + "terminated", + "exit_required", + "not_applicable", + ], + "Vertragsstatus", + ) +} + +fn normalize_exit_plan_status(value: &str) -> SupplierProductSecurityResult { + normalize_enum( + value, + &[ + "not_recorded", + "missing", + "draft", + "planned", + "tested", + "needs_update", + "accepted", + "not_applicable", + ], + "Exit-Plan-Status", + ) +} + +fn normalize_enum( + value: &str, + allowed: &[&str], + field: &str, +) -> SupplierProductSecurityResult { + let normalized = normalize_lossy(value); + if allowed.contains(&normalized.as_str()) { + Ok(normalized) + } else { + Err(SupplierProductSecurityError::invalid(format!( + "{field} ist nicht unterstuetzt." + ))) + } +} + +fn normalize_lossy(value: &str) -> String { + value.trim().to_ascii_lowercase().replace('-', "_") +} + +fn parse_string_vec(value: &str) -> Vec { + serde_json::from_str::>(value).unwrap_or_default() +} + +fn parse_i64_vec(value: &str) -> Vec { + serde_json::from_str::>(value).unwrap_or_default() +} + +fn parse_json_object(value: String) -> Value { + serde_json::from_str(&value).unwrap_or_else(|_| json!({})) +} + +fn json_ids(ids: &[String]) -> String { + serde_json::to_string(ids).unwrap_or_else(|_| "[]".to_string()) +} + +fn json_ids_i64(ids: &[i64]) -> String { + serde_json::to_string(ids).unwrap_or_else(|_| "[]".to_string()) +} + +fn criticality_label(value: &str) -> &'static str { + match value { + "critical" => "Kritisch", + "high" => "Hoch", + "medium" => "Mittel", + "low" => "Niedrig", + _ => "Nicht bewertet", + } +} + +fn product_security_status_label(value: &str) -> &'static str { + match value { + "in_review" => "In Pruefung", + "affected" => "Betroffen", + "not_affected" => "Nicht betroffen", + "remediation_required" => "Massnahme erforderlich", + "mitigated" => "Mitigiert", + "closed" => "Geschlossen", + _ => "Nicht bewertet", + } +} + +fn advisory_source_type_label(value: &str) -> &'static str { + match value { + "vendor" => "Hersteller", + "psirt" => "PSIRT", + "csaf" => "CSAF", + "sbom" => "SBOM", + "vex" => "VEX", + "internal" => "Intern", + _ => "Manuell", + } +} + +fn severity_label(value: &str) -> &'static str { + match value { + "critical" => "Kritisch", + "high" => "Hoch", + "medium" => "Mittel", + "low" => "Niedrig", + "info" => "Info", + _ => "Nicht bewertet", + } +} + +fn exploitation_status_label(value: &str) -> &'static str { + match value { + "not_known" => "Nicht bekannt", + "proof_of_concept" => "Proof of Concept", + "active" => "Aktiv", + "exploited" => "Ausgenutzt", + "not_applicable" => "Nicht anwendbar", + _ => "Unbekannt", + } +} + +fn review_status_label(value: &str) -> &'static str { + match value { + "needs_review" => "Review offen", + "in_review" => "In Review", + "accepted_risk" => "Risiko akzeptiert", + "remediation_required" => "Massnahme erforderlich", + "mitigated" => "Mitigiert", + "closed" => "Geschlossen", + "not_applicable" => "Nicht anwendbar", + _ => "Entwurf", + } +} + +fn contract_status_label(value: &str) -> &'static str { + match value { + "draft" => "Entwurf", + "active" => "Aktiv", + "under_review" => "In Vertragspruefung", + "renewal_due" => "Verlaengerung faellig", + "expired" => "Abgelaufen", + "terminated" => "Beendet", + "exit_required" => "Exit erforderlich", + "not_applicable" => "Nicht anwendbar", + _ => "Nicht erfasst", + } +} + +fn exit_plan_status_label(value: &str) -> &'static str { + match value { + "missing" => "Fehlt", + "draft" => "Entwurf", + "planned" => "Geplant", + "tested" => "Getestet", + "needs_update" => "Aktualisierung erforderlich", + "accepted" => "Akzeptiert", + "not_applicable" => "Nicht anwendbar", + _ => "Nicht erfasst", + } +} diff --git a/rust/iscy-backend/tests/http_tests.rs b/rust/iscy-backend/tests/http_tests.rs index 5926348..49109f8 100644 --- a/rust/iscy-backend/tests/http_tests.rs +++ b/rust/iscy-backend/tests/http_tests.rs @@ -31,6 +31,7 @@ use iscy_backend::{ risk_store::RiskStore, roadmap_store::RoadmapStore, security_store::SecurityStore, + supplier_product_security_store::SupplierProductSecurityStore, supplier_store::SupplierStore, tenant_store::TenantStore, wizard_store::WizardStore, @@ -693,8 +694,8 @@ async fn rust_status_page_reports_database_migration_and_build_status() { let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); let html = String::from_utf8(body.to_vec()).unwrap(); assert!(html.contains("Datenbank-Migrationen")); - assert!(html.contains("0033_rust_evidence_integrity_disposition")); - assert!(html.contains("33/33 angewendet")); + assert!(html.contains("0034_rust_supplier_product_security_governance")); + assert!(html.contains("34/34 angewendet")); assert!(html.contains("Version")); assert!(html.contains("Commit")); } @@ -4450,6 +4451,443 @@ async fn supplier_links_are_tenant_scoped_and_idempotent() { assert_eq!(response.status(), StatusCode::NOT_FOUND); } +#[tokio::test] +async fn supplier_product_security_workflow_is_tenant_scoped_and_feeds_review_packs() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + db_admin::run_sqlite_migrations(&pool).await.unwrap(); + sqlx::query( + r#" + INSERT INTO organizations_supplier ( + id, tenant_id, name, service_description, criticality, review_status, + approval_status, risk_assessment + ) VALUES + (50, 42, 'Secure Supplier', 'Managed service supplier', 'CRITICAL', 'approved', 'approved', 'high'), + (99, 99, 'Foreign Supplier', 'Other tenant', 'HIGH', 'approved', 'approved', 'high') + "#, + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + r#" + INSERT INTO product_security_product (id, tenant_id, name, code) + VALUES + (1100, 42, 'Managed Sensor Gateway', 'MSG'), + (9900, 99, 'Foreign Product', 'FOREIGN') + "#, + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + r#" + INSERT INTO evidence_evidenceitem (id, tenant_id, linked_requirement, title, status) + VALUES + (500, 42, 'SUPPLIER-PRODUCT-SECURITY:50', 'PSIRT Review Evidence', 'APPROVED'), + (501, 99, 'SUPPLIER-PRODUCT-SECURITY:99', 'Foreign Evidence', 'APPROVED') + "#, + ) + .execute(&pool) + .await + .unwrap(); + + let app = app_router_with_state( + AppState::default() + .with_supplier_product_security_store(Some( + SupplierProductSecurityStore::from_sqlite_pool(pool.clone()), + )) + .with_report_store(Some(ReportStore::from_sqlite_pool(pool.clone()))), + ); + + let create_payload: serde_json::Value = serde_json::from_str( + r#"{ + "supplier_id": 50, + "product_id": 1100, + "product_or_service": "Managed Sensor Gateway", + "criticality": "critical", + "internal_owner": "Platform Security", + "supplier_security_contact": "psirt@example.test", + "product_security_status": "affected", + "advisory_id": "PSIRT-2026-050", + "advisory_source_type": "psirt", + "advisory_reference": "https://example.test/advisories/psirt-2026-050", + "cve_ids": ["CVE-2026-0050"], + "affected_versions": "1.0 - 1.2", + "fixed_versions": "1.3", + "severity": "critical", + "cvss_score": 9.8, + "epss_score": 0.42, + "exploitation_status": "proof_of_concept", + "affected_assets_summary": "Edge gateways in production-like networks", + "impact_summary": "Remote compromise possible", + "remediation_summary": "Update firmware and verify SBOM/VEX", + "workaround_summary": "Restrict management interface", + "review_status": "needs_review", + "owner": "Supplier Security Owner", + "due_date": "2026-01-31", + "evidence_ids": [500], + "sbom_vex_reference": "https://example.test/sbom/msg-1.3.json", + "open_actions": "Patch window abstimmen", + "management_review_reference": "NIS2/DORA Review Q1", + "contract_status": "active", + "contract_review_date": "2026-01-15", + "next_contract_review_due": "2026-06-30", + "exit_plan_status": "planned", + "exit_plan_version": "v1", + "exit_plan_review_date": "2026-02-01", + "exit_plan_owner": "Vendor Manager", + "critical_service_dependency": true, + "data_processing_relevance": true, + "dora_ict_third_party_relevance": true, + "nis2_supply_chain_relevance": true, + "termination_risk_summary": "Supplier replacement needs controlled migration", + "alternative_supplier_summary": "Fallback supplier under review", + "change_reason": "Initial PSIRT review" + }"#, + ) + .unwrap(); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/suppliers/product-security") + .header("content-type", "application/json") + .header("x-iscy-tenant-id", "42") + .header("x-iscy-user-id", "7") + .header("x-iscy-roles", "AUDITOR") + .body(Body::from(create_payload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/suppliers/product-security") + .header("content-type", "application/json") + .header("x-iscy-tenant-id", "42") + .header("x-iscy-user-id", "7") + .header("x-iscy-roles", "ADMIN") + .body(Body::from(create_payload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let record_id = payload["detail"]["record"]["id"].as_i64().unwrap(); + assert_eq!(payload["detail"]["record"]["supplier_id"], 50); + assert_eq!(payload["detail"]["record"]["review_status"], "needs_review"); + assert_eq!(payload["detail"]["record"]["cve_ids"][0], "CVE-2026-0050"); + assert_eq!( + payload["detail"]["evidence_links"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert!(payload["detail"]["events"] + .as_array() + .unwrap() + .iter() + .all(|event| !event["detail_json"].to_string().contains("/home/"))); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/v1/suppliers/product-security?product=Sensor&status=needs_review&severity=critical&dora_relevant=true&nis2_relevant=true&data_processing_relevant=true&critical_services=true&overdue=true&limit=1") + .header("x-iscy-tenant-id", "42") + .header("x-iscy-user-id", "7") + .header("x-iscy-roles", "AUDITOR") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(payload["summary"]["total_records"], 1); + assert_eq!(payload["summary"]["open_advisories"], 1); + assert_eq!(payload["summary"]["critical_records"], 1); + assert_eq!(payload["summary"]["overdue_reviews"], 1); + assert_eq!(payload["summary"]["dora_relevant"], 1); + assert_eq!(payload["records"].as_array().unwrap().len(), 1); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/v1/suppliers/product-security?dora_relevant=maybe") + .header("x-iscy-tenant-id", "42") + .header("x-iscy-user-id", "7") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(payload["error_code"], "invalid_filter"); + assert!(!payload["message"].to_string().contains("sqlite")); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/v1/suppliers/product-security/{record_id}")) + .header("x-iscy-tenant-id", "99") + .header("x-iscy-user-id", "7") + .header("x-iscy-roles", "ADMIN") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let unsafe_reference = serde_json::json!({ + "advisory_reference": "javascript:alert(1)" + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri(format!("/api/v1/suppliers/product-security/{record_id}")) + .header("content-type", "application/json") + .header("x-iscy-tenant-id", "42") + .header("x-iscy-user-id", "7") + .header("x-iscy-roles", "ADMIN") + .body(Body::from(unsafe_reference.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(payload["error_code"], "invalid_payload"); + assert!(!payload["message"].to_string().contains("SELECT")); + + let update_payload = serde_json::json!({ + "contract_status": "renewal_due", + "exit_plan_status": "needs_update", + "open_actions": "Patch window und Exit-Test einplanen", + "change_reason": "Contract and exit review updated" + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri(format!("/api/v1/suppliers/product-security/{record_id}")) + .header("content-type", "application/json") + .header("x-iscy-tenant-id", "42") + .header("x-iscy-user-id", "7") + .header("x-iscy-roles", "CONTRIBUTOR") + .body(Body::from(update_payload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + payload["detail"]["record"]["contract_status"], + "renewal_due" + ); + assert_eq!( + payload["detail"]["record"]["exit_plan_status"], + "needs_update" + ); + assert!( + payload["detail"]["contract_exit_history"] + .as_array() + .unwrap() + .len() + >= 2 + ); + + let status_payload = serde_json::json!({ + "new_status": "accepted_risk", + "reason": "Business owner accepted temporary residual risk.", + "review_notes": "Review remains visible in management pack." + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!( + "/api/v1/suppliers/product-security/{record_id}/status" + )) + .header("content-type", "application/json") + .header("x-iscy-tenant-id", "42") + .header("x-iscy-user-id", "7") + .header("x-iscy-roles", "ADMIN") + .body(Body::from(status_payload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + payload["detail"]["record"]["review_status"], + "accepted_risk" + ); + + let foreign_evidence = serde_json::json!({"evidence_id": 501, "link_type": "review"}); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!( + "/api/v1/suppliers/product-security/{record_id}/evidence" + )) + .header("content-type", "application/json") + .header("x-iscy-tenant-id", "42") + .header("x-iscy-user-id", "7") + .header("x-iscy-roles", "ADMIN") + .body(Body::from(foreign_evidence.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!( + "/api/v1/suppliers/product-security/{record_id}/events" + )) + .header("x-iscy-tenant-id", "42") + .header("x-iscy-user-id", "7") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(payload["events"] + .as_array() + .unwrap() + .iter() + .any(|event| event["event_type"] == "supplier_product_security_status_changed")); + assert!(!payload.to_string().contains("Authorization")); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/v1/suppliers/50/contract-exit-history") + .header("x-iscy-tenant-id", "42") + .header("x-iscy-user-id", "7") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(payload["history"].as_array().unwrap().len() >= 2); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/suppliers/product-security/?tenant_id=42&user_id=7&status=accepted_risk") + .header("x-iscy-tenant-id", "42") + .header("x-iscy-user-id", "7") + .header("x-iscy-roles", "ADMIN") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("Supplier/Product-Security-Register")); + assert!(html.contains("Produkt/Service")); + assert!(html.contains("Offene Massnahmen")); + assert!(html.contains("Datensatz anlegen")); + assert!(!html.contains("/home/")); + + for pack_type in [ + "nis2_management_summary", + "dora_ict_risk_supplier_incident_summary", + "dsgvo_data_protection_review", + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!( + "/api/v1/regulatory/review-packs/{pack_type}/preview" + )) + .header("content-type", "application/json") + .header("x-iscy-tenant-id", "42") + .header("x-iscy-user-id", "7") + .header("x-iscy-roles", "ADMIN") + .body(Body::from( + serde_json::json!({ + "period_start": "2026-01-01", + "period_end": "2026-12-31" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + payload["preview"]["product_security_json"]["supplier_product_security_records"], + 1 + ); + assert_eq!( + payload["preview"]["gap_summary_json"]["open_supplier_product_security_advisories"], + 1 + ); + assert_eq!( + payload["preview"]["gap_summary_json"]["supplier_product_security_missing_owner"], + 0 + ); + assert!(payload["preview"]["gap_groups_json"] + .as_array() + .unwrap() + .iter() + .flat_map(|group| group["items"].as_array().into_iter().flatten()) + .any(|item| item["key"] == "open_supplier_product_security_advisories")); + } +} + #[tokio::test] async fn supplier_subprocessors_are_supplier_and_tenant_scoped() { let pool = SqlitePoolOptions::new() @@ -13800,7 +14238,8 @@ async fn rust_db_admin_migrates_and_seeds_demo_web_cutover_database() { "0030_rust_notification_dispatch_claim", "0031_rust_supplier_review_workflow", "0032_rust_management_regulatory_templates", - "0033_rust_evidence_integrity_disposition" + "0033_rust_evidence_integrity_disposition", + "0034_rust_supplier_product_security_governance" ] ); assert!( @@ -13997,6 +14436,14 @@ async fn rust_db_admin_migrates_and_seeds_demo_web_cutover_database() { .await .unwrap() ); + for table in [ + "supplier_product_security_record", + "supplier_product_security_evidence_link", + "supplier_product_security_event", + "supplier_contract_exit_history", + ] { + assert!(db_admin::sqlite_table_exists(&pool, table).await.unwrap()); + } let applied_again = db_admin::run_sqlite_migrations(&pool).await.unwrap(); assert!(applied_again.is_empty());