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 f71ced0..dcf4f30 100644 Binary files a/docs/ISCY_Handbuch.pdf and b/docs/ISCY_Handbuch.pdf differ 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 0000000..507f452 Binary files /dev/null and b/docs/assets/iscy-supplier-product-security.png differ 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());