From 8c6f30cf4de477afc33ea489b21c80da592360cb Mon Sep 17 00:00:00 2001 From: Enrico Wilhelm Date: Sun, 7 Jun 2026 09:02:52 +0200 Subject: [PATCH 1/2] Complete Rust runtime cutover --- .env.development.example | 21 +- .env.example | 62 +- .env.production.example | 45 +- .env.stage.example | 32 +- Dockerfile | 58 - Makefile | 29 +- README.md | 275 +--- SUPPORT_MATRIX.md | 62 +- apps/__init__.py | 0 apps/accounts/__init__.py | 0 apps/accounts/admin.py | 11 - apps/accounts/apps.py | 6 - apps/accounts/management/__init__.py | 0 apps/accounts/management/commands/__init__.py | 0 .../accounts/management/commands/seed_demo.py | 100 -- apps/accounts/migrations/0001_initial.py | 67 - apps/accounts/migrations/0002_initial.py | 58 - apps/accounts/migrations/__init__.py | 0 apps/accounts/models.py | 87 - apps/assessments/__init__.py | 0 apps/assessments/admin.py | 6 - apps/assessments/apps.py | 6 - apps/assessments/audit_models.py | 96 -- apps/assessments/forms.py | 34 - apps/assessments/migrations/0001_initial.py | 219 --- apps/assessments/migrations/__init__.py | 0 apps/assessments/models.py | 93 -- apps/assessments/review_models.py | 80 - apps/assessments/services.py | 340 ---- apps/assessments/soa_models.py | 74 - apps/assessments/tests.py | 265 ---- apps/assessments/urls.py | 42 - apps/assessments/v20_forms.py | 98 -- apps/assessments/v20_views.py | 203 --- apps/assessments/views.py | 87 - apps/assets_app/__init__.py | 0 apps/assets_app/admin.py | 10 - apps/assets_app/apps.py | 8 - apps/assets_app/forms.py | 17 - apps/assets_app/migrations/0001_initial.py | 41 - apps/assets_app/migrations/__init__.py | 0 apps/assets_app/models.py | 42 - apps/assets_app/services.py | 162 -- apps/assets_app/tests.py | 140 -- apps/assets_app/urls.py | 10 - apps/assets_app/views.py | 34 - apps/catalog/__init__.py | 0 apps/catalog/admin.py | 26 - apps/catalog/apps.py | 6 - apps/catalog/management/__init__.py | 0 apps/catalog/management/commands/__init__.py | 0 .../management/commands/seed_catalog.py | 240 --- apps/catalog/migrations/0001_initial.py | 99 -- apps/catalog/migrations/__init__.py | 0 apps/catalog/models.py | 102 -- apps/catalog/services.py | 207 --- apps/catalog/tests.py | 134 -- apps/catalog/urls.py | 8 - apps/catalog/views.py | 29 - apps/core/__init__.py | 0 apps/core/admin.py | 0 apps/core/apps.py | 9 - apps/core/checks.py | 65 - apps/core/context_processors.py | 7 - apps/core/forms.py | 59 - apps/core/middleware.py | 15 - apps/core/migrations/0001_initial.py | 38 - apps/core/migrations/__init__.py | 0 apps/core/mixins.py | 80 - apps/core/models.py | 156 -- apps/core/templatetags/__init__.py | 0 apps/core/templatetags/core_extras.py | 55 - apps/core/tests.py | 99 -- apps/core/views.py | 26 - apps/dashboard/__init__.py | 0 apps/dashboard/apps.py | 6 - apps/dashboard/migrations/__init__.py | 0 apps/dashboard/services.py | 77 - apps/dashboard/tests.py | 107 -- apps/dashboard/urls.py | 9 - apps/dashboard/views.py | 232 --- apps/evidence/__init__.py | 0 apps/evidence/admin.py | 16 - apps/evidence/apps.py | 6 - apps/evidence/forms.py | 18 - apps/evidence/migrations/0001_initial.py | 68 - apps/evidence/migrations/__init__.py | 0 apps/evidence/models.py | 89 -- apps/evidence/services.py | 554 ------- apps/evidence/tests.py | 273 ---- apps/evidence/urls.py | 11 - apps/evidence/validators.py | 33 - apps/evidence/views.py | 159 -- apps/guidance/__init__.py | 0 apps/guidance/admin.py | 15 - apps/guidance/apps.py | 6 - apps/guidance/management/__init__.py | 0 apps/guidance/management/commands/__init__.py | 0 .../management/commands/seed_guidance.py | 127 -- apps/guidance/migrations/0001_initial.py | 54 - apps/guidance/migrations/__init__.py | 0 apps/guidance/models.py | 49 - apps/guidance/services.py | 239 --- apps/guidance/tests.py | 135 -- apps/guidance/urls.py | 9 - apps/guidance/views.py | 42 - apps/import_center/__init__.py | 0 apps/import_center/apps.py | 8 - apps/import_center/forms.py | 15 - apps/import_center/migrations/__init__.py | 0 apps/import_center/services.py | 348 ---- apps/import_center/tests.py | 143 -- apps/import_center/urls.py | 12 - apps/import_center/views.py | 182 --- apps/organizations/__init__.py | 0 apps/organizations/admin.py | 8 - apps/organizations/apps.py | 6 - apps/organizations/country_catalog.py | 70 - apps/organizations/forms.py | 49 - apps/organizations/migrations/0001_initial.py | 113 -- apps/organizations/migrations/__init__.py | 0 apps/organizations/models.py | 121 -- apps/organizations/sector_catalog.py | 303 ---- apps/organizations/urls.py | 11 - apps/organizations/views.py | 18 - apps/organizations/views_api.py | 36 - apps/processes/__init__.py | 0 apps/processes/admin.py | 8 - apps/processes/apps.py | 6 - apps/processes/forms.py | 20 - apps/processes/migrations/0001_initial.py | 43 - apps/processes/migrations/__init__.py | 0 apps/processes/models.py | 38 - apps/processes/services.py | 356 ----- apps/processes/tests.py | 177 --- apps/processes/urls.py | 10 - apps/processes/views.py | 72 - apps/product_security/__init__.py | 0 apps/product_security/admin.py | 35 - apps/product_security/apps.py | 7 - apps/product_security/forms.py | 22 - .../commands/seed_product_security.py | 167 -- .../migrations/0001_initial.py | 175 -- ...t_critical_vulnerability_count_and_more.py | 179 --- apps/product_security/migrations/__init__.py | 0 apps/product_security/models.py | 401 ----- apps/product_security/services.py | 349 ---- apps/product_security/services_rust.py | 1045 ------------ apps/product_security/tests.py | 677 -------- apps/product_security/urls.py | 26 - apps/product_security/views.py | 198 --- apps/reports/__init__.py | 0 apps/reports/admin.py | 8 - apps/reports/apps.py | 6 - apps/reports/migrations/0001_initial.py | 46 - ...pshot_compliance_versions_json_and_more.py | 23 - apps/reports/migrations/__init__.py | 0 apps/reports/models.py | 35 - apps/reports/pdf_export.py | 239 --- apps/reports/services.py | 257 --- apps/reports/tests.py | 247 --- apps/reports/urls.py | 11 - apps/reports/views.py | 186 --- apps/requirements_app/__init__.py | 0 apps/requirements_app/admin.py | 30 - apps/requirements_app/apps.py | 6 - apps/requirements_app/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/seed_requirements.py | 729 --------- .../migrations/0001_initial.py | 37 - ...ion_requirement_coverage_level_and_more.py | 109 -- apps/requirements_app/migrations/__init__.py | 0 apps/requirements_app/models.py | 110 -- apps/requirements_app/services.py | 64 - apps/requirements_app/services_rust.py | 248 --- apps/requirements_app/tests.py | 176 --- apps/requirements_app/urls.py | 8 - apps/requirements_app/views.py | 31 - apps/risks/__init__.py | 0 apps/risks/admin.py | 15 - apps/risks/apps.py | 6 - apps/risks/forms.py | 30 - apps/risks/migrations/0001_initial.py | 67 - apps/risks/migrations/__init__.py | 0 apps/risks/models.py | 105 -- apps/risks/services.py | 509 ------ apps/risks/tests.py | 301 ---- apps/risks/urls.py | 11 - apps/risks/views.py | 88 -- apps/roadmap/__init__.py | 0 apps/roadmap/admin.py | 7 - apps/roadmap/apps.py | 6 - apps/roadmap/forms.py | 13 - apps/roadmap/migrations/0001_initial.py | 91 -- apps/roadmap/migrations/__init__.py | 0 apps/roadmap/models.py | 100 -- apps/roadmap/services.py | 526 ------ apps/roadmap/tests.py | 333 ---- apps/roadmap/urls.py | 13 - apps/roadmap/views.py | 503 ------ apps/vulnerability_intelligence/__init__.py | 0 apps/vulnerability_intelligence/admin.py | 17 - apps/vulnerability_intelligence/apps.py | 7 - apps/vulnerability_intelligence/forms.py | 71 - .../management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/_rust_canary.py | 34 - .../management/commands/check_local_llm.py | 42 - .../commands/import_cve_context_csv.py | 137 -- .../management/commands/import_epss_feed.py | 23 - .../management/commands/import_kev_catalog.py | 33 - .../management/commands/import_nvd_cves.py | 38 - .../commands/import_nvd_cves_canary.py | 18 - .../commands/import_nvd_cves_via_rust.py | 18 - .../commands/report_nvd_canary_parity.py | 16 - .../commands/report_nvd_canary_trend.py | 24 - .../management/commands/sync_nvd_recent.py | 34 - .../migrations/0001_initial.py | 82 - ...0002_cveassessment_intelligence_context.py | 76 - ...0003_cverecord_intelligence_feed_fields.py | 46 - .../migrations/__init__.py | 0 apps/vulnerability_intelligence/models.py | 123 -- apps/vulnerability_intelligence/services.py | 947 ----------- .../vulnerability_intelligence/dashboard.html | 94 -- .../vulnerability_intelligence/detail.html | 109 -- .../vulnerability_intelligence/llm_test.html | 56 - apps/vulnerability_intelligence/tests.py | 502 ------ apps/vulnerability_intelligence/urls.py | 11 - apps/vulnerability_intelligence/views.py | 115 -- apps/wizard/__init__.py | 0 apps/wizard/admin.py | 31 - apps/wizard/apps.py | 6 - apps/wizard/forms.py | 71 - apps/wizard/management/__init__.py | 0 apps/wizard/management/commands/__init__.py | 0 apps/wizard/migrations/0001_initial.py | 117 -- apps/wizard/migrations/__init__.py | 0 apps/wizard/models.py | 137 -- apps/wizard/services.py | 1403 ----------------- apps/wizard/tests.py | 355 ----- apps/wizard/urls.py | 20 - apps/wizard/views.py | 360 ----- config/__init__.py | 0 config/asgi.py | 5 - config/settings.py | 185 --- config/urls.py | 33 - config/wsgi.py | 5 - docker-compose.llm.yml | 5 - docker-compose.override.yml | 8 +- docker-compose.prod.yml | 14 +- docker-compose.stage.yml | 14 +- docker-compose.yml | 66 +- docker/entrypoint.sh | 88 -- docker/nginx/default.conf.template | 13 +- docs/ISCY_Handbuch.md | 7 +- docs/PROXMOX_PRODUCTION_RUNBOOK.md | 4 +- docs/RUST_ABLOESE_CHECKLISTE.md | 55 +- docs/RUST_CUTOVER_PLAN.md | 51 +- docs/RUST_CUTOVER_STATUS.md | 108 +- docs/RUST_REWRITE_ROADMAP.md | 160 +- docs/RUST_WEBSTACK_REWRITE_PLAN.md | 54 +- flake.nix | 2 - manage.py | 13 - requirements-llm.txt | 2 - requirements-prod.txt | 2 - requirements.txt | 9 - rust/iscy-backend/Dockerfile | 1 + scripts/backup_compose.sh | 2 +- scripts/download_local_llm.py | 51 - scripts/export_iscy_handbook_pdf.py | 87 - scripts/production_readiness_check.sh | 18 +- scripts/restore_compose.sh | 4 +- templates/assessments/applicability_list.html | 11 - templates/assessments/assessment_list.html | 11 - templates/assessments/audit_detail.html | 46 - templates/assessments/audit_form.html | 11 - templates/assessments/audit_list.html | 27 - templates/assessments/finding_form.html | 12 - templates/assessments/measure_list.html | 11 - templates/assessments/review_action_form.html | 11 - templates/assessments/review_detail.html | 50 - templates/assessments/review_form.html | 18 - templates/assessments/review_list.html | 25 - templates/assessments/soa_detail.html | 32 - templates/assessments/soa_entry_form.html | 13 - templates/assessments/soa_list.html | 21 - templates/assets/asset_list.html | 32 - templates/base.html | 829 ---------- templates/catalog/domain_list.html | 26 - templates/dashboard/home.html | 372 ----- templates/evidence/evidence_form.html | 31 - templates/evidence/evidence_list.html | 55 - templates/guidance/dashboard.html | 119 -- templates/guidance/step_detail.html | 35 - templates/imports/guide.html | 32 - templates/imports/import_center.html | 59 - templates/imports/mapping_assistant.html | 45 - templates/imports/preview.html | 71 - templates/organizations/tenant_list.html | 31 - templates/processes/process_detail.html | 37 - templates/processes/process_list.html | 33 - .../product_security/product_detail.html | 27 - templates/product_security/product_list.html | 80 - .../product_security/roadmap_detail.html | 57 - .../product_security/roadmap_task_form.html | 36 - .../product_security/vulnerability_form.html | 36 - templates/registration/login.html | 15 - templates/reports/report_detail.html | 72 - templates/reports/report_list.html | 35 - templates/requirements/requirement_list.html | 48 - templates/risks/risk_detail.html | 40 - templates/risks/risk_form.html | 19 - templates/risks/risk_list.html | 90 -- templates/roadmap/kanban.html | 39 - templates/roadmap/plan_detail.html | 302 ---- templates/roadmap/plan_list.html | 33 - templates/roadmap/task_form.html | 36 - templates/shared/form.html | 19 - templates/wizard/applicability.html | 58 - templates/wizard/maturity.html | 54 - templates/wizard/profile.html | 62 - templates/wizard/results.html | 146 -- templates/wizard/scope.html | 36 - templates/wizard/start.html | 55 - 324 files changed, 299 insertions(+), 27039 deletions(-) delete mode 100644 Dockerfile delete mode 100644 apps/__init__.py delete mode 100644 apps/accounts/__init__.py delete mode 100644 apps/accounts/admin.py delete mode 100644 apps/accounts/apps.py delete mode 100644 apps/accounts/management/__init__.py delete mode 100644 apps/accounts/management/commands/__init__.py delete mode 100644 apps/accounts/management/commands/seed_demo.py delete mode 100644 apps/accounts/migrations/0001_initial.py delete mode 100644 apps/accounts/migrations/0002_initial.py delete mode 100644 apps/accounts/migrations/__init__.py delete mode 100644 apps/accounts/models.py delete mode 100644 apps/assessments/__init__.py delete mode 100644 apps/assessments/admin.py delete mode 100644 apps/assessments/apps.py delete mode 100644 apps/assessments/audit_models.py delete mode 100644 apps/assessments/forms.py delete mode 100644 apps/assessments/migrations/0001_initial.py delete mode 100644 apps/assessments/migrations/__init__.py delete mode 100644 apps/assessments/models.py delete mode 100644 apps/assessments/review_models.py delete mode 100644 apps/assessments/services.py delete mode 100644 apps/assessments/soa_models.py delete mode 100644 apps/assessments/tests.py delete mode 100644 apps/assessments/urls.py delete mode 100644 apps/assessments/v20_forms.py delete mode 100644 apps/assessments/v20_views.py delete mode 100644 apps/assessments/views.py delete mode 100644 apps/assets_app/__init__.py delete mode 100644 apps/assets_app/admin.py delete mode 100644 apps/assets_app/apps.py delete mode 100644 apps/assets_app/forms.py delete mode 100644 apps/assets_app/migrations/0001_initial.py delete mode 100644 apps/assets_app/migrations/__init__.py delete mode 100644 apps/assets_app/models.py delete mode 100644 apps/assets_app/services.py delete mode 100644 apps/assets_app/tests.py delete mode 100644 apps/assets_app/urls.py delete mode 100644 apps/assets_app/views.py delete mode 100644 apps/catalog/__init__.py delete mode 100644 apps/catalog/admin.py delete mode 100644 apps/catalog/apps.py delete mode 100644 apps/catalog/management/__init__.py delete mode 100644 apps/catalog/management/commands/__init__.py delete mode 100644 apps/catalog/management/commands/seed_catalog.py delete mode 100644 apps/catalog/migrations/0001_initial.py delete mode 100644 apps/catalog/migrations/__init__.py delete mode 100644 apps/catalog/models.py delete mode 100644 apps/catalog/services.py delete mode 100644 apps/catalog/tests.py delete mode 100644 apps/catalog/urls.py delete mode 100644 apps/catalog/views.py delete mode 100644 apps/core/__init__.py delete mode 100644 apps/core/admin.py delete mode 100644 apps/core/apps.py delete mode 100644 apps/core/checks.py delete mode 100644 apps/core/context_processors.py delete mode 100644 apps/core/forms.py delete mode 100644 apps/core/middleware.py delete mode 100644 apps/core/migrations/0001_initial.py delete mode 100644 apps/core/migrations/__init__.py delete mode 100644 apps/core/mixins.py delete mode 100644 apps/core/models.py delete mode 100644 apps/core/templatetags/__init__.py delete mode 100644 apps/core/templatetags/core_extras.py delete mode 100644 apps/core/tests.py delete mode 100644 apps/core/views.py delete mode 100644 apps/dashboard/__init__.py delete mode 100644 apps/dashboard/apps.py delete mode 100644 apps/dashboard/migrations/__init__.py delete mode 100644 apps/dashboard/services.py delete mode 100644 apps/dashboard/tests.py delete mode 100644 apps/dashboard/urls.py delete mode 100644 apps/dashboard/views.py delete mode 100644 apps/evidence/__init__.py delete mode 100644 apps/evidence/admin.py delete mode 100644 apps/evidence/apps.py delete mode 100644 apps/evidence/forms.py delete mode 100644 apps/evidence/migrations/0001_initial.py delete mode 100644 apps/evidence/migrations/__init__.py delete mode 100644 apps/evidence/models.py delete mode 100644 apps/evidence/services.py delete mode 100644 apps/evidence/tests.py delete mode 100644 apps/evidence/urls.py delete mode 100644 apps/evidence/validators.py delete mode 100644 apps/evidence/views.py delete mode 100644 apps/guidance/__init__.py delete mode 100644 apps/guidance/admin.py delete mode 100644 apps/guidance/apps.py delete mode 100644 apps/guidance/management/__init__.py delete mode 100644 apps/guidance/management/commands/__init__.py delete mode 100644 apps/guidance/management/commands/seed_guidance.py delete mode 100644 apps/guidance/migrations/0001_initial.py delete mode 100644 apps/guidance/migrations/__init__.py delete mode 100644 apps/guidance/models.py delete mode 100644 apps/guidance/services.py delete mode 100644 apps/guidance/tests.py delete mode 100644 apps/guidance/urls.py delete mode 100644 apps/guidance/views.py delete mode 100644 apps/import_center/__init__.py delete mode 100644 apps/import_center/apps.py delete mode 100644 apps/import_center/forms.py delete mode 100644 apps/import_center/migrations/__init__.py delete mode 100644 apps/import_center/services.py delete mode 100644 apps/import_center/tests.py delete mode 100644 apps/import_center/urls.py delete mode 100644 apps/import_center/views.py delete mode 100644 apps/organizations/__init__.py delete mode 100644 apps/organizations/admin.py delete mode 100644 apps/organizations/apps.py delete mode 100644 apps/organizations/country_catalog.py delete mode 100644 apps/organizations/forms.py delete mode 100644 apps/organizations/migrations/0001_initial.py delete mode 100644 apps/organizations/migrations/__init__.py delete mode 100644 apps/organizations/models.py delete mode 100644 apps/organizations/sector_catalog.py delete mode 100644 apps/organizations/urls.py delete mode 100644 apps/organizations/views.py delete mode 100644 apps/organizations/views_api.py delete mode 100644 apps/processes/__init__.py delete mode 100644 apps/processes/admin.py delete mode 100644 apps/processes/apps.py delete mode 100644 apps/processes/forms.py delete mode 100644 apps/processes/migrations/0001_initial.py delete mode 100644 apps/processes/migrations/__init__.py delete mode 100644 apps/processes/models.py delete mode 100644 apps/processes/services.py delete mode 100644 apps/processes/tests.py delete mode 100644 apps/processes/urls.py delete mode 100644 apps/processes/views.py delete mode 100644 apps/product_security/__init__.py delete mode 100644 apps/product_security/admin.py delete mode 100644 apps/product_security/apps.py delete mode 100644 apps/product_security/forms.py delete mode 100644 apps/product_security/management/commands/seed_product_security.py delete mode 100644 apps/product_security/migrations/0001_initial.py delete mode 100644 apps/product_security/migrations/0002_productsecuritysnapshot_critical_vulnerability_count_and_more.py delete mode 100644 apps/product_security/migrations/__init__.py delete mode 100644 apps/product_security/models.py delete mode 100644 apps/product_security/services.py delete mode 100644 apps/product_security/services_rust.py delete mode 100644 apps/product_security/tests.py delete mode 100644 apps/product_security/urls.py delete mode 100644 apps/product_security/views.py delete mode 100644 apps/reports/__init__.py delete mode 100644 apps/reports/admin.py delete mode 100644 apps/reports/apps.py delete mode 100644 apps/reports/migrations/0001_initial.py delete mode 100644 apps/reports/migrations/0002_reportsnapshot_compliance_versions_json_and_more.py delete mode 100644 apps/reports/migrations/__init__.py delete mode 100644 apps/reports/models.py delete mode 100644 apps/reports/pdf_export.py delete mode 100644 apps/reports/services.py delete mode 100644 apps/reports/tests.py delete mode 100644 apps/reports/urls.py delete mode 100644 apps/reports/views.py delete mode 100644 apps/requirements_app/__init__.py delete mode 100644 apps/requirements_app/admin.py delete mode 100644 apps/requirements_app/apps.py delete mode 100644 apps/requirements_app/management/__init__.py delete mode 100644 apps/requirements_app/management/commands/__init__.py delete mode 100644 apps/requirements_app/management/commands/seed_requirements.py delete mode 100644 apps/requirements_app/migrations/0001_initial.py delete mode 100644 apps/requirements_app/migrations/0002_mappingversion_requirement_coverage_level_and_more.py delete mode 100644 apps/requirements_app/migrations/__init__.py delete mode 100644 apps/requirements_app/models.py delete mode 100644 apps/requirements_app/services.py delete mode 100644 apps/requirements_app/services_rust.py delete mode 100644 apps/requirements_app/tests.py delete mode 100644 apps/requirements_app/urls.py delete mode 100644 apps/requirements_app/views.py delete mode 100644 apps/risks/__init__.py delete mode 100644 apps/risks/admin.py delete mode 100644 apps/risks/apps.py delete mode 100644 apps/risks/forms.py delete mode 100644 apps/risks/migrations/0001_initial.py delete mode 100644 apps/risks/migrations/__init__.py delete mode 100644 apps/risks/models.py delete mode 100644 apps/risks/services.py delete mode 100644 apps/risks/tests.py delete mode 100644 apps/risks/urls.py delete mode 100644 apps/risks/views.py delete mode 100644 apps/roadmap/__init__.py delete mode 100644 apps/roadmap/admin.py delete mode 100644 apps/roadmap/apps.py delete mode 100644 apps/roadmap/forms.py delete mode 100644 apps/roadmap/migrations/0001_initial.py delete mode 100644 apps/roadmap/migrations/__init__.py delete mode 100644 apps/roadmap/models.py delete mode 100644 apps/roadmap/services.py delete mode 100644 apps/roadmap/tests.py delete mode 100644 apps/roadmap/urls.py delete mode 100644 apps/roadmap/views.py delete mode 100644 apps/vulnerability_intelligence/__init__.py delete mode 100644 apps/vulnerability_intelligence/admin.py delete mode 100644 apps/vulnerability_intelligence/apps.py delete mode 100644 apps/vulnerability_intelligence/forms.py delete mode 100644 apps/vulnerability_intelligence/management/__init__.py delete mode 100644 apps/vulnerability_intelligence/management/commands/__init__.py delete mode 100644 apps/vulnerability_intelligence/management/commands/_rust_canary.py delete mode 100644 apps/vulnerability_intelligence/management/commands/check_local_llm.py delete mode 100644 apps/vulnerability_intelligence/management/commands/import_cve_context_csv.py delete mode 100644 apps/vulnerability_intelligence/management/commands/import_epss_feed.py delete mode 100644 apps/vulnerability_intelligence/management/commands/import_kev_catalog.py delete mode 100644 apps/vulnerability_intelligence/management/commands/import_nvd_cves.py delete mode 100644 apps/vulnerability_intelligence/management/commands/import_nvd_cves_canary.py delete mode 100644 apps/vulnerability_intelligence/management/commands/import_nvd_cves_via_rust.py delete mode 100644 apps/vulnerability_intelligence/management/commands/report_nvd_canary_parity.py delete mode 100644 apps/vulnerability_intelligence/management/commands/report_nvd_canary_trend.py delete mode 100644 apps/vulnerability_intelligence/management/commands/sync_nvd_recent.py delete mode 100644 apps/vulnerability_intelligence/migrations/0001_initial.py delete mode 100644 apps/vulnerability_intelligence/migrations/0002_cveassessment_intelligence_context.py delete mode 100644 apps/vulnerability_intelligence/migrations/0003_cverecord_intelligence_feed_fields.py delete mode 100644 apps/vulnerability_intelligence/migrations/__init__.py delete mode 100644 apps/vulnerability_intelligence/models.py delete mode 100644 apps/vulnerability_intelligence/services.py delete mode 100644 apps/vulnerability_intelligence/templates/vulnerability_intelligence/dashboard.html delete mode 100644 apps/vulnerability_intelligence/templates/vulnerability_intelligence/detail.html delete mode 100644 apps/vulnerability_intelligence/templates/vulnerability_intelligence/llm_test.html delete mode 100644 apps/vulnerability_intelligence/tests.py delete mode 100644 apps/vulnerability_intelligence/urls.py delete mode 100644 apps/vulnerability_intelligence/views.py delete mode 100644 apps/wizard/__init__.py delete mode 100644 apps/wizard/admin.py delete mode 100644 apps/wizard/apps.py delete mode 100644 apps/wizard/forms.py delete mode 100644 apps/wizard/management/__init__.py delete mode 100644 apps/wizard/management/commands/__init__.py delete mode 100644 apps/wizard/migrations/0001_initial.py delete mode 100644 apps/wizard/migrations/__init__.py delete mode 100644 apps/wizard/models.py delete mode 100644 apps/wizard/services.py delete mode 100644 apps/wizard/tests.py delete mode 100644 apps/wizard/urls.py delete mode 100644 apps/wizard/views.py delete mode 100644 config/__init__.py delete mode 100644 config/asgi.py delete mode 100644 config/settings.py delete mode 100644 config/urls.py delete mode 100644 config/wsgi.py delete mode 100755 docker/entrypoint.sh delete mode 100644 manage.py delete mode 100644 requirements-llm.txt delete mode 100644 requirements-prod.txt delete mode 100644 requirements.txt delete mode 100755 scripts/download_local_llm.py delete mode 100644 scripts/export_iscy_handbook_pdf.py delete mode 100644 templates/assessments/applicability_list.html delete mode 100644 templates/assessments/assessment_list.html delete mode 100644 templates/assessments/audit_detail.html delete mode 100644 templates/assessments/audit_form.html delete mode 100644 templates/assessments/audit_list.html delete mode 100644 templates/assessments/finding_form.html delete mode 100644 templates/assessments/measure_list.html delete mode 100644 templates/assessments/review_action_form.html delete mode 100644 templates/assessments/review_detail.html delete mode 100644 templates/assessments/review_form.html delete mode 100644 templates/assessments/review_list.html delete mode 100644 templates/assessments/soa_detail.html delete mode 100644 templates/assessments/soa_entry_form.html delete mode 100644 templates/assessments/soa_list.html delete mode 100644 templates/assets/asset_list.html delete mode 100644 templates/base.html delete mode 100644 templates/catalog/domain_list.html delete mode 100644 templates/dashboard/home.html delete mode 100644 templates/evidence/evidence_form.html delete mode 100644 templates/evidence/evidence_list.html delete mode 100644 templates/guidance/dashboard.html delete mode 100644 templates/guidance/step_detail.html delete mode 100644 templates/imports/guide.html delete mode 100644 templates/imports/import_center.html delete mode 100644 templates/imports/mapping_assistant.html delete mode 100644 templates/imports/preview.html delete mode 100644 templates/organizations/tenant_list.html delete mode 100644 templates/processes/process_detail.html delete mode 100644 templates/processes/process_list.html delete mode 100644 templates/product_security/product_detail.html delete mode 100644 templates/product_security/product_list.html delete mode 100644 templates/product_security/roadmap_detail.html delete mode 100644 templates/product_security/roadmap_task_form.html delete mode 100644 templates/product_security/vulnerability_form.html delete mode 100644 templates/registration/login.html delete mode 100644 templates/reports/report_detail.html delete mode 100644 templates/reports/report_list.html delete mode 100644 templates/requirements/requirement_list.html delete mode 100644 templates/risks/risk_detail.html delete mode 100644 templates/risks/risk_form.html delete mode 100644 templates/risks/risk_list.html delete mode 100644 templates/roadmap/kanban.html delete mode 100644 templates/roadmap/plan_detail.html delete mode 100644 templates/roadmap/plan_list.html delete mode 100644 templates/roadmap/task_form.html delete mode 100644 templates/shared/form.html delete mode 100644 templates/wizard/applicability.html delete mode 100644 templates/wizard/maturity.html delete mode 100644 templates/wizard/profile.html delete mode 100644 templates/wizard/results.html delete mode 100644 templates/wizard/scope.html delete mode 100644 templates/wizard/start.html diff --git a/.env.development.example b/.env.development.example index 6c8f4ea..11c19f3 100644 --- a/.env.development.example +++ b/.env.development.example @@ -1,12 +1,17 @@ -DEBUG=True -SECRET_KEY=dev-secret-key -ALLOWED_HOSTS=127.0.0.1,localhost DATABASE_URL=postgresql://isms:isms@db:5432/isms +RUST_BACKEND_BIND=0.0.0.0:9000 +RUST_BACKEND_URL=http://app:9000 +ISCY_HTTP_PORT=9000 +ISCY_MEDIA_ROOT=/app/media + POSTGRES_DB=isms POSTGRES_USER=isms POSTGRES_PASSWORD=isms -RUN_MIGRATIONS=1 -RUN_SEEDS=1 -RUN_COLLECTSTATIC=0 -APP_SERVER=runserver -LOCAL_LLM_ENABLED=False + +NVD_API_BASE_URL=https://services.nvd.nist.gov +NVD_API_KEY= + +LOCAL_LLM_MODEL_NAME=iscy-rust-llm +LOCAL_LLM_N_CTX=8192 +LOCAL_LLM_N_THREADS=4 +LOCAL_LLM_GPU_LAYERS=0 diff --git a/.env.example b/.env.example index 7f0af9c..029bd6e 100644 --- a/.env.example +++ b/.env.example @@ -1,53 +1,17 @@ -DEBUG=True -SECRET_KEY=change-me -ALLOWED_HOSTS=127.0.0.1,localhost -CSRF_TRUSTED_ORIGINS= -USE_X_FORWARDED_HOST=False -TRUST_X_FORWARDED_PROTO=False -SECURE_SSL_REDIRECT=False -SESSION_COOKIE_SECURE=False -CSRF_COOKIE_SECURE=False -SECURE_HSTS_SECONDS=0 -SECURE_HSTS_INCLUDE_SUBDOMAINS=False -SECURE_HSTS_PRELOAD=False - -LOCAL_LLM_ENABLED=False -LOCAL_LLM_BACKEND=rust_service -LOCAL_LLM_MODEL_NAME=Qwen3-8B-GGUF -LOCAL_LLM_MODEL_PATH= -LOCAL_LLM_RUST_URL=http://rust-backend:9000 -RUST_BACKEND_URL=http://rust-backend:9000 -RUST_ONLY_MODE=True -RISK_SCORING_BACKEND=rust_service -GUIDANCE_SCORING_BACKEND=rust_service -REPORT_SUMMARY_BACKEND=rust_service -REPORT_SNAPSHOT_BACKEND=rust_service -DASHBOARD_SUMMARY_BACKEND=rust_service -CATALOG_BACKEND=rust_service -REQUIREMENTS_BACKEND=rust_service -ASSET_INVENTORY_BACKEND=rust_service -PROCESS_REGISTER_BACKEND=rust_service -RISK_REGISTER_BACKEND=rust_service -EVIDENCE_REGISTER_BACKEND=rust_service -ASSESSMENT_REGISTER_BACKEND=rust_service -ROADMAP_REGISTER_BACKEND=rust_service -WIZARD_RESULTS_BACKEND=rust_service -IMPORT_CENTER_BACKEND=rust_service -PRODUCT_SECURITY_BACKEND=rust_service -RUST_STRICT_MODE=True -LOCAL_LLM_N_CTX=8192 -LOCAL_LLM_N_THREADS=4 -LOCAL_LLM_GPU_LAYERS=0 -LOCAL_LLM_VERBOSE_NATIVE=False -LOCAL_LLM_TEST_MAX_TOKENS=96 -NVD_BASE_URL=https://services.nvd.nist.gov -NVD_API_KEY= - -LOCAL_LLM_HF_REPO_ID=MaziyarPanahi/Qwen3-8B-GGUF -LOCAL_LLM_HF_FILENAME=Qwen3-8B.Q4_K_M.gguf -LOCAL_LLM_TARGET_DIR=models +DATABASE_URL=sqlite:///db.sqlite3 +RUST_BACKEND_BIND=127.0.0.1:9000 +RUST_BACKEND_URL=http://127.0.0.1:9000 +ISCY_HTTP_PORT=9000 +ISCY_MEDIA_ROOT=media POSTGRES_DB=isms POSTGRES_USER=isms POSTGRES_PASSWORD=isms -DATABASE_URL=sqlite:///db.sqlite3 + +NVD_API_BASE_URL=https://services.nvd.nist.gov +NVD_API_KEY= + +LOCAL_LLM_MODEL_NAME=iscy-rust-llm +LOCAL_LLM_N_CTX=8192 +LOCAL_LLM_N_THREADS=4 +LOCAL_LLM_GPU_LAYERS=0 diff --git a/.env.production.example b/.env.production.example index fdb3399..5ef12c8 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,43 +1,20 @@ -DEBUG=False -SECRET_KEY=replace-me-with-a-long-random-secret -ALLOWED_HOSTS=localhost,127.0.0.1 -CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1 -USE_X_FORWARDED_HOST=True -TRUST_X_FORWARDED_PROTO=False -SECURE_SSL_REDIRECT=False -SESSION_COOKIE_SECURE=False -CSRF_COOKIE_SECURE=False -SECURE_HSTS_SECONDS=0 -SECURE_HSTS_INCLUDE_SUBDOMAINS=False -SECURE_HSTS_PRELOAD=False -DATABASE_URL=postgresql://isms:isms@db:5432/isms +DATABASE_URL=postgresql://isms:change-me@db:5432/isms +RUST_BACKEND_BIND=0.0.0.0:9000 +RUST_BACKEND_URL=http://app:9000 +ISCY_MEDIA_ROOT=/app/media + POSTGRES_DB=isms POSTGRES_USER=isms POSTGRES_PASSWORD=change-me -RUN_MIGRATIONS=1 -RUN_SEEDS=0 -RUN_COLLECTSTATIC=1 -APP_SERVER=gunicorn -GUNICORN_WORKERS=3 -GUNICORN_TIMEOUT=120 + NGINX_HOST=localhost NGINX_CLIENT_MAX_BODY_SIZE=50m NGINX_PROXY_READ_TIMEOUT=300 -LOCAL_LLM_ENABLED=False -LOCAL_LLM_BACKEND=rust_service -LOCAL_LLM_MODEL_NAME=Qwen3-8B-GGUF -LOCAL_LLM_MODEL_PATH= -LOCAL_LLM_RUST_URL=http://rust-backend:9000 -RUST_BACKEND_URL=http://rust-backend:9000 -RISK_SCORING_BACKEND=rust_service -GUIDANCE_SCORING_BACKEND=rust_service -REPORT_SUMMARY_BACKEND=rust_service -RUST_STRICT_MODE=True + +NVD_API_BASE_URL=https://services.nvd.nist.gov +NVD_API_KEY= + +LOCAL_LLM_MODEL_NAME=iscy-rust-llm LOCAL_LLM_N_CTX=8192 LOCAL_LLM_N_THREADS=8 LOCAL_LLM_GPU_LAYERS=0 -LOCAL_LLM_VERBOSE_NATIVE=False -LOCAL_LLM_HF_REPO_ID=MaziyarPanahi/Qwen3-8B-GGUF -LOCAL_LLM_HF_FILENAME=Qwen3-8B.Q4_K_M.gguf -LOCAL_LLM_TARGET_DIR=models -NVD_API_KEY= diff --git a/.env.stage.example b/.env.stage.example index f485ced..57c0b33 100644 --- a/.env.stage.example +++ b/.env.stage.example @@ -1,24 +1,20 @@ -DEBUG=False -SECRET_KEY=replace-me-stage-secret -ALLOWED_HOSTS=stage.localhost,localhost,127.0.0.1 -CSRF_TRUSTED_ORIGINS=http://stage.localhost,http://127.0.0.1:8080 -USE_X_FORWARDED_HOST=True -TRUST_X_FORWARDED_PROTO=False -SECURE_SSL_REDIRECT=False -SESSION_COOKIE_SECURE=False -CSRF_COOKIE_SECURE=False -SECURE_HSTS_SECONDS=0 -SECURE_HSTS_INCLUDE_SUBDOMAINS=False -SECURE_HSTS_PRELOAD=False -DATABASE_URL=postgresql://isms:isms@db:5432/isms +DATABASE_URL=postgresql://isms:change-me@db:5432/isms +RUST_BACKEND_BIND=0.0.0.0:9000 +RUST_BACKEND_URL=http://app:9000 +ISCY_MEDIA_ROOT=/app/media + POSTGRES_DB=isms POSTGRES_USER=isms POSTGRES_PASSWORD=change-me -RUN_MIGRATIONS=1 -RUN_SEEDS=0 -RUN_COLLECTSTATIC=1 -APP_SERVER=gunicorn + NGINX_HOST=stage.localhost NGINX_CLIENT_MAX_BODY_SIZE=50m NGINX_PROXY_READ_TIMEOUT=300 -LOCAL_LLM_ENABLED=False + +NVD_API_BASE_URL=https://services.nvd.nist.gov +NVD_API_KEY= + +LOCAL_LLM_MODEL_NAME=iscy-rust-llm +LOCAL_LLM_N_CTX=8192 +LOCAL_LLM_N_THREADS=8 +LOCAL_LLM_GPU_LAYERS=0 diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index e3330e7..0000000 --- a/Dockerfile +++ /dev/null @@ -1,58 +0,0 @@ -FROM ubuntu:24.04 - -ARG DEBIAN_FRONTEND=noninteractive -ARG INSTALL_LOCAL_LLM=false - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 \ - VIRTUAL_ENV=/opt/venv \ - PATH="/opt/venv/bin:$PATH" - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - git \ - python3 \ - python3-venv \ - python3-pip \ - python3-dev \ - build-essential \ - cmake \ - ninja-build \ - pkg-config \ - libpq-dev \ - && rm -rf /var/lib/apt/lists/* - -RUN python3 -m venv $VIRTUAL_ENV && pip install --upgrade pip setuptools wheel - -WORKDIR /app - -COPY requirements.txt requirements-prod.txt requirements-llm.txt ./ -RUN pip install -r requirements-prod.txt - -RUN if [ "$INSTALL_LOCAL_LLM" = "true" ]; then \ - apt-get update && apt-get install -y --no-install-recommends \ - clang \ - libopenblas-dev \ - g++-14 \ - libstdc++-14-dev \ - && rm -rf /var/lib/apt/lists/* && \ - CC=/usr/bin/clang \ - CXX=/usr/bin/clang++ \ - CMAKE_ARGS="-DGGML_BLAS=ON -DGGML_BLAS_VENDOR=OpenBLAS -DLLAMA_BUILD_TOOLS=OFF -DLLAMA_BUILD_EXAMPLES=OFF -DLLAMA_BUILD_SERVER=OFF" \ - FORCE_CMAKE=1 \ - pip install --no-cache-dir --force-reinstall --no-binary=llama-cpp-python llama-cpp-python && \ - pip install 'huggingface_hub>=0.34.0'; \ - fi - -COPY . . - -RUN useradd -ms /bin/bash appuser && \ - mkdir -p /app/media /app/staticfiles /app/models && \ - chown -R appuser:appuser /app /opt/venv - -USER appuser - -EXPOSE 8000 - -ENTRYPOINT ["/app/docker/entrypoint.sh"] diff --git a/Makefile b/Makefile index 7361ca3..0caac42 100644 --- a/Makefile +++ b/Makefile @@ -2,26 +2,17 @@ COMPOSE_DEV=docker compose COMPOSE_STAGE=docker compose -f docker-compose.yml -f docker-compose.stage.yml COMPOSE_PROD=docker compose -f docker-compose.yml -f docker-compose.prod.yml COMPOSE_PROD_LLM=docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.llm.yml -PYTHON_BIN=$(shell if [ -x .venv/bin/python ]; then echo .venv/bin/python; else echo python; fi) -TEAM_TEST_ENV=RUST_ONLY_MODE=False GUIDANCE_SCORING_BACKEND=local RISK_SCORING_BACKEND=local REPORT_SUMMARY_BACKEND=local REPORT_SNAPSHOT_BACKEND=local DASHBOARD_SUMMARY_BACKEND=local CATALOG_BACKEND=local REQUIREMENTS_BACKEND=local ASSET_INVENTORY_BACKEND=local PROCESS_REGISTER_BACKEND=local RISK_REGISTER_BACKEND=local EVIDENCE_REGISTER_BACKEND=local ASSESSMENT_REGISTER_BACKEND=local ROADMAP_REGISTER_BACKEND=local WIZARD_RESULTS_BACKEND=local IMPORT_CENTER_BACKEND=local PRODUCT_SECURITY_BACKEND=local RUST_BACKEND_URL= RUST_BACKEND_MANIFEST=rust/iscy-backend/Cargo.toml -.PHONY: dev-up dev-down stage-up stage-down prod-up prod-down prod-up-llm llm-download backup restore health handbook-pdf local-bootstrap local-check local-test team-test docker-check docker-smoke easy-start prod-readiness rust-build rust-test rust-run rust-init rust-smoke canary-daily rust-import-collection rust-sync-recent rust-canary-parity rust-canary-trend rust-canary-import +.PHONY: dev-up dev-down stage-up stage-down prod-up prod-down prod-up-llm llm-download backup restore health local-bootstrap local-check local-test team-test docker-check docker-smoke easy-start prod-readiness rust-build rust-test rust-run rust-init rust-smoke canary-daily rust-import-collection rust-sync-recent rust-canary-parity rust-canary-trend rust-canary-import -local-bootstrap: - python3 -m venv .venv - mkdir -p static media staticfiles models - . .venv/bin/activate && python -m ensurepip --upgrade && python -m pip install --upgrade pip setuptools wheel && python -m pip install -r requirements.txt +local-bootstrap: rust-init -local-check: - . .venv/bin/activate && python manage.py check +local-check: rust-test -local-test: - . .venv/bin/activate && python manage.py test apps.core apps.reports apps.product_security +local-test: rust-test -team-test: - $(TEAM_TEST_ENV) $(PYTHON_BIN) manage.py check - $(TEAM_TEST_ENV) $(PYTHON_BIN) manage.py test apps.core apps.reports apps.product_security apps.guidance apps.dashboard apps.catalog apps.requirements_app apps.assets_app apps.processes apps.risks apps.evidence apps.assessments apps.roadmap apps.wizard apps.import_center apps.vulnerability_intelligence +team-test: rust-test rust-smoke docker-check: $(COMPOSE_DEV) config >/dev/null @@ -30,9 +21,8 @@ docker-check: $(COMPOSE_PROD_LLM) config >/dev/null docker-smoke: - $(COMPOSE_DEV) up -d db - $(COMPOSE_DEV) run --rm web python manage.py migrate - $(COMPOSE_DEV) run --rm web python manage.py check + $(COMPOSE_DEV) up -d db app + $(COMPOSE_DEV) exec app wget -q -O /dev/null http://127.0.0.1:9000/health/live $(COMPOSE_DEV) down easy-start: @@ -152,7 +142,4 @@ restore: ENV_FILE=.env.production ./scripts/restore_compose.sh $(BACKUP) health: - curl -fsS http://127.0.0.1/health/ready/ - -handbook-pdf: - python3 scripts/export_iscy_handbook_pdf.py + curl -fsS http://127.0.0.1:9000/health/ready diff --git a/README.md b/README.md index b46cf69..77615b9 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,28 @@ # ISCY V23.5 -ISCY ist eine ISMS-/Cybersecurity-Plattform mit ISO 27001-, NIS2- und KRITIS-Unterstuetzung, Product Security, lokalem CVE-Enrichment und lokalem LLM-Betrieb. Der produktive Cutover laeuft auf einen Rust-Axum-Service; lokale Starts, Rust-Sessions und CI-Smokes laufen inzwischen Rust-first, Django/Python bleibt vorerst Legacy-Kompatibilitaet bis zur finalen Dateientfernung. +ISCY ist eine ISMS-/Cybersecurity-Plattform mit ISO 27001-, NIS2- und KRITIS-Unterstuetzung, Product Security, lokalem CVE-Enrichment und lokalem LLM-Betrieb. -## Lokale Entwicklung auf NixOS +Der Runtime-Cutover nach Rust ist abgeschlossen: Die produktive Anwendung laeuft ueber den Rust-Axum-Service in `rust/iscy-backend`. Die fruehere Django/Python-Anwendung, ihre Templates, Settings, Requirements und Startpfade wurden aus dem Repository entfernt. -Fuer NixOS und andere lokale Linux-Setups ist der bevorzugte lokale Pfad jetzt Rust-only: +## Lokaler Start ```bash ./start.sh ``` -Danach ist die Rust-Weboberflaeche unter `http://127.0.0.1:9000/login/` erreichbar. Der Rust-Demo-Login lautet `admin / Admin123!`; die Rust-Userverwaltung fuer Anlage, Bearbeitung, Rollen-/Gruppen-/Direktrechtewechsel und Passwortreset liegt unter `http://127.0.0.1:9000/admin/users/`. +Danach ist die Rust-Weboberflaeche unter `http://127.0.0.1:9000/login/` erreichbar. -Explizit ohne Wrapper: +Demo-Login: -```bash -nix run .#iscy-backend -- init-demo -DATABASE_URL=sqlite:///db.sqlite3 RUST_BACKEND_BIND=127.0.0.1:9000 nix run .#iscy-backend -``` - -Der Dev-Shell in `flake.nix` bringt die Build- und Laufzeitbibliotheken für PostgreSQL, SQLite und den lokalen Rust-Service mit. - -## Rust-Backend auf NixOS starten - -Der Rust-Service kann direkt aus dem Repository-Root gestartet werden: - -```bash -nix run .#iscy-backend +```text +admin / Admin123! ``` -Mit expliziter lokaler Bind-Adresse und SQLite-Datenbank: +Ohne Wrapper: ```bash -RUST_BACKEND_BIND=127.0.0.1:9000 DATABASE_URL=sqlite:///db.sqlite3 nix run .#iscy-backend -``` - -Eine lokale Rust-Demo-Datenbank ohne Django-Migration initialisieren: - -```bash -DATABASE_URL=sqlite:///db.sqlite3 nix run .#iscy-backend -- init-demo -RUST_BACKEND_BIND=127.0.0.1:9000 DATABASE_URL=sqlite:///db.sqlite3 nix run .#iscy-backend +nix run .#iscy-backend -- init-demo +DATABASE_URL=sqlite:///db.sqlite3 RUST_BACKEND_BIND=127.0.0.1:9000 nix run .#iscy-backend ``` Healthcheck: @@ -48,96 +31,27 @@ Healthcheck: curl -fsS http://127.0.0.1:9000/health ``` -Der lokale Wrapper startet denselben Rust-only-Pfad und initialisiert vorher die Rust-Datenbank: - -```bash -./start.sh -``` - -Login im Browser: `http://127.0.0.1:9000/login/` mit `admin / Admin123!`. Danach ist `/admin/users/` fuer User-Liste, User-Anlage, Bearbeitung, Rollen-/Gruppen-/Direktrechtewechsel und Passwortreset ueber Rust verfuegbar. - -## Neu in V23.5 - -- produktionsnähere **Docker-/Compose-Profile** für Development, Stage und Production -- **Nginx Reverse Proxy** für Stage/Production -- persistente Volumes für: - - PostgreSQL - - Media / Evidence - - Static - - lokale Modelle -- **Backup-/Restore-Skripte** für Compose-Deployments -- **Support-Matrix** für offiziell getestete Plattformen und Betriebsmodi -- erweitertes **CI** fuer Rust-Tests, Rust-Bootstrap-Smoke, Nix-Rust-Smoke und Compose-Dateien - -## Kernmodule - -- ISMS- und Umsetzungsplanung -- NIS2-/KRITIS-Relevanzprüfung -- Product Security / CRA / AI Act / IEC 62443 / ISO-SAE 21434 -- CVE- und Vulnerability-Intelligence -- lokales LLM-Enrichment mit Qwen3 GGUF -- EPSS-/KEV-/NIS2-kontextualisierte CVE-Bewertung mit Git-/Repository-Vorbereitung -- Reporting, Dashboard, Roadmap, Evidence, Risks, Requirements - -## Rust-Backend-Pfad (Rewrite-Vorbereitung) - -Der Rust-Service unter `rust/iscy-backend` ist im Compose-Stack als `rust-backend` integrierbar (Health + NVD + LLM-Endpoint als Startpunkt für eine schrittweise Migration). - -```bash -make rust-build -make rust-test -make rust-run -``` - -Zusätzlich stellt `rust-backend` Rust-Weboberflächen für zentrale bisherige Django-Mountpoints bereit (z. B. `/dashboard/`, `/reports/`, `/imports/`, `/admin/users/`, `/cves/`) als Migrationsgrundlage. - -## Vordefinierte Security-Template-Pakete (im Fragenkatalog) - -Im Seed-Katalog sind zusaetzliche, sofort nutzbare Pakete fuer folgende Schwerpunkte enthalten: - -- Cloud-Security (inkl. Cloud-IAM/Schluessel-Absicherung) -- Windows-Hardening (Baseline-orientiert) -- OT-/Produktions-Security (inkl. OPC UA, MES, SCADA, Produktions-Identitaeten) - -## Betriebsmodi - -### Einfachster Start (empfohlen) - -Wenn es "einfach nur laufen" soll: - -```bash -make easy-start -``` - -Der Wrapper `scripts/easy_start.sh` nimmt automatisch den einfacheren Weg: - -- mit Docker vorhanden -> `make dev-up` -- ohne Docker -> lokaler Rust-Fallback via `./start.sh` +## Docker Compose -### 1. Lokale Entwicklung +Development: ```bash cp .env.development.example .env make dev-up ``` -App: `http://127.0.0.1:8000` +App: `http://127.0.0.1:9000` -**Docker-Funktion (einfach):** - -- `make docker-check` prueft, ob alle Compose-Dateien gueltig sind. -- `make docker-smoke` startet eine kleine Funktionsprobe (DB + Migration + Django-Check) und beendet sie wieder. - -### 2. Stage +Stage: ```bash cp .env.stage.example .env make stage-up ``` -App: `http://127.0.0.1:8080` +App via Nginx: `http://127.0.0.1:8080` -### 3. Production ohne lokales LLM +Production: ```bash cp .env.production.example .env @@ -145,136 +59,71 @@ make prod-readiness make prod-up ``` -App: `http://127.0.0.1` +App via Nginx: `http://127.0.0.1` -### 4. Production mit lokalem LLM +## Rust Backend -```bash -cp .env.production.example .env -make prod-up-llm -``` - -Hinweis: Der Rust-LLM-Pfad nutzt den Service `rust-backend` im Compose-Stack; `make llm-download` ist nur noch ein kompatibler No-Op. - -## Backup / Restore - -Backup erzeugen: +Wichtige lokale Befehle: ```bash -ENV_FILE=.env.production ./scripts/backup_compose.sh -``` - -Restore ausführen: - -```bash -ENV_FILE=.env.production ./scripts/restore_compose.sh backups/ +make rust-build +make rust-test +make rust-smoke +make team-test ``` -## Lokales LLM ohne Docker (Rust-Service) - -Der funktionierende Referenzpfad auf Ubuntu 24.04 ist: - -- Rust Toolchain (`rustup`, `cargo`) -- laufender `rust/iscy-backend` Service -- `LOCAL_LLM_BACKEND=rust_service` -- `LOCAL_LLM_RUST_URL=http://127.0.0.1:9000` -- `RISK_SCORING_BACKEND=rust_service` -- `GUIDANCE_SCORING_BACKEND=rust_service` -- `REPORT_SUMMARY_BACKEND=rust_service` -- `REPORT_SNAPSHOT_BACKEND=rust_service` -- `DASHBOARD_SUMMARY_BACKEND=rust_service` -- `CATALOG_BACKEND=rust_service` -- `REQUIREMENTS_BACKEND=rust_service` -- `ASSET_INVENTORY_BACKEND=rust_service` -- `PROCESS_REGISTER_BACKEND=rust_service` -- `RISK_REGISTER_BACKEND=rust_service` -- `EVIDENCE_REGISTER_BACKEND=rust_service` -- `ASSESSMENT_REGISTER_BACKEND=rust_service` -- `ROADMAP_REGISTER_BACKEND=rust_service` -- `WIZARD_RESULTS_BACKEND=rust_service` -- `IMPORT_CENTER_BACKEND=rust_service` -- `PRODUCT_SECURITY_BACKEND=rust_service` -- `RUST_ONLY_MODE=True` (verbietet lokale Legacy-Fallbacks fuer migrierte Backends) -- `RUST_STRICT_MODE=True` (erzwingt Rust-Backends ohne Fallback) - -Danach kann Rust direkt gestartet werden: +`make team-test` ist jetzt ein Rust-only Gate aus Rust-Tests und HTTP-Smoke. -```bash -./start.sh -``` +Das Backend stellt serverseitige Weboberflaechen und APIs fuer die migrierten Produktbereiche bereit, unter anderem: -Auf NixOS bitte bevorzugt ueber `nix develop` arbeiten. -Auf Debian/Ubuntu und aktuellen apt-basierten Derivaten kann der Rust-Service direkt per `cargo run` gestartet werden. +- `/dashboard/` +- `/navigator/` +- `/catalog/` +- `/requirements/` +- `/assessments/` +- `/organizations/` +- `/risks/` +- `/evidence/` +- `/reports/` +- `/roadmap/` +- `/assets/` +- `/imports/` +- `/processes/` +- `/product-security/` +- `/cves/` +- `/admin/users/` -## Lokale Vulnerability-Feeds +## Vulnerability Feeds -Für lokale EPSS-/KEV-Anreicherung und Git-/Scanner-Vorbereitung stehen Management-Commands bereit: +Rust-CLI: ```bash -python3 manage.py import_epss_feed /pfad/zu/epss_scores.csv -python3 manage.py import_kev_catalog /pfad/zu/known_exploited_vulnerabilities.json --reset-missing -RUST_BACKEND_URL=http://127.0.0.1:9000 iscy-canary import-collection --has-kev --max-pages 2 -RUST_BACKEND_URL=http://127.0.0.1:9000 iscy-canary sync-recent --hours 24 --max-pages 2 -RUST_BACKEND_URL=http://127.0.0.1:9000 iscy-canary import CVE-2026-1234 CVE-2026-5678 -RUST_BACKEND_URL=http://127.0.0.1:9000 iscy-canary import CVE-2026-1234 --skip-healthcheck -iscy-canary parity --out-dir reports/canary CVE-2026-1234 CVE-2026-5678 -iscy-canary trend --reports-dir reports/canary --window 30 --max-mismatch-rate 0.5 --enforce-gate -python3 manage.py import_cve_context_csv /pfad/zu/cve_context.csv --user-id +RUST_BACKEND_URL=http://127.0.0.1:9000 cargo run --manifest-path rust/iscy-backend/Cargo.toml --bin iscy-canary -- import-collection --has-kev --max-pages 2 +RUST_BACKEND_URL=http://127.0.0.1:9000 cargo run --manifest-path rust/iscy-backend/Cargo.toml --bin iscy-canary -- sync-recent --hours 24 --max-pages 2 +RUST_BACKEND_URL=http://127.0.0.1:9000 cargo run --manifest-path rust/iscy-backend/Cargo.toml --bin iscy-canary -- import CVE-2026-1234 ``` -Ab V23.5 ist der Vulnerability-Import standardmäßig auf **Rust-only-Normalisierung** gestellt (`VULN_INTEL_RUST_ONLY=True`). -Wenn `RUST_BACKEND_URL` fehlt, brechen CVE-Upserts absichtlich mit Fehler ab, um Mischbetrieb zu vermeiden. -NVD-Collection-Imports laufen im Rust-only-Modus nicht mehr über den Python-Service; die Django-Commands `import_nvd_cves` und `sync_nvd_recent` sind Kompatibilitäts-Wrapper um die Rust-CLI. -Außerdem ist der Cutover-Default jetzt `RUST_ONLY_MODE=True` und `RUST_STRICT_MODE=True`, damit migrierte Pfade ohne Rust-Backend nicht still auf Legacy-Fallbacks zurückfallen. - -Direkte NVD-Collection-Imports koennen optional mit `--cve-tag`, `--cpe-name`, `--last-mod-start-date` und `--last-mod-end-date` gefiltert werden. -`sync_nvd_recent` ist fuer regelmaessige Jobs gedacht (z. B. stündlich/taeglich) und nutzt automatisch `lastModStartDate/lastModEndDate`. - -`import_cve_context_csv` ist für externe Git-/SBOM-/Scanner-Pipelines gedacht. Wichtige CSV-Spalten sind z. B.: -`cve_id`, `product`, `release`, `component`, `repository_name`, `repository_url`, `git_ref`, `source_package`, `source_package_version`, `exposure`, `asset_criticality`, `nis2_relevant`. - -## Handbuch - -Ein fachliches Handbuch fuer ISCY liegt in `docs/ISCY_Handbuch.md`. -Proxmox-Produktiv-Runbook: `docs/PROXMOX_PRODUCTION_RUNBOOK.md`. -Completion-Backlog: `docs/PROJECT_COMPLETION_BACKLOG.md`. -Rust-Rewrite-Roadmap: `docs/RUST_REWRITE_ROADMAP.md`. -Rust-Cutover-Plan: `docs/RUST_CUTOVER_PLAN.md`. -Rust-Cutover-Status: `docs/RUST_CUTOVER_STATUS.md`. -Rust-Ablöse-Checkliste: `docs/RUST_ABLOESE_CHECKLISTE.md`. -Rust-Webstack-Umbauplan: `docs/RUST_WEBSTACK_REWRITE_PLAN.md`. - -PDF-Export: +NVD-Konfiguration: -```bash -make handbook-pdf +```text +NVD_API_BASE_URL=https://services.nvd.nist.gov +NVD_API_KEY= ``` -Das Skript erzeugt `docs/ISCY_Handbuch.pdf`. - ## CI -GitHub Actions prüft: - -- Rust-Formatierung, Clippy und Rust-Backend-Tests -- Rust-DB-/Bootstrap-Smoke inklusive Healthcheck, Rust-Session-Cookie und zentralen API-Probes -- Nix-Rust-App-Smoke über das flake-basierte Backend -- Validierung aller Compose-Dateien inklusive Stage und Production - -## Lokale Kurzbefehle - -```bash -make local-bootstrap -make local-check -make local-test -make team-test -make rust-smoke -``` +GitHub Actions prueft: -`make local-test` deckt aktuell die Basis-Gesundheitschecks, mandantenbezogene Report-Views und die Product-Security-Routen ab. -`make team-test` ist der Legacy-Django-Kompatibilitaetscheck. -`make rust-smoke` ist der Rust-only Betriebs-Smoke mit DB-Bootstrap, Healthcheck, Rust-Session-Cookie, Dashboard ohne Query-Kontext, Evidence-Upload, Import-Center-Preview/CSV-Probe und zentralen API-Probes. +- Rust-Formatierung +- Clippy +- Rust-Backend-Tests +- Rust-DB-/HTTP-Smoke +- Nix-Rust-App-Smoke +- Compose-Konfigurationen fuer Development, Stage, Production und LLM-Profil -## Support-Matrix +## Dokumentation -Siehe [`SUPPORT_MATRIX.md`](SUPPORT_MATRIX.md). +- Handbuch: `docs/ISCY_Handbuch.md` +- Proxmox-Produktiv-Runbook: `docs/PROXMOX_PRODUCTION_RUNBOOK.md` +- Rust-Cutover-Status: `docs/RUST_CUTOVER_STATUS.md` +- Rust-Abloese-Checkliste: `docs/RUST_ABLOESE_CHECKLISTE.md` diff --git a/SUPPORT_MATRIX.md b/SUPPORT_MATRIX.md index c7857b0..53c456a 100644 --- a/SUPPORT_MATRIX.md +++ b/SUPPORT_MATRIX.md @@ -1,26 +1,23 @@ # Support Matrix -## Officially supported host modes +## Officially Supported Host Modes -| Mode | Host OS | Python | Database | Local LLM | Status | -|---|---|---:|---|---|---| -| Bare metal / venv | Ubuntu 24.04 LTS | 3.12 | PostgreSQL 15/16 or SQLite (dev) | Optional | Supported | -| Bare metal / venv | Ubuntu derivatives (24.04 base, apt-basiert) | 3.11/3.12 | PostgreSQL 15/16 or SQLite (dev) | Supported (best-effort) | Supported | -| Bare metal / venv | Debian 12 | 3.11/3.12 | PostgreSQL 15/16 or SQLite (dev) | Core app only officially; local LLM best-effort | Supported (core) | -| Bare metal / venv | Debian derivatives (aktuell, apt-basiert) | 3.11/3.12 | PostgreSQL 15/16 or SQLite (dev) | Supported (best-effort) | Supported | -| Bare metal / nix develop | NixOS (flake.nix) | 3.11 | PostgreSQL 15/16 or SQLite (dev) | Optional inkl. lokaler Build-Toolchain | Supported | -| Docker / Compose | Any Linux host with Docker Engine + Compose | bundled | PostgreSQL 16 container | Optional via `docker-compose.llm.yml` | Preferred | +| Mode | Host OS | Runtime | Database | Local LLM | Status | +|---|---|---|---|---|---| +| Bare metal / Nix | NixOS or Linux with Nix flakes | Rust via `nix run .#iscy-backend` | PostgreSQL 15/16 or SQLite dev | Optional | Preferred | +| Bare metal / Cargo | Ubuntu 24.04 LTS or current Debian derivatives | Rust stable | PostgreSQL 15/16 or SQLite dev | Optional | Supported | +| Docker / Compose | Linux host with Docker Engine + Compose | Rust container | PostgreSQL 16 container | Optional via `docker-compose.llm.yml` | Preferred for shared envs | -## Deployment profiles +## Deployment Profiles | Profile | Files | Reverse proxy | Persistent volumes | Target | |---|---|---|---|---| -| Development | `docker-compose.yml` + `docker-compose.override.yml` | no | db, media, static | local dev | -| Stage | `docker-compose.yml` + `docker-compose.stage.yml` | nginx | db, media, static, model | shared test / UAT | -| Production | `docker-compose.yml` + `docker-compose.prod.yml` | nginx | db, media, static, model | controlled production | -| Production + local LLM | `docker-compose.yml` + `docker-compose.prod.yml` + `docker-compose.llm.yml` | nginx | db, media, static, model | product security / CVE enrichment | +| Development | `docker-compose.yml` + `docker-compose.override.yml` | no | db, media | local dev | +| Stage | `docker-compose.yml` + `docker-compose.stage.yml` | nginx | db, media | shared test / UAT | +| Production | `docker-compose.yml` + `docker-compose.prod.yml` | nginx | db, media | controlled production | +| Production + local LLM | `docker-compose.yml` + `docker-compose.prod.yml` + `docker-compose.llm.yml` | nginx | db, media | product security / CVE enrichment | -## CPU / architecture assumptions +## CPU / Architecture Assumptions | Item | Supported | |---|---| @@ -28,38 +25,33 @@ | ARM64 | not yet officially tested | | GPU offload | optional, not part of the official support baseline | -## Local LLM support +## Local LLM Support | Component | Supported baseline | |---|---| -| Backend | `llama-cpp-python` | -| Model family | Qwen3 GGUF | -| Recommended model | `Qwen3-8B.Q4_K_M.gguf` | -| Build path | `clang` + OpenBLAS + apt-Fallback (`g++-14`/`g++`, passende `libstdc++-dev`) | -| Model download | `scripts/download_local_llm.py` or `make llm-download` | +| Backend | Rust service | +| Model family | configured through Rust runtime variables | +| Build path | Rust stable, OpenSSL, PostgreSQL/SQLite client libs | -## Backup / restore baseline +## Backup / Restore Baseline | Area | Mechanism | Script | |---|---|---| | PostgreSQL | `pg_dump` / `psql` via compose | `scripts/backup_compose.sh`, `scripts/restore_compose.sh` | | Media / evidence | tar archive from mounted volume | same scripts | -| Static / models | tar archive from mounted volume | same scripts | -## Not officially supported +## Not Officially Supported -- Python minor versions outside the documented range -- musl-based Linux runtimes for the current `llama-cpp-python` wheel path -- unmanaged host installs without `.venv` +- Python/Django runtime deployment +- unmanaged host installs without Rust toolchain or Nix - undocumented OS upgrades without smoke test / CI validation -## Upgrade policy recommendation +## Upgrade Policy Recommendation -After any host OS, Python, compiler, PostgreSQL or `llama-cpp-python` update: +After any host OS, Rust toolchain, compiler, PostgreSQL or container base update: -1. run `python manage.py check` -2. run migrations on a copy/staging database -3. run `python manage.py check_local_llm` -4. run a CVE enrichment smoke test -5. validate `docker compose -f docker-compose.yml -f docker-compose.prod.yml config` -6. only then promote to shared/stable environment +1. run `cargo test --manifest-path rust/iscy-backend/Cargo.toml` +2. run `cargo clippy --manifest-path rust/iscy-backend/Cargo.toml --all-targets -- -D warnings` +3. run `make rust-smoke` +4. validate `docker compose -f docker-compose.yml -f docker-compose.prod.yml config` +5. only then promote to shared/stable environment diff --git a/apps/__init__.py b/apps/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/accounts/__init__.py b/apps/accounts/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py deleted file mode 100644 index eae47df..0000000 --- a/apps/accounts/admin.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.contrib import admin -from django.contrib.auth.admin import UserAdmin -from .models import User - - -@admin.register(User) -class CustomUserAdmin(UserAdmin): - fieldsets = UserAdmin.fieldsets + ( - ('ISMS', {'fields': ('role', 'tenant', 'job_title')}), - ) - list_display = ('username', 'email', 'role', 'tenant', 'is_staff') diff --git a/apps/accounts/apps.py b/apps/accounts/apps.py deleted file mode 100644 index bd9d60a..0000000 --- a/apps/accounts/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class AccountsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.accounts' diff --git a/apps/accounts/management/__init__.py b/apps/accounts/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/accounts/management/commands/__init__.py b/apps/accounts/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/accounts/management/commands/seed_demo.py b/apps/accounts/management/commands/seed_demo.py deleted file mode 100644 index 5a413ff..0000000 --- a/apps/accounts/management/commands/seed_demo.py +++ /dev/null @@ -1,100 +0,0 @@ -from django.contrib.auth import get_user_model -from django.core.management.base import BaseCommand -from apps.organizations.models import BusinessUnit, Supplier, Tenant -from apps.processes.models import Process -from apps.risks.models import Risk -from apps.requirements_app.models import Requirement - - -class Command(BaseCommand): - help = 'Seeds demo tenant, admin user and initial demo content.' - - def handle(self, *args, **options): - tenant, _ = Tenant.objects.get_or_create( - slug='demo-gmbh', - defaults={ - 'name': 'Demo GmbH', - 'country': 'DE', - 'operation_countries': ['DE', 'AT', 'NL'], - 'description': 'Der Scope umfasst die Softwareentwicklung, zentrale Plattformdienste und den Betrieb geschäftskritischer Systeme.', - 'sector': 'DIGITAL_PROVIDERS', - 'employee_count': 120, - 'annual_revenue_million': 18, - 'balance_sheet_million': 12, - 'critical_services': 'Kundenplattform, Identity Services, Entwicklungsplattform', - 'supply_chain_role': 'Digitaler Dienstleister mit Kunden- und Lieferkettenabhängigkeiten', - 'nis2_relevant': False, - 'kritis_relevant': False, - }, - ) - - User = get_user_model() - admin_user, created = User.objects.get_or_create( - username='admin', - defaults={ - 'email': 'admin@example.com', - 'role': 'ADMIN', - 'tenant': tenant, - 'is_superuser': True, - 'is_staff': True, - 'first_name': 'Admin', - 'last_name': 'User', - }, - ) - if created: - admin_user.set_password('Admin123!') - admin_user.save() - - for framework, code, title, domain, sector_package, evidence_guidance in [ - ('ISO27001', 'A.5.1', 'Policies for information security', 'Policies', 'ALL', 'Policy, Freigabe, Review-Protokolle und Kommunikationsnachweise hinterlegen.'), - ('ISO27001', 'A.5.9', 'Inventory of information and other associated assets', 'Asset Management', 'ALL', 'Registerauszug, Owner-Zuordnung und Klassifizierung belegen.'), - ('ISO27001', 'A.5.23', 'Information security for use of cloud services', 'Supplier & Cloud Security', 'DIGITAL', 'Cloud-Architektur, Shared-Responsibility-Matrix und Sicherheitsnachweise der Provider beifügen.'), - ('NIS2', 'NIS2-RA-01', 'Risk analysis and information system security policies', 'Governance', 'ALL', 'Risikoanalyse, Management-Freigaben und Sicherheitsrichtlinien beilegen.'), - ('NIS2', 'NIS2-BC-01', 'Business continuity, backup and disaster recovery', 'Resilience', 'CRITICAL_INFRA', 'Backup-/Restore-Tests, BCM-Pläne und Wiederanlaufnachweise dokumentieren.'), - ('NIS2', 'NIS2-SC-01', 'Supply chain security', 'Third-Party Risk', 'ALL', 'Lieferantenregister, Kritikalitätsbewertung und Vertragsanforderungen beilegen.'), - ]: - Requirement.objects.get_or_create( - framework=framework, - code=code, - defaults={ - 'title': title, - 'domain': domain, - 'description': title, - 'guidance': 'Initial demo requirement', - 'sector_package': sector_package, - 'evidence_guidance': evidence_guidance, - 'evidence_examples': 'Policy, Screenshot, Export, Review-Protokoll, Ticket oder Auditnachweis.', - }, - ) - - for name in ['Geschäftsführung', 'Informationssicherheit und Compliance', 'IT-Betrieb', 'Softwareentwicklung', 'Backoffice-Betrieb']: - BusinessUnit.objects.get_or_create(tenant=tenant, name=name) - - for name in ['Secure Software Development Lifecycle', 'Incident Management', 'Lieferantenbewertung']: - Process.objects.get_or_create( - tenant=tenant, - name=name, - defaults={'description': 'Demoprozess', 'status': Process.Status.PARTIAL, 'documented': True, 'implemented': True}, - ) - - for name in ['Cloud Hosting Provider', 'Identity Provider', 'Managed SOC Provider']: - Supplier.objects.get_or_create( - tenant=tenant, - name=name, - defaults={'service_description': 'Demolieferant aus dem Seed', 'criticality': 'HIGH'}, - ) - - Risk.objects.get_or_create( - tenant=tenant, - title='Unvollständige Nachweise für sichere Entwicklung und Reviews', - defaults={ - 'description': 'Reviews finden statt, sind aber noch nicht konsistent dokumentiert.', - 'impact': 4, - 'likelihood': 3, - 'status': Risk.Status.IDENTIFIED, - 'treatment_strategy': Risk.Treatment.MITIGATE, - 'treatment_plan': 'Checklisten, Freigaben und Evidenzablage einführen.', - }, - ) - - self.stdout.write(self.style.SUCCESS('Demo data created. Login: admin / Admin123!')) diff --git a/apps/accounts/migrations/0001_initial.py b/apps/accounts/migrations/0001_initial.py deleted file mode 100644 index a5ad2a2..0000000 --- a/apps/accounts/migrations/0001_initial.py +++ /dev/null @@ -1,67 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-09 14:39 - -import django.contrib.auth.models -import django.contrib.auth.validators -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), - ] - - operations = [ - migrations.CreateModel( - name='Role', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('code', models.CharField(choices=[('MANAGEMENT', 'Management'), ('CISO', 'CISO / Security Officer'), ('ISMS_MANAGER', 'ISMS Manager'), ('COMPLIANCE_MANAGER', 'Compliance Manager'), ('PROCESS_OWNER', 'Process Owner'), ('RISK_OWNER', 'Risk Owner'), ('AUDITOR', 'Auditor'), ('ADMIN', 'Administrator'), ('CONTRIBUTOR', 'Contributor')], max_length=32, unique=True)), - ('label', models.CharField(max_length=100)), - ('description', models.TextField(blank=True)), - ], - options={ - 'ordering': ['code'], - }, - ), - migrations.CreateModel( - name='UserRole', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('granted_at', models.DateTimeField(auto_now_add=True)), - ], - options={ - 'ordering': ['user', 'role'], - }, - ), - migrations.CreateModel( - name='User', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), - ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('role', models.CharField(choices=[('MANAGEMENT', 'Management'), ('CISO', 'CISO / Security Officer'), ('ISMS_MANAGER', 'ISMS Manager'), ('COMPLIANCE_MANAGER', 'Compliance Manager'), ('PROCESS_OWNER', 'Process Owner'), ('RISK_OWNER', 'Risk Owner'), ('AUDITOR', 'Auditor'), ('ADMIN', 'Administrator'), ('CONTRIBUTOR', 'Contributor')], default='CONTRIBUTOR', help_text='Deprecated: Nutze roles M2M stattdessen.', max_length=32)), - ('job_title', models.CharField(blank=True, max_length=255)), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), - ], - options={ - 'verbose_name': 'user', - 'verbose_name_plural': 'users', - 'abstract': False, - }, - managers=[ - ('objects', django.contrib.auth.models.UserManager()), - ], - ), - ] diff --git a/apps/accounts/migrations/0002_initial.py b/apps/accounts/migrations/0002_initial.py deleted file mode 100644 index bc5a53a..0000000 --- a/apps/accounts/migrations/0002_initial.py +++ /dev/null @@ -1,58 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-09 14:39 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('accounts', '0001_initial'), - ('auth', '0012_alter_user_first_name_max_length'), - ('organizations', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='tenant', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='organizations.tenant'), - ), - migrations.AddField( - model_name='user', - name='user_permissions', - field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions'), - ), - migrations.AddField( - model_name='userrole', - name='granted_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='granted_roles', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='userrole', - name='role', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_roles', to='accounts.role'), - ), - migrations.AddField( - model_name='userrole', - name='scope_tenant', - field=models.ForeignKey(blank=True, help_text='Optional: Rolle gilt nur fuer diesen Tenant. Leer = global.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='scoped_user_roles', to='organizations.tenant'), - ), - migrations.AddField( - model_name='userrole', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_roles', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='user', - name='roles', - field=models.ManyToManyField(blank=True, related_name='users', through='accounts.UserRole', through_fields=('user', 'role'), to='accounts.role'), - ), - migrations.AlterUniqueTogether( - name='userrole', - unique_together={('user', 'role', 'scope_tenant')}, - ), - ] diff --git a/apps/accounts/migrations/__init__.py b/apps/accounts/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/accounts/models.py b/apps/accounts/models.py deleted file mode 100644 index f9ffdcd..0000000 --- a/apps/accounts/models.py +++ /dev/null @@ -1,87 +0,0 @@ -from django.contrib.auth.models import AbstractUser -from django.db import models - - -class Role(models.Model): - """F18: Eigenstaendiges Role-Model fuer M2M-Beziehungen.""" - class Code(models.TextChoices): - MANAGEMENT = 'MANAGEMENT', 'Management' - CISO = 'CISO', 'CISO / Security Officer' - ISMS_MANAGER = 'ISMS_MANAGER', 'ISMS Manager' - COMPLIANCE_MANAGER = 'COMPLIANCE_MANAGER', 'Compliance Manager' - PROCESS_OWNER = 'PROCESS_OWNER', 'Process Owner' - RISK_OWNER = 'RISK_OWNER', 'Risk Owner' - AUDITOR = 'AUDITOR', 'Auditor' - ADMIN = 'ADMIN', 'Administrator' - CONTRIBUTOR = 'CONTRIBUTOR', 'Contributor' - - code = models.CharField(max_length=32, choices=Code.choices, unique=True) - label = models.CharField(max_length=100) - description = models.TextField(blank=True) - - class Meta: - ordering = ['code'] - - def __str__(self): - return self.label - - -class User(AbstractUser): - # F18: Beibehalten fuer Abwaertskompatibilitaet, wird durch roles M2M ergaenzt - class LegacyRole(models.TextChoices): - MANAGEMENT = 'MANAGEMENT', 'Management' - CISO = 'CISO', 'CISO / Security Officer' - ISMS_MANAGER = 'ISMS_MANAGER', 'ISMS Manager' - COMPLIANCE_MANAGER = 'COMPLIANCE_MANAGER', 'Compliance Manager' - PROCESS_OWNER = 'PROCESS_OWNER', 'Process Owner' - RISK_OWNER = 'RISK_OWNER', 'Risk Owner' - AUDITOR = 'AUDITOR', 'Auditor' - ADMIN = 'ADMIN', 'Administrator' - CONTRIBUTOR = 'CONTRIBUTOR', 'Contributor' - - role = models.CharField(max_length=32, choices=LegacyRole.choices, default=LegacyRole.CONTRIBUTOR, - help_text='Deprecated: Nutze roles M2M stattdessen.') - tenant = models.ForeignKey('organizations.Tenant', on_delete=models.SET_NULL, null=True, blank=True, related_name='users') - job_title = models.CharField(max_length=255, blank=True) - - # F18: M2M Rollen mit optionalem Scope - roles = models.ManyToManyField(Role, through='UserRole', through_fields=('user', 'role'), blank=True, related_name='users') - - def __str__(self): - return self.get_full_name() or self.username - - def has_role(self, role_code, scope_tenant=None): - """Prueft ob der User eine bestimmte Rolle hat (optional im Scope eines Tenants).""" - qs = self.user_roles.filter(role__code=role_code) - if scope_tenant: - qs = qs.filter(models.Q(scope_tenant=scope_tenant) | models.Q(scope_tenant__isnull=True)) - return qs.exists() - - @property - def role_codes(self): - """Alle Rollencodes dieses Users.""" - codes = set(self.user_roles.values_list('role__code', flat=True)) - if self.role and self.role not in codes: - codes.add(self.role) - return codes - - -class UserRole(models.Model): - """F18: Zuordnung User <-> Rolle mit optionalem Tenant-Scope.""" - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='user_roles') - role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name='user_roles') - scope_tenant = models.ForeignKey( - 'organizations.Tenant', on_delete=models.CASCADE, - null=True, blank=True, related_name='scoped_user_roles', - help_text='Optional: Rolle gilt nur fuer diesen Tenant. Leer = global.', - ) - granted_at = models.DateTimeField(auto_now_add=True) - granted_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='granted_roles') - - class Meta: - unique_together = ('user', 'role', 'scope_tenant') - ordering = ['user', 'role'] - - def __str__(self): - scope = f' ({self.scope_tenant})' if self.scope_tenant else '' - return f'{self.user} -> {self.role}{scope}' diff --git a/apps/assessments/__init__.py b/apps/assessments/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/assessments/admin.py b/apps/assessments/admin.py deleted file mode 100644 index e7abe4d..0000000 --- a/apps/assessments/admin.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.contrib import admin -from .models import ApplicabilityAssessment, Assessment, Measure - -admin.site.register(ApplicabilityAssessment) -admin.site.register(Assessment) -admin.site.register(Measure) diff --git a/apps/assessments/apps.py b/apps/assessments/apps.py deleted file mode 100644 index 1967615..0000000 --- a/apps/assessments/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class AssessmentsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.assessments' diff --git a/apps/assessments/audit_models.py b/apps/assessments/audit_models.py deleted file mode 100644 index 4031885..0000000 --- a/apps/assessments/audit_models.py +++ /dev/null @@ -1,96 +0,0 @@ -"""V20: Audit-Modul – internes/externes Audit, Findings, Corrective Actions.""" -from django.db import models -from apps.core.models import TenantRelationValidationMixin, TimeStampedModel - - -class Audit(TenantRelationValidationMixin, TimeStampedModel): - tenant_relation_fields = { - 'created_by': 'tenant_id', - } - - class AuditType(models.TextChoices): - INTERNAL = 'INTERNAL', 'Internes Audit' - EXTERNAL = 'EXTERNAL', 'Externes Audit' - SURVEILLANCE = 'SURVEILLANCE', 'Ueberwachungsaudit' - RECERTIFICATION = 'RECERTIFICATION', 'Rezertifizierungsaudit' - - class Status(models.TextChoices): - PLANNED = 'PLANNED', 'Geplant' - IN_PROGRESS = 'IN_PROGRESS', 'Laufend' - COMPLETED = 'COMPLETED', 'Abgeschlossen' - CANCELLED = 'CANCELLED', 'Abgebrochen' - - tenant = models.ForeignKey('organizations.Tenant', on_delete=models.CASCADE, related_name='audits') - title = models.CharField(max_length=255) - audit_type = models.CharField(max_length=20, choices=AuditType.choices, default=AuditType.INTERNAL) - status = models.CharField(max_length=16, choices=Status.choices, default=Status.PLANNED) - lead_auditor = models.CharField(max_length=255, blank=True) - audit_team = models.TextField(blank=True, help_text='Auditteam-Mitglieder, je Zeile ein Name') - scope = models.TextField(blank=True, help_text='Auditumfang und -kriterien') - objectives = models.TextField(blank=True) - planned_start = models.DateField(null=True, blank=True) - planned_end = models.DateField(null=True, blank=True) - actual_start = models.DateField(null=True, blank=True) - actual_end = models.DateField(null=True, blank=True) - conclusion = models.TextField(blank=True, help_text='Gesamtfazit des Audits') - created_by = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='created_audits') - - class Meta: - ordering = ['-planned_start', '-created_at'] - - def __str__(self): - return self.title - - @property - def findings_count(self): - return self.findings.count() - - @property - def open_findings_count(self): - return self.findings.exclude(status__in=['CLOSED', 'VERIFIED']).count() - - -class AuditFinding(TenantRelationValidationMixin, TimeStampedModel): - tenant_source = 'audit__tenant_id' - tenant_relation_fields = { - 'audit': 'tenant_id', - 'responsible': 'tenant_id', - 'verified_by': 'tenant_id', - } - - class Severity(models.TextChoices): - MAJOR_NC = 'MAJOR_NC', 'Hauptabweichung (Major NC)' - MINOR_NC = 'MINOR_NC', 'Nebenabweichung (Minor NC)' - OBSERVATION = 'OBSERVATION', 'Beobachtung' - OPPORTUNITY = 'OPPORTUNITY', 'Verbesserungspotenzial' - POSITIVE = 'POSITIVE', 'Positive Feststellung' - - class Status(models.TextChoices): - OPEN = 'OPEN', 'Offen' - IN_PROGRESS = 'IN_PROGRESS', 'In Bearbeitung' - CORRECTIVE_ACTION = 'CORRECTIVE_ACTION', 'Korrekturmassnahme definiert' - IMPLEMENTED = 'IMPLEMENTED', 'Umgesetzt' - VERIFIED = 'VERIFIED', 'Verifiziert / wirksam' - CLOSED = 'CLOSED', 'Geschlossen' - - audit = models.ForeignKey(Audit, on_delete=models.CASCADE, related_name='findings') - finding_number = models.CharField(max_length=32) - title = models.CharField(max_length=255) - description = models.TextField() - severity = models.CharField(max_length=16, choices=Severity.choices, default=Severity.MINOR_NC) - status = models.CharField(max_length=20, choices=Status.choices, default=Status.OPEN) - requirement_reference = models.CharField(max_length=128, blank=True, help_text='z.B. A.5.1, NIS2-21-2a') - evidence_reference = models.TextField(blank=True) - responsible = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='assigned_findings') - due_date = models.DateField(null=True, blank=True) - root_cause = models.TextField(blank=True) - corrective_action = models.TextField(blank=True) - verification_notes = models.TextField(blank=True) - verified_by = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='verified_findings') - verified_at = models.DateTimeField(null=True, blank=True) - - class Meta: - ordering = ['severity', 'finding_number'] - - def __str__(self): - return f'{self.finding_number}: {self.title}' diff --git a/apps/assessments/forms.py b/apps/assessments/forms.py deleted file mode 100644 index e0e70e8..0000000 --- a/apps/assessments/forms.py +++ /dev/null @@ -1,34 +0,0 @@ -from django import forms -from apps.core.forms import TenantScopedModelForm -from .models import ApplicabilityAssessment, Assessment, Measure - - -class ApplicabilityAssessmentForm(TenantScopedModelForm): - class Meta: - model = ApplicabilityAssessment - fields = ['sector', 'company_size', 'critical_services', 'supply_chain_role', 'status', 'reasoning'] - - -class AssessmentForm(TenantScopedModelForm): - tenant_scoped_fields = { - 'process': 'tenant', - 'owner': 'tenant', - } - - class Meta: - model = Assessment - fields = ['process', 'requirement', 'owner', 'status', 'score', 'notes', 'evidence_summary'] - - -class MeasureForm(TenantScopedModelForm): - tenant_scoped_fields = { - 'assessment': 'tenant', - 'owner': 'tenant', - } - - class Meta: - model = Measure - fields = ['assessment', 'owner', 'title', 'description', 'priority', 'status', 'due_date'] - widgets = { - 'due_date': forms.DateInput(attrs={'type': 'date'}), - } diff --git a/apps/assessments/migrations/0001_initial.py b/apps/assessments/migrations/0001_initial.py deleted file mode 100644 index 28b8802..0000000 --- a/apps/assessments/migrations/0001_initial.py +++ /dev/null @@ -1,219 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-09 14:39 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('organizations', '0001_initial'), - ('processes', '0001_initial'), - ('requirements_app', '0001_initial'), - ('wizard', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='ApplicabilityAssessment', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('sector', models.CharField(max_length=255)), - ('company_size', models.CharField(blank=True, max_length=255)), - ('critical_services', models.TextField(blank=True)), - ('supply_chain_role', models.CharField(blank=True, max_length=255)), - ('status', models.CharField(choices=[('RELEVANT', 'Voraussichtlich relevant'), ('POSSIBLY_RELEVANT', 'Möglicherweise relevant'), ('NOT_DIRECTLY_RELEVANT', 'Derzeit nicht direkt relevant')], max_length=32)), - ('reasoning', models.TextField()), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applicability_assessments', to='organizations.tenant')), - ], - options={ - 'ordering': ['-created_at'], - }, - ), - migrations.CreateModel( - name='Assessment', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('status', models.CharField(choices=[('FULFILLED', 'Ausreichend erfüllt'), ('PARTIAL', 'Teilweise erfüllt'), ('INFORMAL', 'Informal vorhanden'), ('DOCUMENTED_NOT_IMPLEMENTED', 'Dokumentiert, aber nicht umgesetzt'), ('IMPLEMENTED_NO_EVIDENCE', 'Umgesetzt, aber nicht nachweisbar'), ('MISSING', 'Fehlt vollständig')], default='MISSING', max_length=32)), - ('score', models.PositiveSmallIntegerField(default=0)), - ('notes', models.TextField(blank=True)), - ('evidence_summary', models.TextField(blank=True)), - ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assessments', to=settings.AUTH_USER_MODEL)), - ('process', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assessments', to='processes.process')), - ('requirement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assessments', to='requirements_app.requirement')), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assessments', to='organizations.tenant')), - ], - options={ - 'ordering': ['requirement__framework', 'requirement__code'], - 'unique_together': {('process', 'requirement')}, - }, - ), - migrations.CreateModel( - name='Audit', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('title', models.CharField(max_length=255)), - ('audit_type', models.CharField(choices=[('INTERNAL', 'Internes Audit'), ('EXTERNAL', 'Externes Audit'), ('SURVEILLANCE', 'Ueberwachungsaudit'), ('RECERTIFICATION', 'Rezertifizierungsaudit')], default='INTERNAL', max_length=20)), - ('status', models.CharField(choices=[('PLANNED', 'Geplant'), ('IN_PROGRESS', 'Laufend'), ('COMPLETED', 'Abgeschlossen'), ('CANCELLED', 'Abgebrochen')], default='PLANNED', max_length=16)), - ('lead_auditor', models.CharField(blank=True, max_length=255)), - ('audit_team', models.TextField(blank=True, help_text='Auditteam-Mitglieder, je Zeile ein Name')), - ('scope', models.TextField(blank=True, help_text='Auditumfang und -kriterien')), - ('objectives', models.TextField(blank=True)), - ('planned_start', models.DateField(blank=True, null=True)), - ('planned_end', models.DateField(blank=True, null=True)), - ('actual_start', models.DateField(blank=True, null=True)), - ('actual_end', models.DateField(blank=True, null=True)), - ('conclusion', models.TextField(blank=True, help_text='Gesamtfazit des Audits')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_audits', to=settings.AUTH_USER_MODEL)), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='audits', to='organizations.tenant')), - ], - options={ - 'ordering': ['-planned_start', '-created_at'], - }, - ), - migrations.CreateModel( - name='AuditFinding', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('finding_number', models.CharField(max_length=32)), - ('title', models.CharField(max_length=255)), - ('description', models.TextField()), - ('severity', models.CharField(choices=[('MAJOR_NC', 'Hauptabweichung (Major NC)'), ('MINOR_NC', 'Nebenabweichung (Minor NC)'), ('OBSERVATION', 'Beobachtung'), ('OPPORTUNITY', 'Verbesserungspotenzial'), ('POSITIVE', 'Positive Feststellung')], default='MINOR_NC', max_length=16)), - ('status', models.CharField(choices=[('OPEN', 'Offen'), ('IN_PROGRESS', 'In Bearbeitung'), ('CORRECTIVE_ACTION', 'Korrekturmassnahme definiert'), ('IMPLEMENTED', 'Umgesetzt'), ('VERIFIED', 'Verifiziert / wirksam'), ('CLOSED', 'Geschlossen')], default='OPEN', max_length=20)), - ('requirement_reference', models.CharField(blank=True, help_text='z.B. A.5.1, NIS2-21-2a', max_length=128)), - ('evidence_reference', models.TextField(blank=True)), - ('due_date', models.DateField(blank=True, null=True)), - ('root_cause', models.TextField(blank=True)), - ('corrective_action', models.TextField(blank=True)), - ('verification_notes', models.TextField(blank=True)), - ('verified_at', models.DateTimeField(blank=True, null=True)), - ('audit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='findings', to='assessments.audit')), - ('responsible', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_findings', to=settings.AUTH_USER_MODEL)), - ('verified_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='verified_findings', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'ordering': ['severity', 'finding_number'], - }, - ), - migrations.CreateModel( - name='ManagementReview', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('title', models.CharField(default='Management Review', max_length=255)), - ('status', models.CharField(choices=[('PLANNED', 'Geplant'), ('AGENDA_SET', 'Agenda erstellt'), ('IN_SESSION', 'Sitzung laeuft'), ('PROTOCOL_DRAFT', 'Protokoll-Entwurf'), ('COMPLETED', 'Abgeschlossen')], default='PLANNED', max_length=20)), - ('review_date', models.DateField(blank=True, null=True)), - ('attendees', models.TextField(blank=True, help_text='Teilnehmer, je Zeile ein Name/Rolle')), - ('location', models.CharField(blank=True, max_length=255)), - ('input_isms_status', models.TextField(blank=True, help_text='Status des ISMS seit letzter Review')), - ('input_audit_results', models.TextField(blank=True, help_text='Ergebnisse interner/externer Audits')), - ('input_risk_status', models.TextField(blank=True, help_text='Aktueller Risikostatus und Veraenderungen')), - ('input_incidents', models.TextField(blank=True, help_text='Sicherheitsvorfaelle und Trends')), - ('input_metrics', models.TextField(blank=True, help_text='Kennzahlen und Messergebnisse')), - ('input_feedback', models.TextField(blank=True, help_text='Feedback von interessierten Parteien')), - ('input_improvement', models.TextField(blank=True, help_text='Moeglichkeiten zur Verbesserung')), - ('input_changes', models.TextField(blank=True, help_text='Aenderungen die das ISMS betreffen')), - ('output_decisions', models.TextField(blank=True, help_text='Getroffene Entscheidungen')), - ('output_actions', models.TextField(blank=True, help_text='Beschlossene Massnahmen mit Verantwortlichen und Fristen')), - ('output_resource_needs', models.TextField(blank=True, help_text='Ressourcenbedarf')), - ('output_improvement', models.TextField(blank=True, help_text='Verbesserungsmassnahmen')), - ('protocol_summary', models.TextField(blank=True, help_text='Zusammenfassung des Protokolls')), - ('next_review_date', models.DateField(blank=True, null=True)), - ('chairperson', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='chaired_reviews', to=settings.AUTH_USER_MODEL)), - ('session', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='management_reviews', to='wizard.assessmentsession')), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='management_reviews', to='organizations.tenant')), - ], - options={ - 'ordering': ['-review_date', '-created_at'], - }, - ), - migrations.CreateModel( - name='Measure', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('title', models.CharField(max_length=255)), - ('description', models.TextField(blank=True)), - ('priority', models.CharField(choices=[('LOW', 'Low'), ('MEDIUM', 'Medium'), ('HIGH', 'High')], default='MEDIUM', max_length=16)), - ('status', models.CharField(choices=[('OPEN', 'Open'), ('IN_PROGRESS', 'In Progress'), ('BLOCKED', 'Blocked'), ('DONE', 'Done')], default='OPEN', max_length=16)), - ('due_date', models.DateField(blank=True, null=True)), - ('assessment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='measures', to='assessments.assessment')), - ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='measures', to=settings.AUTH_USER_MODEL)), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='measures', to='organizations.tenant')), - ], - options={ - 'ordering': ['status', 'due_date'], - }, - ), - migrations.CreateModel( - name='ReviewAction', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('title', models.CharField(max_length=255)), - ('description', models.TextField(blank=True)), - ('due_date', models.DateField(blank=True, null=True)), - ('status', models.CharField(choices=[('OPEN', 'Offen'), ('IN_PROGRESS', 'In Bearbeitung'), ('DONE', 'Erledigt'), ('OVERDUE', 'Ueberfaellig')], default='OPEN', max_length=16)), - ('completion_notes', models.TextField(blank=True)), - ('responsible', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='review_actions', to=settings.AUTH_USER_MODEL)), - ('review', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='actions', to='assessments.managementreview')), - ], - options={ - 'ordering': ['status', 'due_date'], - }, - ), - migrations.CreateModel( - name='SoADocument', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('title', models.CharField(default='Statement of Applicability', max_length=255)), - ('version', models.CharField(default='1.0', max_length=32)), - ('status', models.CharField(choices=[('DRAFT', 'Entwurf'), ('IN_REVIEW', 'In Pruefung'), ('APPROVED', 'Freigegeben')], default='DRAFT', max_length=16)), - ('approved_at', models.DateTimeField(blank=True, null=True)), - ('notes', models.TextField(blank=True)), - ('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_soas', to=settings.AUTH_USER_MODEL)), - ('session', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='soa_documents', to='wizard.assessmentsession')), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='soa_documents', to='organizations.tenant')), - ], - options={ - 'ordering': ['-created_at'], - }, - ), - migrations.CreateModel( - name='SoAEntry', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('is_applicable', models.BooleanField(default=True)), - ('justification', models.TextField(blank=True, help_text='Begruendung fuer Anwendbarkeit oder Ausschluss')), - ('implementation_status', models.CharField(choices=[('NOT_STARTED', 'Nicht begonnen'), ('PARTIAL', 'Teilweise umgesetzt'), ('IMPLEMENTED', 'Umgesetzt'), ('NOT_APPLICABLE', 'Nicht anwendbar')], default='NOT_STARTED', max_length=20)), - ('evidence_reference', models.TextField(blank=True, help_text='Verweis auf Evidenzen oder Dokumente')), - ('notes', models.TextField(blank=True)), - ('control_owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='soa_controls', to=settings.AUTH_USER_MODEL)), - ('requirement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='soa_entries', to='requirements_app.requirement')), - ('soa', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='assessments.soadocument')), - ], - options={ - 'ordering': ['requirement__framework', 'requirement__code'], - 'unique_together': {('soa', 'requirement')}, - }, - ), - ] diff --git a/apps/assessments/migrations/__init__.py b/apps/assessments/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/assessments/models.py b/apps/assessments/models.py deleted file mode 100644 index 7fdbdee..0000000 --- a/apps/assessments/models.py +++ /dev/null @@ -1,93 +0,0 @@ -from django.db import models -from apps.core.models import TenantRelationValidationMixin, TimeStampedModel - - -class ApplicabilityAssessment(TenantRelationValidationMixin, TimeStampedModel): - class Status(models.TextChoices): - RELEVANT = 'RELEVANT', 'Voraussichtlich relevant' - POSSIBLY_RELEVANT = 'POSSIBLY_RELEVANT', 'Möglicherweise relevant' - NOT_DIRECTLY_RELEVANT = 'NOT_DIRECTLY_RELEVANT', 'Derzeit nicht direkt relevant' - - tenant = models.ForeignKey('organizations.Tenant', on_delete=models.CASCADE, related_name='applicability_assessments') - sector = models.CharField(max_length=255) - company_size = models.CharField(max_length=255, blank=True) - critical_services = models.TextField(blank=True) - supply_chain_role = models.CharField(max_length=255, blank=True) - status = models.CharField(max_length=32, choices=Status.choices) - reasoning = models.TextField() - - class Meta: - ordering = ['-created_at'] - - def __str__(self): - return f'{self.tenant} - {self.status}' - - -class Assessment(TenantRelationValidationMixin, TimeStampedModel): - tenant_relation_fields = { - 'process': 'tenant_id', - 'owner': 'tenant_id', - } - - class Status(models.TextChoices): - FULFILLED = 'FULFILLED', 'Ausreichend erfüllt' - PARTIAL = 'PARTIAL', 'Teilweise erfüllt' - INFORMAL = 'INFORMAL', 'Informal vorhanden' - DOCUMENTED_NOT_IMPLEMENTED = 'DOCUMENTED_NOT_IMPLEMENTED', 'Dokumentiert, aber nicht umgesetzt' - IMPLEMENTED_NO_EVIDENCE = 'IMPLEMENTED_NO_EVIDENCE', 'Umgesetzt, aber nicht nachweisbar' - MISSING = 'MISSING', 'Fehlt vollständig' - - tenant = models.ForeignKey('organizations.Tenant', on_delete=models.CASCADE, related_name='assessments') - process = models.ForeignKey('processes.Process', on_delete=models.CASCADE, related_name='assessments') - requirement = models.ForeignKey('requirements_app.Requirement', on_delete=models.CASCADE, related_name='assessments') - owner = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='assessments') - status = models.CharField(max_length=32, choices=Status.choices, default=Status.MISSING) - score = models.PositiveSmallIntegerField(default=0) - notes = models.TextField(blank=True) - evidence_summary = models.TextField(blank=True) - - class Meta: - unique_together = ('process', 'requirement') - ordering = ['requirement__framework', 'requirement__code'] - - def __str__(self): - return f'{self.process} -> {self.requirement}' - - -class Measure(TenantRelationValidationMixin, TimeStampedModel): - tenant_relation_fields = { - 'assessment': 'tenant_id', - 'owner': 'tenant_id', - } - - class Priority(models.TextChoices): - LOW = 'LOW', 'Low' - MEDIUM = 'MEDIUM', 'Medium' - HIGH = 'HIGH', 'High' - - class Status(models.TextChoices): - OPEN = 'OPEN', 'Open' - IN_PROGRESS = 'IN_PROGRESS', 'In Progress' - BLOCKED = 'BLOCKED', 'Blocked' - DONE = 'DONE', 'Done' - - tenant = models.ForeignKey('organizations.Tenant', on_delete=models.CASCADE, related_name='measures') - assessment = models.ForeignKey(Assessment, on_delete=models.SET_NULL, null=True, blank=True, related_name='measures') - owner = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='measures') - title = models.CharField(max_length=255) - description = models.TextField(blank=True) - priority = models.CharField(max_length=16, choices=Priority.choices, default=Priority.MEDIUM) - status = models.CharField(max_length=16, choices=Status.choices, default=Status.OPEN) - due_date = models.DateField(null=True, blank=True) - - class Meta: - ordering = ['status', 'due_date'] - - def __str__(self): - return self.title - - -# V20: Import new models so Django can discover them -from .soa_models import SoADocument, SoAEntry # noqa: E402, F401 -from .audit_models import Audit, AuditFinding # noqa: E402, F401 -from .review_models import ManagementReview, ReviewAction # noqa: E402, F401 diff --git a/apps/assessments/review_models.py b/apps/assessments/review_models.py deleted file mode 100644 index 1cb8107..0000000 --- a/apps/assessments/review_models.py +++ /dev/null @@ -1,80 +0,0 @@ -"""V20: Management-Review-Workflow mit Agenda und Protokoll.""" -from django.db import models -from apps.core.models import TenantRelationValidationMixin, TimeStampedModel - - -class ManagementReview(TenantRelationValidationMixin, TimeStampedModel): - tenant_relation_fields = { - 'session': 'tenant_id', - 'chairperson': 'tenant_id', - } - - class Status(models.TextChoices): - PLANNED = 'PLANNED', 'Geplant' - AGENDA_SET = 'AGENDA_SET', 'Agenda erstellt' - IN_SESSION = 'IN_SESSION', 'Sitzung laeuft' - PROTOCOL_DRAFT = 'PROTOCOL_DRAFT', 'Protokoll-Entwurf' - COMPLETED = 'COMPLETED', 'Abgeschlossen' - - tenant = models.ForeignKey('organizations.Tenant', on_delete=models.CASCADE, related_name='management_reviews') - session = models.ForeignKey('wizard.AssessmentSession', on_delete=models.SET_NULL, null=True, blank=True, related_name='management_reviews') - title = models.CharField(max_length=255, default='Management Review') - status = models.CharField(max_length=20, choices=Status.choices, default=Status.PLANNED) - review_date = models.DateField(null=True, blank=True) - chairperson = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='chaired_reviews') - attendees = models.TextField(blank=True, help_text='Teilnehmer, je Zeile ein Name/Rolle') - location = models.CharField(max_length=255, blank=True) - - # ISO 27001 Kap. 9.3 – Inputs - input_isms_status = models.TextField(blank=True, help_text='Status des ISMS seit letzter Review') - input_audit_results = models.TextField(blank=True, help_text='Ergebnisse interner/externer Audits') - input_risk_status = models.TextField(blank=True, help_text='Aktueller Risikostatus und Veraenderungen') - input_incidents = models.TextField(blank=True, help_text='Sicherheitsvorfaelle und Trends') - input_metrics = models.TextField(blank=True, help_text='Kennzahlen und Messergebnisse') - input_feedback = models.TextField(blank=True, help_text='Feedback von interessierten Parteien') - input_improvement = models.TextField(blank=True, help_text='Moeglichkeiten zur Verbesserung') - input_changes = models.TextField(blank=True, help_text='Aenderungen die das ISMS betreffen') - - # Outputs / Entscheidungen - output_decisions = models.TextField(blank=True, help_text='Getroffene Entscheidungen') - output_actions = models.TextField(blank=True, help_text='Beschlossene Massnahmen mit Verantwortlichen und Fristen') - output_resource_needs = models.TextField(blank=True, help_text='Ressourcenbedarf') - output_improvement = models.TextField(blank=True, help_text='Verbesserungsmassnahmen') - - # Protocol - protocol_summary = models.TextField(blank=True, help_text='Zusammenfassung des Protokolls') - next_review_date = models.DateField(null=True, blank=True) - - class Meta: - ordering = ['-review_date', '-created_at'] - - def __str__(self): - return f'{self.title} – {self.review_date or "ungeplant"}' - - -class ReviewAction(TenantRelationValidationMixin, TimeStampedModel): - tenant_source = 'review__tenant_id' - tenant_relation_fields = { - 'review': 'tenant_id', - 'responsible': 'tenant_id', - } - - class Status(models.TextChoices): - OPEN = 'OPEN', 'Offen' - IN_PROGRESS = 'IN_PROGRESS', 'In Bearbeitung' - DONE = 'DONE', 'Erledigt' - OVERDUE = 'OVERDUE', 'Ueberfaellig' - - review = models.ForeignKey(ManagementReview, on_delete=models.CASCADE, related_name='actions') - title = models.CharField(max_length=255) - description = models.TextField(blank=True) - responsible = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='review_actions') - due_date = models.DateField(null=True, blank=True) - status = models.CharField(max_length=16, choices=Status.choices, default=Status.OPEN) - completion_notes = models.TextField(blank=True) - - class Meta: - ordering = ['status', 'due_date'] - - def __str__(self): - return self.title diff --git a/apps/assessments/services.py b/apps/assessments/services.py deleted file mode 100644 index 8e01a3b..0000000 --- a/apps/assessments/services.py +++ /dev/null @@ -1,340 +0,0 @@ -import json -from dataclasses import dataclass -from datetime import date, datetime -from urllib.request import Request, urlopen - -from django.conf import settings - -from apps.assessments.models import ApplicabilityAssessment, Assessment, Measure - - -@dataclass(frozen=True) -class AssessmentRelatedRef: - id: int | None - name: str - - def __str__(self) -> str: - return self.name - - -@dataclass(frozen=True) -class AssessmentRequirementRef: - id: int - framework: str - code: str - title: str - - def __str__(self) -> str: - return f'{self.framework} - {self.code}' - - -@dataclass(frozen=True) -class ApplicabilityBridgeItem: - id: int - tenant: object - tenant_id: int - tenant_name: str - sector: str - company_size: str - critical_services: str - supply_chain_role: str - status: str - status_label: str - reasoning: str - created_at: datetime | None - updated_at: datetime | None - - @property - def pk(self) -> int: - return self.id - - def get_status_display(self) -> str: - return self.status_label - - -@dataclass(frozen=True) -class AssessmentBridgeItem: - id: int - tenant: object - tenant_id: int - process: AssessmentRelatedRef - process_id: int - requirement: AssessmentRequirementRef - requirement_id: int - owner: AssessmentRelatedRef | None - owner_id: int | None - status: str - status_label: str - score: int - notes: str - evidence_summary: str - created_at: datetime | None - updated_at: datetime | None - - @property - def pk(self) -> int: - return self.id - - def get_status_display(self) -> str: - return self.status_label - - -@dataclass(frozen=True) -class MeasureBridgeItem: - id: int - tenant: object - tenant_id: int - assessment: AssessmentRelatedRef | None - assessment_id: int | None - owner: AssessmentRelatedRef | None - owner_id: int | None - title: str - description: str - priority: str - priority_label: str - status: str - status_label: str - due_date: date | None - created_at: datetime | None - updated_at: datetime | None - - @property - def pk(self) -> int: - return self.id - - def get_priority_display(self) -> str: - return self.priority_label - - def get_status_display(self) -> str: - return self.status_label - - -class AssessmentRustClient: - @staticmethod - def _base_url() -> str: - base = (getattr(settings, 'RUST_BACKEND_URL', '') or '').strip() - return base.rstrip('/') - - @staticmethod - def fetch_applicability(request, tenant, timeout: int = 8) -> list[ApplicabilityBridgeItem]: - payload = AssessmentRustClient._fetch_items( - request, - tenant, - '/api/v1/assessments/applicability', - timeout=timeout, - ) - return [ - AssessmentRustClient._applicability_from_payload(item, tenant) - for item in payload - if isinstance(item, dict) - ] - - @staticmethod - def fetch_assessments(request, tenant, timeout: int = 8) -> list[AssessmentBridgeItem]: - payload = AssessmentRustClient._fetch_items( - request, - tenant, - '/api/v1/assessments', - timeout=timeout, - ) - return [ - AssessmentRustClient._assessment_from_payload(item, tenant) - for item in payload - if isinstance(item, dict) - ] - - @staticmethod - def fetch_measures(request, tenant, timeout: int = 8) -> list[MeasureBridgeItem]: - payload = AssessmentRustClient._fetch_items( - request, - tenant, - '/api/v1/assessments/measures', - timeout=timeout, - ) - return [ - AssessmentRustClient._measure_from_payload(item, tenant) - for item in payload - if isinstance(item, dict) - ] - - @staticmethod - def _fetch_items(request, tenant, path: str, timeout: int) -> list[dict]: - base = AssessmentRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = AssessmentRustClient._authenticated_request(request, tenant, f'{base}{path}') - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - - tenant_id = AssessmentRustClient._int_field(payload, 'tenant_id') - if tenant_id != int(tenant.id): - raise RuntimeError('Rust-Assessmentliste gehoert nicht zum angefragten Tenant.') - return payload.get('items', []) - - @staticmethod - def _applicability_from_payload(item: dict, tenant) -> ApplicabilityBridgeItem: - status = str(item.get('status') or ApplicabilityAssessment.Status.POSSIBLY_RELEVANT) - return ApplicabilityBridgeItem( - id=AssessmentRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=AssessmentRustClient._int_field(item, 'tenant_id'), - tenant_name=str(item.get('tenant_name') or ''), - sector=str(item.get('sector') or ''), - company_size=str(item.get('company_size') or ''), - critical_services=str(item.get('critical_services') or ''), - supply_chain_role=str(item.get('supply_chain_role') or ''), - status=status, - status_label=str(item.get('status_label') or dict(ApplicabilityAssessment.Status.choices).get(status, status)), - reasoning=str(item.get('reasoning') or ''), - created_at=AssessmentRustClient._datetime_field(item, 'created_at'), - updated_at=AssessmentRustClient._datetime_field(item, 'updated_at'), - ) - - @staticmethod - def _assessment_from_payload(item: dict, tenant) -> AssessmentBridgeItem: - process_id = AssessmentRustClient._int_field(item, 'process_id') - process_name = str(item.get('process_name') or '').strip() - requirement = AssessmentRequirementRef( - id=AssessmentRustClient._int_field(item, 'requirement_id'), - framework=str(item.get('requirement_framework') or ''), - code=str(item.get('requirement_code') or ''), - title=str(item.get('requirement_title') or ''), - ) - owner_id = AssessmentRustClient._optional_int_field(item, 'owner_id') - owner_display = str(item.get('owner_display') or '').strip() - status = str(item.get('status') or Assessment.Status.MISSING) - return AssessmentBridgeItem( - id=AssessmentRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=AssessmentRustClient._int_field(item, 'tenant_id'), - process=AssessmentRelatedRef(process_id, process_name), - process_id=process_id, - requirement=requirement, - requirement_id=requirement.id, - owner=AssessmentRelatedRef(owner_id, owner_display) if owner_display else None, - owner_id=owner_id, - status=status, - status_label=str(item.get('status_label') or dict(Assessment.Status.choices).get(status, status)), - score=AssessmentRustClient._int_field(item, 'score'), - notes=str(item.get('notes') or ''), - evidence_summary=str(item.get('evidence_summary') or ''), - created_at=AssessmentRustClient._datetime_field(item, 'created_at'), - updated_at=AssessmentRustClient._datetime_field(item, 'updated_at'), - ) - - @staticmethod - def _measure_from_payload(item: dict, tenant) -> MeasureBridgeItem: - assessment_id = AssessmentRustClient._optional_int_field(item, 'assessment_id') - assessment_display = str(item.get('assessment_display') or '').strip() - owner_id = AssessmentRustClient._optional_int_field(item, 'owner_id') - owner_display = str(item.get('owner_display') or '').strip() - priority = str(item.get('priority') or Measure.Priority.MEDIUM) - status = str(item.get('status') or Measure.Status.OPEN) - return MeasureBridgeItem( - id=AssessmentRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=AssessmentRustClient._int_field(item, 'tenant_id'), - assessment=AssessmentRelatedRef(assessment_id, assessment_display) if assessment_display else None, - assessment_id=assessment_id, - owner=AssessmentRelatedRef(owner_id, owner_display) if owner_display else None, - owner_id=owner_id, - title=str(item.get('title') or ''), - description=str(item.get('description') or ''), - priority=priority, - priority_label=str(item.get('priority_label') or dict(Measure.Priority.choices).get(priority, priority)), - status=status, - status_label=str(item.get('status_label') or dict(Measure.Status.choices).get(status, status)), - due_date=AssessmentRustClient._date_field(item, 'due_date'), - created_at=AssessmentRustClient._datetime_field(item, 'created_at'), - updated_at=AssessmentRustClient._datetime_field(item, 'updated_at'), - ) - - @staticmethod - def _authenticated_request(request, tenant, url: str) -> Request: - user = getattr(request, 'user', None) - user_id = getattr(user, 'id', None) - if not user_id: - raise RuntimeError('Assessment-Rust-Bridge braucht einen authentifizierten User.') - - rust_request = Request(url) - rust_request.add_header('Accept', 'application/json') - rust_request.add_header('X-ISCY-Tenant-ID', str(tenant.id)) - rust_request.add_header('X-ISCY-User-ID', str(user_id)) - user_email = (getattr(user, 'email', '') or '').strip() - if user_email: - rust_request.add_header('X-ISCY-User-Email', user_email) - return rust_request - - @staticmethod - def _int_field(payload: dict, key: str) -> int: - return int(payload.get(key) or 0) - - @staticmethod - def _optional_int_field(payload: dict, key: str) -> int | None: - value = payload.get(key) - if value in (None, ''): - return None - return int(value) - - @staticmethod - def _datetime_field(payload: dict, key: str) -> datetime | None: - value = str(payload.get(key) or '').strip() - if not value: - return None - return datetime.fromisoformat(value.replace('Z', '+00:00')) - - @staticmethod - def _date_field(payload: dict, key: str) -> date | None: - value = str(payload.get(key) or '').strip() - if not value: - return None - return date.fromisoformat(value[:10]) - - -class AssessmentRegisterBridge: - @staticmethod - def fetch_applicability(request, tenant): - if tenant is None: - return None - return AssessmentRegisterBridge._fetch( - lambda: AssessmentRustClient.fetch_applicability(request, tenant), - ) - - @staticmethod - def fetch_assessments(request, tenant): - if tenant is None: - return None - return AssessmentRegisterBridge._fetch( - lambda: AssessmentRustClient.fetch_assessments(request, tenant), - ) - - @staticmethod - def fetch_measures(request, tenant): - if tenant is None: - return None - return AssessmentRegisterBridge._fetch( - lambda: AssessmentRustClient.fetch_measures(request, tenant), - ) - - @staticmethod - def _fetch(fetcher): - backend = str(getattr(settings, 'ASSESSMENT_REGISTER_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not AssessmentRustClient._base_url(): - if AssessmentRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust assessment register backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return fetcher() - except Exception as exc: - if AssessmentRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust assessment register backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def _strict_rust_mode() -> bool: - return bool(getattr(settings, 'RUST_STRICT_MODE', False)) diff --git a/apps/assessments/soa_models.py b/apps/assessments/soa_models.py deleted file mode 100644 index f5d50a7..0000000 --- a/apps/assessments/soa_models.py +++ /dev/null @@ -1,74 +0,0 @@ -"""V20: Statement of Applicability (SoA) – ISO 27001 Pflichtdokument.""" -from django.db import models -from apps.core.models import TenantRelationValidationMixin, TimeStampedModel - - -class SoADocument(TenantRelationValidationMixin, TimeStampedModel): - tenant_relation_fields = { - 'session': 'tenant_id', - 'approved_by': 'tenant_id', - } - - """Ein SoA-Dokument pro Tenant/Session.""" - class Status(models.TextChoices): - DRAFT = 'DRAFT', 'Entwurf' - IN_REVIEW = 'IN_REVIEW', 'In Pruefung' - APPROVED = 'APPROVED', 'Freigegeben' - - tenant = models.ForeignKey('organizations.Tenant', on_delete=models.CASCADE, related_name='soa_documents') - session = models.ForeignKey('wizard.AssessmentSession', on_delete=models.SET_NULL, null=True, blank=True, related_name='soa_documents') - title = models.CharField(max_length=255, default='Statement of Applicability') - version = models.CharField(max_length=32, default='1.0') - status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT) - approved_by = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='approved_soas') - approved_at = models.DateTimeField(null=True, blank=True) - notes = models.TextField(blank=True) - - class Meta: - ordering = ['-created_at'] - - def __str__(self): - return f'{self.title} v{self.version} ({self.tenant.name})' - - @property - def applicable_count(self): - return self.entries.filter(is_applicable=True).count() - - @property - def not_applicable_count(self): - return self.entries.filter(is_applicable=False).count() - - @property - def implemented_count(self): - return self.entries.filter(is_applicable=True, implementation_status='IMPLEMENTED').count() - - -class SoAEntry(TenantRelationValidationMixin, TimeStampedModel): - tenant_source = 'soa__tenant_id' - tenant_relation_fields = { - 'soa': 'tenant_id', - 'control_owner': 'tenant_id', - } - - """Eine Zeile im SoA – entspricht einem Annex-A Control.""" - class ImplementationStatus(models.TextChoices): - NOT_STARTED = 'NOT_STARTED', 'Nicht begonnen' - PARTIAL = 'PARTIAL', 'Teilweise umgesetzt' - IMPLEMENTED = 'IMPLEMENTED', 'Umgesetzt' - NOT_APPLICABLE = 'NOT_APPLICABLE', 'Nicht anwendbar' - - soa = models.ForeignKey(SoADocument, on_delete=models.CASCADE, related_name='entries') - requirement = models.ForeignKey('requirements_app.Requirement', on_delete=models.CASCADE, related_name='soa_entries') - is_applicable = models.BooleanField(default=True) - justification = models.TextField(blank=True, help_text='Begruendung fuer Anwendbarkeit oder Ausschluss') - implementation_status = models.CharField(max_length=20, choices=ImplementationStatus.choices, default=ImplementationStatus.NOT_STARTED) - control_owner = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='soa_controls') - evidence_reference = models.TextField(blank=True, help_text='Verweis auf Evidenzen oder Dokumente') - notes = models.TextField(blank=True) - - class Meta: - unique_together = ('soa', 'requirement') - ordering = ['requirement__framework', 'requirement__code'] - - def __str__(self): - return f'{self.requirement.code} – {"Anwendbar" if self.is_applicable else "Ausgeschlossen"}' diff --git a/apps/assessments/tests.py b/apps/assessments/tests.py deleted file mode 100644 index 1aa00ed..0000000 --- a/apps/assessments/tests.py +++ /dev/null @@ -1,265 +0,0 @@ -import json -from unittest.mock import Mock, patch - -from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings -from django.urls import reverse - -from apps.assessments.models import ApplicabilityAssessment, Assessment, Measure -from apps.organizations.models import Tenant -from apps.processes.models import Process -from apps.requirements_app.models import Requirement - - -User = get_user_model() - - -@override_settings(ASSESSMENT_REGISTER_BACKEND='local', RUST_BACKEND_URL='') -class AssessmentViewTests(TestCase): - def setUp(self): - self.tenant_a = Tenant.objects.create(name='Tenant A', slug='tenant-a', country='DE') - self.tenant_b = Tenant.objects.create(name='Tenant B', slug='tenant-b', country='DE') - self.user_a = User.objects.create_user( - username='tenant-a-user', - email='assessment-user@example.test', - password='testpass123', - tenant=self.tenant_a, - first_name='Ada', - last_name='Lovelace', - ) - self.user_b = User.objects.create_user( - username='tenant-b-user', - password='testpass123', - tenant=self.tenant_b, - ) - self.process_a = Process.objects.create( - tenant=self.tenant_a, - owner=self.user_a, - name='Incident Intake', - status=Process.Status.PARTIAL, - ) - self.process_b = Process.objects.create( - tenant=self.tenant_b, - owner=self.user_b, - name='Foreign Process', - status=Process.Status.SUFFICIENT, - ) - self.requirement = Requirement.objects.create( - framework=Requirement.Framework.ISO27001, - code='A.5.17', - title='Authentication Information', - domain='IAM', - description='Protect authentication information.', - ) - self.applicability_a = ApplicabilityAssessment.objects.create( - tenant=self.tenant_a, - sector='MSSP', - company_size='medium', - critical_services='Managed detection and response', - supply_chain_role='critical supplier', - status=ApplicabilityAssessment.Status.RELEVANT, - reasoning='Digital provider with critical customer services', - ) - self.applicability_b = ApplicabilityAssessment.objects.create( - tenant=self.tenant_b, - sector='Retail', - status=ApplicabilityAssessment.Status.NOT_DIRECTLY_RELEVANT, - reasoning='Foreign tenant', - ) - self.assessment_a = Assessment.objects.create( - tenant=self.tenant_a, - process=self.process_a, - requirement=self.requirement, - owner=self.user_a, - status=Assessment.Status.PARTIAL, - score=3, - notes='MFA rollout started', - ) - self.assessment_b = Assessment.objects.create( - tenant=self.tenant_b, - process=self.process_b, - requirement=self.requirement, - owner=self.user_b, - status=Assessment.Status.FULFILLED, - score=5, - ) - self.measure_a = Measure.objects.create( - tenant=self.tenant_a, - assessment=self.assessment_a, - owner=self.user_a, - title='MFA ausrollen', - priority=Measure.Priority.HIGH, - status=Measure.Status.OPEN, - due_date='2026-05-01', - ) - self.measure_b = Measure.objects.create( - tenant=self.tenant_b, - assessment=self.assessment_b, - owner=self.user_b, - title='Foreign Measure', - priority=Measure.Priority.LOW, - status=Measure.Status.OPEN, - ) - - def test_assessment_list_only_shows_current_tenant(self): - self.client.force_login(self.user_a) - - response = self.client.get(reverse('assessments:list')) - - self.assertEqual(response.status_code, 200) - self.assertEqual(list(response.context['items']), [self.assessment_a]) - self.assertEqual(response.context['assessment_register_source'], 'django') - self.assertContains(response, 'Incident Intake') - self.assertNotContains(response, 'Foreign Process') - - @patch('apps.assessments.services.urlopen') - def test_applicability_list_can_use_rust_register_bridge(self, mock_urlopen): - self._mock_rust_response(mock_urlopen, [self._rust_applicability_payload(sector='Rust MSSP')]) - self.client.force_login(self.user_a) - - with self.settings(ASSESSMENT_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.get(reverse('assessments:applicability_list')) - - self.assertEqual(response.status_code, 200) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual(rust_request.full_url, 'http://rust-backend:9000/api/v1/assessments/applicability') - self.assertEqual(rust_request.get_header('X-iscy-tenant-id'), str(self.tenant_a.id)) - self.assertEqual(rust_request.get_header('X-iscy-user-id'), str(self.user_a.id)) - self.assertEqual(response.context['assessment_register_source'], 'rust_service') - self.assertEqual(list(response.context['items'])[0].sector, 'Rust MSSP') - self.assertContains(response, 'Rust MSSP') - self.assertContains(response, 'Voraussichtlich relevant') - - @patch('apps.assessments.services.urlopen') - def test_assessment_list_can_use_rust_register_bridge(self, mock_urlopen): - self._mock_rust_response(mock_urlopen, [self._rust_assessment_payload()]) - self.client.force_login(self.user_a) - - with self.settings(ASSESSMENT_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.get(reverse('assessments:list')) - - self.assertEqual(response.status_code, 200) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual(rust_request.full_url, 'http://rust-backend:9000/api/v1/assessments') - items = list(response.context['items']) - self.assertEqual(response.context['assessment_register_source'], 'rust_service') - self.assertEqual(items[0].process.name, 'Incident Intake') - self.assertEqual(str(items[0].requirement), 'ISO27001 - A.5.17') - self.assertEqual(items[0].get_status_display(), 'Teilweise erfüllt') - self.assertContains(response, 'Incident Intake') - self.assertContains(response, 'Teilweise erfüllt') - - @patch('apps.assessments.services.urlopen') - def test_measure_list_can_use_rust_register_bridge(self, mock_urlopen): - self._mock_rust_response(mock_urlopen, [self._rust_measure_payload(title='Rust MFA ausrollen')]) - self.client.force_login(self.user_a) - - with self.settings(ASSESSMENT_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.get(reverse('assessments:measure_list')) - - self.assertEqual(response.status_code, 200) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual(rust_request.full_url, 'http://rust-backend:9000/api/v1/assessments/measures') - items = list(response.context['items']) - self.assertEqual(response.context['assessment_register_source'], 'rust_service') - self.assertEqual(items[0].title, 'Rust MFA ausrollen') - self.assertEqual(items[0].get_priority_display(), 'High') - self.assertEqual(items[0].get_status_display(), 'Open') - self.assertContains(response, 'Rust MFA ausrollen') - self.assertContains(response, 'High') - - @patch('apps.assessments.services.urlopen', side_effect=OSError('backend down')) - def test_assessment_list_falls_back_to_django_when_rust_unavailable_in_non_strict_mode(self, _mock_urlopen): - self.client.force_login(self.user_a) - - with self.settings( - ASSESSMENT_REGISTER_BACKEND='rust_service', - RUST_BACKEND_URL='http://rust-backend:9000', - RUST_STRICT_MODE=False, - ): - response = self.client.get(reverse('assessments:list')) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['assessment_register_source'], 'django') - self.assertEqual(list(response.context['items']), [self.assessment_a]) - - def test_assessment_list_raises_in_strict_mode_without_rust_backend_url(self): - self.client.force_login(self.user_a) - - with self.settings(ASSESSMENT_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='', RUST_STRICT_MODE=True): - with self.assertRaises(RuntimeError): - self.client.get(reverse('assessments:list')) - - def _mock_rust_response(self, mock_urlopen, items): - response_mock = Mock() - response_mock.read.return_value = json.dumps({ - 'api_version': 'v1', - 'tenant_id': self.tenant_a.id, - 'items': items, - }).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - mock_urlopen.return_value = response_ctx - - def _rust_applicability_payload(self, **overrides): - payload = { - 'id': self.applicability_a.id, - 'tenant_id': self.tenant_a.id, - 'tenant_name': 'Tenant A', - 'sector': 'MSSP', - 'company_size': 'medium', - 'critical_services': 'Managed detection and response', - 'supply_chain_role': 'critical supplier', - 'status': 'RELEVANT', - 'status_label': 'Voraussichtlich relevant', - 'reasoning': 'Digital provider with critical customer services', - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - } - payload.update(overrides) - return payload - - def _rust_assessment_payload(self, **overrides): - payload = { - 'id': self.assessment_a.id, - 'tenant_id': self.tenant_a.id, - 'process_id': self.process_a.id, - 'process_name': 'Incident Intake', - 'requirement_id': self.requirement.id, - 'requirement_framework': 'ISO27001', - 'requirement_code': 'A.5.17', - 'requirement_title': 'Authentication Information', - 'owner_id': self.user_a.id, - 'owner_display': 'Ada Lovelace', - 'status': 'PARTIAL', - 'status_label': 'Teilweise erfüllt', - 'score': 3, - 'notes': 'MFA rollout started', - 'evidence_summary': 'Screenshots available', - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - } - payload.update(overrides) - return payload - - def _rust_measure_payload(self, **overrides): - payload = { - 'id': self.measure_a.id, - 'tenant_id': self.tenant_a.id, - 'assessment_id': self.assessment_a.id, - 'assessment_display': 'Incident Intake -> ISO27001 - A.5.17', - 'owner_id': self.user_a.id, - 'owner_display': 'Ada Lovelace', - 'title': 'MFA ausrollen', - 'description': 'Roll out phishing-resistant MFA', - 'priority': 'HIGH', - 'priority_label': 'High', - 'status': 'OPEN', - 'status_label': 'Open', - 'due_date': '2026-05-01', - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - } - payload.update(overrides) - return payload diff --git a/apps/assessments/urls.py b/apps/assessments/urls.py deleted file mode 100644 index 53724de..0000000 --- a/apps/assessments/urls.py +++ /dev/null @@ -1,42 +0,0 @@ -from django.urls import path -from .views import ( - ApplicabilityCreateView, ApplicabilityListView, - AssessmentCreateView, AssessmentListView, - MeasureCreateView, MeasureListView, -) -from .v20_views import ( - SoAListView, SoAGenerateView, SoADetailView, SoAEntryUpdateView, - AuditListView, AuditCreateView, AuditDetailView, AuditUpdateView, - FindingCreateView, FindingUpdateView, - ReviewListView, ReviewCreateView, ReviewDetailView, ReviewUpdateView, - ReviewActionCreateView, -) - -app_name = 'assessments' - -urlpatterns = [ - path('', AssessmentListView.as_view(), name='list'), - path('new/', AssessmentCreateView.as_view(), name='create'), - path('applicability/', ApplicabilityListView.as_view(), name='applicability_list'), - path('applicability/new/', ApplicabilityCreateView.as_view(), name='applicability_create'), - path('measures/', MeasureListView.as_view(), name='measure_list'), - path('measures/new/', MeasureCreateView.as_view(), name='measure_create'), - # V20: SoA - path('soa/', SoAListView.as_view(), name='soa_list'), - path('soa/generate/', SoAGenerateView.as_view(), name='soa_generate'), - path('soa//', SoADetailView.as_view(), name='soa_detail'), - path('soa/entry//edit/', SoAEntryUpdateView.as_view(), name='soa_entry_edit'), - # V20: Audit - path('audits/', AuditListView.as_view(), name='audit_list'), - path('audits/new/', AuditCreateView.as_view(), name='audit_create'), - path('audits//', AuditDetailView.as_view(), name='audit_detail'), - path('audits//edit/', AuditUpdateView.as_view(), name='audit_edit'), - path('audits//findings/new/', FindingCreateView.as_view(), name='finding_create'), - path('findings//edit/', FindingUpdateView.as_view(), name='finding_edit'), - # V20: Management Review - path('reviews/', ReviewListView.as_view(), name='review_list'), - path('reviews/new/', ReviewCreateView.as_view(), name='review_create'), - path('reviews//', ReviewDetailView.as_view(), name='review_detail'), - path('reviews//edit/', ReviewUpdateView.as_view(), name='review_edit'), - path('reviews//actions/new/', ReviewActionCreateView.as_view(), name='review_action_create'), -] diff --git a/apps/assessments/v20_forms.py b/apps/assessments/v20_forms.py deleted file mode 100644 index cf4cd88..0000000 --- a/apps/assessments/v20_forms.py +++ /dev/null @@ -1,98 +0,0 @@ -"""V20: Forms fuer SoA, Audit, Management Review.""" -from django import forms -from apps.core.forms import TenantScopedModelForm -from .soa_models import SoAEntry -from .audit_models import Audit, AuditFinding -from .review_models import ManagementReview, ReviewAction - - -class SoAEntryForm(TenantScopedModelForm): - tenant_scoped_fields = { - 'control_owner': 'tenant', - } - - class Meta: - model = SoAEntry - fields = ['is_applicable', 'justification', 'implementation_status', 'control_owner', 'evidence_reference', 'notes'] - widgets = { - 'justification': forms.Textarea(attrs={'rows': 2}), - 'evidence_reference': forms.Textarea(attrs={'rows': 2}), - 'notes': forms.Textarea(attrs={'rows': 2}), - } - - -class AuditForm(TenantScopedModelForm): - class Meta: - model = Audit - fields = ['title', 'audit_type', 'status', 'lead_auditor', 'audit_team', 'scope', 'objectives', - 'planned_start', 'planned_end', 'actual_start', 'actual_end', 'conclusion'] - widgets = { - 'audit_team': forms.Textarea(attrs={'rows': 3}), - 'scope': forms.Textarea(attrs={'rows': 3}), - 'objectives': forms.Textarea(attrs={'rows': 2}), - 'conclusion': forms.Textarea(attrs={'rows': 3}), - 'planned_start': forms.DateInput(attrs={'type': 'date'}), - 'planned_end': forms.DateInput(attrs={'type': 'date'}), - 'actual_start': forms.DateInput(attrs={'type': 'date'}), - 'actual_end': forms.DateInput(attrs={'type': 'date'}), - } - - -class AuditFindingForm(TenantScopedModelForm): - tenant_scoped_fields = { - 'responsible': 'tenant', - } - - class Meta: - model = AuditFinding - fields = ['finding_number', 'title', 'description', 'severity', 'status', - 'requirement_reference', 'evidence_reference', 'responsible', 'due_date', - 'root_cause', 'corrective_action', 'verification_notes'] - widgets = { - 'description': forms.Textarea(attrs={'rows': 3}), - 'evidence_reference': forms.Textarea(attrs={'rows': 2}), - 'root_cause': forms.Textarea(attrs={'rows': 2}), - 'corrective_action': forms.Textarea(attrs={'rows': 3}), - 'verification_notes': forms.Textarea(attrs={'rows': 2}), - 'due_date': forms.DateInput(attrs={'type': 'date'}), - } - - -class ManagementReviewForm(TenantScopedModelForm): - tenant_scoped_fields = { - 'chairperson': 'tenant', - 'session': 'tenant', - } - - class Meta: - model = ManagementReview - fields = [ - 'title', 'status', 'review_date', 'chairperson', 'attendees', 'location', - 'input_isms_status', 'input_audit_results', 'input_risk_status', - 'input_incidents', 'input_metrics', 'input_feedback', 'input_improvement', 'input_changes', - 'output_decisions', 'output_actions', 'output_resource_needs', 'output_improvement', - 'protocol_summary', 'next_review_date', - ] - widgets = {k: forms.Textarea(attrs={'rows': 3}) for k in [ - 'attendees', 'input_isms_status', 'input_audit_results', 'input_risk_status', - 'input_incidents', 'input_metrics', 'input_feedback', 'input_improvement', 'input_changes', - 'output_decisions', 'output_actions', 'output_resource_needs', 'output_improvement', - 'protocol_summary', - ]} - widgets['review_date'] = forms.DateInput(attrs={'type': 'date'}) - widgets['next_review_date'] = forms.DateInput(attrs={'type': 'date'}) - - -class ReviewActionForm(TenantScopedModelForm): - tenant_scoped_fields = { - 'responsible': 'tenant', - } - - class Meta: - model = ReviewAction - fields = ['title', 'description', 'responsible', 'due_date', 'status', 'completion_notes'] - widgets = { - 'description': forms.Textarea(attrs={'rows': 2}), - 'completion_notes': forms.Textarea(attrs={'rows': 2}), - 'due_date': forms.DateInput(attrs={'type': 'date'}), - } diff --git a/apps/assessments/v20_views.py b/apps/assessments/v20_views.py deleted file mode 100644 index a8da613..0000000 --- a/apps/assessments/v20_views.py +++ /dev/null @@ -1,203 +0,0 @@ -"""V20: Views fuer SoA, Audit und Management Review.""" -from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin -from django.shortcuts import get_object_or_404, redirect -from django.urls import reverse, reverse_lazy -from django.utils import timezone -from django.views.generic import CreateView, DetailView, FormView, ListView, UpdateView, View - -from apps.core.mixins import TenantAccessMixin, TenantCreateMixin -from apps.requirements_app.models import Requirement -from apps.wizard.services import WizardService -from .soa_models import SoADocument, SoAEntry -from .audit_models import Audit, AuditFinding -from .review_models import ManagementReview, ReviewAction -from .v20_forms import ( - SoAEntryForm, AuditForm, AuditFindingForm, - ManagementReviewForm, ReviewActionForm, -) - - -# ═══════════════════════ SoA ═══════════════════════ - -class SoAListView(TenantAccessMixin, ListView): - model = SoADocument - template_name = 'assessments/soa_list.html' - context_object_name = 'documents' - -class SoAGenerateView(LoginRequiredMixin, View): - """Generiert ein SoA-Dokument aus den aktiven ISO-27001-Requirements.""" - def post(self, request): - tenant = WizardService.get_default_tenant(request.user) - if not tenant: - messages.error(request, 'Kein Tenant zugeordnet.') - return redirect('assessments:soa_list') - soa = SoADocument.objects.create( - tenant=tenant, - title=f'Statement of Applicability – {tenant.name}', - version='1.0', - ) - created = 0 - for req in Requirement.objects.filter(framework='ISO27001', is_active=True): - SoAEntry.objects.create( - soa=soa, requirement=req, - is_applicable=True, - implementation_status=SoAEntry.ImplementationStatus.NOT_STARTED, - ) - created += 1 - messages.success(request, f'SoA erstellt mit {created} Annex-A-Controls. Bitte Anwendbarkeit und Status je Control pruefen.') - return redirect('assessments:soa_detail', pk=soa.pk) - - -class SoADetailView(TenantAccessMixin, DetailView): - model = SoADocument - template_name = 'assessments/soa_detail.html' - context_object_name = 'soa' - - def get_context_data(self, **kwargs): - ctx = super().get_context_data(**kwargs) - entries = self.object.entries.select_related('requirement', 'control_owner').all() - ctx['entries'] = entries - ctx['applicable_count'] = sum(1 for e in entries if e.is_applicable) - ctx['implemented_count'] = sum(1 for e in entries if e.is_applicable and e.implementation_status == 'IMPLEMENTED') - ctx['excluded_count'] = sum(1 for e in entries if not e.is_applicable) - return ctx - - -class SoAEntryUpdateView(TenantAccessMixin, UpdateView): - model = SoAEntry - form_class = SoAEntryForm - template_name = 'assessments/soa_entry_form.html' - tenant_filter_field = 'soa__tenant' - - def get_success_url(self): - return reverse('assessments:soa_detail', kwargs={'pk': self.object.soa_id}) - - -# ═══════════════════════ Audit ═══════════════════════ - -class AuditListView(TenantAccessMixin, ListView): - model = Audit - template_name = 'assessments/audit_list.html' - context_object_name = 'audits' - -class AuditCreateView(TenantCreateMixin, CreateView): - model = Audit - form_class = AuditForm - template_name = 'assessments/audit_form.html' - - def form_valid(self, form): - form.instance.created_by = self.request.user - return super().form_valid(form) - - def get_success_url(self): - return reverse('assessments:audit_detail', kwargs={'pk': self.object.pk}) - - -class AuditDetailView(TenantAccessMixin, DetailView): - model = Audit - template_name = 'assessments/audit_detail.html' - context_object_name = 'audit' - - def get_context_data(self, **kwargs): - ctx = super().get_context_data(**kwargs) - findings = self.object.findings.select_related('responsible').all() - ctx['findings'] = findings - ctx['major_count'] = sum(1 for f in findings if f.severity == 'MAJOR_NC') - ctx['minor_count'] = sum(1 for f in findings if f.severity == 'MINOR_NC') - ctx['observation_count'] = sum(1 for f in findings if f.severity == 'OBSERVATION') - ctx['open_count'] = sum(1 for f in findings if f.status not in ('CLOSED', 'VERIFIED')) - return ctx - - -class AuditUpdateView(TenantAccessMixin, UpdateView): - model = Audit - form_class = AuditForm - template_name = 'assessments/audit_form.html' - - def get_success_url(self): - return reverse('assessments:audit_detail', kwargs={'pk': self.object.pk}) - - -class FindingCreateView(TenantAccessMixin, CreateView): - model = AuditFinding - form_class = AuditFindingForm - template_name = 'assessments/finding_form.html' - tenant_filter_field = 'audit__tenant' - - def form_valid(self, form): - audit = get_object_or_404(Audit.objects.filter(tenant=self.get_tenant()), pk=self.kwargs['audit_pk']) - form.instance.audit = audit - return super().form_valid(form) - - def get_context_data(self, **kwargs): - ctx = super().get_context_data(**kwargs) - ctx['audit'] = get_object_or_404(Audit.objects.filter(tenant=self.get_tenant()), pk=self.kwargs['audit_pk']) - return ctx - - def get_success_url(self): - return reverse('assessments:audit_detail', kwargs={'pk': self.kwargs['audit_pk']}) - - -class FindingUpdateView(TenantAccessMixin, UpdateView): - model = AuditFinding - form_class = AuditFindingForm - template_name = 'assessments/finding_form.html' - tenant_filter_field = 'audit__tenant' - - def get_success_url(self): - return reverse('assessments:audit_detail', kwargs={'pk': self.object.audit_id}) - - -# ═══════════════════════ Management Review ═══════════════════════ - -class ReviewListView(TenantAccessMixin, ListView): - model = ManagementReview - template_name = 'assessments/review_list.html' - context_object_name = 'reviews' - -class ReviewCreateView(TenantCreateMixin, CreateView): - model = ManagementReview - form_class = ManagementReviewForm - template_name = 'assessments/review_form.html' - - def form_valid(self, form): - return super().form_valid(form) - - def get_success_url(self): - return reverse('assessments:review_detail', kwargs={'pk': self.object.pk}) - - -class ReviewDetailView(TenantAccessMixin, DetailView): - model = ManagementReview - template_name = 'assessments/review_detail.html' - context_object_name = 'review' - - def get_context_data(self, **kwargs): - ctx = super().get_context_data(**kwargs) - ctx['actions'] = self.object.actions.select_related('responsible').all() - return ctx - - -class ReviewUpdateView(TenantAccessMixin, UpdateView): - model = ManagementReview - form_class = ManagementReviewForm - template_name = 'assessments/review_form.html' - - def get_success_url(self): - return reverse('assessments:review_detail', kwargs={'pk': self.object.pk}) - - -class ReviewActionCreateView(TenantAccessMixin, CreateView): - model = ReviewAction - form_class = ReviewActionForm - template_name = 'assessments/review_action_form.html' - tenant_filter_field = 'review__tenant' - - def form_valid(self, form): - review = get_object_or_404(ManagementReview.objects.filter(tenant=self.get_tenant()), pk=self.kwargs['review_pk']) - form.instance.review = review - return super().form_valid(form) - - def get_success_url(self): - return reverse('assessments:review_detail', kwargs={'pk': self.kwargs['review_pk']}) diff --git a/apps/assessments/views.py b/apps/assessments/views.py deleted file mode 100644 index 30d2931..0000000 --- a/apps/assessments/views.py +++ /dev/null @@ -1,87 +0,0 @@ -from django.urls import reverse_lazy -from django.views.generic import CreateView, ListView -from apps.core.mixins import TenantAccessMixin, TenantCreateMixin -from .forms import ApplicabilityAssessmentForm, AssessmentForm, MeasureForm -from .models import ApplicabilityAssessment, Assessment, Measure -from .services import AssessmentRegisterBridge - - -class ApplicabilityListView(TenantAccessMixin, ListView): - model = ApplicabilityAssessment - template_name = 'assessments/applicability_list.html' - context_object_name = 'items' - - def get_queryset(self): - rust_items = AssessmentRegisterBridge.fetch_applicability(self.request, self.get_tenant()) - if rust_items is not None: - self.assessment_register_source = 'rust_service' - return rust_items - - self.assessment_register_source = 'django' - return super().get_queryset() - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['assessment_register_source'] = getattr(self, 'assessment_register_source', 'django') - return context - - -class ApplicabilityCreateView(TenantCreateMixin, CreateView): - model = ApplicabilityAssessment - form_class = ApplicabilityAssessmentForm - template_name = 'shared/form.html' - success_url = reverse_lazy('assessments:applicability_list') - - -class AssessmentListView(TenantAccessMixin, ListView): - model = Assessment - template_name = 'assessments/assessment_list.html' - context_object_name = 'items' - - def get_queryset(self): - rust_items = AssessmentRegisterBridge.fetch_assessments(self.request, self.get_tenant()) - if rust_items is not None: - self.assessment_register_source = 'rust_service' - return rust_items - - self.assessment_register_source = 'django' - return super().get_queryset().select_related('process', 'requirement', 'owner') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['assessment_register_source'] = getattr(self, 'assessment_register_source', 'django') - return context - - -class AssessmentCreateView(TenantCreateMixin, CreateView): - model = Assessment - form_class = AssessmentForm - template_name = 'shared/form.html' - success_url = reverse_lazy('assessments:list') - - -class MeasureListView(TenantAccessMixin, ListView): - model = Measure - template_name = 'assessments/measure_list.html' - context_object_name = 'items' - - def get_queryset(self): - rust_items = AssessmentRegisterBridge.fetch_measures(self.request, self.get_tenant()) - if rust_items is not None: - self.assessment_register_source = 'rust_service' - return rust_items - - self.assessment_register_source = 'django' - return super().get_queryset().select_related('assessment', 'owner') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['assessment_register_source'] = getattr(self, 'assessment_register_source', 'django') - return context - - -class MeasureCreateView(TenantCreateMixin, CreateView): - model = Measure - form_class = MeasureForm - template_name = 'shared/form.html' - success_url = reverse_lazy('assessments:measure_list') diff --git a/apps/assets_app/__init__.py b/apps/assets_app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/assets_app/admin.py b/apps/assets_app/admin.py deleted file mode 100644 index e7c96af..0000000 --- a/apps/assets_app/admin.py +++ /dev/null @@ -1,10 +0,0 @@ - -from django.contrib import admin -from .models import InformationAsset - - -@admin.register(InformationAsset) -class InformationAssetAdmin(admin.ModelAdmin): - list_display = ('name', 'tenant', 'asset_type', 'criticality', 'is_in_scope') - list_filter = ('asset_type', 'criticality', 'is_in_scope') - search_fields = ('name', 'description') diff --git a/apps/assets_app/apps.py b/apps/assets_app/apps.py deleted file mode 100644 index 4c7c121..0000000 --- a/apps/assets_app/apps.py +++ /dev/null @@ -1,8 +0,0 @@ - -from django.apps import AppConfig - - -class AssetsAppConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.assets_app' - verbose_name = 'Informationswerte' diff --git a/apps/assets_app/forms.py b/apps/assets_app/forms.py deleted file mode 100644 index 2ee0b1a..0000000 --- a/apps/assets_app/forms.py +++ /dev/null @@ -1,17 +0,0 @@ -from django import forms -from apps.core.forms import TenantScopedModelForm -from .models import InformationAsset - - -class InformationAssetForm(TenantScopedModelForm): - tenant_scoped_fields = { - 'business_unit': 'tenant', - 'owner': 'tenant', - } - - class Meta: - model = InformationAsset - fields = [ - 'business_unit', 'owner', 'name', 'asset_type', 'criticality', 'description', - 'confidentiality', 'integrity', 'availability', 'lifecycle_status', 'is_in_scope' - ] diff --git a/apps/assets_app/migrations/0001_initial.py b/apps/assets_app/migrations/0001_initial.py deleted file mode 100644 index df4e080..0000000 --- a/apps/assets_app/migrations/0001_initial.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-09 14:39 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('organizations', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='InformationAsset', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=255)), - ('asset_type', models.CharField(choices=[('APPLICATION', 'Anwendung'), ('DATA', 'Datenbestand'), ('INFRASTRUCTURE', 'Infrastruktur'), ('SERVICE', 'Service / Plattform'), ('DOCUMENT', 'Dokumentation'), ('OTHER', 'Sonstiges')], default='APPLICATION', max_length=24)), - ('criticality', models.CharField(choices=[('VERY_HIGH', 'Sehr hoch'), ('HIGH', 'Hoch'), ('MEDIUM', 'Mittel'), ('LOW', 'Niedrig')], default='MEDIUM', max_length=16)), - ('description', models.TextField(blank=True)), - ('confidentiality', models.CharField(blank=True, max_length=32)), - ('integrity', models.CharField(blank=True, max_length=32)), - ('availability', models.CharField(blank=True, max_length=32)), - ('lifecycle_status', models.CharField(blank=True, max_length=64)), - ('is_in_scope', models.BooleanField(default=True)), - ('business_unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='information_assets', to='organizations.businessunit')), - ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_information_assets', to=settings.AUTH_USER_MODEL)), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='information_assets', to='organizations.tenant')), - ], - options={ - 'ordering': ['name'], - }, - ), - ] diff --git a/apps/assets_app/migrations/__init__.py b/apps/assets_app/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/assets_app/models.py b/apps/assets_app/models.py deleted file mode 100644 index 5d9e134..0000000 --- a/apps/assets_app/models.py +++ /dev/null @@ -1,42 +0,0 @@ -from django.db import models -from apps.core.models import TenantRelationValidationMixin, TimeStampedModel - - -class InformationAsset(TenantRelationValidationMixin, TimeStampedModel): - tenant_relation_fields = { - 'business_unit': 'tenant_id', - 'owner': 'tenant_id', - } - - class Criticality(models.TextChoices): - VERY_HIGH = 'VERY_HIGH', 'Sehr hoch' - HIGH = 'HIGH', 'Hoch' - MEDIUM = 'MEDIUM', 'Mittel' - LOW = 'LOW', 'Niedrig' - - class Type(models.TextChoices): - APPLICATION = 'APPLICATION', 'Anwendung' - DATA = 'DATA', 'Datenbestand' - INFRASTRUCTURE = 'INFRASTRUCTURE', 'Infrastruktur' - SERVICE = 'SERVICE', 'Service / Plattform' - DOCUMENT = 'DOCUMENT', 'Dokumentation' - OTHER = 'OTHER', 'Sonstiges' - - tenant = models.ForeignKey('organizations.Tenant', on_delete=models.CASCADE, related_name='information_assets') - business_unit = models.ForeignKey('organizations.BusinessUnit', on_delete=models.SET_NULL, null=True, blank=True, related_name='information_assets') - owner = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='owned_information_assets') - name = models.CharField(max_length=255) - asset_type = models.CharField(max_length=24, choices=Type.choices, default=Type.APPLICATION) - criticality = models.CharField(max_length=16, choices=Criticality.choices, default=Criticality.MEDIUM) - description = models.TextField(blank=True) - confidentiality = models.CharField(max_length=32, blank=True) - integrity = models.CharField(max_length=32, blank=True) - availability = models.CharField(max_length=32, blank=True) - lifecycle_status = models.CharField(max_length=64, blank=True) - is_in_scope = models.BooleanField(default=True) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name diff --git a/apps/assets_app/services.py b/apps/assets_app/services.py deleted file mode 100644 index 703f130..0000000 --- a/apps/assets_app/services.py +++ /dev/null @@ -1,162 +0,0 @@ -import json -from dataclasses import dataclass -from urllib.request import Request, urlopen - -from django.conf import settings - - -@dataclass(frozen=True) -class AssetRelatedRef: - id: int | None - name: str - - def __str__(self) -> str: - return self.name - - -@dataclass(frozen=True) -class InformationAssetListItem: - id: int - tenant: object - tenant_id: int - business_unit: AssetRelatedRef | None - business_unit_id: int | None - owner: AssetRelatedRef | None - owner_id: int | None - name: str - asset_type: str - asset_type_label: str - criticality: str - criticality_label: str - description: str - confidentiality: str - integrity: str - availability: str - lifecycle_status: str - is_in_scope: bool - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - def get_asset_type_display(self) -> str: - return self.asset_type_label - - def get_criticality_display(self) -> str: - return self.criticality_label - - -class AssetRustClient: - @staticmethod - def _base_url() -> str: - base = (getattr(settings, 'RUST_BACKEND_URL', '') or '').strip() - return base.rstrip('/') - - @staticmethod - def fetch_assets(request, tenant, timeout: int = 8) -> list[InformationAssetListItem]: - base = AssetRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = AssetRustClient._authenticated_request( - request, - tenant, - f'{base}/api/v1/assets/information-assets', - ) - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - - tenant_id = AssetRustClient._int_field(payload, 'tenant_id') - if tenant_id != int(tenant.id): - raise RuntimeError('Rust-Assetliste gehoert nicht zum angefragten Tenant.') - - return [ - AssetRustClient._asset_from_payload(item, tenant) - for item in payload.get('assets', []) - if isinstance(item, dict) - ] - - @staticmethod - def _asset_from_payload(item: dict, tenant) -> InformationAssetListItem: - business_unit_id = AssetRustClient._optional_int_field(item, 'business_unit_id') - business_unit_name = str(item.get('business_unit_name') or '').strip() - owner_id = AssetRustClient._optional_int_field(item, 'owner_id') - owner_display = str(item.get('owner_display') or '').strip() - return InformationAssetListItem( - id=AssetRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=AssetRustClient._int_field(item, 'tenant_id'), - business_unit=AssetRelatedRef(business_unit_id, business_unit_name) if business_unit_name else None, - business_unit_id=business_unit_id, - owner=AssetRelatedRef(owner_id, owner_display) if owner_display else None, - owner_id=owner_id, - name=str(item.get('name') or ''), - asset_type=str(item.get('asset_type') or ''), - asset_type_label=str(item.get('asset_type_label') or item.get('asset_type') or ''), - criticality=str(item.get('criticality') or ''), - criticality_label=str(item.get('criticality_label') or item.get('criticality') or ''), - description=str(item.get('description') or ''), - confidentiality=str(item.get('confidentiality') or ''), - integrity=str(item.get('integrity') or ''), - availability=str(item.get('availability') or ''), - lifecycle_status=str(item.get('lifecycle_status') or ''), - is_in_scope=bool(item.get('is_in_scope')), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - - @staticmethod - def _authenticated_request(request, tenant, url: str) -> Request: - user = getattr(request, 'user', None) - user_id = getattr(user, 'id', None) - if not user_id: - raise RuntimeError('Asset-Rust-Bridge braucht einen authentifizierten User.') - - rust_request = Request(url) - rust_request.add_header('Accept', 'application/json') - rust_request.add_header('X-ISCY-Tenant-ID', str(tenant.id)) - rust_request.add_header('X-ISCY-User-ID', str(user_id)) - user_email = (getattr(user, 'email', '') or '').strip() - if user_email: - rust_request.add_header('X-ISCY-User-Email', user_email) - return rust_request - - @staticmethod - def _int_field(payload: dict, key: str) -> int: - return int(payload.get(key) or 0) - - @staticmethod - def _optional_int_field(payload: dict, key: str) -> int | None: - value = payload.get(key) - if value in (None, ''): - return None - return int(value) - - -class AssetInventoryBridge: - @staticmethod - def fetch_list(request, tenant): - if tenant is None: - return None - - backend = str(getattr(settings, 'ASSET_INVENTORY_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not AssetRustClient._base_url(): - if AssetInventoryBridge._strict_rust_mode(): - raise RuntimeError('Rust asset inventory backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return AssetRustClient.fetch_assets(request, tenant) - except Exception as exc: - if AssetInventoryBridge._strict_rust_mode(): - raise RuntimeError('Rust asset inventory backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def _strict_rust_mode() -> bool: - return bool(getattr(settings, 'RUST_STRICT_MODE', False)) diff --git a/apps/assets_app/tests.py b/apps/assets_app/tests.py deleted file mode 100644 index 18722b9..0000000 --- a/apps/assets_app/tests.py +++ /dev/null @@ -1,140 +0,0 @@ -import json -from unittest.mock import Mock, patch - -from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings -from django.urls import reverse - -from apps.assets_app.models import InformationAsset -from apps.organizations.models import BusinessUnit, Tenant - - -User = get_user_model() - - -@override_settings(ASSET_INVENTORY_BACKEND='local', RUST_BACKEND_URL='') -class InformationAssetViewTests(TestCase): - def setUp(self): - self.tenant_a = Tenant.objects.create(name='Tenant A', slug='tenant-a', country='DE') - self.tenant_b = Tenant.objects.create(name='Tenant B', slug='tenant-b', country='DE') - - self.user_a = User.objects.create_user( - username='tenant-a-user', - email='asset-user@example.test', - password='testpass123', - tenant=self.tenant_a, - first_name='Ada', - last_name='Lovelace', - ) - self.user_b = User.objects.create_user( - username='tenant-b-user', - password='testpass123', - tenant=self.tenant_b, - ) - self.business_unit = BusinessUnit.objects.create(tenant=self.tenant_a, name='Digital Services') - self.asset_a = InformationAsset.objects.create( - tenant=self.tenant_a, - business_unit=self.business_unit, - owner=self.user_a, - name='Customer Portal', - asset_type=InformationAsset.Type.APPLICATION, - criticality=InformationAsset.Criticality.HIGH, - description='External customer platform', - ) - self.asset_b = InformationAsset.objects.create( - tenant=self.tenant_b, - owner=self.user_b, - name='Foreign CRM', - asset_type=InformationAsset.Type.SERVICE, - criticality=InformationAsset.Criticality.LOW, - ) - - def test_asset_list_only_shows_current_tenant(self): - self.client.force_login(self.user_a) - - response = self.client.get(reverse('assets:list')) - - self.assertEqual(response.status_code, 200) - assets = list(response.context['assets']) - self.assertEqual(assets, [self.asset_a]) - self.assertEqual(response.context['asset_inventory_source'], 'django') - self.assertContains(response, 'Customer Portal') - self.assertNotContains(response, 'Foreign CRM') - - @patch('apps.assets_app.services.urlopen') - def test_asset_list_can_use_rust_inventory_bridge(self, mock_urlopen): - response_mock = Mock() - response_mock.read.return_value = json.dumps({ - 'api_version': 'v1', - 'tenant_id': self.tenant_a.id, - 'assets': [ - { - 'id': self.asset_a.id, - 'tenant_id': self.tenant_a.id, - 'business_unit_id': self.business_unit.id, - 'business_unit_name': 'Digital Services', - 'owner_id': self.user_a.id, - 'owner_display': 'Ada Lovelace', - 'name': 'Rust Customer Portal', - 'asset_type': 'APPLICATION', - 'asset_type_label': 'Anwendung', - 'criticality': 'HIGH', - 'criticality_label': 'Hoch', - 'description': 'External customer platform', - 'confidentiality': 'HIGH', - 'integrity': 'HIGH', - 'availability': 'MEDIUM', - 'lifecycle_status': 'active', - 'is_in_scope': True, - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - } - ], - }).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - mock_urlopen.return_value = response_ctx - self.client.force_login(self.user_a) - - with self.settings(ASSET_INVENTORY_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.get(reverse('assets:list')) - - self.assertEqual(response.status_code, 200) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual(rust_request.full_url, 'http://rust-backend:9000/api/v1/assets/information-assets') - self.assertEqual(rust_request.get_header('X-iscy-tenant-id'), str(self.tenant_a.id)) - self.assertEqual(rust_request.get_header('X-iscy-user-id'), str(self.user_a.id)) - self.assertEqual(rust_request.get_header('X-iscy-user-email'), 'asset-user@example.test') - assets = list(response.context['assets']) - self.assertEqual(response.context['asset_inventory_source'], 'rust_service') - self.assertEqual(len(assets), 1) - self.assertEqual(assets[0].name, 'Rust Customer Portal') - self.assertEqual(assets[0].tenant.name, 'Tenant A') - self.assertEqual(assets[0].get_asset_type_display(), 'Anwendung') - self.assertEqual(assets[0].get_criticality_display(), 'Hoch') - self.assertContains(response, 'Rust Customer Portal') - self.assertContains(response, 'Ada Lovelace') - - @patch('apps.assets_app.services.urlopen', side_effect=OSError('backend down')) - def test_asset_list_falls_back_to_django_when_rust_unavailable_in_non_strict_mode(self, _mock_urlopen): - self.client.force_login(self.user_a) - - with self.settings( - ASSET_INVENTORY_BACKEND='rust_service', - RUST_BACKEND_URL='http://rust-backend:9000', - RUST_STRICT_MODE=False, - ): - response = self.client.get(reverse('assets:list')) - - self.assertEqual(response.status_code, 200) - assets = list(response.context['assets']) - self.assertEqual(response.context['asset_inventory_source'], 'django') - self.assertEqual(assets, [self.asset_a]) - - def test_asset_list_raises_in_strict_mode_without_rust_backend_url(self): - self.client.force_login(self.user_a) - - with self.settings(ASSET_INVENTORY_BACKEND='rust_service', RUST_BACKEND_URL='', RUST_STRICT_MODE=True): - with self.assertRaises(RuntimeError): - self.client.get(reverse('assets:list')) diff --git a/apps/assets_app/urls.py b/apps/assets_app/urls.py deleted file mode 100644 index d86f745..0000000 --- a/apps/assets_app/urls.py +++ /dev/null @@ -1,10 +0,0 @@ - -from django.urls import path -from .views import InformationAssetCreateView, InformationAssetListView - -app_name = 'assets' - -urlpatterns = [ - path('', InformationAssetListView.as_view(), name='list'), - path('create/', InformationAssetCreateView.as_view(), name='create'), -] diff --git a/apps/assets_app/views.py b/apps/assets_app/views.py deleted file mode 100644 index e2c738f..0000000 --- a/apps/assets_app/views.py +++ /dev/null @@ -1,34 +0,0 @@ -from django.contrib.auth.mixins import LoginRequiredMixin -from django.urls import reverse_lazy -from django.views.generic import CreateView, ListView -from apps.core.mixins import TenantAccessMixin, TenantCreateMixin -from .forms import InformationAssetForm -from .models import InformationAsset -from .services import AssetInventoryBridge - - -class InformationAssetListView(TenantAccessMixin, ListView): - model = InformationAsset - template_name = 'assets/asset_list.html' - context_object_name = 'assets' - - def get_queryset(self): - rust_assets = AssetInventoryBridge.fetch_list(self.request, self.get_tenant()) - if rust_assets is not None: - self.asset_inventory_source = 'rust_service' - return rust_assets - - self.asset_inventory_source = 'django' - return super().get_queryset().select_related('business_unit', 'owner') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['asset_inventory_source'] = getattr(self, 'asset_inventory_source', 'django') - return context - - -class InformationAssetCreateView(TenantCreateMixin, CreateView): - model = InformationAsset - form_class = InformationAssetForm - template_name = 'shared/form.html' - success_url = reverse_lazy('assets:list') diff --git a/apps/catalog/__init__.py b/apps/catalog/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/catalog/admin.py b/apps/catalog/admin.py deleted file mode 100644 index 4a2370b..0000000 --- a/apps/catalog/admin.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.contrib import admin -from .models import AssessmentDomain, AssessmentQuestion, AnswerOption, RecommendationRule - - -class AnswerOptionInline(admin.TabularInline): - model = AnswerOption - extra = 0 - - -class RecommendationRuleInline(admin.TabularInline): - model = RecommendationRule - extra = 0 - - -@admin.register(AssessmentDomain) -class AssessmentDomainAdmin(admin.ModelAdmin): - list_display = ('sort_order', 'name', 'code', 'weight') - search_fields = ('name', 'code') - - -@admin.register(AssessmentQuestion) -class AssessmentQuestionAdmin(admin.ModelAdmin): - list_display = ('sort_order', 'code', 'question_kind', 'wizard_step', 'domain', 'applies_to_nis2') - list_filter = ('question_kind', 'wizard_step', 'domain', 'applies_to_nis2') - search_fields = ('code', 'text') - inlines = [AnswerOptionInline, RecommendationRuleInline] diff --git a/apps/catalog/apps.py b/apps/catalog/apps.py deleted file mode 100644 index 77fda12..0000000 --- a/apps/catalog/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class CatalogConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.catalog' diff --git a/apps/catalog/management/__init__.py b/apps/catalog/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/catalog/management/commands/__init__.py b/apps/catalog/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/catalog/management/commands/seed_catalog.py b/apps/catalog/management/commands/seed_catalog.py deleted file mode 100644 index 62d6b68..0000000 --- a/apps/catalog/management/commands/seed_catalog.py +++ /dev/null @@ -1,240 +0,0 @@ -"""F06: Erweiterter Fragenkatalog (3-5 Fragen pro Domaene statt 1). - F07: Fragen referenzieren explizit ISO 27001:2022 Annex-A Controls. -""" - -from django.core.management.base import BaseCommand -from apps.catalog.models import AssessmentDomain, AssessmentQuestion, AnswerOption, RecommendationRule - - -MATURITY_OPTIONS = [ - ('missing', 'Nicht vorhanden', 0, 'Es gibt keinen belastbaren Prozess oder Nachweis.'), - ('informal', 'Informal vorhanden', 1, 'Die Praxis existiert vereinzelt, aber nicht kontrolliert.'), - ('documented', 'Dokumentiert', 2, 'Der Prozess ist beschrieben, aber noch nicht konsistent umgesetzt.'), - ('implemented', 'Dokumentiert und umgesetzt', 3, 'Der Prozess ist beschrieben und wird angewendet.'), - ('evidenced', 'Umgesetzt und nachweisbar', 4, 'Es gibt Nachweise fuer die Umsetzung.'), - ('reviewed', 'Reviewed / wirksam', 5, 'Der Prozess ist wirksam, reviewed und verbessert.'), - ('na', 'Nicht relevant', 0, 'Fuer den aktuellen Scope nicht relevant.'), -] - - -class Command(BaseCommand): - help = 'Seed assessment domains, questions (F06 expanded), options and recommendation rules.' - - def handle(self, *args, **options): - domains = [ - ('GOV', 'Governance & Scope', 'Management, Scope und Verantwortlichkeiten.', 10, 10), - ('PROC', 'Asset- und Prozessmanagement', 'Register, Klassifizierung und Verantwortlichkeiten.', 10, 20), - ('SUP', 'Lieferkette & Third Parties', 'Dienstleister, Sicherheitsanforderungen und Bewertungen.', 8, 30), - ('IAM', 'IAM & Notfallkommunikation', 'Zugriffssteuerung, privilegierte Konten und Eskalation.', 9, 40), - ('CLOUD', 'Cloud & externe Dienste', 'Cloud-Sicherheit und Shared Responsibility.', 8, 50), - ('SDLC', 'Secure Development & Lifecycle', 'Sichere Entwicklung und Change-Kontrollen.', 10, 60), - ('CYBER', 'Cyberhygiene & Schwachstellenmanagement', 'Patchen, Hardening und Basis-Security.', 9, 70), - ('CRYPTO', 'Kryptografie', 'Schluessel, Verschluesselung und Schutz sensibler Daten.', 7, 80), - ('PHYS', 'Physische Sicherheit', 'Standortschutz und Zugangskontrollen.', 6, 90), - ('DETECT', 'Logging, Monitoring & Angriffserkennung', 'Zentrales Logging und Alarmierung.', 10, 100), - ('INC', 'Incident Management', 'Meldewege, Reaktion und Lessons Learned.', 10, 110), - ('BCM', 'Backup, BCM & Wiederanlauf', 'Backups, Wiederherstellung und Resilienz.', 10, 120), - ('AWARE', 'Awareness & Schulung', 'Rollenbezogene Schulung und Sensibilisierung.', 7, 130), - ('DOC', 'Policies, Dokumentation & Reviews', 'Freigaben, Reviews und Versionierung.', 8, 140), - ('PSM', 'Product Security Management', 'Produkt-Sicherheitsgovernance, CRA und Lifecycle Security.', 10, 150), - ('PSIRT', 'PSIRT & Vulnerability Handling', 'Schwachstellenaufnahme, Triage, Advisories und Patches.', 10, 160), - ('AIGOV', 'AI Governance & AI Security', 'AI-Systeminventar, Governance und Nachweise.', 9, 170), - ('OTSEC', 'Industrial / OT Security', 'IEC-62443-orientierte Industrial Security Controls.', 8, 180), - ('AUTO', 'Automotive Cybersecurity', 'ISO/SAE-21434-orientierte Automotive Cybersecurity.', 8, 190), - ] - domain_map = {} - for code, name, description, weight, sort_order in domains: - domain, _ = AssessmentDomain.objects.update_or_create( - code=code, - defaults={'name': name, 'description': description, 'weight': weight, 'sort_order': sort_order}, - ) - domain_map[code] = domain - - # --- Applicability-Fragen (unveraendert) --- - applicability_questions = [ - ('APP_SECTOR', 'Gehoert das Unternehmen zu einem regulierungsnahen oder kritischen Sektor?', 'Sektor und Geschaeftsmodell sind der erste Filter fuer NIS2/KRITIS.', True), - ('APP_DIGITAL', 'Erbringt das Unternehmen digitale oder kritische Dienste?', 'Digitale Dienste erhoehen die Wahrscheinlichkeit regulatorischer Relevanz.', True), - ('APP_EMPLOYEES', 'Wie gross ist das Unternehmen gemessen an Mitarbeitenden?', 'Schwellenwerte sind fuer die Einordnung wichtig.', True), - ('APP_FINANCIAL', 'Wie ist die Groessenordnung von Umsatz bzw. Bilanzsumme?', 'Auch finanzielle Schwellen beeinflussen die Betroffenheit.', True), - ('APP_SUPPLY', 'Hat das Unternehmen eine kritische Rolle in der Lieferkette anderer regulierter Unternehmen?', 'Auch indirekte Betroffenheit ist fachlich relevant.', True), - ] - applicability_options = { - 'APP_SECTOR': [('clear_relevant', 'Ja, eindeutig regulierungsnah/kritisch', 5), ('somewhat_relevant', 'Teilweise / nahe dran', 3), ('not_relevant', 'Nein, derzeit nicht erkennbar', 0)], - 'APP_DIGITAL': [('yes_core', 'Ja, als Kerngeschaeft', 5), ('yes_supporting', 'Ja, als unterstuetzende Leistung', 3), ('no', 'Nein', 0)], - 'APP_EMPLOYEES': [('ge_250', '>= 250', 5), ('ge_50', '50 - 249', 3), ('lt_50', '< 50', 0)], - 'APP_FINANCIAL': [('high', 'Schwellwerte sicher ueberschritten', 5), ('medium', 'vermutlich ueberschritten', 3), ('low', 'eher darunter', 0)], - 'APP_SUPPLY': [('critical', 'Ja, kritische Abhaengigkeiten vorhanden', 4), ('moderate', 'Teilweise relevant', 2), ('none', 'Nein', 0)], - } - for index, (code, text, why, nis2) in enumerate(applicability_questions, start=1): - question, _ = AssessmentQuestion.objects.update_or_create( - code=code, - defaults={ - 'domain': None, 'text': text, 'help_text': text, 'why_it_matters': why, - 'question_kind': AssessmentQuestion.Kind.APPLICABILITY, - 'wizard_step': AssessmentQuestion.Step.APPLICABILITY, - 'weight': 10, 'is_required': True, 'applies_to_iso27001': False, - 'applies_to_nis2': nis2, 'sort_order': index * 10, - }, - ) - for opt_idx, (slug, label, score) in enumerate(applicability_options[code], start=1): - AnswerOption.objects.update_or_create( - question=question, slug=slug, - defaults={'label': label, 'score': score, 'sort_order': opt_idx * 10}, - ) - - # --- F06: Erweiterter Maturity-Fragenkatalog (3-5 pro Domaene) --- - # Format: (code, domain, text, why, annex_a_ref, rec_title, priority, effort, measure_type, target_phase, iso, nis2) - maturity_questions = [ - # GOV (5 Fragen) – Annex A 5.1-5.4, 5.31 - ('GOV_SCOPE', 'GOV', 'Ist der Geltungsbereich des ISMS definiert und dokumentiert?', 'Ohne klaren Scope sind spaetere Bewertungen unscharf. [A.5.1]', 'ISMS-Scope formalisieren und genehmigen.', 'HIGH', 'SMALL', 'DOCUMENTARY', 'Phase 1 – Governance', True, True), - ('GOV_ROLES', 'GOV', 'Sind Rollen und Verantwortlichkeiten fuer Informationssicherheit dokumentiert?', 'Verantwortlichkeiten sind Grundlage fuer Wirksamkeit und Auditfaehigkeit. [A.5.2]', 'RACI und Verantwortlichkeiten fuer CISO, Process Owner und Risk Owner festlegen.', 'HIGH', 'SMALL', 'ORGANIZATIONAL', 'Phase 1 – Governance', True, True), - ('GOV_POLICY', 'GOV', 'Gibt es eine freigegebene Informationssicherheits-Policy?', 'Die Policy ist die Grundlage des ISMS und zeigt Management-Commitment. [A.5.1]', 'IS-Policy erstellen, genehmigen und kommunizieren.', 'CRITICAL', 'SMALL', 'DOCUMENTARY', 'Phase 1 – Governance', True, True), - ('GOV_MGMT_REVIEW', 'GOV', 'Wird eine regelmaessige Management-Review durchgefuehrt?', 'ISO 27001 Kap. 9.3 fordert regelmaessige Bewertung durch die oberste Leitung.', 'Management-Review-Prozess mit Agenda und Teilnehmern etablieren.', 'HIGH', 'SMALL', 'ORGANIZATIONAL', 'Phase 6 – Audit Readiness', True, False), - ('GOV_LEGAL', 'GOV', 'Werden rechtliche und regulatorische Anforderungen systematisch erfasst?', 'Compliance-Verpflichtungen muessen identifiziert und bewertet werden. [A.5.31]', 'Register rechtlicher Anforderungen aufbauen und pflegen.', 'MEDIUM', 'MEDIUM', 'DOCUMENTARY', 'Phase 1 – Governance', True, True), - - # PROC (4 Fragen) – Annex A 5.9-5.14 - ('PROC_REGISTER', 'PROC', 'Existiert ein aktuelles Register fuer kritische Prozesse und Assets?', 'Ohne Register sind Risiken und Massnahmen nicht belastbar ableitbar. [A.5.9]', 'Prozess- und Asset-Register vervollstaendigen und klassifizieren.', 'HIGH', 'MEDIUM', 'DOCUMENTARY', 'Phase 2 – Transparenz', True, True), - ('PROC_CLASSIFY', 'PROC', 'Werden Informationen und Assets nach Schutzbedarf klassifiziert?', 'Klassifizierung ist Grundlage fuer angemessene Schutzmassnahmen. [A.5.12, A.5.13]', 'Klassifizierungsschema und Handhabungsrichtlinien einfuehren.', 'HIGH', 'MEDIUM', 'DOCUMENTARY', 'Phase 2 – Transparenz', True, True), - ('PROC_OWNER', 'PROC', 'Sind fuer alle kritischen Assets und Prozesse Verantwortliche benannt?', 'Ownership sichert Pflege, Schutz und Aktualisierung. [A.5.9]', 'Asset-Ownership durchgehend zuweisen und dokumentieren.', 'HIGH', 'SMALL', 'ORGANIZATIONAL', 'Phase 2 – Transparenz', True, True), - ('PROC_LIFECYCLE', 'PROC', 'Gibt es Regeln fuer den Lebenszyklus von Assets (Beschaffung bis Entsorgung)?', 'Luecken im Lifecycle fuehren zu unkontrollierten Datenbestaenden. [A.5.11]', 'Lifecycle-Richtlinien fuer Assets definieren.', 'MEDIUM', 'MEDIUM', 'DOCUMENTARY', 'Phase 5 – Strukturmassnahmen', True, False), - - # SUP (3 Fragen) – Annex A 5.19-5.23 - ('SUP_REGISTER', 'SUP', 'Gibt es ein Lieferantenregister mit Kritikalitaetsbewertung?', 'Lieferkettenrisiken sind fuer NIS2 und ISO 27001 relevant. [A.5.19]', 'Lieferanten inventarisieren und nach Kritikalitaet bewerten.', 'HIGH', 'MEDIUM', 'ORGANIZATIONAL', 'Phase 2 – Transparenz', True, True), - ('SUP_CONTRACTS', 'SUP', 'Enthalten Lieferantenvertraege Sicherheitsanforderungen?', 'Vertragliche Absicherung ist Grundlage fuer Durchsetzbarkeit. [A.5.20]', 'Sicherheitsklauseln in Mustervertraege aufnehmen.', 'HIGH', 'MEDIUM', 'DOCUMENTARY', 'Phase 5 – Strukturmassnahmen', True, True), - ('SUP_MONITORING', 'SUP', 'Werden kritische Dienstleister regelmaessig ueberwacht und bewertet?', 'Laufende Kontrolle sichert dauerhaftes Sicherheitsniveau. [A.5.22]', 'Lieferanten-Review-Zyklus einfuehren.', 'MEDIUM', 'MEDIUM', 'ORGANIZATIONAL', 'Phase 5 – Strukturmassnahmen', True, True), - - # IAM (4 Fragen) – Annex A 5.15-5.18, 8.2-8.5 - ('IAM_MFA', 'IAM', 'Ist MFA fuer kritische Konten und Remote-Zugriffe umgesetzt?', 'MFA ist ein typischer Quick Win mit hoher Wirkung. [A.8.5]', 'MFA-Konzept erstellen und fuer kritische Konten umsetzen.', 'CRITICAL', 'MEDIUM', 'TECHNICAL', 'Phase 4 – Quick Wins', True, True), - ('IAM_PRIVILEGED', 'IAM', 'Werden privilegierte Konten gesondert verwaltet und ueberwacht?', 'Privilegierte Zugaenge sind bevorzugtes Angriffsziel. [A.8.2]', 'PAM-Konzept einfuehren und privilegierte Konten inventarisieren.', 'CRITICAL', 'LARGE', 'TECHNICAL', 'Phase 4 – Quick Wins', True, True), - ('IAM_LIFECYCLE', 'IAM', 'Gibt es Prozesse fuer Eintritt, Austritt und Rollenwechsel?', 'Identity Lifecycle verhindert verwaiste Konten. [A.5.16, A.5.18]', 'Joiner-Mover-Leaver-Prozess formalisieren.', 'HIGH', 'MEDIUM', 'ORGANIZATIONAL', 'Phase 2 – Transparenz', True, True), - ('IAM_ACCESS_REVIEW', 'IAM', 'Werden Zugriffsrechte regelmaessig reviewed?', 'Regelmaessige Reviews verhindern Rechteanhaeufung. [A.5.18]', 'Access-Review-Zyklus (mind. jaehrlich) etablieren.', 'HIGH', 'MEDIUM', 'ORGANIZATIONAL', 'Phase 5 – Strukturmassnahmen', True, False), - - # CLOUD (3 Fragen) – Annex A 5.23, 8.26 - ('CLOUD_INVENTORY', 'CLOUD', 'Sind Cloud-Dienste inventarisiert und mit Sicherheitsvorgaben hinterlegt?', 'Cloud-Nutzung braucht Transparenz und Verantwortungsabgrenzung. [A.5.23]', 'Cloud-Services inventarisieren und Shared Responsibility dokumentieren.', 'HIGH', 'MEDIUM', 'DOCUMENTARY', 'Phase 2 – Transparenz', True, True), - ('CLOUD_CONFIG', 'CLOUD', 'Werden Cloud-Konfigurationen regelmaessig auf Sicherheit geprueft?', 'Fehlkonfigurationen sind eine der haeufigsten Cloud-Schwachstellen. [A.8.26]', 'Cloud Security Posture Reviews einfuehren.', 'HIGH', 'MEDIUM', 'TECHNICAL', 'Phase 4 – Quick Wins', True, True), - ('CLOUD_IDENTITY', 'CLOUD', 'Sind Cloud-Admin-Rollen, Service Accounts und API-Schluessel nach Least-Privilege abgesichert?', 'Cloud-Kompromittierungen entstehen haeufig ueber ueberprivilegierte Identitaeten und Schluessel.', 'Cloud-IAM-Baseline mit Least-Privilege, Schluesselrotation und break-glass-Prozess umsetzen.', 'CRITICAL', 'MEDIUM', 'TECHNICAL', 'Phase 4 – Quick Wins', True, True), - ('CLOUD_EXIT', 'CLOUD', 'Gibt es eine Exit-Strategie fuer kritische Cloud-Dienste?', 'Vendor-Lock-in kann Resilienz und Verfuegbarkeit gefaehrden.', 'Exit-Strategien und Datenmigration fuer kritische Cloud-Dienste planen.', 'MEDIUM', 'SMALL', 'DOCUMENTARY', 'Phase 5 – Strukturmassnahmen', True, False), - - # SDLC (3 Fragen) – Annex A 8.25-8.31 - ('SDLC_SECURE', 'SDLC', 'Gibt es Anforderungen fuer sichere Softwareentwicklung und Code Reviews?', 'Fuer Softwareunternehmen ist Secure Development ein Kernbaustein. [A.8.25]', 'Sichere Entwicklungsrichtlinie und Review-Standards etablieren.', 'CRITICAL', 'LARGE', 'TECHNICAL', 'Phase 5 – Strukturmassnahmen', True, True), - ('SDLC_TEST', 'SDLC', 'Werden Sicherheitstests (SAST, DAST, Pentests) regelmaessig durchgefuehrt?', 'Tests decken Schwachstellen vor dem Produktivbetrieb auf. [A.8.29]', 'Sicherheitstests in CI/CD-Pipeline integrieren.', 'HIGH', 'LARGE', 'TECHNICAL', 'Phase 5 – Strukturmassnahmen', True, True), - ('SDLC_CHANGE', 'SDLC', 'Gibt es einen formalisierten Change-Management-Prozess?', 'Unkontrollierte Aenderungen sind ein haeufiger Angriffsvektor. [A.8.32]', 'Change-Management-Prozess mit Freigaben einfuehren.', 'HIGH', 'MEDIUM', 'ORGANIZATIONAL', 'Phase 5 – Strukturmassnahmen', True, False), - - # CYBER (3 Fragen) – Annex A 8.7-8.12, 8.19 - ('CYBER_PATCH', 'CYBER', 'Gibt es ein geregeltes Patch- und Schwachstellenmanagement?', 'Cyberhygiene reduziert Basisrisiken signifikant. [A.8.8]', 'Patch- und Schwachstellenmanagement mit Fristen definieren.', 'HIGH', 'MEDIUM', 'TECHNICAL', 'Phase 4 – Quick Wins', True, True), - ('CYBER_HARDENING', 'CYBER', 'Werden Systeme nach Hardening-Standards konfiguriert?', 'Minimalkonfiguration reduziert die Angriffsflaeche. [A.8.9]', 'Hardening-Baselines fuer Server und Endgeraete definieren.', 'HIGH', 'MEDIUM', 'TECHNICAL', 'Phase 4 – Quick Wins', True, True), - ('CYBER_WINDOWS_HARDENING', 'CYBER', 'Sind Windows-Server und Clients nach Baselines gehaertet (z. B. Defender, Firewall, SMB, Makros, lokale Adminrechte)?', 'Windows-Hardening reduziert gaengige Initial-Access- und Lateral-Movement-Pfade deutlich.', 'Windows-Hardening-Baseline definieren, per GPO/MDM ausrollen und regelmaessig pruefen.', 'HIGH', 'MEDIUM', 'TECHNICAL', 'Phase 4 – Quick Wins', True, True), - ('CYBER_MALWARE', 'CYBER', 'Ist ein Schutz gegen Schadsoftware auf allen relevanten Systemen aktiv?', 'Malware-Schutz ist eine grundlegende Abwehrmassnahme. [A.8.7]', 'Endpoint-Protection auf allen Endgeraeten und Servern sicherstellen.', 'HIGH', 'SMALL', 'TECHNICAL', 'Phase 4 – Quick Wins', True, True), - - # CRYPTO (3 Fragen) – Annex A 8.24 - ('CRYPTO_POLICY', 'CRYPTO', 'Sind Verschluesselungsanforderungen und Schluesselverantwortlichkeiten definiert?', 'Kryptografie schuetzt sensible Daten und Schnittstellen. [A.8.24]', 'Kryptografie-Policy und Schluesselmanagement definieren.', 'MEDIUM', 'MEDIUM', 'DOCUMENTARY', 'Phase 5 – Strukturmassnahmen', True, True), - ('CRYPTO_TRANSIT', 'CRYPTO', 'Werden Daten bei Uebertragung verschluesselt (TLS, VPN)?', 'Unverschluesselte Uebertragung ermoeglicht Abhoeren. [A.8.24]', 'TLS/VPN fuer alle internen und externen Verbindungen sicherstellen.', 'HIGH', 'MEDIUM', 'TECHNICAL', 'Phase 4 – Quick Wins', True, True), - ('CRYPTO_REST', 'CRYPTO', 'Werden sensible Daten im Ruhezustand verschluesselt?', 'Verschluesselung at rest schuetzt bei Datentraegerverlust. [A.8.24]', 'Verschluesselung fuer Datenbanken und Backups pruefen und einrichten.', 'MEDIUM', 'MEDIUM', 'TECHNICAL', 'Phase 5 – Strukturmassnahmen', True, False), - - # PHYS (3 Fragen) – Annex A 7.1-7.4 - ('PHYS_ACCESS', 'PHYS', 'Gibt es physische Zugangskontrollen fuer Serverraeume und relevante Standorte?', 'Standortschutz ist Teil eines ganzheitlichen ISMS. [A.7.2]', 'Physische Zugangskontrollen dokumentieren.', 'MEDIUM', 'SMALL', 'ORGANIZATIONAL', 'Phase 5 – Strukturmassnahmen', True, True), - ('PHYS_VISITOR', 'PHYS', 'Gibt es ein Besuchermanagement fuer sicherheitsrelevante Bereiche?', 'Unkontrollierter Zutritt Dritter gefaehrdet physische Sicherheit. [A.7.2]', 'Besuchermanagement-Prozess einfuehren.', 'LOW', 'SMALL', 'ORGANIZATIONAL', 'Phase 5 – Strukturmassnahmen', True, False), - ('PHYS_ENV', 'PHYS', 'Sind Umgebungskontrollen (Klima, Brand, Wasser) fuer Serverraeume vorhanden?', 'Umgebungsereignisse koennen Verfuegbarkeit gefaehrden. [A.7.5]', 'Umgebungskontrollen pruefen und dokumentieren.', 'MEDIUM', 'MEDIUM', 'TECHNICAL', 'Phase 5 – Strukturmassnahmen', True, False), - - # DETECT (4 Fragen) – Annex A 8.15-8.16 - ('DETECT_LOGGING', 'DETECT', 'Existieren zentrales Logging und sicherheitsrelevante Alarmierungen?', 'Ohne Erkennung bleiben Sicherheitsvorfaelle lange unentdeckt. [A.8.15]', 'Logging, Monitoring und Alarmierungswege aufbauen.', 'CRITICAL', 'LARGE', 'TECHNICAL', 'Phase 4 – Quick Wins', True, True), - ('DETECT_SIEM', 'DETECT', 'Werden Logs korreliert und automatisiert ausgewertet (SIEM o.ae.)?', 'Korrelation erkennt komplexe Angriffsmuster. [A.8.16]', 'SIEM oder vergleichbare Log-Korrelation einrichten.', 'HIGH', 'LARGE', 'TECHNICAL', 'Phase 5 – Strukturmassnahmen', True, True), - ('DETECT_RETENTION', 'DETECT', 'Werden Logs ausreichend lange aufbewahrt?', 'Aufbewahrungsdauer muss regulatorische und forensische Anforderungen erfuellen.', 'Log-Retention-Policy definieren (min. 90 Tage).', 'MEDIUM', 'SMALL', 'DOCUMENTARY', 'Phase 5 – Strukturmassnahmen', True, True), - ('DETECT_NETWORK', 'DETECT', 'Gibt es Netzwerk-Segmentierung und Monitoring kritischer Zonenuebergaenge?', 'Segmentierung begrenzt laterale Ausbreitung von Angreifern.', 'Netzwerk-Segmentierungskonzept umsetzen.', 'HIGH', 'LARGE', 'TECHNICAL', 'Phase 5 – Strukturmassnahmen', True, True), - - # INC (4 Fragen) – Annex A 5.24-5.28 - ('INC_RESPONSE', 'INC', 'Gibt es einen dokumentierten Incident-Management-Prozess mit Eskalationswegen?', 'Reaktionsfaehigkeit ist fuer NIS2 und Audit-Readiness zentral. [A.5.24]', 'Incident-Response-Prozess inklusive Meldewegen definieren.', 'CRITICAL', 'MEDIUM', 'ORGANIZATIONAL', 'Phase 4 – Quick Wins', True, True), - ('INC_NOTIFY', 'INC', 'Sind Meldewege an Behoerden und Betroffene definiert (NIS2 Art. 23)?', 'NIS2 fordert 24h-Fruehwarnung und 72h-Meldung bei erheblichen Vorfaellen.', 'Meldeprozess an BSI/CSIRT definieren und testen.', 'CRITICAL', 'MEDIUM', 'ORGANIZATIONAL', 'Phase 4 – Quick Wins', False, True), - ('INC_LESSONS', 'INC', 'Werden nach Vorfaellen Lessons Learned durchgefuehrt?', 'Kontinuierliche Verbesserung erfordert systematische Nachbereitung. [A.5.27]', 'Post-Incident-Review-Prozess etablieren.', 'MEDIUM', 'SMALL', 'ORGANIZATIONAL', 'Phase 5 – Strukturmassnahmen', True, True), - ('INC_DRILL', 'INC', 'Werden Incident-Response-Uebungen regelmaessig durchgefuehrt?', 'Uebungen decken Luecken im Prozess auf bevor ein echter Vorfall eintritt.', 'Jaehrliche IR-Uebung oder Tabletop-Simulation planen.', 'HIGH', 'MEDIUM', 'ORGANIZATIONAL', 'Phase 6 – Audit Readiness', True, True), - - # BCM (4 Fragen) – Annex A 5.29-5.30, 8.13-8.14 - ('BCM_BACKUP', 'BCM', 'Sind Backup, Restore und Wiederanlauf getestet und nachweisbar?', 'Resilienz und Wiederanlauf sind zentrale Kontrollziele. [A.8.13]', 'Backup-/Restore-Konzept testen und Evidenzen sichern.', 'HIGH', 'MEDIUM', 'TECHNICAL', 'Phase 5 – Strukturmassnahmen', True, True), - ('BCM_BIA', 'BCM', 'Wurde eine Business-Impact-Analyse durchgefuehrt?', 'BIA identifiziert kritische Prozesse und maximale Ausfallzeiten. [A.5.29]', 'BIA fuer Kernprozesse durchfuehren und dokumentieren.', 'HIGH', 'MEDIUM', 'DOCUMENTARY', 'Phase 2 – Transparenz', True, True), - ('BCM_PLAN', 'BCM', 'Existieren Wiederanlaufplaene fuer kritische Prozesse?', 'Ohne Plaene dauert die Wiederherstellung unverhältnismäßig lange. [A.5.30]', 'Wiederanlaufplaene mit RTO/RPO erstellen.', 'HIGH', 'MEDIUM', 'DOCUMENTARY', 'Phase 5 – Strukturmassnahmen', True, True), - ('BCM_TEST', 'BCM', 'Werden BCM-Plaene regelmaessig getestet?', 'Ungetestete Plaene sind im Ernstfall nicht verlaesslich.', 'Jaehrlichen BCM-Test mit Dokumentation durchfuehren.', 'MEDIUM', 'MEDIUM', 'ORGANIZATIONAL', 'Phase 6 – Audit Readiness', True, True), - - # AWARE (3 Fragen) – Annex A 6.3 - ('AWARE_TRAINING', 'AWARE', 'Gibt es rollenbezogene Awareness- und Security-Schulungen?', 'Awareness staerkt die Wirksamkeit organisatorischer Controls. [A.6.3]', 'Awareness-Programm mit Nachweisen etablieren.', 'MEDIUM', 'SMALL', 'ORGANIZATIONAL', 'Phase 4 – Quick Wins', True, True), - ('AWARE_PHISHING', 'AWARE', 'Werden Phishing-Simulationen oder Social-Engineering-Tests durchgefuehrt?', 'Realistische Tests messen die tatsaechliche Widerstandsfaehigkeit.', 'Phishing-Simulationen jaehrlich durchfuehren.', 'MEDIUM', 'MEDIUM', 'TECHNICAL', 'Phase 5 – Strukturmassnahmen', True, False), - ('AWARE_ONBOARDING', 'AWARE', 'Ist Informationssicherheit Teil des Onboarding-Prozesses?', 'Fruehe Sensibilisierung reduziert Risiken durch neue Mitarbeitende. [A.6.3]', 'IS-Schulung in Onboarding-Prozess integrieren.', 'MEDIUM', 'SMALL', 'ORGANIZATIONAL', 'Phase 4 – Quick Wins', True, False), - # DOC (3 Fragen) – Annex A 5.1, 5.37 - ('DOC_REVIEWS', 'DOC', 'Werden Policies und Prozesse regelmaessig reviewed und versioniert?', 'Review-Zyklen sind fuer Reifegrad und Auditfaehigkeit relevant. [A.5.37]', 'Review-Zyklen und Versionierung verbindlich einfuehren.', 'HIGH', 'SMALL', 'DOCUMENTARY', 'Phase 6 – Audit Readiness', True, True), - ('DOC_APPROVAL', 'DOC', 'Gibt es einen definierten Freigabeprozess fuer Richtlinien?', 'Freigegebene Dokumente haben bindende Wirkung. [A.5.1]', 'Freigabeworkflow fuer Richtlinien etablieren.', 'MEDIUM', 'SMALL', 'DOCUMENTARY', 'Phase 1 – Governance', True, False), - ('DOC_COMMUNICATION', 'DOC', 'Werden freigegebene Richtlinien aktiv an Betroffene kommuniziert?', 'Policies ohne Kommunikation sind wirkungslos. [A.5.1]', 'Kommunikationsplan fuer Policies erstellen.', 'MEDIUM', 'SMALL', 'ORGANIZATIONAL', 'Phase 5 – Strukturmassnahmen', True, False), - - # PSM / Product Security (4 Fragen) - ('PSM_SECURE_BY_DESIGN', 'PSM', 'Sind Product-Security-Anforderungen fuer Produkte mit digitalen Elementen definiert?', 'CRA und Product Security verlangen Secure by Design und Lifecycle Governance.', 'Product-Security-Anforderungen und Secure-by-Design-Leitplanken definieren.', 'CRITICAL', 'MEDIUM', 'DOCUMENTARY', 'Phase 1 – Governance', True, False, True, False, False, False), - ('PSM_RELEASE_GATES', 'PSM', 'Gibt es Security Release Gates und Freigabekriterien fuer Produkt-Releases?', 'Produktfreigaben brauchen Security-Sign-off, Test- und Risikokriterien.', 'Release-Gates und Security Sign-off fuer Produkt-Releases etablieren.', 'HIGH', 'MEDIUM', 'ORGANIZATIONAL', 'Phase 5 – Strukturmassnahmen', True, False, True, False, False, False), - ('PSM_SBOM', 'PSM', 'Werden Komponenten, Bibliotheken und SBOM-bezogene Informationen gepflegt?', 'CRA-/Product-Security-Readiness braucht Transparenz ueber Komponenten und Abhaengigkeiten.', 'SBOM- und Komponenten-Governance aufbauen.', 'HIGH', 'MEDIUM', 'TECHNICAL', 'Phase 2 – Transparenz', True, False, True, False, False, False), - ('PSM_SUPPORT', 'PSM', 'Sind Support-, Patch- und End-of-Life-Verantwortungen fuer Produkte geregelt?', 'Product Security umfasst auch Wartung, Support und Lifecycle-Verantwortung.', 'Support-/Patch-/EOL-Verantwortungen fuer Produkte definieren.', 'HIGH', 'MEDIUM', 'DOCUMENTARY', 'Phase 5 – Strukturmassnahmen', False, False, True, False, False, False), - - # PSIRT (3 Fragen) - ('PSIRT_PROCESS', 'PSIRT', 'Gibt es einen dokumentierten PSIRT-/Vulnerability-Handling-Prozess?', 'Schwachstellenmanagement fuer Produkte braucht Triage, Advisory- und Patch-Prozesse.', 'PSIRT- und Vulnerability-Handling-Prozess einrichten.', 'CRITICAL', 'MEDIUM', 'ORGANIZATIONAL', 'Phase 4 – Quick Wins', False, False, True, False, False, False), - ('PSIRT_DISCLOSURE', 'PSIRT', 'Sind Disclosure-, Advisory- und Kundenkommunikationswege definiert?', 'Produkt-Schwachstellen erfordern koordinierte Offenlegung und Kundennotifikation.', 'Coordinated Disclosure und Advisory-Workflow definieren.', 'HIGH', 'MEDIUM', 'DOCUMENTARY', 'Phase 5 – Strukturmassnahmen', False, False, True, False, False, False), - ('PSIRT_TRACKING', 'PSIRT', 'Werden Produkt-Schwachstellen inklusive Fix-/Mitigation-Status nachverfolgt?', 'Product Security braucht nachvollziehbares Tracking von offenen Findings bis zur Behebung.', 'Vulnerability-Tracking und Remediation-SLA einfuehren.', 'HIGH', 'MEDIUM', 'TECHNICAL', 'Phase 4 – Quick Wins', False, False, True, False, False, False), - - # AI Governance (3 Fragen) - ('AI_INVENTORY', 'AIGOV', 'Gibt es ein Inventar fuer AI-Systeme, Modelle und Provider?', 'AI-Governance beginnt mit Transparenz ueber Systeme, Modelle und externe Provider.', 'AI-System- und Provider-Register aufbauen.', 'HIGH', 'SMALL', 'DOCUMENTARY', 'Phase 2 – Transparenz', False, False, False, True, False, False), - ('AI_RISK_CLASS', 'AIGOV', 'Werden AI-Use-Cases risikobasiert klassifiziert und dokumentiert?', 'AI Act Readiness erfordert je nach Scope eine nachvollziehbare Klassifizierung.', 'AI-Risikoklassifizierung und Governance-Leitfaden definieren.', 'HIGH', 'MEDIUM', 'ORGANIZATIONAL', 'Phase 1 – Governance', False, False, False, True, False, False), - ('AI_MONITORING', 'AIGOV', 'Sind Logging, Monitoring und Human Oversight fuer relevante AI-Funktionen geregelt?', 'Fuer relevante AI-Funktionen sind Oversight und Monitoring wesentliche Governance-Bausteine.', 'AI-Monitoring und Human-Oversight-Prozess aufbauen.', 'HIGH', 'MEDIUM', 'ORGANIZATIONAL', 'Phase 5 – Strukturmassnahmen', False, False, False, True, False, False), - - # OT / IEC 62443 (3 Fragen) - ('OT_ZONES', 'OTSEC', 'Sind OT-/IACS-Zonen, Conduits und Segmentierungsprinzipien definiert?', 'IEC 62443-orientierte Security braucht Segmentierung und Systemgrenzen.', 'Zonen-/Conduit-Modell und Segmentierung dokumentieren.', 'HIGH', 'LARGE', 'TECHNICAL', 'Phase 5 – Strukturmassnahmen', False, False, False, False, True, False), - ('OT_COMPONENTS', 'OTSEC', 'Werden industrielle Komponenten und Integrationsverantwortungen sicherheitsbezogen erfasst?', 'Industrial Security benoetigt Komponenten-, Supplier- und Integrator-Transparenz.', 'OT-Komponenten- und Integrator-Register pflegen.', 'MEDIUM', 'MEDIUM', 'DOCUMENTARY', 'Phase 2 – Transparenz', False, False, False, False, True, False), - ('OT_OPCUA_SCADA', 'OTSEC', 'Sind OPC-UA-, MES- und SCADA-Schnittstellen mit Authentisierung, Verschluesselung und Netzwerkgrenzen abgesichert?', 'Offene OT-Schnittstellen ohne Härtung erhoehen das Risiko fuer Manipulation und Produktionsausfall.', 'OT-Schnittstellen-Baseline fuer OPC UA/MES/SCADA definieren und ueberwachen.', 'CRITICAL', 'LARGE', 'TECHNICAL', 'Phase 5 – Strukturmassnahmen', False, False, False, False, True, False), - ('OT_IDENTPRO_ACCESS', 'OTSEC', 'Sind Identitaeten fuer Produktionssysteme (z. B. IdentPro, Engineering-Workstations, Fernwartung) zentral gesteuert und protokolliert?', 'Unkontrollierte Identitaeten in der Produktion sind ein zentraler Angriffsvektor.', 'Zentrales IAM/PAM fuer Produktionszugriffe mit Sitzungstracking und Freigabeprozess umsetzen.', 'CRITICAL', 'LARGE', 'TECHNICAL', 'Phase 5 – Strukturmassnahmen', False, False, False, False, True, False), - ('OT_LEVELS', 'OTSEC', 'Sind Security Levels bzw. industrielle Schutzanforderungen definiert?', 'IEC 62443 arbeitet mit Security Levels und systematischer Schutzbedarfsermittlung.', 'Security Levels und industrielle Schutzanforderungen definieren.', 'MEDIUM', 'MEDIUM', 'ORGANIZATIONAL', 'Phase 5 – Strukturmassnahmen', False, False, False, False, True, False), - - # Automotive / ISO-SAE 21434 (3 Fragen) - ('AUTO_TARA', 'AUTO', 'Wird eine Threat Analysis and Risk Assessment (TARA) fuer relevante Systeme durchgefuehrt?', 'ISO/SAE 21434 verlangt Cybersecurity Engineering und TARA ueber den Lebenszyklus.', 'TARA-Prozess und Templates etablieren.', 'HIGH', 'LARGE', 'ORGANIZATIONAL', 'Phase 5 – Strukturmassnahmen', False, False, False, False, False, True), - ('AUTO_TRACE', 'AUTO', 'Sind Cybersecurity-Ziele und Nachweise ueber Anforderungen, Tests und Releases rueckverfolgbar?', 'Automotive Cybersecurity braucht Traceability ueber den Produktlebenszyklus.', 'Traceability von Cybersecurity-Zielen zu Anforderungen und Tests aufbauen.', 'HIGH', 'LARGE', 'DOCUMENTARY', 'Phase 5 – Strukturmassnahmen', False, False, False, False, False, True), - ('AUTO_FIELD', 'AUTO', 'Gibt es Field-Monitoring, Incident-Handling und Update-Faehigkeit fuer ausgelieferte Systeme?', 'Lifecycle Cybersecurity im Fahrzeugkontext umfasst Betrieb, Monitoring und Updates.', 'Field-Monitoring- und Updateability-Konzept etablieren.', 'HIGH', 'LARGE', 'TECHNICAL', 'Phase 5 – Strukturmassnahmen', False, False, False, False, False, True), - ] - - - for idx, item in enumerate(maturity_questions, start=1): - if len(item) == 11: - code, domain_code, text, why, rec_title, priority, effort, measure_type, target_phase, iso, nis2 = item - cra = ai_act = iec62443 = iso_sae_21434 = False - elif len(item) == 15: - code, domain_code, text, why, rec_title, priority, effort, measure_type, target_phase, iso, nis2, cra, ai_act, iec62443, iso_sae_21434 = item - else: - raise ValueError(f'Unexpected question tuple length for {item!r}') - question, _ = AssessmentQuestion.objects.update_or_create( - code=code, - defaults={ - 'domain': domain_map[domain_code], - 'text': text, - 'help_text': text, - 'why_it_matters': why, - 'question_kind': AssessmentQuestion.Kind.MATURITY, - 'wizard_step': AssessmentQuestion.Step.MATURITY, - 'weight': domain_map[domain_code].weight, - 'is_required': True, - 'applies_to_iso27001': iso, - 'applies_to_nis2': nis2, - 'applies_to_cra': cra, - 'applies_to_ai_act': ai_act, - 'applies_to_iec62443': iec62443, - 'applies_to_iso_sae_21434': iso_sae_21434, - 'applies_to_product_security': any([cra, ai_act, iec62443, iso_sae_21434]), - 'sort_order': idx * 10, - }, - ) - for opt_idx, (slug, label, score, description) in enumerate(MATURITY_OPTIONS, start=1): - AnswerOption.objects.update_or_create( - question=question, slug=slug, - defaults={'label': label, 'score': score, 'description': description, 'sort_order': opt_idx * 10, 'is_na': slug == 'na'}, - ) - RecommendationRule.objects.update_or_create( - question=question, max_score_threshold=2, title=rec_title, - defaults={ - 'description': f'Diese Massnahme wird empfohlen, weil die Frage "{text}" derzeit nicht ausreichend beantwortet wurde.', - 'priority': priority, 'effort': effort, 'measure_type': measure_type, - 'owner_role': 'ISMS Manager', 'target_phase': target_phase, 'sort_order': 10, - }, - ) - - total_q = AssessmentQuestion.objects.filter(question_kind=AssessmentQuestion.Kind.MATURITY).count() - self.stdout.write(self.style.SUCCESS(f'Catalog seeded: {total_q} Maturity-Fragen ueber {len(domains)} Domaenen.')) diff --git a/apps/catalog/migrations/0001_initial.py b/apps/catalog/migrations/0001_initial.py deleted file mode 100644 index 1565eee..0000000 --- a/apps/catalog/migrations/0001_initial.py +++ /dev/null @@ -1,99 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-09 14:39 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='AssessmentDomain', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('code', models.CharField(max_length=64, unique=True)), - ('name', models.CharField(max_length=255)), - ('description', models.TextField(blank=True)), - ('weight', models.PositiveIntegerField(default=10)), - ('sort_order', models.PositiveIntegerField(default=0)), - ], - options={ - 'ordering': ['sort_order', 'name'], - }, - ), - migrations.CreateModel( - name='AssessmentQuestion', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('code', models.CharField(max_length=64, unique=True)), - ('text', models.CharField(max_length=500)), - ('help_text', models.TextField(blank=True)), - ('why_it_matters', models.TextField(blank=True)), - ('question_kind', models.CharField(choices=[('APPLICABILITY', 'Betroffenheit'), ('MATURITY', 'Reifegrad')], max_length=20)), - ('wizard_step', models.CharField(choices=[('applicability', 'Betroffenheit'), ('maturity', 'Reifegrad')], max_length=20)), - ('weight', models.PositiveIntegerField(default=10)), - ('is_required', models.BooleanField(default=True)), - ('applies_to_iso27001', models.BooleanField(default=True)), - ('applies_to_nis2', models.BooleanField(default=False)), - ('applies_to_cra', models.BooleanField(default=False)), - ('applies_to_ai_act', models.BooleanField(default=False)), - ('applies_to_iec62443', models.BooleanField(default=False)), - ('applies_to_iso_sae_21434', models.BooleanField(default=False)), - ('applies_to_product_security', models.BooleanField(default=False)), - ('sort_order', models.PositiveIntegerField(default=0)), - ('domain', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='catalog.assessmentdomain')), - ], - options={ - 'ordering': ['wizard_step', 'sort_order', 'code'], - }, - ), - migrations.CreateModel( - name='RecommendationRule', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('max_score_threshold', models.IntegerField(default=2)), - ('title', models.CharField(max_length=255)), - ('description', models.TextField()), - ('priority', models.CharField(choices=[('CRITICAL', 'Kritisch'), ('HIGH', 'Hoch'), ('MEDIUM', 'Mittel'), ('LOW', 'Niedrig')], default='MEDIUM', max_length=16)), - ('effort', models.CharField(choices=[('SMALL', 'Klein'), ('MEDIUM', 'Mittel'), ('LARGE', 'Groß')], default='MEDIUM', max_length=16)), - ('measure_type', models.CharField(choices=[('ORGANIZATIONAL', 'Organisatorisch'), ('TECHNICAL', 'Technisch'), ('DOCUMENTARY', 'Dokumentarisch')], default='ORGANIZATIONAL', max_length=20)), - ('owner_role', models.CharField(blank=True, max_length=64)), - ('target_phase', models.CharField(blank=True, max_length=64)), - ('sort_order', models.PositiveIntegerField(default=0)), - ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recommendation_rules', to='catalog.assessmentquestion')), - ], - options={ - 'ordering': ['question', 'sort_order'], - }, - ), - migrations.CreateModel( - name='AnswerOption', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('slug', models.SlugField(max_length=64)), - ('label', models.CharField(max_length=255)), - ('score', models.IntegerField(default=0)), - ('description', models.TextField(blank=True)), - ('sort_order', models.PositiveIntegerField(default=0)), - ('is_na', models.BooleanField(default=False)), - ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='catalog.assessmentquestion')), - ], - options={ - 'ordering': ['question', 'sort_order'], - 'unique_together': {('question', 'slug')}, - }, - ), - ] diff --git a/apps/catalog/migrations/__init__.py b/apps/catalog/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/catalog/models.py b/apps/catalog/models.py deleted file mode 100644 index 081a351..0000000 --- a/apps/catalog/models.py +++ /dev/null @@ -1,102 +0,0 @@ -from django.db import models -from apps.core.models import TimeStampedModel - - -class AssessmentDomain(TimeStampedModel): - code = models.CharField(max_length=64, unique=True) - name = models.CharField(max_length=255) - description = models.TextField(blank=True) - weight = models.PositiveIntegerField(default=10) - sort_order = models.PositiveIntegerField(default=0) - - class Meta: - ordering = ['sort_order', 'name'] - - def __str__(self): - return self.name - - -class AssessmentQuestion(TimeStampedModel): - class Kind(models.TextChoices): - APPLICABILITY = 'APPLICABILITY', 'Betroffenheit' - MATURITY = 'MATURITY', 'Reifegrad' - - class Step(models.TextChoices): - APPLICABILITY = 'applicability', 'Betroffenheit' - MATURITY = 'maturity', 'Reifegrad' - - domain = models.ForeignKey(AssessmentDomain, on_delete=models.CASCADE, related_name='questions', null=True, blank=True) - code = models.CharField(max_length=64, unique=True) - text = models.CharField(max_length=500) - help_text = models.TextField(blank=True) - why_it_matters = models.TextField(blank=True) - question_kind = models.CharField(max_length=20, choices=Kind.choices) - wizard_step = models.CharField(max_length=20, choices=Step.choices) - weight = models.PositiveIntegerField(default=10) - is_required = models.BooleanField(default=True) - applies_to_iso27001 = models.BooleanField(default=True) - applies_to_nis2 = models.BooleanField(default=False) - applies_to_cra = models.BooleanField(default=False) - applies_to_ai_act = models.BooleanField(default=False) - applies_to_iec62443 = models.BooleanField(default=False) - applies_to_iso_sae_21434 = models.BooleanField(default=False) - applies_to_product_security = models.BooleanField(default=False) - sort_order = models.PositiveIntegerField(default=0) - - class Meta: - ordering = ['wizard_step', 'sort_order', 'code'] - - def __str__(self): - return self.text - - -class AnswerOption(TimeStampedModel): - question = models.ForeignKey(AssessmentQuestion, on_delete=models.CASCADE, related_name='options') - slug = models.SlugField(max_length=64) - label = models.CharField(max_length=255) - score = models.IntegerField(default=0) - description = models.TextField(blank=True) - sort_order = models.PositiveIntegerField(default=0) - is_na = models.BooleanField(default=False) - - class Meta: - unique_together = ('question', 'slug') - ordering = ['question', 'sort_order'] - - def __str__(self): - return f'{self.question.code} - {self.label}' - - -class RecommendationRule(TimeStampedModel): - class Priority(models.TextChoices): - CRITICAL = 'CRITICAL', 'Kritisch' - HIGH = 'HIGH', 'Hoch' - MEDIUM = 'MEDIUM', 'Mittel' - LOW = 'LOW', 'Niedrig' - - class Effort(models.TextChoices): - SMALL = 'SMALL', 'Klein' - MEDIUM = 'MEDIUM', 'Mittel' - LARGE = 'LARGE', 'Groß' - - class MeasureType(models.TextChoices): - ORGANIZATIONAL = 'ORGANIZATIONAL', 'Organisatorisch' - TECHNICAL = 'TECHNICAL', 'Technisch' - DOCUMENTARY = 'DOCUMENTARY', 'Dokumentarisch' - - question = models.ForeignKey(AssessmentQuestion, on_delete=models.CASCADE, related_name='recommendation_rules') - max_score_threshold = models.IntegerField(default=2) - title = models.CharField(max_length=255) - description = models.TextField() - priority = models.CharField(max_length=16, choices=Priority.choices, default=Priority.MEDIUM) - effort = models.CharField(max_length=16, choices=Effort.choices, default=Effort.MEDIUM) - measure_type = models.CharField(max_length=20, choices=MeasureType.choices, default=MeasureType.ORGANIZATIONAL) - owner_role = models.CharField(max_length=64, blank=True) - target_phase = models.CharField(max_length=64, blank=True) - sort_order = models.PositiveIntegerField(default=0) - - class Meta: - ordering = ['question', 'sort_order'] - - def __str__(self): - return self.title diff --git a/apps/catalog/services.py b/apps/catalog/services.py deleted file mode 100644 index 4545c08..0000000 --- a/apps/catalog/services.py +++ /dev/null @@ -1,207 +0,0 @@ -import json -from dataclasses import dataclass, field -from urllib.request import Request, urlopen - -from django.conf import settings - - -class CatalogRelatedList: - def __init__(self, items=None): - self._items = list(items or []) - - def all(self): - return list(self._items) - - def count(self): - return len(self._items) - - def add(self, item): - self._items.append(item) - - def __iter__(self): - return iter(self._items) - - def __len__(self): - return len(self._items) - - -@dataclass(frozen=True) -class CatalogQuestionBridgeItem: - id: int - domain_id: int | None - code: str - text: str - help_text: str - why_it_matters: str - question_kind: str - question_kind_label: str - wizard_step: str - wizard_step_label: str - weight: int - is_required: bool - applies_to_iso27001: bool - applies_to_nis2: bool - applies_to_cra: bool - applies_to_ai_act: bool - applies_to_iec62443: bool - applies_to_iso_sae_21434: bool - applies_to_product_security: bool - sort_order: int - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - def get_question_kind_display(self) -> str: - return self.question_kind_label - - def get_wizard_step_display(self) -> str: - return self.wizard_step_label - - -@dataclass -class CatalogDomainBridgeItem: - id: int - code: str - name: str - description: str - weight: int - sort_order: int - question_count: int - created_at: str - updated_at: str - questions: CatalogRelatedList = field(default_factory=CatalogRelatedList) - - @property - def pk(self) -> int: - return self.id - - def __str__(self) -> str: - return self.name - - -@dataclass(frozen=True) -class CatalogDomainLibraryBridgeResult: - domains: list[CatalogDomainBridgeItem] - question_count: int - - -class CatalogRustClient: - @staticmethod - def _base_url() -> str: - base = (getattr(settings, 'RUST_BACKEND_URL', '') or '').strip() - return base.rstrip('/') - - @staticmethod - def fetch_domain_library(request, timeout: int = 8) -> CatalogDomainLibraryBridgeResult: - base = CatalogRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = CatalogRustClient._authenticated_request(request, f'{base}/api/v1/catalog/domains') - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - - domains = [ - CatalogRustClient._domain_from_payload(item) - for item in payload.get('domains', []) - if isinstance(item, dict) - ] - return CatalogDomainLibraryBridgeResult( - domains=domains, - question_count=int(payload.get('question_count') or 0), - ) - - @staticmethod - def _domain_from_payload(item: dict) -> CatalogDomainBridgeItem: - domain = CatalogDomainBridgeItem( - id=int(item.get('id') or 0), - code=str(item.get('code') or ''), - name=str(item.get('name') or ''), - description=str(item.get('description') or ''), - weight=int(item.get('weight') or 0), - sort_order=int(item.get('sort_order') or 0), - question_count=int(item.get('question_count') or 0), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - for question_payload in item.get('questions', []): - if isinstance(question_payload, dict): - domain.questions.add(CatalogRustClient._question_from_payload(question_payload)) - return domain - - @staticmethod - def _question_from_payload(item: dict) -> CatalogQuestionBridgeItem: - return CatalogQuestionBridgeItem( - id=int(item.get('id') or 0), - domain_id=CatalogRustClient._optional_int(item.get('domain_id')), - code=str(item.get('code') or ''), - text=str(item.get('text') or ''), - help_text=str(item.get('help_text') or ''), - why_it_matters=str(item.get('why_it_matters') or ''), - question_kind=str(item.get('question_kind') or ''), - question_kind_label=str(item.get('question_kind_label') or ''), - wizard_step=str(item.get('wizard_step') or ''), - wizard_step_label=str(item.get('wizard_step_label') or ''), - weight=int(item.get('weight') or 0), - is_required=bool(item.get('is_required')), - applies_to_iso27001=bool(item.get('applies_to_iso27001')), - applies_to_nis2=bool(item.get('applies_to_nis2')), - applies_to_cra=bool(item.get('applies_to_cra')), - applies_to_ai_act=bool(item.get('applies_to_ai_act')), - applies_to_iec62443=bool(item.get('applies_to_iec62443')), - applies_to_iso_sae_21434=bool(item.get('applies_to_iso_sae_21434')), - applies_to_product_security=bool(item.get('applies_to_product_security')), - sort_order=int(item.get('sort_order') or 0), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - - @staticmethod - def _authenticated_request(request, url: str) -> Request: - user = getattr(request, 'user', None) - user_id = getattr(user, 'id', None) - tenant_id = getattr(getattr(user, 'tenant', None), 'id', None) - if not user_id or not tenant_id: - raise RuntimeError('Catalog-Rust-Bridge braucht authentifizierten Tenant-Kontext.') - - rust_request = Request(url) - rust_request.add_header('Accept', 'application/json') - rust_request.add_header('X-ISCY-Tenant-ID', str(tenant_id)) - rust_request.add_header('X-ISCY-User-ID', str(user_id)) - user_email = (getattr(user, 'email', '') or '').strip() - if user_email: - rust_request.add_header('X-ISCY-User-Email', user_email) - return rust_request - - @staticmethod - def _optional_int(value) -> int | None: - if value in (None, ''): - return None - return int(value) - - -class CatalogBridge: - @staticmethod - def fetch_domain_library(request): - backend = str(getattr(settings, 'CATALOG_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not CatalogRustClient._base_url(): - if CatalogBridge._strict_rust_mode(): - raise RuntimeError('Rust catalog backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return CatalogRustClient.fetch_domain_library(request) - except Exception as exc: - if CatalogBridge._strict_rust_mode(): - raise RuntimeError('Rust catalog backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def _strict_rust_mode() -> bool: - return bool(getattr(settings, 'RUST_STRICT_MODE', False)) diff --git a/apps/catalog/tests.py b/apps/catalog/tests.py deleted file mode 100644 index 6642c71..0000000 --- a/apps/catalog/tests.py +++ /dev/null @@ -1,134 +0,0 @@ -import json -from unittest.mock import Mock, patch - -from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings -from django.urls import reverse - -from apps.organizations.models import Tenant - -from .models import AssessmentDomain, AssessmentQuestion - - -User = get_user_model() - - -@override_settings(CATALOG_BACKEND='local', RUST_BACKEND_URL='') -class CatalogViewTests(TestCase): - def setUp(self): - self.tenant = Tenant.objects.create(name='Tenant A', slug='tenant-a', country='DE') - self.user = User.objects.create_user( - username='catalog-user', - email='catalog@example.test', - password='testpass123', - tenant=self.tenant, - ) - self.domain = AssessmentDomain.objects.create( - code='GOV', - name='Governance', - description='Governance controls', - sort_order=1, - ) - AssessmentQuestion.objects.create( - domain=self.domain, - code='GOV-APP-1', - text='Ist der Scope geklaert?', - question_kind=AssessmentQuestion.Kind.APPLICABILITY, - wizard_step=AssessmentQuestion.Step.APPLICABILITY, - sort_order=1, - ) - - def test_catalog_view_uses_local_catalog_by_default(self): - self.client.force_login(self.user) - - response = self.client.get(reverse('catalog:domains')) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['catalog_source'], 'django') - self.assertEqual(response.context['question_count'], 1) - self.assertContains(response, 'Governance') - - @patch('apps.catalog.services.urlopen') - def test_catalog_view_can_use_rust_bridge(self, mock_urlopen): - self._mock_rust_response(mock_urlopen, { - 'api_version': 'v1', - 'question_count': 1, - 'domains': [{ - 'id': self.domain.id, - 'code': 'GOV', - 'name': 'Rust Governance', - 'description': 'Rust catalog', - 'weight': 10, - 'sort_order': 1, - 'question_count': 1, - 'created_at': '2026-04-19T10:00:00Z', - 'updated_at': '2026-04-19T11:00:00Z', - 'questions': [{ - 'id': 10, - 'domain_id': self.domain.id, - 'code': 'GOV-RUST-1', - 'text': 'Rust-Frage', - 'help_text': '', - 'why_it_matters': '', - 'question_kind': 'MATURITY', - 'question_kind_label': 'Reifegrad', - 'wizard_step': 'maturity', - 'wizard_step_label': 'Reifegrad', - 'weight': 10, - 'is_required': True, - 'applies_to_iso27001': True, - 'applies_to_nis2': True, - 'applies_to_cra': False, - 'applies_to_ai_act': False, - 'applies_to_iec62443': False, - 'applies_to_iso_sae_21434': False, - 'applies_to_product_security': False, - 'sort_order': 1, - 'created_at': '2026-04-19T10:00:00Z', - 'updated_at': '2026-04-19T11:00:00Z', - }], - }], - }) - self.client.force_login(self.user) - - with self.settings(CATALOG_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.get(reverse('catalog:domains')) - - self.assertEqual(response.status_code, 200) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual(rust_request.full_url, 'http://rust-backend:9000/api/v1/catalog/domains') - self.assertEqual(rust_request.get_header('X-iscy-tenant-id'), str(self.tenant.id)) - self.assertEqual(response.context['catalog_source'], 'rust_service') - self.assertEqual(response.context['question_count'], 1) - self.assertContains(response, 'Rust Governance') - self.assertContains(response, 'Rust-Frage') - - @patch('apps.catalog.services.urlopen', side_effect=OSError('backend down')) - def test_catalog_view_falls_back_to_django_when_rust_unavailable_in_non_strict_mode(self, _mock_urlopen): - self.client.force_login(self.user) - - with self.settings( - CATALOG_BACKEND='rust_service', - RUST_BACKEND_URL='http://rust-backend:9000', - RUST_STRICT_MODE=False, - ): - response = self.client.get(reverse('catalog:domains')) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['catalog_source'], 'django') - self.assertContains(response, 'Governance') - - def test_catalog_view_raises_in_strict_mode_without_rust_backend_url(self): - self.client.force_login(self.user) - - with self.settings(CATALOG_BACKEND='rust_service', RUST_BACKEND_URL='', RUST_STRICT_MODE=True): - with self.assertRaises(RuntimeError): - self.client.get(reverse('catalog:domains')) - - def _mock_rust_response(self, mock_urlopen, payload): - response_mock = Mock() - response_mock.read.return_value = json.dumps(payload).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - mock_urlopen.return_value = response_ctx diff --git a/apps/catalog/urls.py b/apps/catalog/urls.py deleted file mode 100644 index 9ce2caa..0000000 --- a/apps/catalog/urls.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.urls import path -from .views import DomainListView - -app_name = 'catalog' - -urlpatterns = [ - path('', DomainListView.as_view(), name='domains'), -] diff --git a/apps/catalog/views.py b/apps/catalog/views.py deleted file mode 100644 index defc298..0000000 --- a/apps/catalog/views.py +++ /dev/null @@ -1,29 +0,0 @@ -from django.contrib.auth.mixins import LoginRequiredMixin -from django.views.generic import ListView -from .models import AssessmentDomain, AssessmentQuestion -from .services import CatalogBridge - - -class DomainListView(LoginRequiredMixin, ListView): - model = AssessmentDomain - template_name = 'catalog/domain_list.html' - context_object_name = 'domains' - - def get_queryset(self): - rust_library = CatalogBridge.fetch_domain_library(self.request) - if rust_library is not None: - self.catalog_source = 'rust_service' - self.catalog_question_count = rust_library.question_count - return rust_library.domains - - self.catalog_source = 'django' - return AssessmentDomain.objects.prefetch_related('questions') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - if getattr(self, 'catalog_source', 'django') == 'rust_service': - context['question_count'] = self.catalog_question_count - else: - context['question_count'] = AssessmentQuestion.objects.count() - context['catalog_source'] = getattr(self, 'catalog_source', 'django') - return context diff --git a/apps/core/__init__.py b/apps/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/core/admin.py b/apps/core/admin.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/core/apps.py b/apps/core/apps.py deleted file mode 100644 index 77278a7..0000000 --- a/apps/core/apps.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.apps import AppConfig - - -class CoreConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.core' - - def ready(self): - from . import checks # noqa: F401 diff --git a/apps/core/checks.py b/apps/core/checks.py deleted file mode 100644 index aaba5ad..0000000 --- a/apps/core/checks.py +++ /dev/null @@ -1,65 +0,0 @@ -from django.conf import settings -from django.core.checks import Error, register - - -RUST_SERVICE_BACKEND_SETTINGS = ( - 'LOCAL_LLM_BACKEND', - 'RISK_SCORING_BACKEND', - 'GUIDANCE_SCORING_BACKEND', - 'REPORT_SUMMARY_BACKEND', - 'REPORT_SNAPSHOT_BACKEND', - 'DASHBOARD_SUMMARY_BACKEND', - 'CATALOG_BACKEND', - 'REQUIREMENTS_BACKEND', - 'ASSET_INVENTORY_BACKEND', - 'PROCESS_REGISTER_BACKEND', - 'RISK_REGISTER_BACKEND', - 'EVIDENCE_REGISTER_BACKEND', - 'ASSESSMENT_REGISTER_BACKEND', - 'ROADMAP_REGISTER_BACKEND', - 'WIZARD_RESULTS_BACKEND', - 'IMPORT_CENTER_BACKEND', - 'PRODUCT_SECURITY_BACKEND', -) - - -@register() -def rust_only_cutover_checks(app_configs, **kwargs): - if not getattr(settings, 'RUST_ONLY_MODE', False): - return [] - - errors = [] - if not getattr(settings, 'RUST_BACKEND_URL', '').strip(): - errors.append(Error( - 'RUST_ONLY_MODE ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.', - hint='Setze RUST_BACKEND_URL auf den laufenden Rust-Service, z. B. http://127.0.0.1:9000 oder http://rust-backend:9000.', - id='iscy.E001', - )) - - if not getattr(settings, 'RUST_STRICT_MODE', False): - errors.append(Error( - 'RUST_ONLY_MODE ist aktiv, aber RUST_STRICT_MODE ist deaktiviert.', - hint='Setze RUST_STRICT_MODE=True, damit Django nicht still auf Legacy-Python-Pfade zurueckfaellt.', - id='iscy.E002', - )) - - if not getattr(settings, 'VULN_INTEL_RUST_ONLY', False): - errors.append(Error( - 'RUST_ONLY_MODE ist aktiv, aber VULN_INTEL_RUST_ONLY ist deaktiviert.', - hint='Setze VULN_INTEL_RUST_ONLY=True, damit CVE-Importe und Normalisierung nicht ueber Python-Fallbacks laufen.', - id='iscy.E003', - )) - - legacy_backends = [ - name - for name in RUST_SERVICE_BACKEND_SETTINGS - if str(getattr(settings, name, '') or '').strip().lower() != 'rust_service' - ] - if legacy_backends: - errors.append(Error( - 'RUST_ONLY_MODE ist aktiv, aber migrierte Backends sind nicht auf rust_service gesetzt.', - hint='Setze diese Backends auf rust_service: ' + ', '.join(legacy_backends), - id='iscy.E004', - )) - - return errors diff --git a/apps/core/context_processors.py b/apps/core/context_processors.py deleted file mode 100644 index 0f0766e..0000000 --- a/apps/core/context_processors.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.conf import settings - - -def app_metadata(request): - return { - 'APP_DISPLAY_NAME': getattr(settings, 'APP_DISPLAY_NAME', 'ISCY'), - } diff --git a/apps/core/forms.py b/apps/core/forms.py deleted file mode 100644 index 32cb72d..0000000 --- a/apps/core/forms.py +++ /dev/null @@ -1,59 +0,0 @@ -from django import forms - - -def _resolve_relation_path(obj, path): - current = obj - for part in path.split('__'): - if current is None: - return None - current = getattr(current, part, None) - return current - - -def _resolve_tenant_id(obj, tenant_path): - tenant_ref = _resolve_relation_path(obj, tenant_path.replace('_id', '')) - if tenant_ref is None and tenant_path.endswith('_id'): - tenant_ref = _resolve_relation_path(obj, tenant_path) - if tenant_ref is None: - tenant_ref = _resolve_relation_path(obj, tenant_path) - if hasattr(tenant_ref, 'pk'): - return tenant_ref.pk - return tenant_ref - - -class TenantScopedModelForm(forms.ModelForm): - """Filtert FK-Felder tenant-spezifisch und validiert die Auswahl.""" - - tenant_scoped_fields = {} - - def __init__(self, *args, tenant=None, **kwargs): - self.tenant = tenant - super().__init__(*args, **kwargs) - self._apply_tenant_scope() - - def _apply_tenant_scope(self): - for field_name, tenant_path in self.tenant_scoped_fields.items(): - field = self.fields.get(field_name) - if field is None or not hasattr(field, 'queryset'): - continue - if self.tenant is None: - field.queryset = field.queryset.none() - continue - queryset = field.queryset - if tenant_path == 'tenant' and hasattr(queryset, 'for_tenant'): - field.queryset = queryset.for_tenant(self.tenant) - else: - field.queryset = queryset.filter(**{tenant_path: self.tenant}) - - def clean(self): - cleaned_data = super().clean() - if self.tenant is None: - return cleaned_data - for field_name, tenant_path in self.tenant_scoped_fields.items(): - value = cleaned_data.get(field_name) - if value is None: - continue - related_tenant_id = _resolve_tenant_id(value, tenant_path) - if related_tenant_id != self.tenant.pk: - self.add_error(field_name, 'Objekt gehört zu einem anderen Mandanten.') - return cleaned_data diff --git a/apps/core/middleware.py b/apps/core/middleware.py deleted file mode 100644 index 3d4deb6..0000000 --- a/apps/core/middleware.py +++ /dev/null @@ -1,15 +0,0 @@ -"""F09: Tenant-Isolation-Middleware. - -Setzt request.tenant aus dem eingeloggten User. Alle Views und QuerySets -koennen sich darauf verlassen, dass request.tenant den aktuellen Mandanten -enthaelt (oder None fuer nicht-authentifizierte Requests). -""" - -from django.utils.deprecation import MiddlewareMixin - - -class TenantMiddleware(MiddlewareMixin): - def process_request(self, request): - request.tenant = None - if hasattr(request, 'user') and request.user.is_authenticated: - request.tenant = getattr(request.user, 'tenant', None) diff --git a/apps/core/migrations/0001_initial.py b/apps/core/migrations/0001_initial.py deleted file mode 100644 index 0cc756f..0000000 --- a/apps/core/migrations/0001_initial.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-09 14:39 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('organizations', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='AuditLog', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('timestamp', models.DateTimeField(auto_now_add=True, db_index=True)), - ('action', models.CharField(choices=[('CREATE', 'Erstellt'), ('UPDATE', 'Geaendert'), ('DELETE', 'Geloescht'), ('STATUS_CHANGE', 'Status geaendert'), ('LOGIN', 'Login'), ('EXPORT', 'Export')], max_length=20)), - ('entity_type', models.CharField(db_index=True, max_length=128)), - ('entity_id', models.PositiveBigIntegerField(blank=True, null=True)), - ('entity_label', models.CharField(blank=True, max_length=255)), - ('changes_json', models.JSONField(blank=True, default=dict)), - ('ip_address', models.GenericIPAddressField(blank=True, null=True)), - ('extra', models.JSONField(blank=True, default=dict)), - ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_logs', to='organizations.tenant')), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_logs', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'ordering': ['-timestamp'], - 'indexes': [models.Index(fields=['tenant', '-timestamp'], name='core_auditl_tenant__ff4b5b_idx'), models.Index(fields=['entity_type', 'entity_id'], name='core_auditl_entity__244637_idx')], - }, - ), - ] diff --git a/apps/core/migrations/__init__.py b/apps/core/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/core/mixins.py b/apps/core/mixins.py deleted file mode 100644 index d581e48..0000000 --- a/apps/core/mixins.py +++ /dev/null @@ -1,80 +0,0 @@ -"""F14: Zentrale Autorisierungspruefung auf Objektebene. - -TenantAccessMixin stellt sicher, dass eingeloggte User nur auf -Objekte ihres eigenen Tenants zugreifen koennen. -""" -import inspect - -from django.contrib.auth.mixins import LoginRequiredMixin -from django.core.exceptions import PermissionDenied - - -class TenantAccessMixin(LoginRequiredMixin): - """Mixin das Login + Tenant-Zugriffspruefung kombiniert. - - Views die get_object() nutzen (DetailView, UpdateView etc.) werden - automatisch geprueft. Fuer andere Views kann check_tenant_access() - manuell aufgerufen werden. - """ - - tenant_filter_field = 'tenant' - - def get_tenant(self): - return getattr(self.request, 'tenant', None) - - def filter_queryset_for_tenant(self, qs): - tenant = self.get_tenant() - tenant_filter_field = getattr(self, 'tenant_filter_field', 'tenant') - if tenant is None: - return qs.none() - if tenant_filter_field == 'tenant' and hasattr(qs, 'for_tenant'): - return qs.for_tenant(tenant) - if tenant_filter_field: - return qs.filter(**{tenant_filter_field: tenant}) - return qs - - def get_queryset(self): - return self.filter_queryset_for_tenant(super().get_queryset()) - - def check_tenant_access(self, obj): - """Prueft ob das Objekt zum Tenant des Request-Users gehoert.""" - tenant = self.get_tenant() - if tenant is None: - raise PermissionDenied('Kein Tenant zugeordnet.') - current = obj - for part in self.tenant_filter_field.split('__'): - current = getattr(current, part, None) - if current is None: - break - obj_tenant = current or getattr(obj, 'tenant', None) or getattr(obj, 'tenant_id', None) - if obj_tenant is None: - return # Kein Tenant-bezogenes Objekt - tenant_id = obj_tenant if isinstance(obj_tenant, int) else getattr(obj_tenant, 'pk', None) - if tenant_id != tenant.pk: - raise PermissionDenied('Kein Zugriff auf diesen Mandanten.') - - def get_object(self, queryset=None): - obj = super().get_object(queryset) - self.check_tenant_access(obj) - return obj - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - form_class = self.get_form_class() - init_signature = inspect.signature(form_class.__init__) - accepts_tenant = 'tenant' in init_signature.parameters or any( - parameter.kind == inspect.Parameter.VAR_KEYWORD - for parameter in init_signature.parameters.values() - ) - if accepts_tenant: - kwargs.setdefault('tenant', self.get_tenant()) - return kwargs - - -class TenantCreateMixin(TenantAccessMixin): - """Setzt den Tenant serverseitig vor dem Speichern.""" - - def form_valid(self, form): - if hasattr(form.instance, 'tenant_id'): - form.instance.tenant = self.get_tenant() - return super().form_valid(form) diff --git a/apps/core/models.py b/apps/core/models.py deleted file mode 100644 index 4dabd29..0000000 --- a/apps/core/models.py +++ /dev/null @@ -1,156 +0,0 @@ -from django.conf import settings -from django.core.exceptions import ValidationError -from django.db import models - - -class TimeStampedModel(models.Model): - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - abstract = True - - -def _resolve_relation_path(obj, path): - current = obj - for part in path.split('__'): - if current is None: - return None - current = getattr(current, part, None) - return current - - -class TenantRelationValidationMixin(models.Model): - """Validiert, dass deklarierte Relationen im selben Tenant liegen.""" - - tenant_source = 'tenant_id' - tenant_relation_fields = {} - - class Meta: - abstract = True - - def clean(self): - super().clean() - tenant_id = _resolve_relation_path(self, self.tenant_source) - if tenant_id is None and self.tenant_source.endswith('_id'): - tenant = _resolve_relation_path(self, self.tenant_source[:-3]) - tenant_id = getattr(tenant, 'pk', None) - if tenant_id is None: - return - - errors = {} - for field_name, tenant_path in self.tenant_relation_fields.items(): - related_obj = getattr(self, field_name, None) - if related_obj is None: - continue - related_tenant_id = _resolve_relation_path(related_obj, tenant_path) - if related_tenant_id != tenant_id: - errors[field_name] = 'Objekt gehört zu einem anderen Mandanten.' - if errors: - raise ValidationError(errors) - - def save(self, *args, **kwargs): - self.full_clean() - return super().save(*args, **kwargs) - - -# --- F09: TenantQuerySet / TenantManager --- - -class TenantQuerySet(models.QuerySet): - """QuerySet das automatisch nach Tenant filtert.""" - def for_tenant(self, tenant): - if tenant is None: - return self.none() - return self.filter(tenant=tenant) - - -class TenantManager(models.Manager): - def get_queryset(self): - return TenantQuerySet(self.model, using=self._db) - - def for_tenant(self, tenant): - return self.get_queryset().for_tenant(tenant) - - -class TenantModel(TenantRelationValidationMixin, TimeStampedModel): - """Abstrakte Basis fuer alle mandantenbezogenen Models. - - Stellt sicher, dass ein TenantManager vorhanden ist und der - Tenant-FK immer gesetzt wird. - """ - tenant = models.ForeignKey( - 'organizations.Tenant', - on_delete=models.CASCADE, - related_name='%(app_label)s_%(class)ss', - ) - - objects = TenantManager() - - class Meta: - abstract = True - - -# --- F10: Audit-Trail --- - -class AuditLog(models.Model): - """Zentraler Audit-Trail fuer revisionssichere Protokollierung.""" - - class Action(models.TextChoices): - CREATE = 'CREATE', 'Erstellt' - UPDATE = 'UPDATE', 'Geaendert' - DELETE = 'DELETE', 'Geloescht' - STATUS_CHANGE = 'STATUS_CHANGE', 'Status geaendert' - LOGIN = 'LOGIN', 'Login' - EXPORT = 'EXPORT', 'Export' - - timestamp = models.DateTimeField(auto_now_add=True, db_index=True) - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, blank=True, - related_name='audit_logs', - ) - tenant = models.ForeignKey( - 'organizations.Tenant', - on_delete=models.SET_NULL, - null=True, blank=True, - related_name='audit_logs', - ) - action = models.CharField(max_length=20, choices=Action.choices) - entity_type = models.CharField(max_length=128, db_index=True) - entity_id = models.PositiveBigIntegerField(null=True, blank=True) - entity_label = models.CharField(max_length=255, blank=True) - changes_json = models.JSONField(default=dict, blank=True) - ip_address = models.GenericIPAddressField(null=True, blank=True) - extra = models.JSONField(default=dict, blank=True) - - class Meta: - ordering = ['-timestamp'] - indexes = [ - models.Index(fields=['tenant', '-timestamp']), - models.Index(fields=['entity_type', 'entity_id']), - ] - - def __str__(self): - return f'{self.timestamp} {self.action} {self.entity_type}#{self.entity_id}' - - @classmethod - def log(cls, *, request=None, user=None, tenant=None, action, entity, changes=None, extra=None): - _user = user or (getattr(request, 'user', None) if request else None) - _tenant = tenant or (getattr(request, 'tenant', None) if request else None) - ip = None - if request: - ip = request.META.get('HTTP_X_FORWARDED_FOR', request.META.get('REMOTE_ADDR', '')) - if ',' in (ip or ''): - ip = ip.split(',')[0].strip() - return cls.objects.create( - user=_user if (_user and getattr(_user, 'is_authenticated', False)) else None, - tenant=_tenant, - action=action, - entity_type=entity.__class__.__name__, - entity_id=getattr(entity, 'pk', None), - entity_label=str(entity)[:255], - changes_json=changes or {}, - ip_address=ip or None, - extra=extra or {}, - ) diff --git a/apps/core/templatetags/__init__.py b/apps/core/templatetags/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/core/templatetags/core_extras.py b/apps/core/templatetags/core_extras.py deleted file mode 100644 index 1abdf51..0000000 --- a/apps/core/templatetags/core_extras.py +++ /dev/null @@ -1,55 +0,0 @@ -from django import template -import json - -register = template.Library() - -@register.filter -def get_item(mapping, key): - try: - return mapping.get(key) - except Exception: - return None - -@register.filter -def to_json(value): - return json.dumps(value) - -@register.filter -def heat_class(score): - try: - score = int(score) - except Exception: - score = 0 - if score <= 20: - return "heat-critical" - if score <= 40: - return "heat-high" - if score <= 60: - return "heat-medium" - if score <= 80: - return "heat-low" - return "heat-good" - -@register.filter -def ampel_class(score): - try: - score = int(score) - except Exception: - score = 0 - if score <= 20: - return "bg-danger" - if score <= 40: - return "bg-warning" - if score <= 80: - return "bg-primary" - return "bg-success" - -@register.filter -def priority_badge(priority): - mapping = { - "CRITICAL": "text-bg-danger", - "HIGH": "text-bg-warning", - "MEDIUM": "text-bg-primary", - "LOW": "text-bg-success", - } - return mapping.get(str(priority or '').upper(), 'text-bg-secondary') diff --git a/apps/core/tests.py b/apps/core/tests.py deleted file mode 100644 index 9f5d33d..0000000 --- a/apps/core/tests.py +++ /dev/null @@ -1,99 +0,0 @@ -from django.test import TestCase -from django.urls import reverse -from django.conf import settings - -from apps.core.checks import rust_only_cutover_checks - - -class HealthViewTests(TestCase): - def test_live_health(self): - response = self.client.get(reverse('health_live')) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()['status'], 'ok') - - def test_ready_health(self): - response = self.client.get(reverse('health_ready')) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()['database'], 'ok') - - def test_rust_strict_mode_default_enabled(self): - self.assertTrue(settings.RUST_STRICT_MODE) - - def test_rust_only_cutover_check_rejects_missing_backend_url(self): - with self.settings(RUST_ONLY_MODE=True, RUST_BACKEND_URL=''): - errors = rust_only_cutover_checks(None) - - self.assertIn('iscy.E001', {error.id for error in errors}) - - def test_rust_only_cutover_check_rejects_legacy_backend_switches(self): - with self.settings( - RUST_ONLY_MODE=True, - RUST_BACKEND_URL='http://rust-backend:9000', - RUST_STRICT_MODE=True, - VULN_INTEL_RUST_ONLY=True, - REPORT_SNAPSHOT_BACKEND='local', - ): - errors = rust_only_cutover_checks(None) - - self.assertIn('iscy.E004', {error.id for error in errors}) - - def test_rust_only_cutover_check_allows_explicit_test_fallback_mode(self): - with self.settings(RUST_ONLY_MODE=False, RUST_BACKEND_URL='', REPORT_SNAPSHOT_BACKEND='local'): - errors = rust_only_cutover_checks(None) - - self.assertEqual(errors, []) - - -class WebUiParitySmokeTests(TestCase): - """Smoke checks that core web entry points still exist after Rust backend migration.""" - - def test_primary_mount_points_are_reachable_or_protected(self): - # 200 = public/health/login, 302 = auth protection redirect to login. - allowed_statuses = {200, 302} - paths = [ - '/login/', - '/', - '/navigator/', - '/dashboard/', - '/catalog/', - '/reports/', - '/roadmap/', - '/evidence/', - '/assets/', - '/imports/', - '/processes/', - '/requirements/', - '/risks/', - '/assessments/', - '/organizations/', - '/product-security/', - '/cves/', - ] - for path in paths: - with self.subTest(path=path): - response = self.client.get(path, follow=False) - self.assertIn(response.status_code, allowed_statuses) - - def test_key_named_routes_still_resolve(self): - # These route names represent core user-facing flows present before Rust service integration. - route_names = [ - 'wizard:start', - 'dashboard:home', - 'guidance:dashboard', - 'catalog:domains', - 'reports:list', - 'roadmap:list', - 'evidence:list', - 'assets:list', - 'imports:center', - 'processes:list', - 'requirements:list', - 'risks:list', - 'assessments:list', - 'organizations:list', - 'product_security:list', - 'vulnerability_intelligence:dashboard', - ] - for name in route_names: - with self.subTest(route_name=name): - self.assertTrue(reverse(name)) diff --git a/apps/core/views.py b/apps/core/views.py deleted file mode 100644 index 16ca693..0000000 --- a/apps/core/views.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.db import connections -from django.http import JsonResponse - - -def live_health(request): - return JsonResponse({'status': 'ok', 'service': 'isms-planner', 'check': 'live'}) - - -def ready_health(request): - db_ok = False - db_error = '' - try: - connections['default'].cursor() - db_ok = True - except Exception as exc: # pragma: no cover - db_error = str(exc) - status_code = 200 if db_ok else 503 - payload = { - 'status': 'ok' if db_ok else 'degraded', - 'service': 'isms-planner', - 'check': 'ready', - 'database': 'ok' if db_ok else 'error', - } - if db_error: - payload['database_error'] = db_error - return JsonResponse(payload, status=status_code) diff --git a/apps/dashboard/__init__.py b/apps/dashboard/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/dashboard/apps.py b/apps/dashboard/apps.py deleted file mode 100644 index 4a7ec75..0000000 --- a/apps/dashboard/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class DashboardConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.dashboard' diff --git a/apps/dashboard/migrations/__init__.py b/apps/dashboard/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/dashboard/services.py b/apps/dashboard/services.py deleted file mode 100644 index 9ad5a68..0000000 --- a/apps/dashboard/services.py +++ /dev/null @@ -1,77 +0,0 @@ -import json -from urllib.request import Request, urlopen - -from django.conf import settings - - -class DashboardRustClient: - @staticmethod - def _base_url() -> str: - base = (getattr(settings, 'RUST_BACKEND_URL', '') or '').strip() - return base.rstrip('/') - - @staticmethod - def fetch_summary(request, tenant, timeout: int = 8) -> dict: - base = DashboardRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - user = getattr(request, 'user', None) - user_id = getattr(user, 'id', None) - if not user_id: - raise RuntimeError('Dashboard-Rust-Bridge braucht einen authentifizierten User.') - - rust_request = Request(f'{base}/api/v1/dashboard/summary') - rust_request.add_header('Accept', 'application/json') - rust_request.add_header('X-ISCY-Tenant-ID', str(tenant.id)) - rust_request.add_header('X-ISCY-User-ID', str(user_id)) - user_email = (getattr(user, 'email', '') or '').strip() - if user_email: - rust_request.add_header('X-ISCY-User-Email', user_email) - - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - - tenant_id = int(payload.get('tenant_id') or 0) - if tenant_id != int(tenant.id): - raise RuntimeError('Rust-Dashboard-Summary gehoert nicht zum angefragten Tenant.') - - return { - 'process_count': DashboardRustClient._int_field(payload, 'process_count'), - 'asset_count': DashboardRustClient._int_field(payload, 'asset_count'), - 'open_risk_count': DashboardRustClient._int_field(payload, 'open_risk_count'), - 'evidence_count': DashboardRustClient._int_field(payload, 'evidence_count'), - 'open_task_count': DashboardRustClient._int_field(payload, 'open_task_count'), - 'latest_report': payload.get('latest_report') or None, - } - - @staticmethod - def _int_field(payload: dict, key: str) -> int: - return int(payload.get(key) or 0) - - -class DashboardSummaryBridge: - @staticmethod - def fetch(request, tenant): - if tenant is None: - return None - - backend = str(getattr(settings, 'DASHBOARD_SUMMARY_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not DashboardRustClient._base_url(): - if DashboardSummaryBridge._strict_rust_mode(): - raise RuntimeError('Rust dashboard summary backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return DashboardRustClient.fetch_summary(request, tenant) - except Exception as exc: - if DashboardSummaryBridge._strict_rust_mode(): - raise RuntimeError('Rust dashboard summary backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def _strict_rust_mode() -> bool: - return bool(getattr(settings, 'RUST_STRICT_MODE', False)) diff --git a/apps/dashboard/tests.py b/apps/dashboard/tests.py deleted file mode 100644 index e7133da..0000000 --- a/apps/dashboard/tests.py +++ /dev/null @@ -1,107 +0,0 @@ -import json -from unittest.mock import Mock, patch - -from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase - -from apps.assets_app.models import InformationAsset -from apps.dashboard.services import DashboardRustClient, DashboardSummaryBridge -from apps.dashboard.views import DashboardDataMixin -from apps.evidence.models import EvidenceItem -from apps.organizations.models import Tenant -from apps.processes.models import Process -from apps.risks.models import Risk - - -class DashboardRustBridgeTests(TestCase): - def setUp(self): - self.tenant = Tenant.objects.create( - name='Tenant SOC', - slug='tenant-soc', - country='DE', - description='Tenant fuer Dashboard-Rust-Bridge-Test', - sector='MSSP', - ) - self.user = get_user_model().objects.create_user( - username='dashboard-user', - email='dashboard@example.test', - password='secret', - tenant=self.tenant, - ) - self.request = RequestFactory().get('/dashboard/') - self.request.user = self.user - - @patch('apps.dashboard.services.urlopen') - def test_dashboard_rust_client_fetches_summary_with_tenant_headers(self, mock_urlopen): - response_mock = Mock() - response_mock.read.return_value = json.dumps({ - 'api_version': 'v1', - 'tenant_id': self.tenant.id, - 'process_count': 2, - 'asset_count': 3, - 'open_risk_count': 4, - 'evidence_count': 5, - 'open_task_count': 6, - 'latest_report': {'id': 11, 'title': 'April Readiness'}, - }).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - mock_urlopen.return_value = response_ctx - - with self.settings(RUST_BACKEND_URL='http://rust-backend:9000'): - summary = DashboardRustClient.fetch_summary(self.request, self.tenant) - - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual(rust_request.full_url, 'http://rust-backend:9000/api/v1/dashboard/summary') - self.assertEqual(rust_request.get_header('X-iscy-tenant-id'), str(self.tenant.id)) - self.assertEqual(rust_request.get_header('X-iscy-user-id'), str(self.user.id)) - self.assertEqual(summary['process_count'], 2) - self.assertEqual(summary['latest_report']['id'], 11) - - def test_dashboard_bridge_raises_in_strict_mode_without_backend_url(self): - with self.settings(RUST_BACKEND_URL='', DASHBOARD_SUMMARY_BACKEND='rust_service', RUST_STRICT_MODE=True): - with self.assertRaises(RuntimeError): - DashboardSummaryBridge.fetch(self.request, self.tenant) - - def test_dashboard_bridge_is_disabled_for_local_backend(self): - with self.settings(RUST_BACKEND_URL='', DASHBOARD_SUMMARY_BACKEND='local', RUST_STRICT_MODE=True): - self.assertIsNone(DashboardSummaryBridge.fetch(self.request, self.tenant)) - - @patch('apps.dashboard.views.DashboardSummaryBridge.fetch') - def test_dashboard_context_uses_rust_summary_counts_when_available(self, mock_fetch): - mock_fetch.return_value = { - 'process_count': 10, - 'asset_count': 11, - 'open_risk_count': 12, - 'evidence_count': 13, - 'open_task_count': 14, - } - - context = DashboardDataMixin().build_dashboard_context(self.request) - - self.assertEqual(context['dashboard_summary_source'], 'rust_service') - self.assertEqual(context['process_count'], 10) - self.assertEqual(context['asset_count'], 11) - self.assertEqual(context['open_risk_count'], 12) - self.assertEqual(context['evidence_count'], 13) - self.assertEqual(context['open_task_count'], 14) - - def test_dashboard_context_falls_back_to_django_counts_for_local_backend(self): - Process.objects.create(tenant=self.tenant, name='Incident Intake') - InformationAsset.objects.create(tenant=self.tenant, name='SIEM') - Risk.objects.create( - tenant=self.tenant, - title='Credential Phishing', - description='Phishing kann Account Compromise ausloesen.', - ) - EvidenceItem.objects.create(tenant=self.tenant, title='SOC Playbook') - - with self.settings(DASHBOARD_SUMMARY_BACKEND='local', RUST_BACKEND_URL=''): - context = DashboardDataMixin().build_dashboard_context(self.request) - - self.assertEqual(context['dashboard_summary_source'], 'django') - self.assertEqual(context['process_count'], 1) - self.assertEqual(context['asset_count'], 1) - self.assertEqual(context['open_risk_count'], 1) - self.assertEqual(context['evidence_count'], 1) diff --git a/apps/dashboard/urls.py b/apps/dashboard/urls.py deleted file mode 100644 index d1dca9a..0000000 --- a/apps/dashboard/urls.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.urls import path -from .views import DashboardView, DashboardPortfolioPdfView - -app_name = 'dashboard' - -urlpatterns = [ - path('', DashboardView.as_view(), name='home'), - path('portfolio.pdf', DashboardPortfolioPdfView.as_view(), name='portfolio-pdf'), -] diff --git a/apps/dashboard/views.py b/apps/dashboard/views.py deleted file mode 100644 index 9fbe702..0000000 --- a/apps/dashboard/views.py +++ /dev/null @@ -1,232 +0,0 @@ -import io - -from django.contrib.auth.mixins import LoginRequiredMixin -from django.db.models import Count -from django.http import HttpResponse -from django.views.generic import TemplateView, View -from reportlab.lib import colors -from reportlab.lib.pagesizes import A4, landscape -from reportlab.lib.styles import getSampleStyleSheet -from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle - -from apps.assets_app.models import InformationAsset -from apps.dashboard.services import DashboardSummaryBridge -from apps.evidence.models import EvidenceItem, RequirementEvidenceNeed -from apps.organizations.models import Tenant -from apps.processes.models import Process -from apps.reports.models import ReportSnapshot -from apps.roadmap.models import RoadmapTask -from apps.risks.models import Risk -from apps.wizard.services import WizardService - - -class DashboardDataMixin: - def build_dashboard_context(self, request): - tenant = WizardService.get_default_tenant(request.user) - rust_summary = DashboardSummaryBridge.fetch(request, tenant) - latest_report = ReportSnapshot.objects.filter(tenant=tenant).order_by('-created_at').first() if tenant else None - roadmap_tasks = RoadmapTask.objects.filter(phase__plan__tenant=tenant) if tenant else RoadmapTask.objects.none() - - selected_sector = request.GET.get('sector', '').strip() - selected_country = request.GET.get('country', '').strip() - selected_tenant = request.GET.get('tenant', '').strip() - - all_tenants = Tenant.objects.all() - comparison_rows = [] - for item in all_tenants: - if selected_sector and item.sector != selected_sector: - continue - countries = item.operation_countries or ([] if not item.country else [item.country]) - country_labels = item.country_labels - if selected_country and selected_country not in countries and selected_country not in country_labels: - continue - report = ReportSnapshot.objects.filter(tenant=item).order_by('-created_at').first() - comparison_rows.append({ - 'tenant': item, - 'sector': item.sector_label, - 'sector_code': item.sector, - 'countries': item.countries_display, - 'iso': report.iso_readiness_percent if report else 0, - 'nis2': report.nis2_readiness_percent if report else 0, - 'open_tasks': RoadmapTask.objects.filter(phase__plan__tenant=item).exclude(status=RoadmapTask.Status.DONE).count(), - 'evidence': EvidenceItem.objects.filter(tenant=item).count(), - 'report_id': report.id if report else None, - }) - - sector_rollup = {} - country_rollup = {} - for row in comparison_rows: - sector_rollup.setdefault(row['sector'], {'count': 0, 'iso_total': 0, 'nis2_total': 0, 'open_tasks': 0, 'evidence': 0}) - sector_rollup[row['sector']]['count'] += 1 - sector_rollup[row['sector']]['iso_total'] += row['iso'] - sector_rollup[row['sector']]['nis2_total'] += row['nis2'] - sector_rollup[row['sector']]['open_tasks'] += row['open_tasks'] - sector_rollup[row['sector']]['evidence'] += row['evidence'] - for label in row['countries'].split(', '): - if not label or label == '-': - continue - country_rollup.setdefault(label, {'count': 0, 'iso_total': 0, 'nis2_total': 0, 'open_tasks': 0, 'evidence': 0}) - country_rollup[label]['count'] += 1 - country_rollup[label]['iso_total'] += row['iso'] - country_rollup[label]['nis2_total'] += row['nis2'] - country_rollup[label]['open_tasks'] += row['open_tasks'] - country_rollup[label]['evidence'] += row['evidence'] - sector_summary = [ - {'sector': sector, 'tenant_count': values['count'], 'avg_iso': int(values['iso_total'] / values['count']) if values['count'] else 0, 'avg_nis2': int(values['nis2_total'] / values['count']) if values['count'] else 0, 'open_tasks': values['open_tasks'], 'evidence': values['evidence']} - for sector, values in sector_rollup.items() - ] - country_summary = [ - {'country': country, 'tenant_count': values['count'], 'avg_iso': int(values['iso_total'] / values['count']) if values['count'] else 0, 'avg_nis2': int(values['nis2_total'] / values['count']) if values['count'] else 0, 'open_tasks': values['open_tasks'], 'evidence': values['evidence']} - for country, values in country_rollup.items() - ] - - drilldown_tenant = Tenant.objects.filter(pk=selected_tenant).first() if selected_tenant else None - drilldown_report = ReportSnapshot.objects.filter(tenant=drilldown_tenant).order_by('-created_at').first() if drilldown_tenant else None - drilldown_open_tasks = RoadmapTask.objects.filter(phase__plan__tenant=drilldown_tenant).exclude(status=RoadmapTask.Status.DONE) if drilldown_tenant else [] - drilldown_evidence_needs = RequirementEvidenceNeed.objects.filter(tenant=drilldown_tenant) if drilldown_tenant else [] - - sector_heatmap = [ - {'label': item['sector'], 'score': item['avg_nis2'], 'subscore': item['avg_iso'], 'meta': f"{item['tenant_count']} Mandant(en) · {item['open_tasks']} offene Tasks"} - for item in sector_summary - ] - country_heatmap = [ - {'label': item['country'], 'score': item['avg_iso'], 'subscore': item['avg_nis2'], 'meta': f"{item['tenant_count']} Mandant(en) · {item['evidence']} Evidenzen"} - for item in country_summary - ] - - - top_risks = list( - Risk.objects.filter(tenant=tenant) - .exclude(status=Risk.Status.CLOSED) - .select_related('process')[:6] - ) if tenant else [] - top_gaps = (latest_report.top_gaps_json[:6] if latest_report and latest_report.top_gaps_json else []) - top_measures = (latest_report.top_measures_json[:6] if latest_report and latest_report.top_measures_json else []) - avg_iso = int(sum(r['iso'] for r in comparison_rows) / len(comparison_rows)) if comparison_rows else 0 - avg_nis2 = int(sum(r['nis2'] for r in comparison_rows) / len(comparison_rows)) if comparison_rows else 0 - total_tenants = len(comparison_rows) - critical_risk_count = sum(1 for risk in top_risks if risk.risk_level == 'CRITICAL') - high_risk_count = Risk.objects.filter(tenant=tenant).exclude(status=Risk.Status.CLOSED).filter(impact__gte=4, likelihood__gte=3).count() if tenant else 0 - sector_summary = sorted(sector_summary, key=lambda x: (-x['avg_nis2'], x['sector'])) - country_summary = sorted(country_summary, key=lambda x: (-x['avg_iso'], x['country'])) - - return { - 'tenant': tenant, - 'process_count': _rust_or_local_count(rust_summary, 'process_count', lambda: Process.objects.filter(tenant=tenant).count() if tenant else 0), - 'asset_count': _rust_or_local_count(rust_summary, 'asset_count', lambda: InformationAsset.objects.filter(tenant=tenant).count() if tenant else 0), - 'open_risk_count': _rust_or_local_count(rust_summary, 'open_risk_count', lambda: Risk.objects.filter(tenant=tenant).exclude(status=Risk.Status.CLOSED).count() if tenant else 0), - 'evidence_count': _rust_or_local_count(rust_summary, 'evidence_count', lambda: EvidenceItem.objects.filter(tenant=tenant).count() if tenant else 0), - 'open_task_count': _rust_or_local_count(rust_summary, 'open_task_count', lambda: roadmap_tasks.exclude(status=RoadmapTask.Status.DONE).count()), - 'dashboard_summary_source': 'rust_service' if rust_summary else 'django', - 'status_distribution': roadmap_tasks.values('status').annotate(total=Count('id')).order_by('status'), - 'latest_report': latest_report, - 'recent_risks': Risk.objects.filter(tenant=tenant).select_related('process')[:10] if tenant else [], - 'sector_context': WizardService.get_sector_context(tenant) if tenant else None, - 'country_context': WizardService.get_country_context(tenant) if tenant else None, - 'total_tenants': total_tenants, - 'portfolio_avg_iso': avg_iso, - 'portfolio_avg_nis2': avg_nis2, - 'top_risks': top_risks, - 'top_gaps': top_gaps, - 'top_measures': top_measures, - 'critical_risk_count': critical_risk_count, - 'high_risk_count': high_risk_count, - 'comparison_rows': comparison_rows, - 'sector_summary': sector_summary, - 'country_summary': country_summary, - 'sector_heatmap': sector_heatmap, - 'country_heatmap': country_heatmap, - 'selected_sector': selected_sector, - 'selected_country': selected_country, - 'selected_tenant': selected_tenant, - 'available_sectors': sorted({(item.sector, item.sector_label) for item in Tenant.objects.all() if item.sector}, key=lambda x: x[1]), - 'available_countries': sorted({label for item in Tenant.objects.all() for label in item.country_labels if label}), - 'all_tenants': all_tenants, - 'drilldown_tenant': drilldown_tenant, - 'drilldown_report': drilldown_report, - 'drilldown_open_tasks': list(drilldown_open_tasks[:8]) if drilldown_tenant else [], - 'drilldown_need_summary': { - 'open': drilldown_evidence_needs.filter(status=RequirementEvidenceNeed.Status.OPEN).count() if drilldown_tenant else 0, - 'partial': drilldown_evidence_needs.filter(status=RequirementEvidenceNeed.Status.PARTIAL).count() if drilldown_tenant else 0, - 'covered': drilldown_evidence_needs.filter(status=RequirementEvidenceNeed.Status.COVERED).count() if drilldown_tenant else 0, - }, - } - - -def _rust_or_local_count(rust_summary, key, fallback): - if rust_summary and key in rust_summary: - return int(rust_summary.get(key) or 0) - return fallback() - - -class DashboardView(LoginRequiredMixin, DashboardDataMixin, TemplateView): - template_name = 'dashboard/home.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context.update(self.build_dashboard_context(self.request)) - return context - - -class DashboardPortfolioPdfView(LoginRequiredMixin, DashboardDataMixin, View): - def get(self, request, *args, **kwargs): - context = self.build_dashboard_context(request) - buffer = io.BytesIO() - doc = SimpleDocTemplate(buffer, pagesize=landscape(A4), title='Portfolio Dashboard') - styles = getSampleStyleSheet() - story = [] - story.append(Paragraph('Portfolio-Sicht / Standort- und Sektorvergleich', styles['Title'])) - story.append(Paragraph('Gefilterte Portfolio-Sicht über Mandanten, Sektoren und operative Länder.', styles['BodyText'])) - story.append(Spacer(1, 12)) - if context['comparison_rows']: - data = [['Mandant', 'Sektor', 'Länder', 'ISO', 'NIS2', 'Offene Tasks', 'Evidenzen']] - for row in context['comparison_rows']: - data.append([row['tenant'].name, row['sector'], row['countries'], f"{row['iso']}%", f"{row['nis2']}%", row['open_tasks'], row['evidence']]) - table = Table(data, repeatRows=1) - table.setStyle(TableStyle([ - ('BACKGROUND', (0,0), (-1,0), colors.HexColor('#0b3d91')), - ('TEXTCOLOR', (0,0), (-1,0), colors.white), - ('GRID', (0,0), (-1,-1), 0.4, colors.grey), - ('ROWBACKGROUNDS', (0,1), (-1,-1), [colors.whitesmoke, colors.HexColor('#eef4fb')]), - ])) - story.append(Paragraph('Mandantenvergleich', styles['Heading2'])) - story.append(table) - story.append(Spacer(1, 12)) - if context['sector_summary']: - story.append(Paragraph('Sektor-Heatmap', styles['Heading2'])) - sector_data = [['Sektor', 'Ø ISO', 'Ø NIS2', 'Mandanten', 'Offene Tasks']] - for item in context['sector_summary']: - sector_data.append([item['sector'], f"{item['avg_iso']}%", f"{item['avg_nis2']}%", item['tenant_count'], item['open_tasks']]) - sector_table = Table(sector_data, repeatRows=1) - sector_style = TableStyle([ - ('BACKGROUND', (0,0), (-1,0), colors.HexColor('#23395d')), - ('TEXTCOLOR', (0,0), (-1,0), colors.white), - ('GRID', (0,0), (-1,-1), 0.4, colors.grey), - ]) - for i, item in enumerate(context['sector_summary'], start=1): - color = colors.HexColor('#2e7d32') if item['avg_nis2'] >= 80 else colors.HexColor('#1565c0') if item['avg_nis2'] >= 60 else colors.HexColor('#f9a825') if item['avg_nis2'] >= 40 else colors.HexColor('#c62828') - sector_style.add('BACKGROUND', (2,i), (2,i), color) - sector_style.add('TEXTCOLOR', (2,i), (2,i), colors.white) - sector_table.setStyle(sector_style) - story.append(sector_table) - story.append(Spacer(1, 12)) - if context['country_summary']: - story.append(Paragraph('Länder-Heatmap', styles['Heading2'])) - country_data = [['Land', 'Ø ISO', 'Ø NIS2', 'Mandanten', 'Evidenzen']] - for item in context['country_summary']: - country_data.append([item['country'], f"{item['avg_iso']}%", f"{item['avg_nis2']}%", item['tenant_count'], item['evidence']]) - country_table = Table(country_data, repeatRows=1) - country_table.setStyle(TableStyle([ - ('BACKGROUND', (0,0), (-1,0), colors.HexColor('#455a64')), - ('TEXTCOLOR', (0,0), (-1,0), colors.white), - ('GRID', (0,0), (-1,-1), 0.4, colors.grey), - ('ROWBACKGROUNDS', (0,1), (-1,-1), [colors.whitesmoke, colors.HexColor('#f6f9fc')]), - ])) - story.append(country_table) - doc.build(story) - pdf = buffer.getvalue() - buffer.close() - response = HttpResponse(content_type='application/pdf') - response['Content-Disposition'] = 'attachment; filename="portfolio-dashboard.pdf"' - response.write(pdf) - return response diff --git a/apps/evidence/__init__.py b/apps/evidence/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/evidence/admin.py b/apps/evidence/admin.py deleted file mode 100644 index b070d05..0000000 --- a/apps/evidence/admin.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.contrib import admin -from .models import EvidenceItem, RequirementEvidenceNeed - - -@admin.register(EvidenceItem) -class EvidenceItemAdmin(admin.ModelAdmin): - list_display = ('title', 'tenant', 'session', 'domain', 'measure', 'status', 'updated_at') - list_filter = ('status', 'domain', 'tenant') - search_fields = ('title', 'description', 'linked_requirement') - - -@admin.register(RequirementEvidenceNeed) -class RequirementEvidenceNeedAdmin(admin.ModelAdmin): - list_display = ('title', 'tenant', 'session', 'requirement', 'status', 'covered_count', 'is_mandatory') - list_filter = ('status', 'is_mandatory', 'tenant') - search_fields = ('title', 'requirement__code', 'requirement__title') diff --git a/apps/evidence/apps.py b/apps/evidence/apps.py deleted file mode 100644 index 56dd8cc..0000000 --- a/apps/evidence/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class EvidenceConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "apps.evidence" diff --git a/apps/evidence/forms.py b/apps/evidence/forms.py deleted file mode 100644 index 89db4c4..0000000 --- a/apps/evidence/forms.py +++ /dev/null @@ -1,18 +0,0 @@ -from django import forms -from apps.core.forms import TenantScopedModelForm -from .models import EvidenceItem - - -class EvidenceItemForm(TenantScopedModelForm): - tenant_scoped_fields = { - 'session': 'tenant', - 'measure': 'session__tenant', - } - - class Meta: - model = EvidenceItem - fields = ['title', 'description', 'requirement', 'linked_requirement', 'domain', 'measure', 'file', 'status', 'review_notes'] - widgets = { - 'description': forms.Textarea(attrs={'rows': 3}), - 'review_notes': forms.Textarea(attrs={'rows': 3}), - } diff --git a/apps/evidence/migrations/0001_initial.py b/apps/evidence/migrations/0001_initial.py deleted file mode 100644 index db85cbd..0000000 --- a/apps/evidence/migrations/0001_initial.py +++ /dev/null @@ -1,68 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-09 14:39 - -import apps.evidence.validators -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('catalog', '0001_initial'), - ('organizations', '0001_initial'), - ('requirements_app', '0001_initial'), - ('wizard', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='EvidenceItem', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('title', models.CharField(max_length=255)), - ('description', models.TextField(blank=True)), - ('linked_requirement', models.CharField(blank=True, max_length=128)), - ('file', models.FileField(blank=True, null=True, upload_to='evidence/%Y/%m/', validators=[apps.evidence.validators.validate_evidence_file])), - ('status', models.CharField(choices=[('DRAFT', 'Entwurf'), ('SUBMITTED', 'Zur Prüfung eingereicht'), ('APPROVED', 'Freigegeben'), ('REJECTED', 'Abgelehnt')], default='DRAFT', max_length=16)), - ('review_notes', models.TextField(blank=True)), - ('reviewed_at', models.DateTimeField(blank=True, null=True)), - ('domain', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='evidence_items', to='catalog.assessmentdomain')), - ('measure', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='evidence_items', to='wizard.generatedmeasure')), - ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_evidence', to=settings.AUTH_USER_MODEL)), - ('requirement', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='evidence_items', to='requirements_app.requirement')), - ('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_evidence', to=settings.AUTH_USER_MODEL)), - ('session', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='evidence_items', to='wizard.assessmentsession')), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evidence_items', to='organizations.tenant')), - ], - options={ - 'ordering': ['-updated_at', 'title'], - }, - ), - migrations.CreateModel( - name='RequirementEvidenceNeed', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('title', models.CharField(max_length=255)), - ('description', models.TextField(blank=True)), - ('is_mandatory', models.BooleanField(default=True)), - ('status', models.CharField(choices=[('OPEN', 'Offen'), ('PARTIAL', 'Teilweise abgedeckt'), ('COVERED', 'Abgedeckt')], default='OPEN', max_length=16)), - ('rationale', models.TextField(blank=True)), - ('covered_count', models.PositiveIntegerField(default=0)), - ('requirement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evidence_needs', to='requirements_app.requirement')), - ('session', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='evidence_needs', to='wizard.assessmentsession')), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evidence_needs', to='organizations.tenant')), - ], - options={ - 'ordering': ['status', 'requirement__framework', 'requirement__code'], - 'unique_together': {('tenant', 'session', 'requirement')}, - }, - ), - ] diff --git a/apps/evidence/migrations/__init__.py b/apps/evidence/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/evidence/models.py b/apps/evidence/models.py deleted file mode 100644 index 83e0cb9..0000000 --- a/apps/evidence/models.py +++ /dev/null @@ -1,89 +0,0 @@ -from django.db import models -from apps.core.models import TenantRelationValidationMixin, TimeStampedModel -from apps.evidence.validators import validate_evidence_file - - -class EvidenceItem(TenantRelationValidationMixin, TimeStampedModel): - tenant_relation_fields = { - 'session': 'tenant_id', - 'measure': 'session__tenant_id', - 'owner': 'tenant_id', - 'reviewed_by': 'tenant_id', - } - - class Status(models.TextChoices): - DRAFT = 'DRAFT', 'Entwurf' - SUBMITTED = 'SUBMITTED', 'Zur Prüfung eingereicht' - APPROVED = 'APPROVED', 'Freigegeben' - REJECTED = 'REJECTED', 'Abgelehnt' - - tenant = models.ForeignKey('organizations.Tenant', on_delete=models.CASCADE, related_name='evidence_items') - session = models.ForeignKey('wizard.AssessmentSession', on_delete=models.CASCADE, related_name='evidence_items', null=True, blank=True) - domain = models.ForeignKey('catalog.AssessmentDomain', on_delete=models.SET_NULL, null=True, blank=True, related_name='evidence_items') - measure = models.ForeignKey('wizard.GeneratedMeasure', on_delete=models.SET_NULL, null=True, blank=True, related_name='evidence_items') - requirement = models.ForeignKey('requirements_app.Requirement', on_delete=models.SET_NULL, null=True, blank=True, related_name='evidence_items') - title = models.CharField(max_length=255) - description = models.TextField(blank=True) - linked_requirement = models.CharField(max_length=128, blank=True) - file = models.FileField(upload_to='evidence/%Y/%m/', blank=True, null=True, validators=[validate_evidence_file]) - status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT) - owner = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='owned_evidence') - review_notes = models.TextField(blank=True) - reviewed_by = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='reviewed_evidence') - reviewed_at = models.DateTimeField(null=True, blank=True) - - class Meta: - ordering = ['-updated_at', 'title'] - - def __str__(self): - return self.title - - @property - def requirement_display(self): - if self.requirement: - return f'{self.requirement.framework} {self.requirement.code} – {self.requirement.title}' - return self.linked_requirement - - -class RequirementEvidenceNeed(TenantRelationValidationMixin, TimeStampedModel): - tenant_relation_fields = { - 'session': 'tenant_id', - } - - class Status(models.TextChoices): - OPEN = 'OPEN', 'Offen' - PARTIAL = 'PARTIAL', 'Teilweise abgedeckt' - COVERED = 'COVERED', 'Abgedeckt' - - tenant = models.ForeignKey('organizations.Tenant', on_delete=models.CASCADE, related_name='evidence_needs') - session = models.ForeignKey('wizard.AssessmentSession', on_delete=models.CASCADE, related_name='evidence_needs', null=True, blank=True) - requirement = models.ForeignKey('requirements_app.Requirement', on_delete=models.CASCADE, related_name='evidence_needs') - title = models.CharField(max_length=255) - description = models.TextField(blank=True) - is_mandatory = models.BooleanField(default=True) - status = models.CharField(max_length=16, choices=Status.choices, default=Status.OPEN) - rationale = models.TextField(blank=True) - covered_count = models.PositiveIntegerField(default=0) - - class Meta: - unique_together = ('tenant', 'session', 'requirement') - ordering = ['status', 'requirement__framework', 'requirement__code'] - - def __str__(self): - return self.title - - @property - def requirement_mapping_version(self): - requirement = self.requirement - if not requirement or not requirement.mapping_version: - return '' - return f'{requirement.mapping_version.program_name} {requirement.framework} v{requirement.mapping_version.version}' - - @property - def requirement_source_citation(self): - requirement = self.requirement - if not requirement or not requirement.primary_source: - return '' - source = requirement.primary_source - parts = [source.authority, source.citation or source.title] - return ' - '.join(part for part in parts if part) diff --git a/apps/evidence/services.py b/apps/evidence/services.py deleted file mode 100644 index 35b0433..0000000 --- a/apps/evidence/services.py +++ /dev/null @@ -1,554 +0,0 @@ -import json -from dataclasses import dataclass -from datetime import datetime -from urllib.parse import urlencode -from urllib.request import Request, urlopen - -from django.conf import settings - -from apps.evidence.models import EvidenceItem, RequirementEvidenceNeed -from apps.organizations.sector_catalog import get_sector_definition -from apps.requirements_app.models import Requirement - - -@dataclass(frozen=True) -class EvidenceRelatedRef: - id: int | None - name: str - - def __str__(self) -> str: - return self.name - - -@dataclass(frozen=True) -class EvidenceMappingVersionRef: - program_name: str - framework: str - version: str - - def __str__(self) -> str: - return f'{self.program_name} {self.framework} {self.version}' - - -@dataclass(frozen=True) -class EvidenceRegulatorySourceRef: - authority: str - citation: str - title: str - - def __str__(self) -> str: - return ' - '.join(part for part in [self.authority, self.citation or self.title] if part) - - -@dataclass(frozen=True) -class EvidenceRequirementRef: - id: int - framework: str - code: str - title: str - mapping_version: EvidenceMappingVersionRef | None - primary_source: EvidenceRegulatorySourceRef | None - - def __str__(self) -> str: - return f'{self.framework} - {self.code}' - - -@dataclass(frozen=True) -class EvidenceItemBridgeItem: - id: int - tenant: object - tenant_id: int - session_id: int | None - domain_id: int | None - measure: EvidenceRelatedRef | None - measure_id: int | None - requirement: EvidenceRequirementRef | None - requirement_id: int | None - title: str - description: str - linked_requirement: str - file: str | None - status: str - status_label: str - owner: EvidenceRelatedRef | None - owner_id: int | None - review_notes: str - reviewed_by: EvidenceRelatedRef | None - reviewed_by_id: int | None - reviewed_at: datetime | None - created_at: datetime | None - updated_at: datetime | None - - @property - def pk(self) -> int: - return self.id - - @property - def requirement_display(self) -> str: - if self.requirement: - return f'{self.requirement.framework} {self.requirement.code} – {self.requirement.title}' - return self.linked_requirement - - def get_status_display(self) -> str: - return self.status_label - - -@dataclass(frozen=True) -class RequirementEvidenceNeedBridgeItem: - id: int - tenant: object - tenant_id: int - session_id: int | None - requirement: EvidenceRequirementRef - requirement_id: int - title: str - description: str - is_mandatory: bool - status: str - status_label: str - rationale: str - covered_count: int - created_at: datetime | None - updated_at: datetime | None - - @property - def pk(self) -> int: - return self.id - - @property - def requirement_mapping_version(self) -> str: - requirement = self.requirement - if not requirement or not requirement.mapping_version: - return '' - return f'{requirement.mapping_version.program_name} {requirement.framework} v{requirement.mapping_version.version}' - - @property - def requirement_source_citation(self) -> str: - requirement = self.requirement - if not requirement or not requirement.primary_source: - return '' - source = requirement.primary_source - parts = [source.authority, source.citation or source.title] - return ' - '.join(part for part in parts if part) - - def get_status_display(self) -> str: - return self.status_label - - -@dataclass(frozen=True) -class EvidenceOverviewBridgeResult: - evidence_items: list[EvidenceItemBridgeItem] - evidence_needs: list[RequirementEvidenceNeedBridgeItem] - need_summary: dict[str, int] - - -class EvidenceRustClient: - @staticmethod - def _base_url() -> str: - base = (getattr(settings, 'RUST_BACKEND_URL', '') or '').strip() - return base.rstrip('/') - - @staticmethod - def fetch_overview(request, tenant, session_id: str | int | None = None, timeout: int = 8) -> EvidenceOverviewBridgeResult: - base = EvidenceRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - url = f'{base}/api/v1/evidence' - if session_id: - url = f'{url}?{urlencode({"session_id": int(session_id)})}' - rust_request = EvidenceRustClient._authenticated_request(request, tenant, url) - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - - tenant_id = EvidenceRustClient._int_field(payload, 'tenant_id') - if tenant_id != int(tenant.id): - raise RuntimeError('Rust-Evidenzliste gehoert nicht zum angefragten Tenant.') - - return EvidenceOverviewBridgeResult( - evidence_items=[ - EvidenceRustClient._evidence_item_from_payload(item, tenant) - for item in payload.get('evidence_items', []) - if isinstance(item, dict) - ], - evidence_needs=[ - EvidenceRustClient._evidence_need_from_payload(item, tenant) - for item in payload.get('evidence_needs', []) - if isinstance(item, dict) - ], - need_summary=EvidenceRustClient._need_summary_from_payload(payload.get('need_summary') or {}), - ) - - @staticmethod - def sync_needs(request, tenant, session_id: int, timeout: int = 8) -> dict: - base = EvidenceRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - payload = EvidenceRustClient._sync_payload() - rust_request = EvidenceRustClient._authenticated_json_request( - request, - tenant, - f'{base}/api/v1/evidence/sessions/{int(session_id)}/needs/sync', - 'POST', - payload, - ) - with urlopen(rust_request, timeout=timeout) as response: - response_payload = json.loads(response.read().decode('utf-8')) - - result = response_payload.get('result') or response_payload - result_session_id = EvidenceRustClient._int_field(result, 'session_id') - if result_session_id != int(session_id): - raise RuntimeError('Rust-Evidenzpflichten-Sync gehoert nicht zur angefragten Session.') - return { - 'created': EvidenceRustClient._int_field(result, 'created'), - 'updated': EvidenceRustClient._int_field(result, 'updated'), - 'need_summary': EvidenceRustClient._need_summary_from_payload(result.get('need_summary') or {}), - } - - @staticmethod - def _sync_payload() -> dict: - thresholds = getattr(settings, 'EVIDENCE_COVERAGE_THRESHOLDS', {'covered': 2, 'partial': 1}) - return { - 'covered_threshold': int(thresholds.get('covered', 2) or 2), - 'partial_threshold': int(thresholds.get('partial', 1) or 1), - } - - @staticmethod - def _evidence_item_from_payload(item: dict, tenant) -> EvidenceItemBridgeItem: - measure_id = EvidenceRustClient._optional_int_field(item, 'measure_id') - measure_title = str(item.get('measure_title') or '').strip() - owner_id = EvidenceRustClient._optional_int_field(item, 'owner_id') - owner_display = str(item.get('owner_display') or '').strip() - reviewed_by_id = EvidenceRustClient._optional_int_field(item, 'reviewed_by_id') - reviewed_by_display = str(item.get('reviewed_by_display') or '').strip() - requirement = EvidenceRustClient._requirement_from_payload(item) - return EvidenceItemBridgeItem( - id=EvidenceRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=EvidenceRustClient._int_field(item, 'tenant_id'), - session_id=EvidenceRustClient._optional_int_field(item, 'session_id'), - domain_id=EvidenceRustClient._optional_int_field(item, 'domain_id'), - measure=EvidenceRelatedRef(measure_id, measure_title) if measure_title else None, - measure_id=measure_id, - requirement=requirement, - requirement_id=EvidenceRustClient._optional_int_field(item, 'requirement_id'), - title=str(item.get('title') or ''), - description=str(item.get('description') or ''), - linked_requirement=str(item.get('linked_requirement') or ''), - file=EvidenceRustClient._optional_str_field(item, 'file_name'), - status=str(item.get('status') or EvidenceItem.Status.DRAFT), - status_label=str(item.get('status_label') or dict(EvidenceItem.Status.choices).get(item.get('status'), 'Entwurf')), - owner=EvidenceRelatedRef(owner_id, owner_display) if owner_display else None, - owner_id=owner_id, - review_notes=str(item.get('review_notes') or ''), - reviewed_by=EvidenceRelatedRef(reviewed_by_id, reviewed_by_display) if reviewed_by_display else None, - reviewed_by_id=reviewed_by_id, - reviewed_at=EvidenceRustClient._datetime_field(item, 'reviewed_at'), - created_at=EvidenceRustClient._datetime_field(item, 'created_at'), - updated_at=EvidenceRustClient._datetime_field(item, 'updated_at'), - ) - - @staticmethod - def _evidence_need_from_payload(item: dict, tenant) -> RequirementEvidenceNeedBridgeItem: - requirement = EvidenceRustClient._requirement_from_payload(item) - if requirement is None: - raise RuntimeError('Rust-Evidenzpflicht hat keine Requirement-Daten geliefert.') - return RequirementEvidenceNeedBridgeItem( - id=EvidenceRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=EvidenceRustClient._int_field(item, 'tenant_id'), - session_id=EvidenceRustClient._optional_int_field(item, 'session_id'), - requirement=requirement, - requirement_id=EvidenceRustClient._int_field(item, 'requirement_id'), - title=str(item.get('title') or ''), - description=str(item.get('description') or ''), - is_mandatory=bool(item.get('is_mandatory')), - status=str(item.get('status') or RequirementEvidenceNeed.Status.OPEN), - status_label=str(item.get('status_label') or dict(RequirementEvidenceNeed.Status.choices).get(item.get('status'), 'Offen')), - rationale=str(item.get('rationale') or ''), - covered_count=EvidenceRustClient._int_field(item, 'covered_count'), - created_at=EvidenceRustClient._datetime_field(item, 'created_at'), - updated_at=EvidenceRustClient._datetime_field(item, 'updated_at'), - ) - - @staticmethod - def _requirement_from_payload(item: dict) -> EvidenceRequirementRef | None: - requirement_id = EvidenceRustClient._optional_int_field(item, 'requirement_id') - framework = str(item.get('requirement_framework') or '').strip() - code = str(item.get('requirement_code') or '').strip() - title = str(item.get('requirement_title') or '').strip() - if not requirement_id or not framework or not code: - return None - - mapping_version = None - mapping_program_name = str(item.get('mapping_program_name') or '').strip() - mapping_version_value = str(item.get('mapping_version') or '').strip() - if mapping_program_name and mapping_version_value: - mapping_version = EvidenceMappingVersionRef( - program_name=mapping_program_name, - framework=framework, - version=mapping_version_value, - ) - - primary_source = None - source_authority = str(item.get('source_authority') or '').strip() - source_citation = str(item.get('source_citation') or '').strip() - source_title = str(item.get('source_title') or '').strip() - if source_authority or source_citation or source_title: - primary_source = EvidenceRegulatorySourceRef( - authority=source_authority, - citation=source_citation, - title=source_title, - ) - - return EvidenceRequirementRef( - id=requirement_id, - framework=framework, - code=code, - title=title, - mapping_version=mapping_version, - primary_source=primary_source, - ) - - @staticmethod - def _need_summary_from_payload(payload: dict) -> dict[str, int]: - return { - 'open': int(payload.get('open') or 0), - 'partial': int(payload.get('partial') or 0), - 'covered': int(payload.get('covered') or 0), - } - - @staticmethod - def _authenticated_request(request, tenant, url: str, *, method: str = 'GET', data: bytes | None = None) -> Request: - user = getattr(request, 'user', None) - user_id = getattr(user, 'id', None) - if not user_id: - raise RuntimeError('Evidence-Rust-Bridge braucht einen authentifizierten User.') - - rust_request = Request(url, data=data, method=method) - rust_request.add_header('Accept', 'application/json') - if data is not None: - rust_request.add_header('Content-Type', 'application/json') - rust_request.add_header('X-ISCY-Tenant-ID', str(tenant.id)) - rust_request.add_header('X-ISCY-User-ID', str(user_id)) - user_email = (getattr(user, 'email', '') or '').strip() - if user_email: - rust_request.add_header('X-ISCY-User-Email', user_email) - return rust_request - - @staticmethod - def _authenticated_json_request(request, tenant, url: str, method: str, payload: dict) -> Request: - data = json.dumps(payload).encode('utf-8') - return EvidenceRustClient._authenticated_request(request, tenant, url, method=method, data=data) - - @staticmethod - def _int_field(payload: dict, key: str) -> int: - return int(payload.get(key) or 0) - - @staticmethod - def _optional_int_field(payload: dict, key: str) -> int | None: - value = payload.get(key) - if value in (None, ''): - return None - return int(value) - - @staticmethod - def _optional_str_field(payload: dict, key: str) -> str | None: - value = str(payload.get(key) or '').strip() - return value or None - - @staticmethod - def _datetime_field(payload: dict, key: str) -> datetime | None: - value = str(payload.get(key) or '').strip() - if not value: - return None - normalized = value.replace('Z', '+00:00') - return datetime.fromisoformat(normalized) - - -class EvidenceRegisterBridge: - @staticmethod - def fetch_overview(request, tenant, session_id: str | int | None = None): - if tenant is None: - return None - - backend = str(getattr(settings, 'EVIDENCE_REGISTER_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not EvidenceRustClient._base_url(): - if EvidenceRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust evidence register backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return EvidenceRustClient.fetch_overview(request, tenant, session_id=session_id) - except Exception as exc: - if EvidenceRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust evidence register backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def sync_needs_for_session(request, session): - tenant = getattr(session, 'tenant', None) - if tenant is None: - return None - - backend = str(getattr(settings, 'EVIDENCE_REGISTER_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not EvidenceRustClient._base_url(): - if EvidenceRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust evidence register backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return EvidenceRustClient.sync_needs(request, tenant, session.id) - except Exception as exc: - if EvidenceRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust evidence need sync backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def _strict_rust_mode() -> bool: - return bool(getattr(settings, 'RUST_STRICT_MODE', False)) - - -class EvidenceNeedService: - DOMAIN_KEYWORDS = { - 'GOV': ['governance', 'scope', 'policy', 'roles', 'management'], - 'PROC': ['asset', 'prozess', 'process', 'inventory', 'register'], - 'SUP': ['supplier', 'liefer', 'third'], - 'IAM': ['identity', 'access', 'iam', 'mfa', 'privileg'], - 'CLOUD': ['cloud', 'shared responsibility', 'saas', 'hosting'], - 'SDLC': ['development', 'sdlc', 'change', 'release', 'code'], - 'CYBER': ['patch', 'vulnerability', 'hardening', 'hygiene'], - 'CRYPTO': ['crypto', 'verschlüssel', 'key'], - 'PHYS': ['physical', 'facility', 'zugang'], - 'DETECT': ['logging', 'monitoring', 'detection', 'alarm'], - 'INC': ['incident', 'meldung', 'response'], - 'BCM': ['backup', 'recovery', 'bcm', 'restore'], - 'AWARE': ['awareness', 'training', 'schulung'], - 'DOC': ['document', 'review', 'policy', 'version'], - } - - @staticmethod - def get_sector_packages(tenant): - sector = get_sector_definition(getattr(tenant, 'sector', None)) - packages = {'ALL'} - if sector.code in {'DIGITAL_PROVIDERS', 'DIGITAL_INFRASTRUCTURE', 'ICT_SERVICE_MANAGEMENT', 'MSSP'}: - packages.add('DIGITAL') - if sector.code in {'BANKING', 'FINANCIAL_MARKET_INFRASTRUCTURE'}: - packages.add('FINANCE') - if sector.kritis_related or sector.code in {'ENERGY', 'TRANSPORT', 'HEALTH', 'DRINKING_WATER', 'WASTEWATER', 'PUBLIC_ADMINISTRATION'}: - packages.add('CRITICAL_INFRA') - return packages - - @staticmethod - def requirement_relevant(requirement, tenant): - pkg = (requirement.sector_package or '').strip().upper() - if not pkg or pkg == 'ALL': - return True - return pkg in EvidenceNeedService.get_sector_packages(tenant) - - @staticmethod - def sync_for_session(session): - tenant = session.tenant - # F12: Konfigurierbare Schwellen aus Settings - thresholds = getattr(settings, 'EVIDENCE_COVERAGE_THRESHOLDS', {'covered': 2, 'partial': 1}) - covered_threshold = thresholds.get('covered', 2) - partial_threshold = thresholds.get('partial', 1) - - created = 0 - updated = 0 - for requirement in Requirement.objects.filter(is_active=True).order_by('framework', 'code'): - if not EvidenceNeedService.requirement_relevant(requirement, tenant): - continue - evidence_qs = EvidenceItem.objects.filter(tenant=tenant, requirement=requirement) - covered_count = evidence_qs.count() - if covered_count >= covered_threshold: - status = RequirementEvidenceNeed.Status.COVERED - elif covered_count >= partial_threshold: - status = RequirementEvidenceNeed.Status.PARTIAL - else: - status = RequirementEvidenceNeed.Status.OPEN - defaults = { - 'title': f'Nachweis für {requirement.framework} {requirement.code}', - 'description': EvidenceNeedService.requirement_description(requirement), - 'is_mandatory': requirement.evidence_required, - 'status': status, - 'rationale': EvidenceNeedService.requirement_rationale(requirement), - 'covered_count': covered_count, - } - obj, was_created = RequirementEvidenceNeed.objects.update_or_create( - tenant=tenant, - session=session, - requirement=requirement, - defaults=defaults, - ) - created += int(was_created) - updated += int(not was_created) - return created, updated - - @staticmethod - def related_needs_for_measure(measure, limit=4): - qs = RequirementEvidenceNeed.objects.filter(tenant=measure.session.tenant) - if measure.session_id: - qs = qs.filter(session=measure.session) - if measure.domain_id: - domain_code = measure.domain.code.upper() - keywords = EvidenceNeedService.DOMAIN_KEYWORDS.get(domain_code, []) - filtered = [] - for need in qs.select_related('requirement'): - haystack = ' '.join([ - need.requirement.domain or '', - need.requirement.title or '', - need.requirement.description or '', - need.title or '', - need.description or '', - ]).lower() - if any(keyword.lower() in haystack for keyword in keywords): - filtered.append(need) - if filtered: - return filtered[:limit] - return list(qs.select_related('requirement').order_by('status', 'requirement__framework', 'requirement__code')[:limit]) - - @staticmethod - def measure_need_summary(measure): - needs = EvidenceNeedService.related_needs_for_measure(measure, limit=6) - summary = { - 'open': 0, - 'partial': 0, - 'covered': 0, - 'total': len(needs), - 'items': needs, - } - for need in needs: - if need.status == RequirementEvidenceNeed.Status.OPEN: - summary['open'] += 1 - elif need.status == RequirementEvidenceNeed.Status.PARTIAL: - summary['partial'] += 1 - elif need.status == RequirementEvidenceNeed.Status.COVERED: - summary['covered'] += 1 - return summary - - @staticmethod - def requirement_description(requirement): - parts = [requirement.evidence_guidance or requirement.description] - if requirement.mapping_version: - parts.append(f'Mapping-Version: {requirement.mapping_version.program_name} {requirement.framework} v{requirement.mapping_version.version}') - if requirement.primary_source: - citation = requirement.primary_source.citation or requirement.primary_source.title - parts.append(f'Quelle: {requirement.primary_source.authority} - {citation}') - return ' | '.join(part for part in parts if part) - - @staticmethod - def requirement_rationale(requirement): - parts = [requirement.evidence_examples or 'Evidenzen, Richtlinien, Screenshots, Freigaben oder Prüfprotokolle hinterlegen.'] - if requirement.legal_reference: - parts.append(f'Referenz: {requirement.legal_reference}') - if requirement.primary_source and requirement.primary_source.url: - parts.append(f'Quelle: {requirement.primary_source.url}') - return ' | '.join(part for part in parts if part) diff --git a/apps/evidence/tests.py b/apps/evidence/tests.py deleted file mode 100644 index 736b663..0000000 --- a/apps/evidence/tests.py +++ /dev/null @@ -1,273 +0,0 @@ -import json -from unittest.mock import Mock, patch - -from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings -from django.urls import reverse - -from apps.evidence.models import EvidenceItem, RequirementEvidenceNeed -from apps.organizations.models import Tenant -from apps.requirements_app.models import MappingVersion, RegulatorySource, Requirement -from apps.wizard.models import AssessmentSession - - -User = get_user_model() - - -@override_settings(EVIDENCE_REGISTER_BACKEND='local', RUST_BACKEND_URL='') -class EvidenceViewTests(TestCase): - def setUp(self): - self.tenant_a = Tenant.objects.create(name='Tenant A', slug='tenant-a', country='DE') - self.tenant_b = Tenant.objects.create(name='Tenant B', slug='tenant-b', country='DE') - self.user_a = User.objects.create_user( - username='tenant-a-user', - email='evidence-user@example.test', - password='testpass123', - tenant=self.tenant_a, - first_name='Ada', - last_name='Lovelace', - ) - self.user_b = User.objects.create_user( - username='tenant-b-user', - password='testpass123', - tenant=self.tenant_b, - ) - self.session_a = AssessmentSession.objects.create(tenant=self.tenant_a, started_by=self.user_a) - self.session_b = AssessmentSession.objects.create(tenant=self.tenant_b, started_by=self.user_b) - self.mapping_version = MappingVersion.objects.create( - framework='ISO27001', - slug='iso27001-2022', - title='ISO 27001', - version='2022', - program_name='ISCY', - ) - self.source = RegulatorySource.objects.create( - framework='ISO27001', - mapping_version=self.mapping_version, - code='A.5.17', - title='Authentication information', - authority='ISO', - citation='A.5.17', - ) - self.requirement = Requirement.objects.create( - framework=Requirement.Framework.ISO27001, - code='A.5.17', - title='Authentication Information', - domain='IAM', - description='Protect authentication information.', - mapping_version=self.mapping_version, - primary_source=self.source, - ) - self.evidence_a = EvidenceItem.objects.create( - tenant=self.tenant_a, - session=self.session_a, - requirement=self.requirement, - title='MFA Rollout Screenshot', - description='Screenshot of enforced MFA policy', - linked_requirement='ISO27001 A.5.17', - status=EvidenceItem.Status.APPROVED, - owner=self.user_a, - ) - self.evidence_b = EvidenceItem.objects.create( - tenant=self.tenant_b, - session=self.session_b, - requirement=self.requirement, - title='Foreign Evidence', - status=EvidenceItem.Status.DRAFT, - owner=self.user_b, - ) - self.need_a = RequirementEvidenceNeed.objects.create( - tenant=self.tenant_a, - session=self.session_a, - requirement=self.requirement, - title='Nachweis für ISO27001 A.5.17', - description='MFA policy evidence', - status=RequirementEvidenceNeed.Status.COVERED, - covered_count=1, - ) - self.need_b = RequirementEvidenceNeed.objects.create( - tenant=self.tenant_b, - session=self.session_b, - requirement=self.requirement, - title='Foreign Need', - status=RequirementEvidenceNeed.Status.OPEN, - ) - - def test_evidence_list_only_shows_current_tenant(self): - self.client.force_login(self.user_a) - - response = self.client.get(reverse('evidence:list')) - - self.assertEqual(response.status_code, 200) - evidence_items = list(response.context['evidence_items']) - evidence_needs = list(response.context['evidence_needs']) - self.assertEqual(evidence_items, [self.evidence_a]) - self.assertEqual(evidence_needs, [self.need_a]) - self.assertEqual(response.context['evidence_register_source'], 'django') - self.assertEqual(response.context['need_summary']['covered'], 1) - self.assertContains(response, 'MFA Rollout Screenshot') - self.assertNotContains(response, 'Foreign Evidence') - - @patch('apps.evidence.services.urlopen') - def test_evidence_list_can_use_rust_register_bridge(self, mock_urlopen): - response_mock = Mock() - response_mock.read.return_value = json.dumps({ - 'api_version': 'v1', - 'tenant_id': self.tenant_a.id, - 'session_id': self.session_a.id, - 'evidence_items': [ - self._rust_evidence_payload(title='Rust MFA Evidence'), - ], - 'evidence_needs': [ - self._rust_need_payload(title='Rust Evidence Need'), - ], - 'need_summary': {'open': 0, 'partial': 0, 'covered': 1}, - }).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - mock_urlopen.return_value = response_ctx - self.client.force_login(self.user_a) - - with self.settings(EVIDENCE_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.get(f"{reverse('evidence:list')}?session={self.session_a.id}") - - self.assertEqual(response.status_code, 200) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual( - rust_request.full_url, - f'http://rust-backend:9000/api/v1/evidence?session_id={self.session_a.id}', - ) - self.assertEqual(rust_request.get_header('X-iscy-tenant-id'), str(self.tenant_a.id)) - self.assertEqual(rust_request.get_header('X-iscy-user-id'), str(self.user_a.id)) - self.assertEqual(rust_request.get_header('X-iscy-user-email'), 'evidence-user@example.test') - evidence_items = list(response.context['evidence_items']) - evidence_needs = list(response.context['evidence_needs']) - self.assertEqual(response.context['evidence_register_source'], 'rust_service') - self.assertEqual(evidence_items[0].title, 'Rust MFA Evidence') - self.assertEqual(evidence_items[0].tenant.name, 'Tenant A') - self.assertEqual(evidence_items[0].get_status_display(), 'Freigegeben') - self.assertEqual(evidence_items[0].owner.name, 'Ada Lovelace') - self.assertEqual(evidence_needs[0].requirement_mapping_version, 'ISCY ISO27001 v2022') - self.assertEqual(response.context['need_summary']['covered'], 1) - self.assertContains(response, 'Rust MFA Evidence') - self.assertContains(response, 'ISO27001 A.5.17') - self.assertContains(response, 'Abgedeckt') - - @patch('apps.evidence.services.urlopen') - def test_evidence_need_sync_can_use_rust_register_bridge(self, mock_urlopen): - response_mock = Mock() - response_mock.read.return_value = json.dumps({ - 'accepted': True, - 'api_version': 'v1', - 'session_id': self.session_a.id, - 'created': 1, - 'updated': 2, - 'need_summary': {'open': 1, 'partial': 1, 'covered': 0}, - }).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - mock_urlopen.return_value = response_ctx - self.client.force_login(self.user_a) - - with self.settings(EVIDENCE_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.post(reverse('evidence:sync-needs', args=[self.session_a.pk])) - - self.assertEqual(response.status_code, 302) - self.assertEqual(response['Location'], f"{reverse('evidence:list')}?session={self.session_a.id}") - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual( - rust_request.full_url, - f'http://rust-backend:9000/api/v1/evidence/sessions/{self.session_a.id}/needs/sync', - ) - self.assertEqual(rust_request.get_method(), 'POST') - self.assertEqual(rust_request.get_header('X-iscy-tenant-id'), str(self.tenant_a.id)) - self.assertEqual(rust_request.get_header('X-iscy-user-id'), str(self.user_a.id)) - payload = json.loads(rust_request.data.decode('utf-8')) - self.assertEqual(payload['covered_threshold'], 2) - self.assertEqual(payload['partial_threshold'], 1) - - @patch('apps.evidence.services.urlopen', side_effect=OSError('backend down')) - def test_evidence_list_falls_back_to_django_when_rust_unavailable_in_non_strict_mode(self, _mock_urlopen): - self.client.force_login(self.user_a) - - with self.settings( - EVIDENCE_REGISTER_BACKEND='rust_service', - RUST_BACKEND_URL='http://rust-backend:9000', - RUST_STRICT_MODE=False, - ): - response = self.client.get(reverse('evidence:list')) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['evidence_register_source'], 'django') - self.assertEqual(list(response.context['evidence_items']), [self.evidence_a]) - - def test_evidence_list_raises_in_strict_mode_without_rust_backend_url(self): - self.client.force_login(self.user_a) - - with self.settings(EVIDENCE_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='', RUST_STRICT_MODE=True): - with self.assertRaises(RuntimeError): - self.client.get(reverse('evidence:list')) - - def _rust_evidence_payload(self, **overrides): - payload = { - 'id': self.evidence_a.id, - 'tenant_id': self.tenant_a.id, - 'session_id': self.session_a.id, - 'domain_id': None, - 'measure_id': None, - 'measure_title': None, - 'requirement_id': self.requirement.id, - 'requirement_framework': 'ISO27001', - 'requirement_code': 'A.5.17', - 'requirement_title': 'Authentication Information', - 'mapping_program_name': 'ISCY', - 'mapping_version': '2022', - 'source_authority': 'ISO', - 'source_citation': 'A.5.17', - 'source_title': 'Authentication information', - 'title': 'MFA Rollout Screenshot', - 'description': 'Screenshot of enforced MFA policy', - 'linked_requirement': 'ISO27001 A.5.17', - 'file_name': None, - 'status': 'APPROVED', - 'status_label': 'Freigegeben', - 'owner_id': self.user_a.id, - 'owner_display': 'Ada Lovelace', - 'review_notes': '', - 'reviewed_by_id': None, - 'reviewed_by_display': None, - 'reviewed_at': None, - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - } - payload.update(overrides) - return payload - - def _rust_need_payload(self, **overrides): - payload = { - 'id': self.need_a.id, - 'tenant_id': self.tenant_a.id, - 'session_id': self.session_a.id, - 'requirement_id': self.requirement.id, - 'requirement_framework': 'ISO27001', - 'requirement_code': 'A.5.17', - 'requirement_title': 'Authentication Information', - 'mapping_program_name': 'ISCY', - 'mapping_version': '2022', - 'source_authority': 'ISO', - 'source_citation': 'A.5.17', - 'source_title': 'Authentication information', - 'title': 'Nachweis für ISO27001 A.5.17', - 'description': 'MFA policy evidence', - 'is_mandatory': True, - 'status': 'COVERED', - 'status_label': 'Abgedeckt', - 'rationale': '', - 'covered_count': 1, - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - } - payload.update(overrides) - return payload diff --git a/apps/evidence/urls.py b/apps/evidence/urls.py deleted file mode 100644 index 78c5e43..0000000 --- a/apps/evidence/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.urls import path -from .views import EvidenceCreateView, EvidenceListView, EvidenceNeedSyncView, EvidenceUpdateView - -app_name = 'evidence' - -urlpatterns = [ - path('', EvidenceListView.as_view(), name='list'), - path('new/', EvidenceCreateView.as_view(), name='create'), - path('session//sync-needs/', EvidenceNeedSyncView.as_view(), name='sync-needs'), - path('/edit/', EvidenceUpdateView.as_view(), name='edit'), -] diff --git a/apps/evidence/validators.py b/apps/evidence/validators.py deleted file mode 100644 index 2b746ad..0000000 --- a/apps/evidence/validators.py +++ /dev/null @@ -1,33 +0,0 @@ -"""F15: Datei-Upload-Validierung fuer Evidenzen.""" -import os -from django.conf import settings -from django.core.exceptions import ValidationError - - -def validate_evidence_file(file): - """Validiert Dateityp und Groesse von Evidence-Uploads.""" - allowed_ext = getattr(settings, 'EVIDENCE_ALLOWED_EXTENSIONS', - ['.pdf', '.docx', '.xlsx', '.png', '.jpg', '.jpeg', '.csv', '.txt']) - max_size_mb = getattr(settings, 'EVIDENCE_MAX_FILE_SIZE_MB', 25) - max_size_bytes = max_size_mb * 1024 * 1024 - - # Dateiendung pruefen - ext = os.path.splitext(file.name)[1].lower() - if ext not in allowed_ext: - raise ValidationError( - f'Dateityp "{ext}" ist nicht erlaubt. Erlaubt: {", ".join(allowed_ext)}' - ) - - # Dateigroesse pruefen - if file.size > max_size_bytes: - raise ValidationError( - f'Datei ist zu gross ({file.size / 1024 / 1024:.1f} MB). Maximum: {max_size_mb} MB.' - ) - - # Content-Type Basispruefung - content_type = getattr(file, 'content_type', '') - suspicious_types = ['application/x-executable', 'application/x-msdos-program', 'text/html'] - if content_type in suspicious_types: - raise ValidationError( - f'Dateityp "{content_type}" ist aus Sicherheitsgruenden nicht erlaubt.' - ) diff --git a/apps/evidence/views.py b/apps/evidence/views.py deleted file mode 100644 index 70361c1..0000000 --- a/apps/evidence/views.py +++ /dev/null @@ -1,159 +0,0 @@ -from django.shortcuts import get_object_or_404, redirect -from django.urls import reverse -from django.views.generic import CreateView, ListView, UpdateView - -from apps.core.mixins import TenantAccessMixin, TenantCreateMixin -from apps.requirements_app.models import Requirement -from apps.wizard.models import AssessmentSession -from apps.wizard.services import WizardService -from .forms import EvidenceItemForm -from .models import EvidenceItem, RequirementEvidenceNeed -from .services import EvidenceNeedService, EvidenceRegisterBridge - - -class EvidenceListView(TenantAccessMixin, ListView): - model = EvidenceItem - template_name = 'evidence/evidence_list.html' - context_object_name = 'items' - - def get_queryset(self): - session_id = self._selected_session() - rust_overview = EvidenceRegisterBridge.fetch_overview(self.request, self.get_tenant(), session_id=session_id) - if rust_overview is not None: - self.evidence_register_source = 'rust_service' - self.evidence_overview = rust_overview - return rust_overview.evidence_items - - self.evidence_register_source = 'django' - qs = super().get_queryset().select_related( - 'session', - 'domain', - 'measure', - 'requirement__mapping_version', - 'requirement__primary_source', - 'owner', - ) - if session_id: - qs = qs.filter(session_id=session_id) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - tenant = self.get_tenant() - selected_session = self._selected_session() - sessions = AssessmentSession.objects.filter(tenant=tenant) if tenant else [] - overview = getattr(self, 'evidence_overview', None) - if overview is not None: - evidence_needs = overview.evidence_needs - need_summary = overview.need_summary - else: - needs = RequirementEvidenceNeed.objects.filter(tenant=tenant).select_related( - 'requirement__mapping_version', - 'requirement__primary_source', - ) - if selected_session: - needs = needs.filter(session_id=selected_session) - need_summary = { - 'open': needs.filter(status=RequirementEvidenceNeed.Status.OPEN).count(), - 'partial': needs.filter(status=RequirementEvidenceNeed.Status.PARTIAL).count(), - 'covered': needs.filter(status=RequirementEvidenceNeed.Status.COVERED).count(), - } - evidence_needs = needs[:30] - - evidence_items = context.get('object_list', []) - context['sessions'] = sessions - context['selected_session'] = selected_session - context['items'] = evidence_items - context['evidence_items'] = evidence_items - context['needs'] = evidence_needs - context['evidence_needs'] = evidence_needs - context['need_summary'] = need_summary - context['evidence_register_source'] = getattr(self, 'evidence_register_source', 'django') - return context - - def _selected_session(self): - return self.request.GET.get('session', '').strip() - - -class EvidenceNeedSyncView(TenantAccessMixin, UpdateView): - model = AssessmentSession - fields = [] - tenant_filter_field = 'tenant' - - def post(self, request, *args, **kwargs): - session = get_object_or_404(self.get_queryset(), pk=kwargs['pk']) - if EvidenceRegisterBridge.sync_needs_for_session(request, session) is None: - EvidenceNeedService.sync_for_session(session) - return redirect(f"{reverse('evidence:list')}?session={session.id}") - - -class EvidenceCreateView(TenantCreateMixin, CreateView): - model = EvidenceItem - form_class = EvidenceItemForm - template_name = 'evidence/evidence_form.html' - - def get_initial(self): - initial = super().get_initial() - session_id = self.request.GET.get('session') - requirement_id = self.request.GET.get('requirement') - if session_id: - initial['session'] = session_id - if requirement_id: - initial['requirement'] = requirement_id - return initial - - def get_form(self, form_class=None): - form = super().get_form(form_class) - tenant = WizardService.get_default_tenant(self.request.user) - session_id = self.request.GET.get('session') - if tenant: - form.fields['requirement'].queryset = Requirement.objects.filter(is_active=True).order_by('framework', 'code') - if session_id: - form.fields['measure'].queryset = form.fields['measure'].queryset.filter(session_id=session_id) - return form - - def form_valid(self, form): - tenant = WizardService.get_default_tenant(self.request.user) - session_id = self.request.GET.get('session') - if session_id and not form.instance.session_id: - form.instance.session = get_object_or_404(AssessmentSession.objects.filter(tenant=tenant), pk=session_id) - form.instance.owner = self.request.user - if form.instance.requirement: - form.instance.linked_requirement = f'{form.instance.requirement.framework} {form.instance.requirement.code}' - response = super().form_valid(form) - if form.instance.session_id: - if EvidenceRegisterBridge.sync_needs_for_session(self.request, form.instance.session) is None: - EvidenceNeedService.sync_for_session(form.instance.session) - return response - - def get_success_url(self): - return reverse('evidence:list') - - -class EvidenceUpdateView(TenantAccessMixin, UpdateView): - model = EvidenceItem - form_class = EvidenceItemForm - template_name = 'evidence/evidence_form.html' - - def get_form(self, form_class=None): - form = super().get_form(form_class) - tenant = WizardService.get_default_tenant(self.request.user) - if tenant: - form.fields['requirement'].queryset = Requirement.objects.filter(is_active=True).order_by('framework', 'code') - return form - - def form_valid(self, form): - if form.cleaned_data.get('status') in {'APPROVED', 'REJECTED'}: - form.instance.reviewed_by = self.request.user - from django.utils import timezone - form.instance.reviewed_at = timezone.now() - if form.instance.requirement: - form.instance.linked_requirement = f'{form.instance.requirement.framework} {form.instance.requirement.code}' - response = super().form_valid(form) - if form.instance.session_id: - if EvidenceRegisterBridge.sync_needs_for_session(self.request, form.instance.session) is None: - EvidenceNeedService.sync_for_session(form.instance.session) - return response - - def get_success_url(self): - return reverse('evidence:list') diff --git a/apps/guidance/__init__.py b/apps/guidance/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/guidance/admin.py b/apps/guidance/admin.py deleted file mode 100644 index 9b11ca8..0000000 --- a/apps/guidance/admin.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.contrib import admin -from .models import GuidanceStep, TenantJourneyState - - -@admin.register(GuidanceStep) -class GuidanceStepAdmin(admin.ModelAdmin): - list_display = ('sort_order', 'title', 'phase', 'code', 'route_name', 'is_required', 'is_active') - list_filter = ('phase', 'is_required', 'is_active') - search_fields = ('title', 'code', 'description') - - -@admin.register(TenantJourneyState) -class TenantJourneyStateAdmin(admin.ModelAdmin): - list_display = ('tenant', 'current_step', 'progress_percent', 'updated_at') - search_fields = ('tenant__name',) diff --git a/apps/guidance/apps.py b/apps/guidance/apps.py deleted file mode 100644 index d938daa..0000000 --- a/apps/guidance/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class GuidanceConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.guidance' diff --git a/apps/guidance/management/__init__.py b/apps/guidance/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/guidance/management/commands/__init__.py b/apps/guidance/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/guidance/management/commands/seed_guidance.py b/apps/guidance/management/commands/seed_guidance.py deleted file mode 100644 index b446708..0000000 --- a/apps/guidance/management/commands/seed_guidance.py +++ /dev/null @@ -1,127 +0,0 @@ -from django.core.management.base import BaseCommand -from apps.guidance.models import GuidanceStep - - -class Command(BaseCommand): - help = 'Seed default guidance steps' - - def handle(self, *args, **options): - steps = [ - { - 'code': 'applicability_checked', - 'phase': 'applicability', - 'title': 'Betroffenheitsanalyse durchführen', - 'description': 'Prüfen Sie strukturiert, ob das Unternehmen NIS2- oder KRITIS-nah ist oder vor allem ISO-27001-Readiness benötigt.', - 'why_it_matters': 'Die App darf ISO 27001 nicht als automatischen Ersatz für NIS2 darstellen. Die Betroffenheitsanalyse schafft zuerst Klarheit über die regulatorische Nähe.', - 'required_inputs': 'Sektor, Unternehmensgröße, kritische Dienstleistungen, Lieferkettenrolle, organisatorische Merkmale.', - 'expected_outputs': 'Erste Einordnung in voraussichtlich relevant, möglicherweise relevant oder derzeit nicht direkt relevant.', - 'definition_of_done': 'Mindestens eine Betroffenheitsanalyse wurde dokumentiert.', - 'route_name': 'assessments:applicability_create', - 'cta_label': 'Betroffenheitsanalyse starten', - 'sort_order': 10, - }, - { - 'code': 'company_scope_defined', - 'phase': 'scope', - 'title': 'ISMS-Scope definieren', - 'description': 'Pflegen Sie Geltungsbereich, Zielbild und organisatorischen Kontext des Mandanten.', - 'why_it_matters': 'Ohne Scope sind Prozess-, Risiko- und Control-Bewertungen nicht belastbar.', - 'required_inputs': 'Unternehmensbeschreibung, Sektor, kritische Leistungen, Standorte, Einheiten.', - 'expected_outputs': 'Dokumentierter ISMS-Geltungsbereich.', - 'definition_of_done': 'Scope ist fachlich beschrieben und im Tenant gepflegt.', - 'route_name': 'organizations:list', - 'cta_label': 'Organisation prüfen', - 'sort_order': 20, - }, - { - 'code': 'requirements_available', - 'phase': 'mapping', - 'title': 'ISCY Requirement Library bereitstellen', - 'description': 'Stellen Sie sicher, dass ISO-27001- und NIS2-nahe Anforderungen vorhanden sind.', - 'why_it_matters': 'Ohne Anforderungen ist kein Gap-Mapping möglich.', - 'required_inputs': 'Requirement-Katalog.', - 'expected_outputs': 'Verfügbare Requirement-Grundlage.', - 'definition_of_done': 'Requirements sind im System vorhanden.', - 'route_name': 'requirements:list', - 'cta_label': 'Requirements ansehen', - 'sort_order': 30, - }, - { - 'code': 'initial_processes_captured', - 'phase': 'processes', - 'title': 'Kritische Prozesse erfassen', - 'description': 'Erfassen Sie mindestens die wichtigsten Geschäfts- oder IT-Prozesse.', - 'why_it_matters': 'Prozesse bilden die Basis für Assessments, Risiken und Maßnahmen.', - 'required_inputs': 'Prozessname, Owner, Kritikalität, Beschreibung.', - 'expected_outputs': 'Initiales Prozessregister.', - 'definition_of_done': 'Mindestens 3 Prozesse sind erfasst.', - 'route_name': 'processes:create', - 'cta_label': 'Prozess anlegen', - 'sort_order': 40, - }, - { - 'code': 'initial_risks_captured', - 'phase': 'assessment', - 'title': 'Erste Risiken dokumentieren', - 'description': 'Erfassen Sie erste Risiken für kritische Prozesse oder Assets.', - 'why_it_matters': 'Risiken helfen bei der Priorisierung von Maßnahmen.', - 'required_inputs': 'Risiko, Beschreibung, Auswirkung, Eintrittswahrscheinlichkeit.', - 'expected_outputs': 'Initiales Risikoregister.', - 'definition_of_done': 'Mindestens 1 Risiko ist erfasst.', - 'route_name': 'risks:create', - 'cta_label': 'Risiko anlegen', - 'sort_order': 50, - }, - { - 'code': 'initial_assessment_done', - 'phase': 'assessment', - 'title': 'Erstes Assessment durchführen', - 'description': 'Bewerten Sie einen Prozess oder eine Anforderung.', - 'why_it_matters': 'Assessments zeigen, was ausreichend ist und wo echte Gaps bestehen.', - 'required_inputs': 'Bewerteter Prozess, Status, Begründung, ggf. Evidenz.', - 'expected_outputs': 'Erstes dokumentiertes Assessment.', - 'definition_of_done': 'Mindestens 1 Assessment ist vorhanden.', - 'route_name': 'assessments:create', - 'cta_label': 'Assessment anlegen', - 'sort_order': 60, - }, - { - 'code': 'soc_phishing_playbook_applied', - 'phase': 'measures', - 'title': 'SOC-Playbook für Phishing anwenden', - 'description': ( - 'Bearbeiten Sie gemeldete Phishing-Fälle entlang einer klaren Kette: ' - 'Scope bestimmen, Informationen korrelieren, Gemeinsamkeiten (IOC/TTP) suchen, ' - 'Vorfallstyp bewerten, Verdacht bestätigen, priorisieren, dokumentieren, ' - 'Containment einleiten und bei Bedarf eskalieren.' - ), - 'why_it_matters': ( - 'Einzel-Alerts werden häufig falsch eingeschätzt. Das Playbook reduziert sowohl ' - 'Unterreaktion (Ausbreitung) als auch Überreaktion (unnötige Eskalation) und schafft ' - 'ein nachvollziehbares Lagebild für SOC, IT und Management.' - ), - 'required_inputs': ( - 'Mail- und Zustellstatus, Klick-/Interaktionsdaten, Auth-Logs (z. B. Entra ID/M365), ' - 'EDR-/SIEM-/Proxy-/DNS-/Firewall-Daten, Sandbox-Ergebnisse, Threat-Intelligence, ' - 'betroffene User/Hosts/privilegierte Konten, Zeitfenster und mögliche Business-Auswirkung.' - ), - 'expected_outputs': ( - 'Klassifizierter Incident-Typ (z. B. Spam, Credential Phishing, BEC, Malware Delivery, ' - 'Account Compromise), priorisierte Dringlichkeit, dokumentierte Evidenzkette, ' - 'eingeleitete Containment-Maßnahmen und begründete Eskalationsentscheidung.' - ), - 'definition_of_done': ( - 'Der Fall ist für Dritte rekonstruierbar dokumentiert: Scope, Korrelationsergebnisse, ' - 'IOC/TTP-Bezüge, Klassifikation, Priorität, Maßnahmen, offene Punkte und klare ' - 'Eskalationsbegründung bzw. Schließungsbegründung sind vorhanden.' - ), - 'route_name': 'assessments:measure_create', - 'cta_label': 'Incident-Maßnahme erfassen', - 'sort_order': 70, - }, - ] - - for item in steps: - GuidanceStep.objects.update_or_create(code=item['code'], defaults=item) - - self.stdout.write(self.style.SUCCESS('Guidance steps seeded.')) diff --git a/apps/guidance/migrations/0001_initial.py b/apps/guidance/migrations/0001_initial.py deleted file mode 100644 index a9ad1f0..0000000 --- a/apps/guidance/migrations/0001_initial.py +++ /dev/null @@ -1,54 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-09 14:39 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('organizations', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='GuidanceStep', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('code', models.CharField(max_length=100, unique=True)), - ('phase', models.CharField(choices=[('applicability', 'Betroffenheit'), ('scope', 'Scope'), ('processes', 'Prozesse'), ('assessment', 'Bewertung'), ('mapping', 'Mapping'), ('measures', 'Maßnahmen'), ('evidence', 'Evidenz'), ('review', 'Review')], max_length=50)), - ('title', models.CharField(max_length=255)), - ('description', models.TextField()), - ('why_it_matters', models.TextField()), - ('required_inputs', models.TextField(blank=True)), - ('expected_outputs', models.TextField(blank=True)), - ('definition_of_done', models.TextField(blank=True)), - ('route_name', models.CharField(blank=True, max_length=100)), - ('cta_label', models.CharField(blank=True, max_length=100)), - ('sort_order', models.PositiveIntegerField(default=0)), - ('is_required', models.BooleanField(default=True)), - ('is_active', models.BooleanField(default=True)), - ], - options={ - 'ordering': ['sort_order', 'title'], - }, - ), - migrations.CreateModel( - name='TenantJourneyState', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('progress_percent', models.PositiveIntegerField(default=0)), - ('summary', models.TextField(blank=True)), - ('next_action_text', models.CharField(blank=True, max_length=255)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('current_step', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='current_for_tenants', to='guidance.guidancestep')), - ('last_completed_step', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='last_completed_for_tenants', to='guidance.guidancestep')), - ('tenant', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='journey_state', to='organizations.tenant')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/apps/guidance/migrations/__init__.py b/apps/guidance/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/guidance/models.py b/apps/guidance/models.py deleted file mode 100644 index 97e4442..0000000 --- a/apps/guidance/models.py +++ /dev/null @@ -1,49 +0,0 @@ -from django.conf import settings -from django.db import models - - -class GuidancePhase(models.TextChoices): - APPLICABILITY = 'applicability', 'Betroffenheit' - SCOPE = 'scope', 'Scope' - PROCESSES = 'processes', 'Prozesse' - ASSESSMENT = 'assessment', 'Bewertung' - MAPPING = 'mapping', 'Mapping' - MEASURES = 'measures', 'Maßnahmen' - EVIDENCE = 'evidence', 'Evidenz' - REVIEW = 'review', 'Review' - - -class GuidanceStep(models.Model): - code = models.CharField(max_length=100, unique=True) - phase = models.CharField(max_length=50, choices=GuidancePhase.choices) - title = models.CharField(max_length=255) - description = models.TextField() - why_it_matters = models.TextField() - required_inputs = models.TextField(blank=True) - expected_outputs = models.TextField(blank=True) - definition_of_done = models.TextField(blank=True) - route_name = models.CharField(max_length=100, blank=True) - cta_label = models.CharField(max_length=100, blank=True) - sort_order = models.PositiveIntegerField(default=0) - is_required = models.BooleanField(default=True) - is_active = models.BooleanField(default=True) - - class Meta: - ordering = ['sort_order', 'title'] - - def __str__(self): - return f'{self.sort_order} - {self.title}' - - -class TenantJourneyState(models.Model): - tenant = models.OneToOneField('organizations.Tenant', on_delete=models.CASCADE, related_name='journey_state') - current_step = models.ForeignKey(GuidanceStep, on_delete=models.SET_NULL, null=True, blank=True, related_name='current_for_tenants') - last_completed_step = models.ForeignKey(GuidanceStep, on_delete=models.SET_NULL, null=True, blank=True, related_name='last_completed_for_tenants') - progress_percent = models.PositiveIntegerField(default=0) - summary = models.TextField(blank=True) - next_action_text = models.CharField(max_length=255, blank=True) - updated_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True) - updated_at = models.DateTimeField(auto_now=True) - - def __str__(self): - return f'JourneyState<{self.tenant.name}>' diff --git a/apps/guidance/services.py b/apps/guidance/services.py deleted file mode 100644 index c8bc5a1..0000000 --- a/apps/guidance/services.py +++ /dev/null @@ -1,239 +0,0 @@ -import json -from urllib.request import Request, urlopen - -from django.conf import settings -from django.urls import NoReverseMatch, reverse -from apps.assessments.models import ApplicabilityAssessment, Assessment, Measure -from apps.guidance.models import GuidanceStep, TenantJourneyState -from apps.organizations.models import Tenant -from apps.processes.models import Process -from apps.requirements_app.models import Requirement -from apps.risks.models import Risk - - -class JourneyService: - @staticmethod - def _strict_rust_mode() -> bool: - return bool(getattr(settings, 'RUST_STRICT_MODE', False)) - - @staticmethod - def get_default_tenant(user=None): - if user and getattr(user, 'tenant_id', None): - tenant = Tenant.objects.filter(pk=user.tenant_id).first() - if tenant: - return tenant - return Tenant.objects.first() - - @staticmethod - def get_or_create_state(tenant: Tenant) -> TenantJourneyState: - state, _ = TenantJourneyState.objects.get_or_create(tenant=tenant) - return state - - @staticmethod - def evaluate_tenant(tenant: Tenant, user=None): - state = JourneyService.get_or_create_state(tenant) - steps = list(GuidanceStep.objects.filter(is_active=True).order_by('sort_order')) - - process_count = Process.objects.filter(tenant=tenant).count() - risk_count = Risk.objects.filter(tenant=tenant).count() - assessment_count = Assessment.objects.filter(tenant=tenant).count() - measure_count = Measure.objects.filter(tenant=tenant).count() - measure_open_count = Measure.objects.filter(tenant=tenant).exclude(status=Measure.Status.DONE).count() - applicability_count = ApplicabilityAssessment.objects.filter(tenant=tenant).count() - requirement_count = Requirement.objects.filter(is_active=True).count() - - completed = 0 - current_step = None - for step in steps: - if JourneyService._is_step_done(step.code, tenant, process_count, risk_count, assessment_count, measure_count, applicability_count, requirement_count): - completed += 1 - elif current_step is None: - current_step = step - - if not steps: - state.current_step = None - state.last_completed_step = None - state.progress_percent = 0 - state.summary = 'Es sind keine Guided Steps konfiguriert.' - state.next_action_text = 'Bitte Guidance Steps anlegen.' - state.updated_by = user - state.save() - return { - 'state': state, - 'next_step_url': '', - 'next_step_label': '', - 'todo_items': [], - } - - state.progress_percent = int((completed / len(steps)) * 100) - state.current_step = current_step - state.last_completed_step = steps[completed - 1] if completed > 0 else None - - rust_eval = JourneyService._evaluate_via_rust( - tenant=tenant, - process_count=process_count, - risk_count=risk_count, - assessment_count=assessment_count, - measure_count=measure_count, - measure_open_count=measure_open_count, - applicability_count=applicability_count, - requirement_count=requirement_count, - ) - if rust_eval: - state.summary = rust_eval['summary'] - state.next_action_text = rust_eval['next_action_text'] - else: - state.summary, state.next_action_text = JourneyService._build_step_message( - current_step.code if current_step else '', tenant, process_count, risk_count, assessment_count, measure_count, measure_open_count, applicability_count - ) if current_step else ( - 'Alle aktuell definierten Guided Steps sind abgeschlossen.', - 'Nächster sinnvoller Schritt: Evidenzen, Reviews und Audit-Vorbereitung vertiefen.', - ) - state.updated_by = user - state.save() - - todo_items = rust_eval['todo_items'] if rust_eval else JourneyService._todo_items( - tenant, process_count, risk_count, assessment_count, measure_count, measure_open_count, applicability_count - ) - next_step_url = JourneyService._resolve_route(current_step.route_name) if current_step and current_step.route_name else '' - next_step_label = current_step.cta_label if current_step and current_step.cta_label else 'Zum Schritt' - return { - 'state': state, - 'next_step_url': next_step_url, - 'next_step_label': next_step_label, - 'todo_items': todo_items, - } - - @staticmethod - def _resolve_route(route_name: str) -> str: - try: - return reverse(route_name) - except NoReverseMatch: - return '' - - @staticmethod - def _evaluate_via_rust(*, tenant: Tenant, process_count: int, risk_count: int, assessment_count: int, - measure_count: int, measure_open_count: int, applicability_count: int, requirement_count: int): - backend = str(getattr(settings, 'GUIDANCE_SCORING_BACKEND', 'rust_service') or '').strip().lower() - rust_url = (getattr(settings, 'RUST_BACKEND_URL', '') or '').strip().rstrip('/') - if backend != 'rust_service' or not rust_url: - if backend == 'rust_service' and JourneyService._strict_rust_mode(): - raise RuntimeError('Rust guidance scoring backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - payload = { - 'description_present': bool(tenant.description), - 'sector_present': bool(tenant.sector), - 'applicability_count': int(applicability_count), - 'process_count': int(process_count), - 'risk_count': int(risk_count), - 'assessment_count': int(assessment_count), - 'measure_count': int(measure_count), - 'measure_open_count': int(measure_open_count), - 'requirement_count': int(requirement_count), - } - request = Request( - f'{rust_url}/api/v1/guidance/evaluate', - data=json.dumps(payload).encode('utf-8'), - method='POST', - ) - request.add_header('Content-Type', 'application/json') - request.add_header('Accept', 'application/json') - try: - with urlopen(request, timeout=8) as response: - data = json.loads(response.read().decode('utf-8')) - return { - 'summary': str(data.get('summary') or ''), - 'next_action_text': str(data.get('next_action_text') or ''), - 'todo_items': [str(x) for x in data.get('todo_items', []) if str(x).strip()], - } - except Exception as exc: - if JourneyService._strict_rust_mode(): - raise RuntimeError('Rust guidance scoring backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def _is_step_done(code: str, tenant: Tenant, process_count: int, risk_count: int, assessment_count: int, measure_count: int, applicability_count: int, requirement_count: int) -> bool: - if code == 'applicability_checked': - return applicability_count >= 1 - if code == 'company_scope_defined': - return bool(tenant.description and tenant.sector) - if code == 'requirements_available': - return requirement_count >= 4 - if code == 'initial_processes_captured': - return process_count >= 3 - if code == 'initial_risks_captured': - return risk_count >= 1 - if code == 'initial_assessment_done': - return assessment_count >= 1 - if code == 'soc_phishing_playbook_applied': - return measure_count >= 1 - return False - - @staticmethod - def _build_step_message(code: str, tenant: Tenant, process_count: int, risk_count: int, assessment_count: int, measure_count: int, measure_open_count: int, applicability_count: int): - if code == 'applicability_checked': - return ( - 'Starten Sie mit der Betroffenheitsanalyse. Erst damit wird klar, ob ISO-27001-Readiness ausreicht oder NIS2-/KRITIS-Nähe vertieft werden sollte.', - 'Bewerten Sie Sektor, Größe, kritische Dienstleistungen und Lieferkettenrolle.', - ) - if code == 'company_scope_defined': - return ( - 'Definieren Sie den Scope des ISMS. Ohne Scope sind spätere Bewertungen fachlich unscharf und für Audits schwer belastbar.', - 'Pflegen Sie Beschreibung, Zielbild, Sektor und kritische Leistungen des Unternehmens.', - ) - if code == 'requirements_available': - return ( - 'Es fehlen Requirement-Grundlagen. Ohne die ISCY Requirement Library ist kein belastbares Mapping gegen ISO 27001 oder NIS2 moeglich.', - 'Stellen Sie sicher, dass die ISCY Requirement Library initial geladen wurde.', - ) - if code == 'initial_processes_captured': - return ( - f'Derzeit sind {process_count} Prozesse erfasst. Für einen belastbaren Einstieg sollten mindestens 3 kritische Prozesse dokumentiert werden.', - 'Erfassen Sie die wichtigsten Geschäfts- oder IT-Prozesse inklusive Owner und Kritikalität.', - ) - if code == 'initial_risks_captured': - return ( - f'Derzeit sind {risk_count} Risiken erfasst. Ohne erste Risiken bleibt die Ableitung von Maßnahmen zu flach.', - 'Erfassen Sie mindestens ein initiales Risiko zu einem kritischen Prozess oder Asset.', - ) - if code == 'initial_assessment_done': - return ( - f'Es gibt aktuell {assessment_count} Assessments und {measure_open_count} offene Maßnahmen. Erst Assessments zeigen, was ausreichend ist und wo echte Gaps bestehen.', - 'Starten Sie die erste Prozess- oder Requirement-Bewertung.', - ) - if code == 'soc_phishing_playbook_applied': - return ( - f'Für den Tenant sind bisher {measure_count} Maßnahmen dokumentiert. Das SOC-Playbook gilt als praktisch verankert, wenn mindestens eine Maßnahme als Incident-Reaktion nachvollziehbar erfasst ist.', - 'Erfassen Sie eine konkrete Incident-Maßnahme (z. B. Mail-Containment, Session-Entzug oder Konto-Absicherung) inklusive Priorität und Status.', - ) - return ( - 'Es gibt einen offenen Guided Step.', - 'Prüfen Sie die geführte Umsetzungslogik.', - ) - - @staticmethod - def _todo_items(tenant: Tenant, process_count: int, risk_count: int, assessment_count: int, measure_count: int, measure_open_count: int, applicability_count: int): - items = [] - if applicability_count == 0: - items.append('Betroffenheitsanalyse anlegen') - if not tenant.description: - items.append('ISMS-Scope und Unternehmensbeschreibung pflegen') - if process_count < 3: - items.append(f'Noch {3 - process_count} kritische Prozesse erfassen') - if risk_count < 1: - items.append('Mindestens ein Risiko dokumentieren') - if assessment_count < 1: - items.append('Erstes Assessment durchführen') - if measure_count < 1: - items.append('Mindestens eine Incident-nahe Maßnahme dokumentieren (SOC-Playbook)') - if measure_open_count > 0: - items.append(f'{measure_open_count} offene Maßnahmen nachverfolgen') - return items - - @staticmethod - def phase_progress(): - phases = [] - for phase_key, phase_label in GuidanceStep._meta.get_field('phase').choices: - total = GuidanceStep.objects.filter(phase=phase_key, is_active=True).count() - phases.append({'key': phase_key, 'label': phase_label, 'total_steps': total}) - return phases diff --git a/apps/guidance/tests.py b/apps/guidance/tests.py deleted file mode 100644 index b9049ad..0000000 --- a/apps/guidance/tests.py +++ /dev/null @@ -1,135 +0,0 @@ -import json -from unittest.mock import Mock, patch - -from django.core.management import call_command -from django.test import TestCase - -from apps.assessments.models import ApplicabilityAssessment, Assessment, Measure -from apps.guidance.services import JourneyService -from apps.organizations.models import Tenant -from apps.processes.models import Process -from apps.requirements_app.models import Requirement -from apps.risks.models import Risk - - -class SocPlaybookGuidanceIntegrationTests(TestCase): - def setUp(self): - call_command('seed_guidance') - - self.tenant = Tenant.objects.create( - name='Tenant SOC', - slug='tenant-soc', - country='DE', - description='Tenant fuer SOC-Playbook-Test', - sector='OTHER', - ) - - ApplicabilityAssessment.objects.create( - tenant=self.tenant, - sector='IT', - company_size='SME', - critical_services='SOC Monitoring', - supply_chain_role='Provider', - status=ApplicabilityAssessment.Status.RELEVANT, - reasoning='Testfall', - ) - - self.processes = [ - Process.objects.create(tenant=self.tenant, name='Incident Intake'), - Process.objects.create(tenant=self.tenant, name='Triage & Korrelation'), - Process.objects.create(tenant=self.tenant, name='Containment & Recovery'), - ] - - Risk.objects.create( - tenant=self.tenant, - process=self.processes[0], - title='Credential Phishing', - description='Unbehandeltes Credential Phishing kann zu Account Compromise fuehren.', - ) - - self.requirement = Requirement.objects.create( - framework=Requirement.Framework.ISO27001, - code='A.5.24', - title='Incident Management', - domain='Incident', - description='Incident-Prozess und Eskalationswege sind etabliert.', - is_active=True, - ) - Requirement.objects.create( - framework=Requirement.Framework.ISO27001, - code='A.5.25', - title='Response', - domain='Incident', - description='Response geplant', - is_active=True, - ) - Requirement.objects.create( - framework=Requirement.Framework.ISO27001, - code='A.5.26', - title='Learning', - domain='Incident', - description='Lessons learned etabliert', - is_active=True, - ) - Requirement.objects.create( - framework=Requirement.Framework.ISO27001, - code='A.5.27', - title='Improvement', - domain='Incident', - description='Kontinuierliche Verbesserung', - is_active=True, - ) - Assessment.objects.create( - tenant=self.tenant, - process=self.processes[0], - requirement=self.requirement, - status=Assessment.Status.PARTIAL, - ) - - def test_soc_playbook_step_is_current_without_measures(self): - evaluation = JourneyService.evaluate_tenant(self.tenant) - - self.assertIsNotNone(evaluation['state'].current_step) - self.assertEqual(evaluation['state'].current_step.code, 'soc_phishing_playbook_applied') - self.assertIn('SOC-Playbook', '\n'.join(evaluation['todo_items'])) - - def test_soc_playbook_step_is_done_when_measure_exists(self): - Measure.objects.create( - tenant=self.tenant, - assessment=Assessment.objects.filter(tenant=self.tenant).first(), - title='Phishing-Mails tenantweit entfernen', - description='Containment gem. SOC-Playbook', - priority=Measure.Priority.HIGH, - status=Measure.Status.IN_PROGRESS, - ) - - evaluation = JourneyService.evaluate_tenant(self.tenant) - - self.assertIsNone(evaluation['state'].current_step) - self.assertEqual(evaluation['state'].progress_percent, 100) - - @patch('apps.guidance.services.urlopen') - def test_rust_guidance_bridge_is_used_when_available(self, mock_urlopen): - response_mock = Mock() - response_mock.read.return_value = json.dumps({ - 'current_step_code': 'soc_phishing_playbook_applied', - 'summary': 'Rust summary', - 'next_action_text': 'Rust next action', - 'todo_items': ['Rust todo'], - }).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - mock_urlopen.return_value = response_ctx - - with self.settings(RUST_BACKEND_URL='http://rust-backend:9000', GUIDANCE_SCORING_BACKEND='rust_service'): - evaluation = JourneyService.evaluate_tenant(self.tenant) - - self.assertEqual(evaluation['state'].summary, 'Rust summary') - self.assertEqual(evaluation['state'].next_action_text, 'Rust next action') - self.assertEqual(evaluation['todo_items'], ['Rust todo']) - - def test_rust_guidance_bridge_raises_in_strict_mode_without_backend(self): - with self.settings(RUST_BACKEND_URL='', GUIDANCE_SCORING_BACKEND='rust_service', RUST_STRICT_MODE=True): - with self.assertRaises(RuntimeError): - JourneyService.evaluate_tenant(self.tenant) diff --git a/apps/guidance/urls.py b/apps/guidance/urls.py deleted file mode 100644 index 0aeaa67..0000000 --- a/apps/guidance/urls.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.urls import path -from .views import GuidanceDashboardView, GuidanceStepDetailView - -app_name = 'guidance' - -urlpatterns = [ - path('', GuidanceDashboardView.as_view(), name='dashboard'), - path('steps//', GuidanceStepDetailView.as_view(), name='step-detail'), -] diff --git a/apps/guidance/views.py b/apps/guidance/views.py deleted file mode 100644 index a7449de..0000000 --- a/apps/guidance/views.py +++ /dev/null @@ -1,42 +0,0 @@ -from django.contrib.auth.mixins import LoginRequiredMixin -from django.views.generic import DetailView, TemplateView -from apps.guidance.models import GuidanceStep -from apps.guidance.services import JourneyService - - -class GuidanceDashboardView(LoginRequiredMixin, TemplateView): - template_name = 'guidance/dashboard.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - tenant = JourneyService.get_default_tenant(self.request.user) - if not tenant: - context['error'] = 'Es ist noch kein Tenant vorhanden.' - return context - - evaluation = JourneyService.evaluate_tenant(tenant, self.request.user) - context['tenant'] = tenant - context['state'] = evaluation['state'] - context['todo_items'] = evaluation['todo_items'] - context['next_step_url'] = evaluation['next_step_url'] - context['next_step_label'] = evaluation['next_step_label'] - context['steps'] = GuidanceStep.objects.filter(is_active=True).order_by('sort_order') - context['phase_progress'] = JourneyService.phase_progress() - return context - - -class GuidanceStepDetailView(LoginRequiredMixin, DetailView): - model = GuidanceStep - template_name = 'guidance/step_detail.html' - context_object_name = 'step' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - tenant = JourneyService.get_default_tenant(self.request.user) - context['tenant'] = tenant - if tenant: - evaluation = JourneyService.evaluate_tenant(tenant, self.request.user) - context['state'] = evaluation['state'] - context['next_step_url'] = evaluation['next_step_url'] - context['next_step_label'] = evaluation['next_step_label'] - return context diff --git a/apps/import_center/__init__.py b/apps/import_center/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/import_center/apps.py b/apps/import_center/apps.py deleted file mode 100644 index 66fd7d2..0000000 --- a/apps/import_center/apps.py +++ /dev/null @@ -1,8 +0,0 @@ - -from django.apps import AppConfig - - -class ImportCenterConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.import_center' - verbose_name = 'Import Center' diff --git a/apps/import_center/forms.py b/apps/import_center/forms.py deleted file mode 100644 index 469fdd3..0000000 --- a/apps/import_center/forms.py +++ /dev/null @@ -1,15 +0,0 @@ - -from django import forms -from django.db import models - - -class DataImportForm(forms.Form): - class ImportType(models.TextChoices): - BUSINESS_UNITS = 'business_units', 'Geschäftsbereiche' - PROCESSES = 'processes', 'Prozesse' - SUPPLIERS = 'suppliers', 'Dienstleister / Lieferanten' - ASSETS = 'assets', 'Informationswerte / Anwendungen' - - import_type = forms.ChoiceField(choices=ImportType.choices, label='Importtyp') - file = forms.FileField(label='CSV oder XLSX-Datei') - replace_existing = forms.BooleanField(required=False, initial=False, label='Bestehende Einträge dieses Typs vor dem Import löschen') diff --git a/apps/import_center/migrations/__init__.py b/apps/import_center/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/import_center/services.py b/apps/import_center/services.py deleted file mode 100644 index 023ed17..0000000 --- a/apps/import_center/services.py +++ /dev/null @@ -1,348 +0,0 @@ -import csv -import io -import json -from dataclasses import dataclass -from pathlib import Path -from typing import Iterable, List, Tuple -from urllib.request import Request, urlopen - -from django.conf import settings -from openpyxl import load_workbook - -from apps.assets_app.models import InformationAsset -from apps.organizations.models import BusinessUnit, Supplier -from apps.processes.models import Process - - -TRUE_VALUES = {"1", "true", "yes", "ja", "y"} - -COLUMN_SYNONYMS = { - 'name': ['Name'], - 'owner_email': ['OwnerEmail', 'Verantwortlicher'], - 'scope': ['Scope'], - 'description': ['Beschreibung', 'Service'], - 'status': ['Status'], - 'business_unit': ['BusinessUnit', 'Geschäftsbereich'], - 'documented': ['Dokumentiert'], - 'implemented': ['Umgesetzt'], - 'evidenced': ['Nachweisbar'], - 'approved': ['Genehmigt'], - 'communicated': ['Kommuniziert'], - 'effective': ['Wirksam'], - 'service_description': ['Beschreibung', 'Service'], - 'criticality': ['Kritikalität'], - 'asset_type': ['Typ'], - 'confidentiality': ['Vertraulichkeit'], - 'integrity': ['Integrität'], - 'availability': ['Verfügbarkeit'], - 'lifecycle_status': ['Lifecycle'], - 'in_scope': ['ImScope'], -} - -TEMPLATE_COLUMNS = { - 'business_units': ['name', 'owner_email'], - 'processes': ['name', 'scope', 'description', 'status', 'business_unit', 'documented', 'implemented', 'evidenced', 'approved', 'communicated', 'effective'], - 'suppliers': ['name', 'service_description', 'criticality'], - 'assets': ['name', 'asset_type', 'criticality', 'description', 'business_unit', 'confidentiality', 'integrity', 'availability', 'lifecycle_status', 'in_scope'], -} - - -@dataclass(frozen=True) -class ImportJobBridgeResult: - tenant_id: int - import_type: str - row_count: int - created: int - updated: int - skipped: int - - -class ImportRustClient: - @staticmethod - def _base_url() -> str: - base = (getattr(settings, 'RUST_BACKEND_URL', '') or '').strip() - return base.rstrip('/') - - @staticmethod - def apply_import( - request, - tenant, - import_type: str, - rows: list[dict], - replace_existing: bool = False, - timeout: int = 30, - ) -> ImportJobBridgeResult: - base = ImportRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - payload = { - 'import_type': import_type, - 'replace_existing': bool(replace_existing), - 'rows': rows, - } - rust_request = ImportRustClient._authenticated_request( - request, - tenant, - f'{base}/api/v1/import-center/jobs', - payload, - ) - with urlopen(rust_request, timeout=timeout) as response: - response_payload = json.loads(response.read().decode('utf-8')) - - result = response_payload.get('result') or {} - tenant_id = int(result.get('tenant_id') or 0) - if tenant_id != int(tenant.id): - raise RuntimeError('Rust-Importjob gehoert nicht zum angefragten Tenant.') - - return ImportJobBridgeResult( - tenant_id=tenant_id, - import_type=str(result.get('import_type') or import_type), - row_count=int(result.get('row_count') or 0), - created=int(result.get('created') or 0), - updated=int(result.get('updated') or 0), - skipped=int(result.get('skipped') or 0), - ) - - @staticmethod - def _authenticated_request(request, tenant, url: str, payload: dict) -> Request: - user = getattr(request, 'user', None) - user_id = getattr(user, 'id', None) - if not user_id: - raise RuntimeError('Import-Rust-Bridge braucht einen authentifizierten User.') - - body = json.dumps(payload).encode('utf-8') - rust_request = Request(url, data=body, method='POST') - rust_request.add_header('Accept', 'application/json') - rust_request.add_header('Content-Type', 'application/json') - rust_request.add_header('X-ISCY-Tenant-ID', str(tenant.id)) - rust_request.add_header('X-ISCY-User-ID', str(user_id)) - user_email = (getattr(user, 'email', '') or '').strip() - if user_email: - rust_request.add_header('X-ISCY-User-Email', user_email) - return rust_request - - -class ImportCenterBridge: - @staticmethod - def apply_import(request, tenant, import_type: str, rows: list[dict], replace_existing: bool = False): - if tenant is None: - return None - - backend = str(getattr(settings, 'IMPORT_CENTER_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not ImportRustClient._base_url(): - if ImportCenterBridge._strict_rust_mode(): - raise RuntimeError('Rust import backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return ImportRustClient.apply_import( - request, - tenant, - import_type, - rows, - replace_existing=replace_existing, - ) - except Exception as exc: - if ImportCenterBridge._strict_rust_mode(): - raise RuntimeError('Rust import backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def _strict_rust_mode() -> bool: - return bool(getattr(settings, 'RUST_STRICT_MODE', False)) - - -class ImportService: - @staticmethod - def read_rows(uploaded_file) -> Tuple[List[str], List[dict]]: - suffix = Path(uploaded_file.name).suffix.lower() - if suffix == '.csv': - data = uploaded_file.read().decode('utf-8-sig') - reader = csv.DictReader(io.StringIO(data)) - rows = [row for row in reader] - return reader.fieldnames or [], rows - if suffix in {'.xlsx', '.xlsm'}: - wb = load_workbook(uploaded_file, read_only=True, data_only=True) - ws = wb.active - values = list(ws.iter_rows(values_only=True)) - if not values: - return [], [] - headers = [str(v).strip() if v is not None else '' for v in values[0]] - rows = [] - for row in values[1:]: - item = {} - for idx, header in enumerate(headers): - item[header] = row[idx] if idx < len(row) else None - rows.append(item) - return headers, rows - raise ValueError('Nur CSV oder XLSX werden unterstützt.') - - @staticmethod - def bool_value(value) -> bool: - return str(value).strip().lower() in TRUE_VALUES - - @staticmethod - def normalize(value): - return str(value).strip() if value is not None else '' - - @staticmethod - def expected_columns(import_type: str): - return TEMPLATE_COLUMNS.get(import_type, []) - - @staticmethod - def default_mapping(import_type: str, headers: list[str]): - expected = ImportService.expected_columns(import_type) - normalized = {str(h).strip().lower(): h for h in headers} - mapping = {} - for canonical in expected: - matched = normalized.get(canonical.lower()) - if not matched: - for synonym in COLUMN_SYNONYMS.get(canonical, []): - if synonym.lower() in normalized: - matched = normalized[synonym.lower()] - break - mapping[canonical] = matched or '' - return mapping - - @staticmethod - def get_mapping_preview(import_type: str, headers: list[str], selected_mapping: dict | None = None): - expected = TEMPLATE_COLUMNS.get(import_type, []) - selected_mapping = selected_mapping or ImportService.default_mapping(import_type, headers) - rows = [] - selected_values = {str(v).strip().lower() for v in selected_mapping.values() if v} - extra_headers = [h for h in headers if str(h).strip().lower() not in selected_values] - for canonical in expected: - matched = selected_mapping.get(canonical, '') - rows.append({ - 'expected': canonical, - 'matched': matched, - 'status': 'ok' if matched else 'missing', - 'synonyms': COLUMN_SYNONYMS.get(canonical, []), - 'required': canonical == 'name', - }) - return rows, extra_headers - - @staticmethod - def apply_mapping(rows: list[dict], selected_mapping: dict): - transformed = [] - for row in rows: - out = {} - for expected, source in selected_mapping.items(): - out[expected] = row.get(source) if source else None - transformed.append(out) - return transformed - - @staticmethod - def import_business_units(tenant, rows: Iterable[dict], replace_existing=False): - if replace_existing: - BusinessUnit.objects.filter(tenant=tenant).delete() - created = updated = 0 - for row in rows: - name = ImportService.normalize(row.get('name') or row.get('Name')) - if not name: - continue - _, was_created = BusinessUnit.objects.update_or_create( - tenant=tenant, - name=name, - defaults={}, - ) - created += int(was_created) - updated += int(not was_created) - return created, updated - - @staticmethod - def import_processes(tenant, rows: Iterable[dict], replace_existing=False): - if replace_existing: - Process.objects.filter(tenant=tenant).delete() - created = updated = 0 - status_map = {choice[0].lower(): choice[0] for choice in Process.Status.choices} - for row in rows: - name = ImportService.normalize(row.get('name') or row.get('Name')) - if not name: - continue - scope = ImportService.normalize(row.get('scope') or row.get('Scope')) - description = ImportService.normalize(row.get('description') or row.get('Beschreibung')) - status_raw = ImportService.normalize(row.get('status') or row.get('Status')).lower() - status = status_map.get(status_raw, Process.Status.MISSING) - business_unit_name = ImportService.normalize(row.get('business_unit') or row.get('BusinessUnit') or row.get('Geschäftsbereich')) - business_unit = None - if business_unit_name: - business_unit, _ = BusinessUnit.objects.get_or_create(tenant=tenant, name=business_unit_name) - obj, was_created = Process.objects.update_or_create( - tenant=tenant, - name=name, - defaults={ - 'scope': scope, - 'description': description, - 'status': status, - 'business_unit': business_unit, - 'documented': ImportService.bool_value(row.get('documented') or row.get('Dokumentiert')), - 'implemented': ImportService.bool_value(row.get('implemented') or row.get('Umgesetzt')), - 'evidenced': ImportService.bool_value(row.get('evidenced') or row.get('Nachweisbar')), - 'approved': ImportService.bool_value(row.get('approved') or row.get('Genehmigt')), - 'communicated': ImportService.bool_value(row.get('communicated') or row.get('Kommuniziert')), - 'effective': ImportService.bool_value(row.get('effective') or row.get('Wirksam')), - }, - ) - created += int(was_created) - updated += int(not was_created) - return created, updated - - @staticmethod - def import_suppliers(tenant, rows: Iterable[dict], replace_existing=False): - if replace_existing: - Supplier.objects.filter(tenant=tenant).delete() - created = updated = 0 - for row in rows: - name = ImportService.normalize(row.get('name') or row.get('Name')) - if not name: - continue - obj, was_created = Supplier.objects.update_or_create( - tenant=tenant, - name=name, - defaults={ - 'service_description': ImportService.normalize(row.get('service_description') or row.get('Beschreibung') or row.get('Service')), - 'criticality': (ImportService.normalize(row.get('criticality') or row.get('Kritikalität')) or 'MEDIUM').upper(), - }, - ) - created += int(was_created) - updated += int(not was_created) - return created, updated - - @staticmethod - def import_assets(tenant, rows: Iterable[dict], replace_existing=False): - if replace_existing: - InformationAsset.objects.filter(tenant=tenant).delete() - created = updated = 0 - type_map = {choice[0].lower(): choice[0] for choice in InformationAsset.Type.choices} - crit_map = {choice[0].lower(): choice[0] for choice in InformationAsset.Criticality.choices} - for row in rows: - name = ImportService.normalize(row.get('name') or row.get('Name')) - if not name: - continue - bu_name = ImportService.normalize(row.get('business_unit') or row.get('Geschäftsbereich')) - business_unit = None - if bu_name: - business_unit, _ = BusinessUnit.objects.get_or_create(tenant=tenant, name=bu_name) - obj, was_created = InformationAsset.objects.update_or_create( - tenant=tenant, - name=name, - defaults={ - 'business_unit': business_unit, - 'asset_type': type_map.get(ImportService.normalize(row.get('asset_type') or row.get('Typ')).lower(), InformationAsset.Type.APPLICATION), - 'criticality': crit_map.get(ImportService.normalize(row.get('criticality') or row.get('Kritikalität')).lower(), InformationAsset.Criticality.MEDIUM), - 'description': ImportService.normalize(row.get('description') or row.get('Beschreibung')), - 'confidentiality': ImportService.normalize(row.get('confidentiality') or row.get('Vertraulichkeit')), - 'integrity': ImportService.normalize(row.get('integrity') or row.get('Integrität')), - 'availability': ImportService.normalize(row.get('availability') or row.get('Verfügbarkeit')), - 'lifecycle_status': ImportService.normalize(row.get('lifecycle_status') or row.get('Lifecycle')), - 'is_in_scope': not (ImportService.normalize(row.get('in_scope') or row.get('ImScope')).lower() in {'0', 'false', 'no', 'nein'}), - }, - ) - created += int(was_created) - updated += int(not was_created) - return created, updated diff --git a/apps/import_center/tests.py b/apps/import_center/tests.py deleted file mode 100644 index dc5f0b3..0000000 --- a/apps/import_center/tests.py +++ /dev/null @@ -1,143 +0,0 @@ -import json -from unittest.mock import Mock, patch - -from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase, override_settings -from django.urls import reverse - -from apps.organizations.models import Tenant - -from .forms import DataImportForm -from .services import ImportCenterBridge - - -User = get_user_model() - - -@override_settings(IMPORT_CENTER_BACKEND='local', RUST_BACKEND_URL='') -class ImportCenterBridgeTests(TestCase): - def setUp(self): - self.tenant = Tenant.objects.create(name='Tenant A', slug='tenant-a', country='DE') - self.user = User.objects.create_user( - username='tenant-a-imports', - email='import-user@example.test', - password='testpass123', - tenant=self.tenant, - ) - self.factory = RequestFactory() - - @patch('apps.import_center.services.urlopen') - def test_import_bridge_posts_rows_to_rust_backend(self, mock_urlopen): - self._mock_rust_response(mock_urlopen, { - 'accepted': True, - 'api_version': 'v1', - 'result': { - 'tenant_id': self.tenant.id, - 'import_type': 'processes', - 'row_count': 1, - 'created': 1, - 'updated': 0, - 'skipped': 0, - }, - }) - request = self.factory.post('/imports/preview/') - request.user = self.user - - with self.settings(IMPORT_CENTER_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - result = ImportCenterBridge.apply_import( - request, - self.tenant, - 'processes', - [{'name': 'Incident Intake', 'status': 'PARTIAL'}], - replace_existing=False, - ) - - rust_request = mock_urlopen.call_args.args[0] - body = json.loads(rust_request.data.decode('utf-8')) - self.assertEqual(rust_request.full_url, 'http://rust-backend:9000/api/v1/import-center/jobs') - self.assertEqual(rust_request.get_header('X-iscy-tenant-id'), str(self.tenant.id)) - self.assertEqual(rust_request.get_header('X-iscy-user-id'), str(self.user.id)) - self.assertEqual(body['import_type'], 'processes') - self.assertEqual(body['rows'][0]['name'], 'Incident Intake') - self.assertEqual(result.created, 1) - self.assertEqual(result.updated, 0) - - @patch('apps.import_center.services.urlopen', side_effect=OSError('backend down')) - def test_import_bridge_falls_back_when_rust_unavailable_in_non_strict_mode(self, _mock_urlopen): - request = self.factory.post('/imports/preview/') - request.user = self.user - - with self.settings( - IMPORT_CENTER_BACKEND='rust_service', - RUST_BACKEND_URL='http://rust-backend:9000', - RUST_STRICT_MODE=False, - ): - result = ImportCenterBridge.apply_import( - request, - self.tenant, - 'business_units', - [{'name': 'Security Operations'}], - ) - - self.assertIsNone(result) - - def test_import_bridge_raises_in_strict_mode_without_rust_backend_url(self): - request = self.factory.post('/imports/preview/') - request.user = self.user - - with self.settings(IMPORT_CENTER_BACKEND='rust_service', RUST_BACKEND_URL='', RUST_STRICT_MODE=True): - with self.assertRaises(RuntimeError): - ImportCenterBridge.apply_import(request, self.tenant, 'business_units', [{'name': 'Security'}]) - - @patch('apps.import_center.services.urlopen') - def test_preview_confirm_can_use_rust_import_backend(self, mock_urlopen): - self._mock_rust_response(mock_urlopen, { - 'accepted': True, - 'api_version': 'v1', - 'result': { - 'tenant_id': self.tenant.id, - 'import_type': 'processes', - 'row_count': 1, - 'created': 1, - 'updated': 0, - 'skipped': 0, - }, - }) - self.client.force_login(self.user) - session = self.client.session - session['import_preview'] = { - 'import_type': DataImportForm.ImportType.PROCESSES, - 'replace_existing': False, - 'headers': ['Name', 'Status'], - 'rows': [{'Name': 'Incident Intake', 'Status': 'PARTIAL'}], - 'mapping_rows': [], - 'selected_mapping': {'name': 'Name', 'status': 'Status'}, - 'extra_headers': [], - 'matched': 2, - } - session.save() - - with self.settings(IMPORT_CENTER_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.post( - reverse('imports:preview'), - { - 'action': 'confirm', - 'map_name': 'Name', - 'map_status': 'Status', - }, - ) - - self.assertRedirects(response, reverse('imports:center')) - rust_request = mock_urlopen.call_args.args[0] - body = json.loads(rust_request.data.decode('utf-8')) - self.assertEqual(body['import_type'], 'processes') - self.assertEqual(body['rows'][0]['name'], 'Incident Intake') - self.assertEqual(body['rows'][0]['status'], 'PARTIAL') - - def _mock_rust_response(self, mock_urlopen, payload): - response_mock = Mock() - response_mock.read.return_value = json.dumps(payload).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - mock_urlopen.return_value = response_ctx diff --git a/apps/import_center/urls.py b/apps/import_center/urls.py deleted file mode 100644 index 574d9c3..0000000 --- a/apps/import_center/urls.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.urls import path -from .views import ImportCenterView, ImportGuideView, ImportMappingAssistantView, ImportPreviewView, ImportTemplateDownloadView - -app_name = 'imports' - -urlpatterns = [ - path('', ImportCenterView.as_view(), name='center'), - path('preview/', ImportPreviewView.as_view(), name='preview'), - path('guide/', ImportGuideView.as_view(), name='guide'), - path('mapping/', ImportMappingAssistantView.as_view(), name='mapping'), - path('templates/./', ImportTemplateDownloadView.as_view(), name='template-download'), -] diff --git a/apps/import_center/views.py b/apps/import_center/views.py deleted file mode 100644 index fe90cac..0000000 --- a/apps/import_center/views.py +++ /dev/null @@ -1,182 +0,0 @@ -from io import BytesIO -import csv - -from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpResponse -from django.shortcuts import redirect -from django.views.generic import FormView, TemplateView, View -from openpyxl import Workbook - -from apps.wizard.services import WizardService -from .forms import DataImportForm -from .services import ImportCenterBridge, ImportService, TEMPLATE_COLUMNS, COLUMN_SYNONYMS - - -class ImportCenterView(LoginRequiredMixin, FormView): - template_name = 'imports/import_center.html' - form_class = DataImportForm - success_url = '/imports/preview/' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - tenant = WizardService.get_default_tenant(self.request.user) - context['tenant'] = tenant - context['examples'] = TEMPLATE_COLUMNS - context['synonyms'] = COLUMN_SYNONYMS - return context - - def form_valid(self, form): - headers, rows = ImportService.read_rows(form.cleaned_data['file']) - rows = rows[:200] - preview_rows = [ - {str(k): '' if v is None else str(v) for k, v in row.items()} - for row in rows - ] - import_type = form.cleaned_data['import_type'] - replace_existing = form.cleaned_data['replace_existing'] - selected_mapping = ImportService.default_mapping(import_type, headers) - mapping_rows, extra_headers = ImportService.get_mapping_preview(import_type, headers, selected_mapping) - matched = sum(1 for row in mapping_rows if row['status'] == 'ok') - self.request.session['import_preview'] = { - 'import_type': import_type, - 'replace_existing': replace_existing, - 'headers': [str(h) for h in headers], - 'rows': preview_rows, - 'mapping_rows': mapping_rows, - 'selected_mapping': selected_mapping, - 'extra_headers': [str(h) for h in extra_headers], - 'matched': matched, - } - messages.info(self.request, f'Import-Vorschau erstellt: {matched}/{len(mapping_rows)} erwartete Spalten erkannt. Bitte prüfen, Zuordnung anpassen und bestätigen.') - return super().form_valid(form) - - -class ImportPreviewView(LoginRequiredMixin, TemplateView): - template_name = 'imports/preview.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - preview = self.request.session.get('import_preview') - context['preview'] = preview - context['sample_rows'] = (preview or {}).get('rows', [])[:10] - context['headers'] = (preview or {}).get('headers', []) - context['selected_mapping'] = (preview or {}).get('selected_mapping', {}) - return context - - def post(self, request, *args, **kwargs): - tenant = WizardService.get_default_tenant(request.user) - preview = request.session.get('import_preview') - if not preview: - messages.error(request, 'Keine Import-Vorschau vorhanden.') - return redirect('imports:center') - - action = request.POST.get('action', 'confirm') - import_type = preview.get('import_type') - headers = preview.get('headers', []) - expected_columns = ImportService.expected_columns(import_type) - selected_mapping = {expected: request.POST.get(f'map_{expected}', '') for expected in expected_columns} - mapping_rows, extra_headers = ImportService.get_mapping_preview(import_type, headers, selected_mapping) - preview['selected_mapping'] = selected_mapping - preview['mapping_rows'] = mapping_rows - preview['extra_headers'] = extra_headers - preview['matched'] = sum(1 for row in mapping_rows if row['status'] == 'ok') - request.session['import_preview'] = preview - - if action == 'update': - messages.info(request, 'Import-Zuordnung aktualisiert. Prüfen Sie nun die Feld-zu-Feld-Zuordnung und bestätigen Sie danach den Import.') - return redirect('imports:preview') - - if not selected_mapping.get('name'): - messages.error(request, 'Die Pflichtzuordnung für das Feld „name“ fehlt. Bitte ordnen Sie mindestens das Namensfeld zu.') - return redirect('imports:preview') - - raw_rows = preview.get('rows', []) - rows = ImportService.apply_mapping(raw_rows, selected_mapping) - replace_existing = preview.get('replace_existing', False) - rust_result = ImportCenterBridge.apply_import( - request, - tenant, - import_type, - rows, - replace_existing=replace_existing, - ) - if rust_result is not None: - created, updated = rust_result.created, rust_result.updated - skipped_note = f' {rust_result.skipped} Zeile(n) ohne Name uebersprungen.' if rust_result.skipped else '' - messages.success(request, f'Import übernommen: {created} neu, {updated} aktualisiert.{skipped_note}') - else: - if import_type == DataImportForm.ImportType.BUSINESS_UNITS: - created, updated = ImportService.import_business_units(tenant, rows, replace_existing=replace_existing) - elif import_type == DataImportForm.ImportType.PROCESSES: - created, updated = ImportService.import_processes(tenant, rows, replace_existing=replace_existing) - elif import_type == DataImportForm.ImportType.SUPPLIERS: - created, updated = ImportService.import_suppliers(tenant, rows, replace_existing=replace_existing) - else: - created, updated = ImportService.import_assets(tenant, rows, replace_existing=replace_existing) - messages.success(request, f'Import übernommen: {created} neu, {updated} aktualisiert.') - request.session.pop('import_preview', None) - return redirect('imports:center') - - -class ImportGuideView(LoginRequiredMixin, TemplateView): - template_name = 'imports/guide.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['examples'] = TEMPLATE_COLUMNS - context['synonyms'] = COLUMN_SYNONYMS - return context - - -class ImportMappingAssistantView(LoginRequiredMixin, TemplateView): - template_name = 'imports/mapping_assistant.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - import_type = self.request.GET.get('type', 'processes') - expected = TEMPLATE_COLUMNS.get(import_type, TEMPLATE_COLUMNS['processes']) - context['selected_type'] = import_type - context['expected_columns'] = expected - context['all_types'] = TEMPLATE_COLUMNS - context['mapping_rows'] = [ - {'expected': col, 'synonyms': COLUMN_SYNONYMS.get(col, []), 'required': col == 'name'} - for col in expected - ] - return context - - -class ImportTemplateDownloadView(LoginRequiredMixin, View): - def get(self, request, *args, **kwargs): - import_type = kwargs['import_type'] - fmt = kwargs['fmt'] - columns = TEMPLATE_COLUMNS.get(import_type) - if not columns: - return HttpResponse(status=404) - filename = f'import-template-{import_type}.{fmt}' - if fmt == 'csv': - response = HttpResponse(content_type='text/csv; charset=utf-8') - response['Content-Disposition'] = f'attachment; filename="{filename}"' - writer = csv.writer(response) - writer.writerow(columns) - return response - wb = Workbook() - ws = wb.active - ws.title = 'Template' - ws.append(columns) - sample = ['' for _ in columns] - if 'name' in columns: - sample[columns.index('name')] = 'Beispiel' - if 'status' in columns: - sample[columns.index('status')] = 'PARTIAL' - if 'criticality' in columns: - sample[columns.index('criticality')] = 'HIGH' - if 'asset_type' in columns: - sample[columns.index('asset_type')] = 'APPLICATION' - ws.append(sample) - output = BytesIO() - wb.save(output) - output.seek(0) - response = HttpResponse(output.getvalue(), content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') - response['Content-Disposition'] = f'attachment; filename="{filename}"' - return response diff --git a/apps/organizations/__init__.py b/apps/organizations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/organizations/admin.py b/apps/organizations/admin.py deleted file mode 100644 index 8ed5d39..0000000 --- a/apps/organizations/admin.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.contrib import admin -from .models import Tenant, LegalEntity, BusinessUnit, Site, Supplier - -admin.site.register(Tenant) -admin.site.register(LegalEntity) -admin.site.register(BusinessUnit) -admin.site.register(Site) -admin.site.register(Supplier) diff --git a/apps/organizations/apps.py b/apps/organizations/apps.py deleted file mode 100644 index 795ff1f..0000000 --- a/apps/organizations/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class OrganizationsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.organizations' diff --git a/apps/organizations/country_catalog.py b/apps/organizations/country_catalog.py deleted file mode 100644 index 781f2c1..0000000 --- a/apps/organizations/country_catalog.py +++ /dev/null @@ -1,70 +0,0 @@ -from dataclasses import dataclass -from typing import Dict, List, Tuple - - -@dataclass(frozen=True) -class CountryDefinition: - code: str - label: str - is_eu_eea: bool = False - is_primary_market_germany: bool = False - - -COUNTRY_DEFINITIONS: List[CountryDefinition] = [ - CountryDefinition('DE', 'Deutschland', is_eu_eea=True, is_primary_market_germany=True), - CountryDefinition('AT', 'Österreich', is_eu_eea=True), - CountryDefinition('BE', 'Belgien', is_eu_eea=True), - CountryDefinition('BG', 'Bulgarien', is_eu_eea=True), - CountryDefinition('HR', 'Kroatien', is_eu_eea=True), - CountryDefinition('CY', 'Zypern', is_eu_eea=True), - CountryDefinition('CZ', 'Tschechien', is_eu_eea=True), - CountryDefinition('DK', 'Dänemark', is_eu_eea=True), - CountryDefinition('EE', 'Estland', is_eu_eea=True), - CountryDefinition('FI', 'Finnland', is_eu_eea=True), - CountryDefinition('FR', 'Frankreich', is_eu_eea=True), - CountryDefinition('GR', 'Griechenland', is_eu_eea=True), - CountryDefinition('HU', 'Ungarn', is_eu_eea=True), - CountryDefinition('IE', 'Irland', is_eu_eea=True), - CountryDefinition('IT', 'Italien', is_eu_eea=True), - CountryDefinition('LV', 'Lettland', is_eu_eea=True), - CountryDefinition('LT', 'Litauen', is_eu_eea=True), - CountryDefinition('LU', 'Luxemburg', is_eu_eea=True), - CountryDefinition('MT', 'Malta', is_eu_eea=True), - CountryDefinition('NL', 'Niederlande', is_eu_eea=True), - CountryDefinition('NO', 'Norwegen', is_eu_eea=True), - CountryDefinition('PL', 'Polen', is_eu_eea=True), - CountryDefinition('PT', 'Portugal', is_eu_eea=True), - CountryDefinition('RO', 'Rumänien', is_eu_eea=True), - CountryDefinition('SK', 'Slowakei', is_eu_eea=True), - CountryDefinition('SI', 'Slowenien', is_eu_eea=True), - CountryDefinition('ES', 'Spanien', is_eu_eea=True), - CountryDefinition('SE', 'Schweden', is_eu_eea=True), - CountryDefinition('IS', 'Island', is_eu_eea=True), - CountryDefinition('LI', 'Liechtenstein', is_eu_eea=True), - CountryDefinition('CH', 'Schweiz'), - CountryDefinition('GB', 'Vereinigtes Königreich'), - CountryDefinition('US', 'Vereinigte Staaten'), - CountryDefinition('CA', 'Kanada'), - CountryDefinition('IN', 'Indien'), - CountryDefinition('JP', 'Japan'), - CountryDefinition('SG', 'Singapur'), - CountryDefinition('AU', 'Australien'), - CountryDefinition('AE', 'Vereinigte Arabische Emirate'), - CountryDefinition('BR', 'Brasilien'), -] - -COUNTRY_MAP: Dict[str, CountryDefinition] = {item.code: item for item in COUNTRY_DEFINITIONS} -COUNTRY_CHOICES: List[Tuple[str, str]] = [(item.code, item.label) for item in COUNTRY_DEFINITIONS] -EU_EEA_CODES = {item.code for item in COUNTRY_DEFINITIONS if item.is_eu_eea} - - -def get_country_definition(code: str | None) -> CountryDefinition: - if not code: - return COUNTRY_MAP['DE'] - return COUNTRY_MAP.get(code, CountryDefinition(code=code, label=code)) - - -def get_country_labels(codes: List[str] | None) -> List[str]: - if not codes: - return [] - return [get_country_definition(code).label for code in codes] diff --git a/apps/organizations/forms.py b/apps/organizations/forms.py deleted file mode 100644 index 4b7ab9f..0000000 --- a/apps/organizations/forms.py +++ /dev/null @@ -1,49 +0,0 @@ -from django import forms -from .models import Tenant -from .country_catalog import COUNTRY_CHOICES - - -class TenantForm(forms.ModelForm): - countries = forms.MultipleChoiceField( - label='Länder / operative Präsenz', - required=False, - choices=COUNTRY_CHOICES, - widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': 10}), - ) - - class Meta: - model = Tenant - fields = [ - 'name', 'slug', 'sector', 'employee_count', 'annual_revenue_million', 'balance_sheet_million', - 'critical_services', 'supply_chain_role', 'description', 'nis2_relevant', 'kritis_relevant', - 'develops_digital_products', 'uses_ai_systems', 'ot_iacs_scope', 'automotive_scope', - 'psirt_defined', 'sbom_required', 'product_security_scope', - ] - widgets = { - 'critical_services': forms.Textarea(attrs={'rows': 3}), - 'description': forms.Textarea(attrs={'rows': 4}), - 'product_security_scope': forms.Textarea(attrs={'rows': 4}), - 'sector': forms.Select(attrs={'class': 'form-select'}), - } - help_texts = { - 'develops_digital_products': 'Aktivieren, wenn Produkte mit digitalen Elementen oder softwareintensive Produkte entwickelt werden.', - 'uses_ai_systems': 'Aktivieren, wenn AI-Systeme/Modelle/Funktionen im Scope sind.', - 'ot_iacs_scope': 'Aktivieren für OT-/IACS-/Industrieprodukte oder Integrationskontexte.', - 'automotive_scope': 'Aktivieren für Automotive-/Fahrzeug-/E/E-bezogene Entwicklung.', - 'psirt_defined': 'Aktivieren, wenn ein PSIRT-/Vulnerability-Handling bereits existiert.', - 'sbom_required': 'Aktivieren, wenn SBOM/Komponenten-Transparenz relevant ist oder gefordert wird.', - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if self.instance and self.instance.pk: - self.fields['countries'].initial = self.instance.operation_countries or ([self.instance.country] if self.instance.country else []) - - def save(self, commit=True): - tenant = super().save(commit=False) - selected_countries = self.cleaned_data.get('countries') or [] - tenant.operation_countries = selected_countries - tenant.country = selected_countries[0] if selected_countries else '' - if commit: - tenant.save() - return tenant diff --git a/apps/organizations/migrations/0001_initial.py b/apps/organizations/migrations/0001_initial.py deleted file mode 100644 index 2d51975..0000000 --- a/apps/organizations/migrations/0001_initial.py +++ /dev/null @@ -1,113 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-09 14:39 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='LegalEntity', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=255)), - ('country', models.CharField(max_length=100)), - ('registration_number', models.CharField(blank=True, max_length=100)), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='Tenant', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=255)), - ('slug', models.SlugField(unique=True)), - ('country', models.CharField(blank=True, max_length=100)), - ('operation_countries', models.JSONField(blank=True, default=list)), - ('description', models.TextField(blank=True)), - ('sector', models.CharField(blank=True, choices=[('ENERGY', 'Energie (Anhang I – hohe Kritikalitaet)'), ('HYDROGEN', 'Wasserstoff (Erzeugung, Speicherung, Transport) (Anhang I – hohe Kritikalitaet)'), ('TRANSPORT', 'Transport und Verkehr (Anhang I – hohe Kritikalitaet)'), ('BANKING', 'Bankwesen (Anhang I – hohe Kritikalitaet)'), ('FINANCIAL_MARKET_INFRASTRUCTURE', 'Finanzmarktinfrastruktur (Anhang I – hohe Kritikalitaet)'), ('HEALTH', 'Gesundheitswesen (Anhang I – hohe Kritikalitaet)'), ('DRINKING_WATER', 'Trinkwasser (Anhang I – hohe Kritikalitaet)'), ('WASTEWATER', 'Abwasser (Anhang I – hohe Kritikalitaet)'), ('DIGITAL_INFRASTRUCTURE', 'Digitale Infrastruktur (Anhang I – hohe Kritikalitaet)'), ('ICT_SERVICE_MANAGEMENT', 'Verwaltung von IKT-Diensten (B2B / MSP / Rechenzentrum) (Anhang I – hohe Kritikalitaet)'), ('MSSP', 'Managed Security Service Provider (MSSP) (Anhang I – hohe Kritikalitaet)'), ('PUBLIC_ADMINISTRATION', 'Oeffentliche Verwaltung (Anhang I – hohe Kritikalitaet)'), ('SPACE', 'Weltraum (Anhang I – hohe Kritikalitaet)'), ('POSTAL_COURIER', 'Post- und Kurierdienste (Anhang II – sonstige kritische Sektoren)'), ('WASTE_MANAGEMENT', 'Abfallbewirtschaftung (Anhang II – sonstige kritische Sektoren)'), ('CHEMICALS', 'Chemie (Anhang II – sonstige kritische Sektoren)'), ('FOOD', 'Lebensmittel (Anhang II – sonstige kritische Sektoren)'), ('MANUFACTURING', 'Verarbeitendes Gewerbe / Herstellung von Waren (Anhang II – sonstige kritische Sektoren)'), ('DIGITAL_PROVIDERS', 'Anbieter digitaler Dienste (Anhang II – sonstige kritische Sektoren)'), ('RESEARCH', 'Forschung (Anhang II – sonstige kritische Sektoren)'), ('OTHER', 'Sonstige / nicht klar zuordenbar (Nicht eindeutig regulierungsnah)')], default='OTHER', max_length=64)), - ('employee_count', models.PositiveIntegerField(default=0)), - ('annual_revenue_million', models.DecimalField(decimal_places=2, default=0, max_digits=10)), - ('balance_sheet_million', models.DecimalField(decimal_places=2, default=0, max_digits=10)), - ('critical_services', models.TextField(blank=True)), - ('supply_chain_role', models.CharField(blank=True, max_length=255)), - ('nis2_relevant', models.BooleanField(default=False)), - ('kritis_relevant', models.BooleanField(default=False)), - ('develops_digital_products', models.BooleanField(default=False)), - ('uses_ai_systems', models.BooleanField(default=False)), - ('ot_iacs_scope', models.BooleanField(default=False)), - ('automotive_scope', models.BooleanField(default=False)), - ('psirt_defined', models.BooleanField(default=False)), - ('sbom_required', models.BooleanField(default=False)), - ('product_security_scope', models.TextField(blank=True)), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='Supplier', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=255)), - ('service_description', models.TextField(blank=True)), - ('criticality', models.CharField(default='MEDIUM', max_length=32)), - ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_suppliers', to=settings.AUTH_USER_MODEL)), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='suppliers', to='organizations.tenant')), - ], - options={ - 'ordering': ['name'], - }, - ), - migrations.CreateModel( - name='Site', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=255)), - ('address', models.TextField(blank=True)), - ('country', models.CharField(max_length=100)), - ('legal_entity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sites', to='organizations.legalentity')), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sites', to='organizations.tenant')), - ], - options={ - 'abstract': False, - }, - ), - migrations.AddField( - model_name='legalentity', - name='tenant', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='legal_entities', to='organizations.tenant'), - ), - migrations.CreateModel( - name='BusinessUnit', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=255)), - ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_business_units', to=settings.AUTH_USER_MODEL)), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='business_units', to='organizations.tenant')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/apps/organizations/migrations/__init__.py b/apps/organizations/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/organizations/models.py b/apps/organizations/models.py deleted file mode 100644 index 34ea84e..0000000 --- a/apps/organizations/models.py +++ /dev/null @@ -1,121 +0,0 @@ -from django.db import models -from apps.core.models import TenantRelationValidationMixin, TimeStampedModel -from .sector_catalog import SECTOR_CHOICES, get_sector_definition -from .country_catalog import get_country_labels - - -class Tenant(TimeStampedModel): - name = models.CharField(max_length=255) - slug = models.SlugField(unique=True) - country = models.CharField(max_length=100, blank=True) - operation_countries = models.JSONField(default=list, blank=True) - description = models.TextField(blank=True) - sector = models.CharField(max_length=64, blank=True, choices=SECTOR_CHOICES, default='OTHER') - employee_count = models.PositiveIntegerField(default=0) - annual_revenue_million = models.DecimalField(max_digits=10, decimal_places=2, default=0) - balance_sheet_million = models.DecimalField(max_digits=10, decimal_places=2, default=0) - critical_services = models.TextField(blank=True) - supply_chain_role = models.CharField(max_length=255, blank=True) - nis2_relevant = models.BooleanField(default=False) - kritis_relevant = models.BooleanField(default=False) - - # Product-security / software-development context - develops_digital_products = models.BooleanField(default=False) - uses_ai_systems = models.BooleanField(default=False) - ot_iacs_scope = models.BooleanField(default=False) - automotive_scope = models.BooleanField(default=False) - psirt_defined = models.BooleanField(default=False) - sbom_required = models.BooleanField(default=False) - product_security_scope = models.TextField(blank=True) - - def __str__(self): - return self.name - - @property - def country_labels(self): - codes = self.operation_countries or ([self.country] if self.country else []) - return get_country_labels(codes) - - @property - def countries_display(self): - labels = self.country_labels - return ", ".join(labels) if labels else "-" - - @property - def sector_profile(self): - return get_sector_definition(self.sector) - - @property - def sector_label(self): - return self.sector_profile.label - - @property - def product_security_summary(self): - if not self.develops_digital_products and not any([self.uses_ai_systems, self.ot_iacs_scope, self.automotive_scope]): - return 'Kein ausgeprägter Product-Security-Scope angegeben.' - active = [] - if self.develops_digital_products: - active.append('CRA/Product Security') - if self.uses_ai_systems: - active.append('AI Act / AI Governance') - if self.ot_iacs_scope: - active.append('IEC 62443 / OT') - if self.automotive_scope: - active.append('ISO/SAE 21434 / Automotive') - return 'Aktive Product-Security-Kontexte: ' + ', '.join(active) - - -class LegalEntity(TenantRelationValidationMixin, TimeStampedModel): - tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='legal_entities') - name = models.CharField(max_length=255) - country = models.CharField(max_length=100) - registration_number = models.CharField(max_length=100, blank=True) - - def __str__(self): - return self.name - - -class BusinessUnit(TenantRelationValidationMixin, TimeStampedModel): - tenant_relation_fields = { - 'owner': 'tenant_id', - } - - tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='business_units') - name = models.CharField(max_length=255) - owner = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='owned_business_units') - - def __str__(self): - return self.name - - -class Site(TenantRelationValidationMixin, TimeStampedModel): - tenant_relation_fields = { - 'legal_entity': 'tenant_id', - } - - tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='sites') - legal_entity = models.ForeignKey(LegalEntity, on_delete=models.CASCADE, related_name='sites') - name = models.CharField(max_length=255) - address = models.TextField(blank=True) - country = models.CharField(max_length=100) - - def __str__(self): - return self.name - - -class Supplier(TenantRelationValidationMixin, TimeStampedModel): - tenant_relation_fields = { - 'owner': 'tenant_id', - } - - tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='suppliers') - name = models.CharField(max_length=255) - service_description = models.TextField(blank=True) - criticality = models.CharField(max_length=32, default='MEDIUM') - owner = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='owned_suppliers') - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name diff --git a/apps/organizations/sector_catalog.py b/apps/organizations/sector_catalog.py deleted file mode 100644 index 1caefbf..0000000 --- a/apps/organizations/sector_catalog.py +++ /dev/null @@ -1,303 +0,0 @@ -from dataclasses import dataclass, field -from typing import Dict, List - - -@dataclass(frozen=True) -class SectorDefinition: - code: str - label: str - nis2_group: str # Anhang I / Anhang II / Nicht zugeordnet - nis2_annex: str = '' # 'I' oder 'II' fuer maschinelle Auswertung - indicative_classification: str = '' - score_bonus: int = 0 - reasoning: str = '' - downstream_impact: str = '' - roadmap_focus: List[str] = field(default_factory=list) - key_domains: List[str] = field(default_factory=list) - special_regime: str = '' - kritis_related: bool = False - # F03: Expliziter Hinweis, dass KRITIS sektorspezifische Schwellenwerte erfordert - kritis_note: str = '' - - -SECTOR_DEFINITIONS: List[SectorDefinition] = [ - SectorDefinition( - code="ENERGY", - label="Energie", - nis2_group="Anhang I – hohe Kritikalitaet", - nis2_annex='I', - indicative_classification="Regelmaessig besonders wichtig / KRITIS-nah", - score_bonus=6, - reasoning="Energie zaehlt in der NIS-2-Systematik zu den Sektoren mit hoher Kritikalitaet und ist in Deutschland regelmaessig KRITIS-nah.", - downstream_impact="Der Prozess fokussiert staerker auf Resilienz, BCM, physische Sicherheit, Lieferkette und Incident-Meldewege.", - roadmap_focus=["Resilienz und Wiederanlauf", "Melde- und Eskalationswege", "Lieferkettenkontrollen"], - key_domains=["BCM", "INC", "SUP", "PHYS", "DETECT"], - kritis_related=True, - kritis_note="KRITIS-Schwellenwerte nach BSI-KritisV Anhang 1 beachten (z.B. 500 MW Nennleistung).", - ), - # F04: Wasserstoff explizit als Unterkategorie - SectorDefinition( - code="HYDROGEN", - label="Wasserstoff (Erzeugung, Speicherung, Transport)", - nis2_group="Anhang I – hohe Kritikalitaet", - nis2_annex='I', - indicative_classification="Regelmaessig besonders wichtig / KRITIS-nah", - score_bonus=6, - reasoning="Wasserstoff ist seit NIS2 explizit als Teilsektor von Energie in Anhang I aufgefuehrt.", - downstream_impact="Behandlung analog Energiesektor. Zusaetzlich Fokus auf Anlagensicherheit und Speicherinfrastruktur.", - roadmap_focus=["Anlagensicherheit", "BCM", "Lieferkette"], - key_domains=["BCM", "PHYS", "INC", "SUP"], - kritis_related=True, - kritis_note="Eigene KRITIS-Schwellenwerte koennen je nach nationaler Umsetzung gelten.", - ), - SectorDefinition( - code="TRANSPORT", - label="Transport und Verkehr", - nis2_group="Anhang I – hohe Kritikalitaet", - nis2_annex='I', - indicative_classification="Regelmaessig besonders wichtig / KRITIS-nah", - score_bonus=6, - reasoning="Transport und Verkehr gehoeren zu den Sektoren mit hoher Kritikalitaet und sind in Deutschland oft KRITIS-nah.", - downstream_impact="Roadmap priorisiert Betriebsstabilitaet, Wiederanlauf, physische Zugaenge und Vorfallsteuerung.", - roadmap_focus=["Betriebsstabilitaet", "BCM", "Incident Management"], - key_domains=["BCM", "PHYS", "INC", "SUP"], - kritis_related=True, - kritis_note="KRITIS-Schwellenwerte nach BSI-KritisV beachten (z.B. Flughaefen ab 20 Mio. Passagiere).", - ), - SectorDefinition( - code="BANKING", - label="Bankwesen", - nis2_group="Anhang I – hohe Kritikalitaet", - nis2_annex='I', - indicative_classification="Regelmaessig besonders wichtig", - score_bonus=6, - reasoning="Bankwesen ist ein NIS-2-Sektor mit hoher Kritikalitaet; zugleich koennen sektorspezifische Regime relevant sein.", - downstream_impact="Die App priorisiert Governance, IAM, Logging, Incident Management und verweist auf sektorale Ueberschneidungen.", - roadmap_focus=["IAM", "Monitoring", "Governance"], - key_domains=["IAM", "DETECT", "INC", "DOC"], - special_regime="DORA-Ueberschneidung pruefen (Verordnung (EU) 2022/2554)", - ), - SectorDefinition( - code="FINANCIAL_MARKET_INFRASTRUCTURE", - label="Finanzmarktinfrastruktur", - nis2_group="Anhang I – hohe Kritikalitaet", - nis2_annex='I', - indicative_classification="Regelmaessig besonders wichtig", - score_bonus=6, - reasoning="Finanzmarktinfrastruktur zaehlt zu den NIS-2-Sektoren mit hoher Kritikalitaet.", - downstream_impact="Zusaetzlicher Fokus auf sektorspezifische Ueberschneidungen, Resilienz und Meldepflichten.", - roadmap_focus=["Resilienz", "Meldewege", "Governance"], - key_domains=["BCM", "INC", "DOC", "DETECT"], - special_regime="DORA-Ueberschneidung pruefen (Verordnung (EU) 2022/2554)", - ), - SectorDefinition( - code="HEALTH", - label="Gesundheitswesen", - nis2_group="Anhang I – hohe Kritikalitaet", - nis2_annex='I', - indicative_classification="Regelmaessig besonders wichtig / KRITIS-nah", - score_bonus=6, - reasoning="Gesundheitswesen ist ein Sektor mit hoher Kritikalitaet und in Deutschland haeufig eng mit KRITIS verknuepft.", - downstream_impact="Roadmap priorisiert Verfuegbarkeit, Wiederanlauf, Lieferantensteuerung und Schutz sensibler Daten.", - roadmap_focus=["BCM", "Lieferkette", "Kryptografie"], - key_domains=["BCM", "SUP", "CRYPTO", "INC"], - kritis_related=True, - kritis_note="KRITIS-Schwellenwerte: z.B. 30.000 vollstationaere Faelle/Jahr fuer Krankenhaeuser.", - ), - SectorDefinition( - code="DRINKING_WATER", - label="Trinkwasser", - nis2_group="Anhang I – hohe Kritikalitaet", - nis2_annex='I', - indicative_classification="Regelmaessig besonders wichtig / KRITIS-nah", - score_bonus=6, - reasoning="Trinkwasser faellt unter die NIS-2-Sektoren mit hoher Kritikalitaet und ist in Deutschland KRITIS-nah.", - downstream_impact="Der Prozess staerkt BCM, physische Sicherheit, Angriffserkennung und Dienstleisterresilienz.", - roadmap_focus=["BCM", "Physische Sicherheit", "Monitoring"], - key_domains=["BCM", "PHYS", "DETECT", "SUP"], - kritis_related=True, - kritis_note="KRITIS-Schwellenwert: 500.000 versorgte Personen.", - ), - SectorDefinition( - code="WASTEWATER", - label="Abwasser", - nis2_group="Anhang I – hohe Kritikalitaet", - nis2_annex='I', - indicative_classification="Regelmaessig besonders wichtig / KRITIS-nah", - score_bonus=6, - reasoning="Abwasser zaehlt zu den hochkritischen NIS-2-Sektoren und ist oft KRITIS-nah.", - downstream_impact="Roadmap hebt Resilienz, Betriebsfaehigkeit, Monitoring und physische Kontrollen hervor.", - roadmap_focus=["Resilienz", "Monitoring", "Physische Sicherheit"], - key_domains=["BCM", "PHYS", "DETECT"], - kritis_related=True, - kritis_note="KRITIS-Schwellenwert: 500.000 angeschlossene Einwohner.", - ), - SectorDefinition( - code="DIGITAL_INFRASTRUCTURE", - label="Digitale Infrastruktur", - nis2_group="Anhang I – hohe Kritikalitaet", - nis2_annex='I', - indicative_classification="Regelmaessig besonders wichtig", - score_bonus=6, - reasoning="Digitale Infrastruktur zaehlt zu den hochkritischen NIS-2-Sektoren.", - downstream_impact="Die App priorisiert Monitoring, Incident Management, Lieferkette und Nachweisfuehrung fuer digitale Kernservices.", - roadmap_focus=["Monitoring", "Incident Management", "Lieferkette"], - key_domains=["DETECT", "INC", "SUP", "CLOUD"], - ), - SectorDefinition( - code="ICT_SERVICE_MANAGEMENT", - label="Verwaltung von IKT-Diensten (B2B / MSP / Rechenzentrum)", - nis2_group="Anhang I – hohe Kritikalitaet", - nis2_annex='I', - indicative_classification="Haeufig wichtig bis besonders wichtig", - score_bonus=6, - reasoning="B2B-IKT-Dienstleister wie MSP oder Rechenzentren sind im NIS-2-Umfeld besonders relevant.", - downstream_impact="Der weitere Prozess fokussiert staerker auf Secure Operations, Lieferkette, Logging und sichere Entwicklungs-/Betriebsprozesse.", - roadmap_focus=["Secure Operations", "Logging", "Lieferkette", "SDLC"], - key_domains=["DETECT", "SUP", "SDLC", "IAM", "CLOUD"], - ), - # F04: MSSP als eigene Kategorie (NIS2 Art. 6 Nr. 40) - SectorDefinition( - code="MSSP", - label="Managed Security Service Provider (MSSP)", - nis2_group="Anhang I – hohe Kritikalitaet", - nis2_annex='I', - indicative_classification="Haeufig wichtig bis besonders wichtig", - score_bonus=6, - reasoning="MSSPs sind in NIS2 Art. 6 Nr. 40 als eigene Einrichtungsart definiert und unterliegen erhoehten Anforderungen.", - downstream_impact="Fokus auf SOC-Prozesse, Threat Detection, Incident Response, Kunden-SLAs und Nachweispflichten gegenueber regulierten Kunden.", - roadmap_focus=["SOC-Prozesse", "Incident Response", "Kundennachweis"], - key_domains=["DETECT", "INC", "SUP", "DOC", "CLOUD"], - ), - SectorDefinition( - code="PUBLIC_ADMINISTRATION", - label="Oeffentliche Verwaltung", - nis2_group="Anhang I – hohe Kritikalitaet", - nis2_annex='I', - indicative_classification="Haeufig besonders wichtig", - score_bonus=6, - reasoning="Oeffentliche Verwaltung gehoert zu den NIS-2-Sektoren mit hoher Kritikalitaet.", - downstream_impact="Roadmap legt zusaetzlichen Fokus auf Governance, Rollen, Meldewege und Dienstleistersteuerung.", - roadmap_focus=["Governance", "Rollen", "Meldewege"], - key_domains=["GOV", "DOC", "INC", "SUP"], - ), - SectorDefinition( - code="SPACE", - label="Weltraum", - nis2_group="Anhang I – hohe Kritikalitaet", - nis2_annex='I', - indicative_classification="Haeufig besonders wichtig", - score_bonus=6, - reasoning="Weltraum ist in Anhang I der NIS-2-Richtlinie als Sektor mit hoher Kritikalitaet aufgefuehrt.", - downstream_impact="Die App priorisiert Resilienz, Lieferkette und klare Verantwortlichkeiten fuer hochkritische Dienste.", - roadmap_focus=["Resilienz", "Lieferkette", "Governance"], - key_domains=["BCM", "SUP", "GOV", "DETECT"], - ), - SectorDefinition( - code="POSTAL_COURIER", - label="Post- und Kurierdienste", - nis2_group="Anhang II – sonstige kritische Sektoren", - nis2_annex='II', - indicative_classification="Haeufig wichtige Einrichtung", - score_bonus=4, - reasoning="Post- und Kurierdienste gehoeren in der NIS-2-Systematik zu den sonstigen kritischen Sektoren.", - downstream_impact="Der weitere Prozess priorisiert Lieferkettenkontrollen, BCM und Vorfallsteuerung.", - roadmap_focus=["Lieferkette", "BCM", "Incident Management"], - key_domains=["SUP", "BCM", "INC"], - ), - SectorDefinition( - code="WASTE_MANAGEMENT", - label="Abfallbewirtschaftung", - nis2_group="Anhang II – sonstige kritische Sektoren", - nis2_annex='II', - indicative_classification="Haeufig wichtige Einrichtung", - score_bonus=4, - reasoning="Abfallbewirtschaftung ist Teil der sonstigen kritischen Sektoren nach NIS-2.", - downstream_impact="Roadmap priorisiert Betriebsstabilitaet, Dienstleistersteuerung und physische Sicherheit.", - roadmap_focus=["Betriebsstabilitaet", "Lieferkette", "Physische Sicherheit"], - key_domains=["SUP", "PHYS", "BCM"], - ), - SectorDefinition( - code="CHEMICALS", - label="Chemie", - nis2_group="Anhang II – sonstige kritische Sektoren", - nis2_annex='II', - indicative_classification="Haeufig wichtige Einrichtung", - score_bonus=4, - reasoning="Produktion, Herstellung und Handel mit chemischen Stoffen zaehlen zu den sonstigen kritischen Sektoren.", - downstream_impact="Der Prozess legt mehr Gewicht auf Lieferkette, physische Sicherheit, Wiederanlauf und Schutz von Produktionsprozessen.", - roadmap_focus=["Lieferkette", "Physische Sicherheit", "Wiederanlauf"], - key_domains=["SUP", "PHYS", "BCM"], - ), - SectorDefinition( - code="FOOD", - label="Lebensmittel", - nis2_group="Anhang II – sonstige kritische Sektoren", - nis2_annex='II', - indicative_classification="Haeufig wichtige Einrichtung", - score_bonus=4, - reasoning="Produktion, Verarbeitung und Vertrieb von Lebensmitteln gehoeren zu den sonstigen kritischen Sektoren.", - downstream_impact="Roadmap priorisiert Lieferkette, BCM und Wiederanlauf sowie verlaessliche Dokumentation.", - roadmap_focus=["Lieferkette", "Resilienz", "Dokumentation"], - key_domains=["SUP", "BCM", "DOC"], - ), - SectorDefinition( - code="MANUFACTURING", - label="Verarbeitendes Gewerbe / Herstellung von Waren", - nis2_group="Anhang II – sonstige kritische Sektoren", - nis2_annex='II', - indicative_classification="Haeufig wichtige Einrichtung", - score_bonus=4, - reasoning="Bestimmte Bereiche des verarbeitenden Gewerbes fallen unter die sonstigen kritischen Sektoren der NIS-2-Systematik.", - downstream_impact="Die App legt mehr Gewicht auf Lieferkette, Lifecycle, Patchen und Betriebsresilienz.", - roadmap_focus=["Lifecycle", "Patchmanagement", "Lieferkette"], - key_domains=["SDLC", "CYBER", "SUP", "BCM"], - ), - SectorDefinition( - code="DIGITAL_PROVIDERS", - label="Anbieter digitaler Dienste", - nis2_group="Anhang II – sonstige kritische Sektoren", - nis2_annex='II', - indicative_classification="Haeufig wichtige Einrichtung", - score_bonus=4, - reasoning="Anbieter digitaler Dienste gehoeren zu den sonstigen kritischen Sektoren.", - downstream_impact="Roadmap priorisiert Cloud, Monitoring, Incident Management und sichere Entwicklungsprozesse.", - roadmap_focus=["Cloud", "Monitoring", "SDLC"], - key_domains=["CLOUD", "DETECT", "INC", "SDLC"], - ), - SectorDefinition( - code="RESEARCH", - label="Forschung", - nis2_group="Anhang II – sonstige kritische Sektoren", - nis2_annex='II', - indicative_classification="Teilweise wichtige Einrichtung", - score_bonus=4, - reasoning="Forschung zaehlt in der NIS-2-Systematik zu den sonstigen kritischen Sektoren.", - downstream_impact="Der Prozess fokussiert staerker auf Schutz sensibler Daten, Kollaboration mit Dritten und Governance.", - roadmap_focus=["Daten- und IP-Schutz", "Lieferkette", "Governance"], - key_domains=["CRYPTO", "SUP", "DOC", "GOV"], - ), - SectorDefinition( - code="OTHER", - label="Sonstige / nicht klar zuordenbar", - nis2_group="Nicht eindeutig regulierungsnah", - nis2_annex='', - indicative_classification="ISO-27001-first / Einzelfallpruefung", - score_bonus=0, - reasoning="Der gewaehlte Sektor ist nicht direkt als NIS-2-Sektor vorgegeben; Relevanz kann dennoch ueber Dienste, Groesse oder Lieferkettenrolle entstehen.", - downstream_impact="Die App legt den Schwerpunkt eher auf ISO-27001-Readiness, kann aber bei weiteren Indikatoren NIS2-Naehe ausweisen.", - roadmap_focus=["Scope", "Register", "Basismassnahmen"], - key_domains=["GOV", "PROC", "DOC"], - ), -] - -SECTOR_MAP: Dict[str, SectorDefinition] = {item.code: item for item in SECTOR_DEFINITIONS} -SECTOR_CHOICES = [(item.code, f"{item.label} ({item.nis2_group})") for item in SECTOR_DEFINITIONS] - - -def get_sector_choices(): - return SECTOR_CHOICES - - -def get_sector_definition(code: str | None) -> SectorDefinition: - return SECTOR_MAP.get(code or "", SECTOR_MAP["OTHER"]) diff --git a/apps/organizations/urls.py b/apps/organizations/urls.py deleted file mode 100644 index da1378e..0000000 --- a/apps/organizations/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.urls import path -from .views import TenantCreateView, TenantListView -from .views_api import SectorContextApiView - -app_name = 'organizations' - -urlpatterns = [ - path('', TenantListView.as_view(), name='list'), - path('new/', TenantCreateView.as_view(), name='create'), - path('api/sector-context/', SectorContextApiView.as_view(), name='sector_context_api'), -] diff --git a/apps/organizations/views.py b/apps/organizations/views.py deleted file mode 100644 index 34304b6..0000000 --- a/apps/organizations/views.py +++ /dev/null @@ -1,18 +0,0 @@ -from django.contrib.auth.mixins import LoginRequiredMixin -from django.urls import reverse_lazy -from django.views.generic import CreateView, ListView -from .forms import TenantForm -from .models import Tenant - - -class TenantListView(LoginRequiredMixin, ListView): - model = Tenant - template_name = 'organizations/tenant_list.html' - context_object_name = 'tenants' - - -class TenantCreateView(LoginRequiredMixin, CreateView): - model = Tenant - form_class = TenantForm - template_name = 'shared/form.html' - success_url = reverse_lazy('organizations:list') diff --git a/apps/organizations/views_api.py b/apps/organizations/views_api.py deleted file mode 100644 index 7713dab..0000000 --- a/apps/organizations/views_api.py +++ /dev/null @@ -1,36 +0,0 @@ -"""V20: JSON-Endpoint fuer Sektor- und Laenderdaten (fuer Live-Updates im Frontend).""" -import json -from django.http import JsonResponse -from django.views import View -from .sector_catalog import get_sector_definition, SECTOR_DEFINITIONS -from .country_catalog import get_country_labels, EU_EEA_CODES - - -class SectorContextApiView(View): - """Gibt Sektorkontext als JSON zurueck. Wird vom Frontend beim Dropdown-Wechsel aufgerufen.""" - def get(self, request): - code = request.GET.get('code', 'OTHER') - sector = get_sector_definition(code) - countries = request.GET.getlist('countries', []) - country_labels = get_country_labels(countries) - is_multi = len(countries) > 1 - has_non_eu = any(c not in EU_EEA_CODES for c in countries) - - return JsonResponse({ - 'code': sector.code, - 'label': sector.label, - 'nis2_group': sector.nis2_group, - 'nis2_annex': sector.nis2_annex, - 'indicative_classification': sector.indicative_classification, - 'reasoning': sector.reasoning, - 'downstream_impact': sector.downstream_impact, - 'roadmap_focus': sector.roadmap_focus, - 'key_domains': sector.key_domains, - 'special_regime': sector.special_regime, - 'kritis_related': sector.kritis_related, - 'kritis_note': getattr(sector, 'kritis_note', ''), - 'score_bonus': sector.score_bonus, - 'countries_display': ', '.join(country_labels) if country_labels else '-', - 'is_multi_country': is_multi, - 'has_non_eu_eea': has_non_eu, - }) diff --git a/apps/processes/__init__.py b/apps/processes/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/processes/admin.py b/apps/processes/admin.py deleted file mode 100644 index 8a17d3d..0000000 --- a/apps/processes/admin.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.contrib import admin -from .models import Process - -@admin.register(Process) -class ProcessAdmin(admin.ModelAdmin): - list_display = ('name', 'tenant', 'status', 'owner', 'documented', 'implemented', 'effective') - list_filter = ('tenant', 'status', 'documented', 'implemented', 'effective') - search_fields = ('name', 'description', 'scope') diff --git a/apps/processes/apps.py b/apps/processes/apps.py deleted file mode 100644 index 9215c9d..0000000 --- a/apps/processes/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class ProcessesConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.processes' diff --git a/apps/processes/forms.py b/apps/processes/forms.py deleted file mode 100644 index e3bba2b..0000000 --- a/apps/processes/forms.py +++ /dev/null @@ -1,20 +0,0 @@ -from django import forms -from apps.core.forms import TenantScopedModelForm -from .models import Process - - -class ProcessForm(TenantScopedModelForm): - tenant_scoped_fields = { - 'business_unit': 'tenant', - 'owner': 'tenant', - } - - class Meta: - model = Process - fields = [ - 'business_unit', 'owner', 'name', 'scope', 'description', 'status', - 'documented', 'approved', 'communicated', 'implemented', 'effective', 'evidenced', 'reviewed_at' - ] - widgets = { - 'reviewed_at': forms.DateInput(attrs={'type': 'date'}), - } diff --git a/apps/processes/migrations/0001_initial.py b/apps/processes/migrations/0001_initial.py deleted file mode 100644 index e575c71..0000000 --- a/apps/processes/migrations/0001_initial.py +++ /dev/null @@ -1,43 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-09 14:39 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('organizations', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Process', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=255)), - ('scope', models.CharField(blank=True, max_length=255)), - ('description', models.TextField(blank=True)), - ('status', models.CharField(choices=[('SUFFICIENT', 'Vorhanden und ausreichend'), ('PARTIAL', 'Vorhanden, aber unvollständig'), ('INFORMAL', 'Informal vorhanden'), ('DOCUMENTED_NOT_IMPLEMENTED', 'Dokumentiert, aber nicht umgesetzt'), ('IMPLEMENTED_NO_EVIDENCE', 'Umgesetzt, aber nicht nachweisbar'), ('MISSING', 'Fehlt vollständig')], default='MISSING', max_length=32)), - ('documented', models.BooleanField(default=False)), - ('approved', models.BooleanField(default=False)), - ('communicated', models.BooleanField(default=False)), - ('implemented', models.BooleanField(default=False)), - ('effective', models.BooleanField(default=False)), - ('evidenced', models.BooleanField(default=False)), - ('reviewed_at', models.DateField(blank=True, null=True)), - ('business_unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='processes', to='organizations.businessunit')), - ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_processes', to=settings.AUTH_USER_MODEL)), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='processes', to='organizations.tenant')), - ], - options={ - 'ordering': ['name'], - }, - ), - ] diff --git a/apps/processes/migrations/__init__.py b/apps/processes/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/processes/models.py b/apps/processes/models.py deleted file mode 100644 index f0f34fb..0000000 --- a/apps/processes/models.py +++ /dev/null @@ -1,38 +0,0 @@ -from django.db import models -from apps.core.models import TenantRelationValidationMixin, TimeStampedModel - - -class Process(TenantRelationValidationMixin, TimeStampedModel): - tenant_relation_fields = { - 'business_unit': 'tenant_id', - 'owner': 'tenant_id', - } - - class Status(models.TextChoices): - SUFFICIENT = 'SUFFICIENT', 'Vorhanden und ausreichend' - PARTIAL = 'PARTIAL', 'Vorhanden, aber unvollständig' - INFORMAL = 'INFORMAL', 'Informal vorhanden' - DOCUMENTED_NOT_IMPLEMENTED = 'DOCUMENTED_NOT_IMPLEMENTED', 'Dokumentiert, aber nicht umgesetzt' - IMPLEMENTED_NO_EVIDENCE = 'IMPLEMENTED_NO_EVIDENCE', 'Umgesetzt, aber nicht nachweisbar' - MISSING = 'MISSING', 'Fehlt vollständig' - - tenant = models.ForeignKey('organizations.Tenant', on_delete=models.CASCADE, related_name='processes') - business_unit = models.ForeignKey('organizations.BusinessUnit', on_delete=models.SET_NULL, null=True, blank=True, related_name='processes') - owner = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='owned_processes') - name = models.CharField(max_length=255) - scope = models.CharField(max_length=255, blank=True) - description = models.TextField(blank=True) - status = models.CharField(max_length=32, choices=Status.choices, default=Status.MISSING) - documented = models.BooleanField(default=False) - approved = models.BooleanField(default=False) - communicated = models.BooleanField(default=False) - implemented = models.BooleanField(default=False) - effective = models.BooleanField(default=False) - evidenced = models.BooleanField(default=False) - reviewed_at = models.DateField(null=True, blank=True) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name diff --git a/apps/processes/services.py b/apps/processes/services.py deleted file mode 100644 index 682c290..0000000 --- a/apps/processes/services.py +++ /dev/null @@ -1,356 +0,0 @@ -"""F05: 10-Dimensionen-Bewertungslogik fuer Prozesse/Kontrollen. - -Leitet aus den Boolean-Feldern des Process-Models automatisch einen -Reifegradstatus und eine Gap-Indikation ab. Die 10 Dimensionen aus -der Projektdokumentation: - 1. dokumentiert - 2. genehmigt (approved) - 3. kommuniziert - 4. implementiert - 5. operativ wirksam (effective) - 6. evidenzbasiert (evidenced) - 7. Verantwortlichkeit zugewiesen (owner) - 8. reviewed (reviewed_at) - 9. versioniert -> hier: reviewed_at Datum vorhanden - 10. historisiert -> hier: Change-Log Eintraege (ueber AuditLog) - -Fehlende Evidenzen duerfen die Wirksamkeit nicht automatisch widerlegen, -senken aber den Nachweisstatus. -""" - -import json -from dataclasses import dataclass -from datetime import date -from typing import List, Optional -from urllib.error import HTTPError -from urllib.request import Request, urlopen - -from django.conf import settings -from apps.processes.models import Process - - -STATUS_LABELS = dict(Process.Status.choices) - - -@dataclass(frozen=True) -class ProcessRelatedRef: - id: int | None - name: str - - def __str__(self) -> str: - return self.name - - -@dataclass(frozen=True) -class ProcessBridgeItem: - id: int - tenant: object - tenant_id: int - business_unit: ProcessRelatedRef | None - business_unit_id: int | None - owner: ProcessRelatedRef | None - owner_id: int | None - name: str - scope: str - description: str - status: str - status_label: str - documented: bool - approved: bool - communicated: bool - implemented: bool - effective: bool - evidenced: bool - reviewed_at: date | None - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - def get_status_display(self) -> str: - return self.status_label or STATUS_LABELS.get(self.status, self.status) - - -class ProcessRustClient: - @staticmethod - def _base_url() -> str: - base = (getattr(settings, 'RUST_BACKEND_URL', '') or '').strip() - return base.rstrip('/') - - @staticmethod - def fetch_processes(request, tenant, timeout: int = 8) -> list[ProcessBridgeItem]: - base = ProcessRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = ProcessRustClient._authenticated_request( - request, - tenant, - f'{base}/api/v1/processes', - ) - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - - tenant_id = ProcessRustClient._int_field(payload, 'tenant_id') - if tenant_id != int(tenant.id): - raise RuntimeError('Rust-Prozessliste gehoert nicht zum angefragten Tenant.') - - return [ - ProcessRustClient._process_from_payload(item, tenant) - for item in payload.get('processes', []) - if isinstance(item, dict) - ] - - @staticmethod - def fetch_process_detail(request, tenant, process_id: int, timeout: int = 8): - base = ProcessRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = ProcessRustClient._authenticated_request( - request, - tenant, - f'{base}/api/v1/processes/{int(process_id)}', - ) - try: - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - except HTTPError as exc: - if exc.code == 404: - return None - raise - - process = payload.get('process') or {} - if not isinstance(process, dict): - raise RuntimeError('Rust-Prozessdetail hat kein process-Objekt geliefert.') - process_tenant_id = ProcessRustClient._int_field(process, 'tenant_id') - if process_tenant_id != int(tenant.id): - raise RuntimeError('Rust-Prozessdetail gehoert nicht zum angefragten Tenant.') - return ProcessRustClient._process_from_payload(process, tenant) - - @staticmethod - def _process_from_payload(item: dict, tenant) -> ProcessBridgeItem: - business_unit_id = ProcessRustClient._optional_int_field(item, 'business_unit_id') - business_unit_name = str(item.get('business_unit_name') or '').strip() - owner_id = ProcessRustClient._optional_int_field(item, 'owner_id') - owner_display = str(item.get('owner_display') or '').strip() - status = str(item.get('status') or Process.Status.MISSING) - return ProcessBridgeItem( - id=ProcessRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=ProcessRustClient._int_field(item, 'tenant_id'), - business_unit=ProcessRelatedRef(business_unit_id, business_unit_name) if business_unit_name else None, - business_unit_id=business_unit_id, - owner=ProcessRelatedRef(owner_id, owner_display) if owner_display else None, - owner_id=owner_id, - name=str(item.get('name') or ''), - scope=str(item.get('scope') or ''), - description=str(item.get('description') or ''), - status=status, - status_label=str(item.get('status_label') or STATUS_LABELS.get(status, status)), - documented=bool(item.get('documented')), - approved=bool(item.get('approved')), - communicated=bool(item.get('communicated')), - implemented=bool(item.get('implemented')), - effective=bool(item.get('effective')), - evidenced=bool(item.get('evidenced')), - reviewed_at=ProcessRustClient._date_field(item, 'reviewed_at'), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - - @staticmethod - def _authenticated_request(request, tenant, url: str) -> Request: - user = getattr(request, 'user', None) - user_id = getattr(user, 'id', None) - if not user_id: - raise RuntimeError('Process-Rust-Bridge braucht einen authentifizierten User.') - - rust_request = Request(url) - rust_request.add_header('Accept', 'application/json') - rust_request.add_header('X-ISCY-Tenant-ID', str(tenant.id)) - rust_request.add_header('X-ISCY-User-ID', str(user_id)) - user_email = (getattr(user, 'email', '') or '').strip() - if user_email: - rust_request.add_header('X-ISCY-User-Email', user_email) - return rust_request - - @staticmethod - def _int_field(payload: dict, key: str) -> int: - return int(payload.get(key) or 0) - - @staticmethod - def _optional_int_field(payload: dict, key: str) -> int | None: - value = payload.get(key) - if value in (None, ''): - return None - return int(value) - - @staticmethod - def _date_field(payload: dict, key: str) -> date | None: - value = str(payload.get(key) or '').strip() - if not value: - return None - return date.fromisoformat(value[:10]) - - -class ProcessRegisterBridge: - @staticmethod - def fetch_list(request, tenant): - if tenant is None: - return None - - backend = str(getattr(settings, 'PROCESS_REGISTER_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not ProcessRustClient._base_url(): - if ProcessRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust process register backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return ProcessRustClient.fetch_processes(request, tenant) - except Exception as exc: - if ProcessRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust process register backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def fetch_detail(request, tenant, process_id: int): - if tenant is None: - return None - - backend = str(getattr(settings, 'PROCESS_REGISTER_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not ProcessRustClient._base_url(): - if ProcessRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust process register backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return ProcessRustClient.fetch_process_detail(request, tenant, process_id) - except Exception as exc: - if ProcessRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust process register backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def _strict_rust_mode() -> bool: - return bool(getattr(settings, 'RUST_STRICT_MODE', False)) - - -@dataclass -class DimensionResult: - name: str - fulfilled: bool - weight: int = 1 - - -@dataclass -class ProcessMaturityResult: - process: Process - dimensions: List[DimensionResult] - score_percent: int - maturity_label: str - gap_level: str - is_auditable: bool - explanation: str - - -class ProcessMaturityService: - - @staticmethod - def assess(process: Process) -> ProcessMaturityResult: - """Bewertet einen Prozess anhand aller 10 Dimensionen.""" - has_owner = process.owner_id is not None - has_review = process.reviewed_at is not None - - dimensions = [ - DimensionResult('Dokumentiert', process.documented, weight=2), - DimensionResult('Genehmigt', process.approved, weight=1), - DimensionResult('Kommuniziert', process.communicated, weight=1), - DimensionResult('Implementiert', process.implemented, weight=2), - DimensionResult('Operativ wirksam', process.effective, weight=2), - DimensionResult('Evidenzbasiert', process.evidenced, weight=2), - DimensionResult('Verantwortlichkeit zugewiesen', has_owner, weight=1), - DimensionResult('Reviewed', has_review, weight=1), - # Versioniert und historisiert werden ueber reviewed_at + AuditLog abgeleitet - DimensionResult('Versioniert', has_review and process.documented, weight=1), - DimensionResult('Historisiert', has_review, weight=1), - ] - - max_score = sum(d.weight for d in dimensions) - raw_score = sum(d.weight for d in dimensions if d.fulfilled) - percent = int((raw_score / max_score) * 100) if max_score else 0 - - # Fachliche Trennung: 'fachlich vorhanden' vs 'auditierbar nachweisbar' - is_functionally_present = process.implemented or process.effective - is_auditable = ( - process.documented - and process.evidenced - and has_owner - and has_review - ) - - if percent >= 80: - maturity, gap = 'Fortgeschritten / auditnah', 'LOW' - elif percent >= 60: - maturity, gap = 'Brauchbare Readiness', 'LOW' - elif percent >= 40: - maturity, gap = 'Grundlagen vorhanden', 'MEDIUM' - elif percent >= 20: - maturity, gap = 'Sehr niedriger Reifegrad', 'HIGH' - else: - maturity, gap = 'Kritisch', 'CRITICAL' - - # Erklarung - missing = [d.name for d in dimensions if not d.fulfilled] - if not missing: - explanation = 'Alle Dimensionen sind erfuellt. Prozess ist auditnah.' - elif is_functionally_present and not is_auditable: - explanation = f'Prozess ist fachlich vorhanden, aber nicht auditierbar nachweisbar. Fehlend: {", ".join(missing)}.' - else: - explanation = f'Fehlende Dimensionen: {", ".join(missing)}.' - - return ProcessMaturityResult( - process=process, - dimensions=dimensions, - score_percent=percent, - maturity_label=maturity, - gap_level=gap, - is_auditable=is_auditable, - explanation=explanation, - ) - - @staticmethod - def assess_all(tenant) -> List[ProcessMaturityResult]: - """Bewertet alle Prozesse eines Tenants.""" - processes = Process.objects.filter(tenant=tenant).select_related('owner') - return [ProcessMaturityService.assess(p) for p in processes] - - @staticmethod - def tenant_summary(tenant) -> dict: - """Zusammenfassung der Reifegradverteilung fuer ein Tenant.""" - results = ProcessMaturityService.assess_all(tenant) - if not results: - return {'total': 0, 'average_percent': 0, 'auditable_count': 0, 'gap_distribution': {}} - - total = len(results) - avg = int(sum(r.score_percent for r in results) / total) if total else 0 - auditable = sum(1 for r in results if r.is_auditable) - gap_dist = {} - for r in results: - gap_dist[r.gap_level] = gap_dist.get(r.gap_level, 0) + 1 - - return { - 'total': total, - 'average_percent': avg, - 'auditable_count': auditable, - 'auditable_percent': int((auditable / total) * 100) if total else 0, - 'gap_distribution': gap_dist, - } diff --git a/apps/processes/tests.py b/apps/processes/tests.py deleted file mode 100644 index 393a22e..0000000 --- a/apps/processes/tests.py +++ /dev/null @@ -1,177 +0,0 @@ -import json -from unittest.mock import Mock, patch - -from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings -from django.urls import reverse - -from apps.organizations.models import BusinessUnit, Tenant -from apps.processes.models import Process - - -User = get_user_model() - - -@override_settings(PROCESS_REGISTER_BACKEND='local', RUST_BACKEND_URL='') -class ProcessViewTests(TestCase): - def setUp(self): - self.tenant_a = Tenant.objects.create(name='Tenant A', slug='tenant-a', country='DE') - self.tenant_b = Tenant.objects.create(name='Tenant B', slug='tenant-b', country='DE') - self.user_a = User.objects.create_user( - username='tenant-a-user', - email='process-user@example.test', - password='testpass123', - tenant=self.tenant_a, - first_name='Ada', - last_name='Lovelace', - ) - self.user_b = User.objects.create_user( - username='tenant-b-user', - password='testpass123', - tenant=self.tenant_b, - ) - self.business_unit = BusinessUnit.objects.create(tenant=self.tenant_a, name='Security Operations') - self.process_a = Process.objects.create( - tenant=self.tenant_a, - business_unit=self.business_unit, - owner=self.user_a, - name='Incident Intake', - scope='SOC', - description='SOC intake process', - status=Process.Status.PARTIAL, - documented=True, - approved=True, - communicated=True, - implemented=True, - ) - self.process_b = Process.objects.create( - tenant=self.tenant_b, - owner=self.user_b, - name='Foreign Process', - status=Process.Status.SUFFICIENT, - ) - - def test_process_list_only_shows_current_tenant(self): - self.client.force_login(self.user_a) - - response = self.client.get(reverse('processes:list')) - - self.assertEqual(response.status_code, 200) - processes = list(response.context['processes']) - self.assertEqual(processes, [self.process_a]) - self.assertEqual(response.context['process_register_source'], 'django') - self.assertContains(response, 'Incident Intake') - self.assertNotContains(response, 'Foreign Process') - - @patch('apps.processes.services.urlopen') - def test_process_list_can_use_rust_register_bridge(self, mock_urlopen): - response_mock = Mock() - response_mock.read.return_value = json.dumps({ - 'api_version': 'v1', - 'tenant_id': self.tenant_a.id, - 'processes': [ - self._rust_process_payload(name='Rust Incident Intake'), - ], - }).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - mock_urlopen.return_value = response_ctx - self.client.force_login(self.user_a) - - with self.settings(PROCESS_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.get(reverse('processes:list')) - - self.assertEqual(response.status_code, 200) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual(rust_request.full_url, 'http://rust-backend:9000/api/v1/processes') - self.assertEqual(rust_request.get_header('X-iscy-tenant-id'), str(self.tenant_a.id)) - self.assertEqual(rust_request.get_header('X-iscy-user-id'), str(self.user_a.id)) - self.assertEqual(rust_request.get_header('X-iscy-user-email'), 'process-user@example.test') - processes = list(response.context['processes']) - self.assertEqual(response.context['process_register_source'], 'rust_service') - self.assertEqual(len(processes), 1) - self.assertEqual(processes[0].name, 'Rust Incident Intake') - self.assertEqual(processes[0].tenant.name, 'Tenant A') - self.assertEqual(processes[0].get_status_display(), 'Vorhanden, aber unvollständig') - self.assertContains(response, 'Rust Incident Intake') - self.assertContains(response, 'Ada Lovelace') - - @patch('apps.processes.services.urlopen') - def test_process_detail_can_use_rust_register_bridge(self, mock_urlopen): - response_mock = Mock() - response_mock.read.return_value = json.dumps({ - 'api_version': 'v1', - 'process': self._rust_process_payload(description='Rust detail process'), - }).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - mock_urlopen.return_value = response_ctx - self.client.force_login(self.user_a) - - with self.settings(PROCESS_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.get(reverse('processes:detail', args=[self.process_a.pk])) - - self.assertEqual(response.status_code, 200) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual( - rust_request.full_url, - f'http://rust-backend:9000/api/v1/processes/{self.process_a.pk}', - ) - self.assertEqual(rust_request.get_header('X-iscy-tenant-id'), str(self.tenant_a.id)) - process = response.context['process'] - self.assertEqual(response.context['process_register_source'], 'rust_service') - self.assertEqual(process.description, 'Rust detail process') - self.assertEqual(process.owner.name, 'Ada Lovelace') - self.assertContains(response, 'Rust detail process') - self.assertContains(response, 'Operativ wirksam') - - @patch('apps.processes.services.urlopen', side_effect=OSError('backend down')) - def test_process_list_falls_back_to_django_when_rust_unavailable_in_non_strict_mode(self, _mock_urlopen): - self.client.force_login(self.user_a) - - with self.settings( - PROCESS_REGISTER_BACKEND='rust_service', - RUST_BACKEND_URL='http://rust-backend:9000', - RUST_STRICT_MODE=False, - ): - response = self.client.get(reverse('processes:list')) - - self.assertEqual(response.status_code, 200) - processes = list(response.context['processes']) - self.assertEqual(response.context['process_register_source'], 'django') - self.assertEqual(processes, [self.process_a]) - - def test_process_list_raises_in_strict_mode_without_rust_backend_url(self): - self.client.force_login(self.user_a) - - with self.settings(PROCESS_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='', RUST_STRICT_MODE=True): - with self.assertRaises(RuntimeError): - self.client.get(reverse('processes:list')) - - def _rust_process_payload(self, **overrides): - payload = { - 'id': self.process_a.id, - 'tenant_id': self.tenant_a.id, - 'business_unit_id': self.business_unit.id, - 'business_unit_name': 'Security Operations', - 'owner_id': self.user_a.id, - 'owner_display': 'Ada Lovelace', - 'name': 'Incident Intake', - 'scope': 'SOC', - 'description': 'SOC intake process', - 'status': 'PARTIAL', - 'status_label': 'Vorhanden, aber unvollständig', - 'documented': True, - 'approved': True, - 'communicated': True, - 'implemented': True, - 'effective': False, - 'evidenced': False, - 'reviewed_at': '2026-04-18', - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - } - payload.update(overrides) - return payload diff --git a/apps/processes/urls.py b/apps/processes/urls.py deleted file mode 100644 index bc5d779..0000000 --- a/apps/processes/urls.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.urls import path -from .views import ProcessCreateView, ProcessDetailView, ProcessListView - -app_name = 'processes' - -urlpatterns = [ - path('', ProcessListView.as_view(), name='list'), - path('new/', ProcessCreateView.as_view(), name='create'), - path('/', ProcessDetailView.as_view(), name='detail'), -] diff --git a/apps/processes/views.py b/apps/processes/views.py deleted file mode 100644 index 05db8c1..0000000 --- a/apps/processes/views.py +++ /dev/null @@ -1,72 +0,0 @@ -from django.contrib.auth.mixins import LoginRequiredMixin -from django.urls import reverse_lazy -from django.views.generic import CreateView, DetailView, ListView -from apps.core.mixins import TenantAccessMixin, TenantCreateMixin -from .forms import ProcessForm -from .models import Process -from .services import ProcessRegisterBridge - - -class ProcessListView(TenantAccessMixin, ListView): - model = Process - template_name = 'processes/process_list.html' - context_object_name = 'processes' - - def get_queryset(self): - rust_processes = ProcessRegisterBridge.fetch_list(self.request, self.get_tenant()) - if rust_processes is not None: - self.process_register_source = 'rust_service' - return rust_processes - - self.process_register_source = 'django' - return super().get_queryset().select_related('business_unit', 'owner') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['process_register_source'] = getattr(self, 'process_register_source', 'django') - return context - - -class ProcessDetailView(TenantAccessMixin, DetailView): - model = Process - template_name = 'processes/process_detail.html' - context_object_name = 'process' - - def get_object(self, queryset=None): - rust_process = ProcessRegisterBridge.fetch_detail( - self.request, - self.get_tenant(), - self.kwargs.get(self.pk_url_kwarg), - ) - if rust_process is not None: - self.process_register_source = 'rust_service' - self.check_tenant_access(rust_process) - return rust_process - - self.process_register_source = 'django' - return super().get_object(queryset) - - def get_context_data(self, **kwargs): - ctx = super().get_context_data(**kwargs) - p = self.object - ctx['process_register_source'] = getattr(self, 'process_register_source', 'django') - ctx['dimensions'] = [ - ('Dokumentiert', p.documented), - ('Genehmigt', p.approved), - ('Kommuniziert', p.communicated), - ('Implementiert', p.implemented), - ('Operativ wirksam', p.effective), - ('Evidenzbasiert', p.evidenced), - ('Owner zugewiesen', p.owner_id is not None), - ('Reviewed', p.reviewed_at is not None), - ('Versioniert', p.reviewed_at is not None and p.documented), - ('Historisiert', p.reviewed_at is not None), - ] - return ctx - - -class ProcessCreateView(TenantCreateMixin, CreateView): - model = Process - form_class = ProcessForm - template_name = 'shared/form.html' - success_url = reverse_lazy('processes:list') diff --git a/apps/product_security/__init__.py b/apps/product_security/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/product_security/admin.py b/apps/product_security/admin.py deleted file mode 100644 index 75e68f5..0000000 --- a/apps/product_security/admin.py +++ /dev/null @@ -1,35 +0,0 @@ -from django.contrib import admin -from .models import ( - AISystem, - Component, - PSIRTCase, - Product, - ProductFamily, - ProductRelease, - ProductSecurityRoadmap, - ProductSecurityRoadmapTask, - ProductSecuritySnapshot, - SecurityAdvisory, - TARA, - ThreatModel, - ThreatScenario, - Vulnerability, -) - -for model in [ - ProductFamily, - Product, - ProductRelease, - Component, - AISystem, - ThreatModel, - ThreatScenario, - TARA, - Vulnerability, - PSIRTCase, - SecurityAdvisory, - ProductSecurityRoadmap, - ProductSecurityRoadmapTask, - ProductSecuritySnapshot, -]: - admin.site.register(model) diff --git a/apps/product_security/apps.py b/apps/product_security/apps.py deleted file mode 100644 index f4a5115..0000000 --- a/apps/product_security/apps.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.apps import AppConfig - - -class ProductSecurityConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.product_security' - verbose_name = 'Product Security' diff --git a/apps/product_security/forms.py b/apps/product_security/forms.py deleted file mode 100644 index a5dec7b..0000000 --- a/apps/product_security/forms.py +++ /dev/null @@ -1,22 +0,0 @@ -from django import forms - -from .models import ProductSecurityRoadmapTask, Vulnerability - - -class ProductSecurityRoadmapTaskUpdateForm(forms.ModelForm): - class Meta: - model = ProductSecurityRoadmapTask - fields = ['status', 'priority', 'owner_role', 'due_in_days', 'dependency_text'] - widgets = { - 'dependency_text': forms.Textarea(attrs={'rows': 3}), - } - - -class ProductSecurityVulnerabilityUpdateForm(forms.ModelForm): - class Meta: - model = Vulnerability - fields = ['severity', 'status', 'remediation_due', 'summary'] - widgets = { - 'remediation_due': forms.DateInput(attrs={'type': 'date'}), - 'summary': forms.Textarea(attrs={'rows': 4}), - } diff --git a/apps/product_security/management/commands/seed_product_security.py b/apps/product_security/management/commands/seed_product_security.py deleted file mode 100644 index 3b5f43d..0000000 --- a/apps/product_security/management/commands/seed_product_security.py +++ /dev/null @@ -1,167 +0,0 @@ -from datetime import date, timedelta - -from django.core.management.base import BaseCommand - -from apps.organizations.models import Tenant -from apps.product_security.models import ( - AISystem, - Component, - PSIRTCase, - Product, - ProductFamily, - ProductRelease, - SecurityAdvisory, - TARA, - ThreatModel, - ThreatScenario, - Vulnerability, -) -from apps.product_security.services import ProductSecurityService - - -class Command(BaseCommand): - help = 'Seed demo product-security data for the demo tenant.' - - def handle(self, *args, **options): - tenant = Tenant.objects.filter(slug='tenant-demo').first() or Tenant.objects.first() - if not tenant: - self.stdout.write(self.style.WARNING('No tenant found. Run seed_demo first.')) - return - - family, _ = ProductFamily.objects.get_or_create( - tenant=tenant, - name='Core Platform', - defaults={'description': 'Digitale Kernprodukte und Services'}, - ) - product, _ = Product.objects.get_or_create( - tenant=tenant, - name='ISMS Secure Platform', - defaults={ - 'family': family, - 'code': 'isms-secure-platform', - 'description': 'Demoprodukt fuer Product Security / Secure Development.', - 'has_digital_elements': True, - 'includes_ai': tenant.uses_ai_systems, - 'ot_iacs_context': tenant.ot_iacs_scope, - 'automotive_context': tenant.automotive_scope, - 'support_window_months': 36, - }, - ) - release, _ = ProductRelease.objects.get_or_create( - tenant=tenant, - product=product, - version='1.0.0', - defaults={ - 'status': ProductRelease.Status.ACTIVE, - 'release_date': date.today() - timedelta(days=30), - 'support_end_date': date.today() + timedelta(days=720), - }, - ) - web_component, _ = Component.objects.get_or_create( - tenant=tenant, - product=product, - name='Web Application', - version='1.0.0', - defaults={'component_type': Component.Type.APPLICATION, 'has_sbom': tenant.sbom_required}, - ) - api_component, _ = Component.objects.get_or_create( - tenant=tenant, - product=product, - name='API Service', - version='1.0.0', - defaults={'component_type': Component.Type.SERVICE, 'has_sbom': tenant.sbom_required}, - ) - dependency_component, _ = Component.objects.get_or_create( - tenant=tenant, - product=product, - name='Auth Library', - version='2.4.1', - defaults={'component_type': Component.Type.LIBRARY, 'is_open_source': True, 'has_sbom': False}, - ) - - threat_model, _ = ThreatModel.objects.get_or_create( - tenant=tenant, - product=product, - release=release, - name='Initial Threat Model', - defaults={ - 'summary': 'Startpunkt fuer Threat Modeling und Security Reviews.', - 'status': ThreatModel.Status.APPROVED, - }, - ) - scenario, _ = ThreatScenario.objects.get_or_create( - tenant=tenant, - threat_model=threat_model, - title='API abuse via weak service account', - defaults={ - 'component': api_component, - 'category': ThreatScenario.Category.ELEVATION, - 'attack_path': 'Compromised token -> privileged API endpoint -> tenant data access.', - 'impact': 'Data exposure and unauthorized privileged action.', - 'severity': ThreatScenario.Severity.HIGH, - 'mitigation_status': 'Compensating controls planned', - }, - ) - TARA.objects.get_or_create( - tenant=tenant, - product=product, - release=release, - scenario=scenario, - name='Initial API abuse TARA', - defaults={ - 'summary': 'Risk decision and treatment strategy for privileged API abuse.', - 'attack_feasibility': 3, - 'impact_score': 4, - 'status': TARA.Status.IN_REVIEW, - 'treatment_decision': 'MFA, scope hardening and review of service accounts', - }, - ) - vuln, _ = Vulnerability.objects.get_or_create( - tenant=tenant, - product=product, - release=release, - component=dependency_component, - title='Outdated auth dependency with known vulnerability', - defaults={ - 'cve': 'CVE-2025-12345', - 'severity': Vulnerability.Severity.HIGH, - 'status': Vulnerability.Status.TRIAGED, - 'remediation_due': date.today() + timedelta(days=21), - 'summary': 'Authentication dependency should be upgraded and regression-tested.', - }, - ) - psirt_case, _ = PSIRTCase.objects.get_or_create( - tenant=tenant, - product=product, - release=release, - vulnerability=vuln, - case_id='PSIRT-2026-001', - defaults={ - 'title': 'Dependency vulnerability triage', - 'severity': vuln.severity, - 'status': PSIRTCase.Status.TRIAGE, - 'disclosure_due': date.today() + timedelta(days=30), - 'summary': 'Triage and customer communication preparation for dependency vulnerability.', - }, - ) - SecurityAdvisory.objects.get_or_create( - tenant=tenant, - product=product, - release=release, - psirt_case=psirt_case, - advisory_id='ADV-2026-001', - defaults={ - 'title': 'Security advisory draft for dependency issue', - 'status': SecurityAdvisory.Status.DRAFT, - 'summary': 'Draft advisory for customers once remediation plan is approved.', - }, - ) - if tenant.uses_ai_systems: - AISystem.objects.get_or_create( - tenant=tenant, - product=product, - name='Assisted Analysis Module', - defaults={'risk_classification': AISystem.RiskClass.LIMITED, 'provider': 'Internal / configurable'}, - ) - ProductSecurityService.sync_snapshot_for_tenant(tenant) - self.stdout.write(self.style.SUCCESS('Product Security demo data seeded.')) diff --git a/apps/product_security/migrations/0001_initial.py b/apps/product_security/migrations/0001_initial.py deleted file mode 100644 index 34cd409..0000000 --- a/apps/product_security/migrations/0001_initial.py +++ /dev/null @@ -1,175 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-09 14:39 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('organizations', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='Product', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=255)), - ('code', models.SlugField(blank=True, max_length=100)), - ('description', models.TextField(blank=True)), - ('has_digital_elements', models.BooleanField(default=True)), - ('includes_ai', models.BooleanField(default=False)), - ('ot_iacs_context', models.BooleanField(default=False)), - ('automotive_context', models.BooleanField(default=False)), - ('support_window_months', models.PositiveIntegerField(default=24)), - ('regulatory_profile_json', models.JSONField(blank=True, default=dict)), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='organizations.tenant')), - ], - options={ - 'ordering': ['name'], - }, - ), - migrations.CreateModel( - name='Component', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=255)), - ('component_type', models.CharField(choices=[('APPLICATION', 'Application'), ('LIBRARY', 'Library'), ('SERVICE', 'Service'), ('MODEL', 'AI Model'), ('FIRMWARE', 'Firmware'), ('OTHER', 'Other')], default='APPLICATION', max_length=16)), - ('version', models.CharField(blank=True, max_length=64)), - ('is_open_source', models.BooleanField(default=False)), - ('has_sbom', models.BooleanField(default=False)), - ('supplier', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='product_components', to='organizations.supplier')), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='organizations.tenant')), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='components', to='product_security.product')), - ], - options={ - 'ordering': ['product__name', 'name'], - 'unique_together': {('product', 'name', 'version')}, - }, - ), - migrations.CreateModel( - name='AISystem', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=255)), - ('use_case', models.TextField(blank=True)), - ('provider', models.CharField(blank=True, max_length=255)), - ('risk_classification', models.CharField(choices=[('NONE', 'Keine besondere AI-Risiko-Klasse'), ('LIMITED', 'Begrenztes Risiko'), ('HIGH', 'High-Risk / erhöhte Governance'), ('GPAI', 'General Purpose AI / Modellbezug')], default='NONE', max_length=16)), - ('in_scope', models.BooleanField(default=True)), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='organizations.tenant')), - ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ai_systems', to='product_security.product')), - ], - options={ - 'ordering': ['name'], - }, - ), - migrations.CreateModel( - name='ProductFamily', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=255)), - ('description', models.TextField(blank=True)), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='organizations.tenant')), - ], - options={ - 'ordering': ['name'], - 'unique_together': {('tenant', 'name')}, - }, - ), - migrations.AddField( - model_name='product', - name='family', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='products', to='product_security.productfamily'), - ), - migrations.CreateModel( - name='ProductSecuritySnapshot', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('cra_applicable', models.BooleanField(default=False)), - ('ai_act_applicable', models.BooleanField(default=False)), - ('iec62443_applicable', models.BooleanField(default=False)), - ('iso_sae_21434_applicable', models.BooleanField(default=False)), - ('cra_readiness_percent', models.PositiveIntegerField(default=0)), - ('ai_act_readiness_percent', models.PositiveIntegerField(default=0)), - ('iec62443_readiness_percent', models.PositiveIntegerField(default=0)), - ('iso_sae_21434_readiness_percent', models.PositiveIntegerField(default=0)), - ('summary', models.TextField(blank=True)), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='snapshots', to='product_security.product')), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='organizations.tenant')), - ], - options={ - 'ordering': ['-created_at'], - }, - ), - migrations.CreateModel( - name='ThreatModel', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=255)), - ('methodology', models.CharField(blank=True, default='STRIDE / ad hoc', max_length=100)), - ('summary', models.TextField(blank=True)), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='threat_models', to='product_security.product')), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='organizations.tenant')), - ], - options={ - 'ordering': ['product__name', 'name'], - }, - ), - migrations.CreateModel( - name='Vulnerability', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('title', models.CharField(max_length=255)), - ('cve', models.CharField(blank=True, max_length=50)), - ('severity', models.CharField(choices=[('CRITICAL', 'Kritisch'), ('HIGH', 'Hoch'), ('MEDIUM', 'Mittel'), ('LOW', 'Niedrig')], default='MEDIUM', max_length=16)), - ('status', models.CharField(choices=[('OPEN', 'Offen'), ('MITIGATED', 'Mitigiert'), ('FIXED', 'Behoben'), ('ACCEPTED', 'Akzeptiert')], default='OPEN', max_length=16)), - ('remediation_due', models.DateField(blank=True, null=True)), - ('summary', models.TextField(blank=True)), - ('component', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vulnerabilities', to='product_security.component')), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vulnerabilities', to='product_security.product')), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='organizations.tenant')), - ], - options={ - 'ordering': ['product__name', 'severity', 'title'], - }, - ), - migrations.AlterUniqueTogether( - name='product', - unique_together={('tenant', 'name')}, - ), - migrations.CreateModel( - name='ProductRelease', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('version', models.CharField(max_length=64)), - ('status', models.CharField(choices=[('PLANNED', 'Geplant'), ('ACTIVE', 'Aktiv'), ('MAINTENANCE', 'Wartung'), ('EOL', 'End of Life')], default='PLANNED', max_length=16)), - ('release_date', models.DateField(blank=True, null=True)), - ('support_end_date', models.DateField(blank=True, null=True)), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='releases', to='product_security.product')), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='organizations.tenant')), - ], - options={ - 'ordering': ['product__name', '-release_date', '-created_at'], - 'unique_together': {('product', 'version')}, - }, - ), - ] diff --git a/apps/product_security/migrations/0002_productsecuritysnapshot_critical_vulnerability_count_and_more.py b/apps/product_security/migrations/0002_productsecuritysnapshot_critical_vulnerability_count_and_more.py deleted file mode 100644 index 73ea206..0000000 --- a/apps/product_security/migrations/0002_productsecuritysnapshot_critical_vulnerability_count_and_more.py +++ /dev/null @@ -1,179 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-09 15:21 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('organizations', '0001_initial'), - ('product_security', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='productsecuritysnapshot', - name='critical_vulnerability_count', - field=models.PositiveIntegerField(default=0), - ), - migrations.AddField( - model_name='productsecuritysnapshot', - name='open_vulnerability_count', - field=models.PositiveIntegerField(default=0), - ), - migrations.AddField( - model_name='productsecuritysnapshot', - name='psirt_readiness_percent', - field=models.PositiveIntegerField(default=0), - ), - migrations.AddField( - model_name='productsecuritysnapshot', - name='threat_model_coverage_percent', - field=models.PositiveIntegerField(default=0), - ), - migrations.AddField( - model_name='threatmodel', - name='release', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='threat_models', to='product_security.productrelease'), - ), - migrations.AddField( - model_name='threatmodel', - name='status', - field=models.CharField(choices=[('DRAFT', 'Entwurf'), ('REVIEW', 'Im Review'), ('APPROVED', 'Freigegeben')], default='DRAFT', max_length=16), - ), - migrations.AddField( - model_name='vulnerability', - name='release', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vulnerabilities', to='product_security.productrelease'), - ), - migrations.AlterField( - model_name='vulnerability', - name='status', - field=models.CharField(choices=[('OPEN', 'Offen'), ('TRIAGED', 'Triagiert'), ('MITIGATED', 'Mitigiert'), ('FIXED', 'Behoben'), ('ACCEPTED', 'Akzeptiert')], default='OPEN', max_length=16), - ), - migrations.CreateModel( - name='ProductSecurityRoadmap', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('title', models.CharField(max_length=255)), - ('summary', models.TextField(blank=True)), - ('generated_from_snapshot', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='roadmaps', to='product_security.productsecuritysnapshot')), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='roadmaps', to='product_security.product')), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='organizations.tenant')), - ], - options={ - 'ordering': ['-created_at'], - }, - ), - migrations.CreateModel( - name='ProductSecurityRoadmapTask', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('phase', models.CharField(choices=[('GOVERNANCE', 'Governance'), ('MODELING', 'Threat Modeling / TARA'), ('DELIVERY', 'Secure Delivery'), ('RESPONSE', 'PSIRT / Response'), ('COMPLIANCE', 'Regulatory Readiness')], default='GOVERNANCE', max_length=16)), - ('title', models.CharField(max_length=255)), - ('description', models.TextField(blank=True)), - ('priority', models.CharField(blank=True, max_length=32)), - ('owner_role', models.CharField(blank=True, max_length=64)), - ('due_in_days', models.PositiveIntegerField(default=30)), - ('dependency_text', models.TextField(blank=True)), - ('status', models.CharField(choices=[('OPEN', 'Offen'), ('PLANNED', 'Geplant'), ('IN_PROGRESS', 'In Umsetzung'), ('DONE', 'Erledigt')], default='OPEN', max_length=16)), - ('related_release', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='product_roadmap_tasks', to='product_security.productrelease')), - ('related_vulnerability', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='product_roadmap_tasks', to='product_security.vulnerability')), - ('roadmap', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='product_security.productsecurityroadmap')), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='organizations.tenant')), - ], - options={ - 'ordering': ['phase', 'priority', 'title'], - }, - ), - migrations.CreateModel( - name='PSIRTCase', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('case_id', models.CharField(max_length=64)), - ('title', models.CharField(max_length=255)), - ('severity', models.CharField(choices=[('CRITICAL', 'Kritisch'), ('HIGH', 'Hoch'), ('MEDIUM', 'Mittel'), ('LOW', 'Niedrig')], default='MEDIUM', max_length=16)), - ('status', models.CharField(choices=[('NEW', 'Neu'), ('TRIAGE', 'Triage'), ('INVESTIGATING', 'In Analyse'), ('REMEDIATING', 'In Behebung'), ('ADVISORY', 'Advisory / Disclosure'), ('CLOSED', 'Abgeschlossen')], default='NEW', max_length=20)), - ('disclosure_due', models.DateField(blank=True, null=True)), - ('summary', models.TextField(blank=True)), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='psirt_cases', to='product_security.product')), - ('release', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='psirt_cases', to='product_security.productrelease')), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='organizations.tenant')), - ('vulnerability', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='psirt_cases', to='product_security.vulnerability')), - ], - options={ - 'ordering': ['product__name', '-created_at'], - 'unique_together': {('tenant', 'case_id')}, - }, - ), - migrations.CreateModel( - name='ThreatScenario', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('title', models.CharField(max_length=255)), - ('category', models.CharField(choices=[('SPOOFING', 'Spoofing'), ('TAMPERING', 'Tampering'), ('REPUDIATION', 'Repudiation'), ('INFO_DISCLOSURE', 'Information Disclosure'), ('DOS', 'Denial of Service'), ('ELEVATION', 'Elevation of Privilege'), ('SAFETY', 'Safety / Physical Impact'), ('SUPPLY_CHAIN', 'Supply Chain'), ('AI_MISUSE', 'AI Misuse / Model Risk'), ('OTHER', 'Other')], default='OTHER', max_length=32)), - ('attack_path', models.TextField(blank=True)), - ('impact', models.TextField(blank=True)), - ('severity', models.CharField(choices=[('CRITICAL', 'Kritisch'), ('HIGH', 'Hoch'), ('MEDIUM', 'Mittel'), ('LOW', 'Niedrig')], default='MEDIUM', max_length=16)), - ('mitigation_status', models.CharField(blank=True, default='Open', max_length=64)), - ('component', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='threat_scenarios', to='product_security.component')), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='organizations.tenant')), - ('threat_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scenarios', to='product_security.threatmodel')), - ], - options={ - 'ordering': ['threat_model__product__name', 'severity', 'title'], - }, - ), - migrations.CreateModel( - name='TARA', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=255)), - ('summary', models.TextField(blank=True)), - ('attack_feasibility', models.PositiveIntegerField(default=2)), - ('impact_score', models.PositiveIntegerField(default=2)), - ('risk_score', models.PositiveIntegerField(default=4)), - ('status', models.CharField(choices=[('OPEN', 'Offen'), ('IN_REVIEW', 'Im Review'), ('ACCEPTED', 'Akzeptiert'), ('MITIGATED', 'Mitigiert')], default='OPEN', max_length=16)), - ('treatment_decision', models.CharField(blank=True, max_length=128)), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='taras', to='product_security.product')), - ('release', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='taras', to='product_security.productrelease')), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='organizations.tenant')), - ('scenario', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='taras', to='product_security.threatscenario')), - ], - options={ - 'ordering': ['product__name', '-risk_score', 'name'], - }, - ), - migrations.CreateModel( - name='SecurityAdvisory', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('advisory_id', models.CharField(max_length=64)), - ('title', models.CharField(max_length=255)), - ('status', models.CharField(choices=[('DRAFT', 'Entwurf'), ('REVIEW', 'Im Review'), ('PUBLISHED', 'Veröffentlicht')], default='DRAFT', max_length=16)), - ('published_on', models.DateField(blank=True, null=True)), - ('summary', models.TextField(blank=True)), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='advisories', to='product_security.product')), - ('psirt_case', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='advisories', to='product_security.psirtcase')), - ('release', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='advisories', to='product_security.productrelease')), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='organizations.tenant')), - ], - options={ - 'ordering': ['product__name', '-created_at'], - 'unique_together': {('tenant', 'advisory_id')}, - }, - ), - ] diff --git a/apps/product_security/migrations/__init__.py b/apps/product_security/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/product_security/models.py b/apps/product_security/models.py deleted file mode 100644 index 17ee96c..0000000 --- a/apps/product_security/models.py +++ /dev/null @@ -1,401 +0,0 @@ -from django.db import models -from apps.core.models import TenantModel - - -class ProductFamily(TenantModel): - name = models.CharField(max_length=255) - description = models.TextField(blank=True) - - class Meta: - ordering = ['name'] - unique_together = ('tenant', 'name') - - def __str__(self): - return self.name - - -class Product(TenantModel): - tenant_relation_fields = { - 'family': 'tenant_id', - } - - family = models.ForeignKey(ProductFamily, on_delete=models.SET_NULL, null=True, blank=True, related_name='products') - name = models.CharField(max_length=255) - code = models.SlugField(max_length=100, blank=True) - description = models.TextField(blank=True) - has_digital_elements = models.BooleanField(default=True) - includes_ai = models.BooleanField(default=False) - ot_iacs_context = models.BooleanField(default=False) - automotive_context = models.BooleanField(default=False) - support_window_months = models.PositiveIntegerField(default=24) - regulatory_profile_json = models.JSONField(default=dict, blank=True) - - class Meta: - ordering = ['name'] - unique_together = ('tenant', 'name') - - def __str__(self): - return self.name - - -class ProductRelease(TenantModel): - tenant_relation_fields = { - 'product': 'tenant_id', - } - - class Status(models.TextChoices): - PLANNED = 'PLANNED', 'Geplant' - ACTIVE = 'ACTIVE', 'Aktiv' - MAINTENANCE = 'MAINTENANCE', 'Wartung' - EOL = 'EOL', 'End of Life' - - product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='releases') - version = models.CharField(max_length=64) - status = models.CharField(max_length=16, choices=Status.choices, default=Status.PLANNED) - release_date = models.DateField(null=True, blank=True) - support_end_date = models.DateField(null=True, blank=True) - - class Meta: - ordering = ['product__name', '-release_date', '-created_at'] - unique_together = ('product', 'version') - - def __str__(self): - return f'{self.product.name} {self.version}' - - -class Component(TenantModel): - tenant_relation_fields = { - 'product': 'tenant_id', - 'supplier': 'tenant_id', - } - - class Type(models.TextChoices): - APPLICATION = 'APPLICATION', 'Application' - LIBRARY = 'LIBRARY', 'Library' - SERVICE = 'SERVICE', 'Service' - MODEL = 'MODEL', 'AI Model' - FIRMWARE = 'FIRMWARE', 'Firmware' - OTHER = 'OTHER', 'Other' - - product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='components') - supplier = models.ForeignKey('organizations.Supplier', on_delete=models.SET_NULL, null=True, blank=True, related_name='product_components') - name = models.CharField(max_length=255) - component_type = models.CharField(max_length=16, choices=Type.choices, default=Type.APPLICATION) - version = models.CharField(max_length=64, blank=True) - is_open_source = models.BooleanField(default=False) - has_sbom = models.BooleanField(default=False) - - class Meta: - ordering = ['product__name', 'name'] - unique_together = ('product', 'name', 'version') - - def __str__(self): - return f'{self.product.name}: {self.name}' - - -class AISystem(TenantModel): - tenant_relation_fields = { - 'product': 'tenant_id', - } - - class RiskClass(models.TextChoices): - NONE = 'NONE', 'Keine besondere AI-Risiko-Klasse' - LIMITED = 'LIMITED', 'Begrenztes Risiko' - HIGH = 'HIGH', 'High-Risk / erhöhte Governance' - GPAI = 'GPAI', 'General Purpose AI / Modellbezug' - - product = models.ForeignKey(Product, on_delete=models.SET_NULL, null=True, blank=True, related_name='ai_systems') - name = models.CharField(max_length=255) - use_case = models.TextField(blank=True) - provider = models.CharField(max_length=255, blank=True) - risk_classification = models.CharField(max_length=16, choices=RiskClass.choices, default=RiskClass.NONE) - in_scope = models.BooleanField(default=True) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - - -class ThreatModel(TenantModel): - tenant_relation_fields = { - 'product': 'tenant_id', - 'release': 'tenant_id', - } - - class Status(models.TextChoices): - DRAFT = 'DRAFT', 'Entwurf' - REVIEW = 'REVIEW', 'Im Review' - APPROVED = 'APPROVED', 'Freigegeben' - - product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='threat_models') - release = models.ForeignKey(ProductRelease, on_delete=models.SET_NULL, null=True, blank=True, related_name='threat_models') - name = models.CharField(max_length=255) - methodology = models.CharField(max_length=100, blank=True, default='STRIDE / ad hoc') - summary = models.TextField(blank=True) - status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT) - - class Meta: - ordering = ['product__name', 'name'] - - def __str__(self): - return self.name - - -class ThreatScenario(TenantModel): - tenant_relation_fields = { - 'threat_model': 'tenant_id', - 'component': 'tenant_id', - } - - class Category(models.TextChoices): - SPOOFING = 'SPOOFING', 'Spoofing' - TAMPERING = 'TAMPERING', 'Tampering' - REPUDIATION = 'REPUDIATION', 'Repudiation' - INFORMATION_DISCLOSURE = 'INFO_DISCLOSURE', 'Information Disclosure' - DENIAL_OF_SERVICE = 'DOS', 'Denial of Service' - ELEVATION = 'ELEVATION', 'Elevation of Privilege' - SAFETY = 'SAFETY', 'Safety / Physical Impact' - SUPPLY_CHAIN = 'SUPPLY_CHAIN', 'Supply Chain' - AI_MISUSE = 'AI_MISUSE', 'AI Misuse / Model Risk' - OTHER = 'OTHER', 'Other' - - class Severity(models.TextChoices): - CRITICAL = 'CRITICAL', 'Kritisch' - HIGH = 'HIGH', 'Hoch' - MEDIUM = 'MEDIUM', 'Mittel' - LOW = 'LOW', 'Niedrig' - - threat_model = models.ForeignKey(ThreatModel, on_delete=models.CASCADE, related_name='scenarios') - component = models.ForeignKey(Component, on_delete=models.SET_NULL, null=True, blank=True, related_name='threat_scenarios') - title = models.CharField(max_length=255) - category = models.CharField(max_length=32, choices=Category.choices, default=Category.OTHER) - attack_path = models.TextField(blank=True) - impact = models.TextField(blank=True) - severity = models.CharField(max_length=16, choices=Severity.choices, default=Severity.MEDIUM) - mitigation_status = models.CharField(max_length=64, blank=True, default='Open') - - class Meta: - ordering = ['threat_model__product__name', 'severity', 'title'] - - def __str__(self): - return self.title - - -class TARA(TenantModel): - tenant_relation_fields = { - 'product': 'tenant_id', - 'release': 'tenant_id', - 'scenario': 'tenant_id', - } - - class Status(models.TextChoices): - OPEN = 'OPEN', 'Offen' - IN_REVIEW = 'IN_REVIEW', 'Im Review' - ACCEPTED = 'ACCEPTED', 'Akzeptiert' - MITIGATED = 'MITIGATED', 'Mitigiert' - - product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='taras') - release = models.ForeignKey(ProductRelease, on_delete=models.SET_NULL, null=True, blank=True, related_name='taras') - scenario = models.ForeignKey(ThreatScenario, on_delete=models.SET_NULL, null=True, blank=True, related_name='taras') - name = models.CharField(max_length=255) - summary = models.TextField(blank=True) - attack_feasibility = models.PositiveIntegerField(default=2) - impact_score = models.PositiveIntegerField(default=2) - risk_score = models.PositiveIntegerField(default=4) - status = models.CharField(max_length=16, choices=Status.choices, default=Status.OPEN) - treatment_decision = models.CharField(max_length=128, blank=True) - - class Meta: - ordering = ['product__name', '-risk_score', 'name'] - - def save(self, *args, **kwargs): - self.risk_score = max(1, self.attack_feasibility) * max(1, self.impact_score) - super().save(*args, **kwargs) - - def __str__(self): - return self.name - - -class Vulnerability(TenantModel): - tenant_relation_fields = { - 'product': 'tenant_id', - 'release': 'tenant_id', - 'component': 'tenant_id', - } - - class Severity(models.TextChoices): - CRITICAL = 'CRITICAL', 'Kritisch' - HIGH = 'HIGH', 'Hoch' - MEDIUM = 'MEDIUM', 'Mittel' - LOW = 'LOW', 'Niedrig' - - class Status(models.TextChoices): - OPEN = 'OPEN', 'Offen' - TRIAGED = 'TRIAGED', 'Triagiert' - MITIGATED = 'MITIGATED', 'Mitigiert' - FIXED = 'FIXED', 'Behoben' - ACCEPTED = 'ACCEPTED', 'Akzeptiert' - - product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='vulnerabilities') - release = models.ForeignKey(ProductRelease, on_delete=models.SET_NULL, null=True, blank=True, related_name='vulnerabilities') - component = models.ForeignKey(Component, on_delete=models.SET_NULL, null=True, blank=True, related_name='vulnerabilities') - title = models.CharField(max_length=255) - cve = models.CharField(max_length=50, blank=True) - severity = models.CharField(max_length=16, choices=Severity.choices, default=Severity.MEDIUM) - status = models.CharField(max_length=16, choices=Status.choices, default=Status.OPEN) - remediation_due = models.DateField(null=True, blank=True) - summary = models.TextField(blank=True) - - class Meta: - ordering = ['product__name', 'severity', 'title'] - - def __str__(self): - return self.title - - -class PSIRTCase(TenantModel): - tenant_relation_fields = { - 'product': 'tenant_id', - 'release': 'tenant_id', - 'vulnerability': 'tenant_id', - } - - class Status(models.TextChoices): - NEW = 'NEW', 'Neu' - TRIAGE = 'TRIAGE', 'Triage' - INVESTIGATING = 'INVESTIGATING', 'In Analyse' - REMEDIATING = 'REMEDIATING', 'In Behebung' - ADVISORY = 'ADVISORY', 'Advisory / Disclosure' - CLOSED = 'CLOSED', 'Abgeschlossen' - - product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='psirt_cases') - release = models.ForeignKey(ProductRelease, on_delete=models.SET_NULL, null=True, blank=True, related_name='psirt_cases') - vulnerability = models.ForeignKey(Vulnerability, on_delete=models.SET_NULL, null=True, blank=True, related_name='psirt_cases') - case_id = models.CharField(max_length=64) - title = models.CharField(max_length=255) - severity = models.CharField(max_length=16, choices=Vulnerability.Severity.choices, default=Vulnerability.Severity.MEDIUM) - status = models.CharField(max_length=20, choices=Status.choices, default=Status.NEW) - disclosure_due = models.DateField(null=True, blank=True) - summary = models.TextField(blank=True) - - class Meta: - ordering = ['product__name', '-created_at'] - unique_together = ('tenant', 'case_id') - - def __str__(self): - return self.case_id - - -class SecurityAdvisory(TenantModel): - tenant_relation_fields = { - 'product': 'tenant_id', - 'release': 'tenant_id', - 'psirt_case': 'tenant_id', - } - - class Status(models.TextChoices): - DRAFT = 'DRAFT', 'Entwurf' - REVIEW = 'REVIEW', 'Im Review' - PUBLISHED = 'PUBLISHED', 'Veröffentlicht' - - product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='advisories') - release = models.ForeignKey(ProductRelease, on_delete=models.SET_NULL, null=True, blank=True, related_name='advisories') - psirt_case = models.ForeignKey(PSIRTCase, on_delete=models.SET_NULL, null=True, blank=True, related_name='advisories') - advisory_id = models.CharField(max_length=64) - title = models.CharField(max_length=255) - status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT) - published_on = models.DateField(null=True, blank=True) - summary = models.TextField(blank=True) - - class Meta: - ordering = ['product__name', '-created_at'] - unique_together = ('tenant', 'advisory_id') - - def __str__(self): - return self.advisory_id - - -class ProductSecurityRoadmap(TenantModel): - tenant_relation_fields = { - 'product': 'tenant_id', - 'generated_from_snapshot': 'tenant_id', - } - - product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='roadmaps') - title = models.CharField(max_length=255) - summary = models.TextField(blank=True) - generated_from_snapshot = models.ForeignKey('ProductSecuritySnapshot', on_delete=models.SET_NULL, null=True, blank=True, related_name='roadmaps') - - class Meta: - ordering = ['-created_at'] - - def __str__(self): - return self.title - - -class ProductSecurityRoadmapTask(TenantModel): - tenant_relation_fields = { - 'roadmap': 'tenant_id', - 'related_release': 'tenant_id', - 'related_vulnerability': 'tenant_id', - } - - class Phase(models.TextChoices): - GOVERNANCE = 'GOVERNANCE', 'Governance' - MODELING = 'MODELING', 'Threat Modeling / TARA' - DELIVERY = 'DELIVERY', 'Secure Delivery' - RESPONSE = 'RESPONSE', 'PSIRT / Response' - COMPLIANCE = 'COMPLIANCE', 'Regulatory Readiness' - - class Status(models.TextChoices): - OPEN = 'OPEN', 'Offen' - PLANNED = 'PLANNED', 'Geplant' - IN_PROGRESS = 'IN_PROGRESS', 'In Umsetzung' - DONE = 'DONE', 'Erledigt' - - roadmap = models.ForeignKey(ProductSecurityRoadmap, on_delete=models.CASCADE, related_name='tasks') - related_release = models.ForeignKey(ProductRelease, on_delete=models.SET_NULL, null=True, blank=True, related_name='product_roadmap_tasks') - related_vulnerability = models.ForeignKey(Vulnerability, on_delete=models.SET_NULL, null=True, blank=True, related_name='product_roadmap_tasks') - phase = models.CharField(max_length=16, choices=Phase.choices, default=Phase.GOVERNANCE) - title = models.CharField(max_length=255) - description = models.TextField(blank=True) - priority = models.CharField(max_length=32, blank=True) - owner_role = models.CharField(max_length=64, blank=True) - due_in_days = models.PositiveIntegerField(default=30) - dependency_text = models.TextField(blank=True) - status = models.CharField(max_length=16, choices=Status.choices, default=Status.OPEN) - - class Meta: - ordering = ['phase', 'priority', 'title'] - - def __str__(self): - return self.title - - -class ProductSecuritySnapshot(TenantModel): - tenant_relation_fields = { - 'product': 'tenant_id', - } - - product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='snapshots') - cra_applicable = models.BooleanField(default=False) - ai_act_applicable = models.BooleanField(default=False) - iec62443_applicable = models.BooleanField(default=False) - iso_sae_21434_applicable = models.BooleanField(default=False) - cra_readiness_percent = models.PositiveIntegerField(default=0) - ai_act_readiness_percent = models.PositiveIntegerField(default=0) - iec62443_readiness_percent = models.PositiveIntegerField(default=0) - iso_sae_21434_readiness_percent = models.PositiveIntegerField(default=0) - threat_model_coverage_percent = models.PositiveIntegerField(default=0) - psirt_readiness_percent = models.PositiveIntegerField(default=0) - open_vulnerability_count = models.PositiveIntegerField(default=0) - critical_vulnerability_count = models.PositiveIntegerField(default=0) - summary = models.TextField(blank=True) - - class Meta: - ordering = ['-created_at'] - - def __str__(self): - return f'Snapshot {self.product.name} {self.created_at:%Y-%m-%d}' diff --git a/apps/product_security/services.py b/apps/product_security/services.py deleted file mode 100644 index afce7d6..0000000 --- a/apps/product_security/services.py +++ /dev/null @@ -1,349 +0,0 @@ -from datetime import timedelta - -from django.utils import timezone - -from apps.catalog.models import AssessmentQuestion -from apps.organizations.sector_catalog import get_sector_definition -from .models import ( - AISystem, - PSIRTCase, - Product, - ProductRelease, - ProductSecurityRoadmap, - ProductSecurityRoadmapTask, - ProductSecuritySnapshot, - SecurityAdvisory, - TARA, - ThreatModel, - Vulnerability, -) - - -class ProductSecurityService: - @staticmethod - def get_regime_matrix(tenant): - sector = get_sector_definition(getattr(tenant, 'sector', None)) - develops = bool(getattr(tenant, 'develops_digital_products', False)) - uses_ai = bool(getattr(tenant, 'uses_ai_systems', False)) - ot_scope = bool(getattr(tenant, 'ot_iacs_scope', False)) - automotive_scope = bool(getattr(tenant, 'automotive_scope', False)) - - cra = develops - ai_act = uses_ai - iec = ot_scope or sector.code in {'ENERGY', 'HYDROGEN', 'DRINKING_WATER', 'WASTEWATER', 'CHEMICALS', 'MANUFACTURING'} - iso21434 = automotive_scope - - matrix = { - 'cra': { - 'applicable': cra, - 'label': 'CRA', - 'reason': 'Relevant fuer Produkte mit digitalen Elementen und deren Lifecycle.' if cra else 'Derzeit kein klarer Product-with-Digital-Elements-Fokus erkennbar.', - }, - 'ai_act': { - 'applicable': ai_act, - 'label': 'AI Act', - 'reason': 'AI-Systeme/Modelle/Funktionen sind im Scope und erfordern Governance und Dokumentation.' if ai_act else 'Kein AI-System-/Modell-Scope angegeben.', - }, - 'iec62443': { - 'applicable': iec, - 'label': 'IEC 62443', - 'reason': 'OT-/IACS-/Industrie- oder kritische Anlagenkontexte im Scope.' if iec else 'Kein expliziter OT-/IACS-Kontext im Scope.', - }, - 'iso_sae_21434': { - 'applicable': iso21434, - 'label': 'ISO/SAE 21434', - 'reason': 'Automotive-/Fahrzeug-/E/E-Kontext angegeben.' if iso21434 else 'Kein Automotive-/Fahrzeugkontext im Scope.', - }, - } - matrix['summary'] = ProductSecurityService.build_summary(tenant, matrix) - return matrix - - @staticmethod - def build_summary(tenant, matrix=None): - matrix = matrix or ProductSecurityService.get_regime_matrix(tenant) - active = [item['label'] for key, item in matrix.items() if isinstance(item, dict) and item.get('applicable')] - if active: - return f'Product Security ist relevant. Aktive Regime/Standards: {", ".join(active)}.' - if getattr(tenant, 'develops_digital_products', False): - return 'Es werden digitale Produkte entwickelt; mindestens CRA-/Secure-Development-Readiness sollte bewertet werden.' - return 'Kein ausgeprägter Product-Security-Scope angegeben. Fokus bleibt auf Enterprise-ISMS.' - - @staticmethod - def generate_measures(session): - tenant = session.tenant - matrix = ProductSecurityService.get_regime_matrix(tenant) - created_titles = [] - if getattr(tenant, 'develops_digital_products', False): - created_titles.append(ProductSecurityService._get_or_create_measure( - session, - title='Product Security Governance und Secure Development Lifecycle etablieren', - description='Secure Development, Security Requirements, Design Reviews, Testing, Release Gates und Lifecycle-Verantwortung fuer digitale Produkte definieren.', - priority='CRITICAL', effort='LARGE', measure_type='ORGANIZATIONAL', target_phase='Phase 5 – Strukturmassnahmen', owner_role='ISMS Manager', reason='Digitale Produkte/Software im Scope.', - )) - created_titles.append(ProductSecurityService._get_or_create_measure( - session, - title='SBOM-/Komponenten- und Dependency-Governance aufbauen', - description='Komponenten, Bibliotheken, OSS und Supplier-Abhaengigkeiten inventarisieren und auf Produkt-/Release-Kontext mappen.', - priority='HIGH', effort='MEDIUM', measure_type='TECHNICAL', target_phase='Phase 2 – Transparenz', owner_role='Compliance Manager', reason='Digitale Produkte erfordern Transparenz ueber Komponenten und Dependencies.', - )) - if matrix['cra']['applicable']: - created_titles.append(ProductSecurityService._get_or_create_measure( - session, - title='CRA-Readiness fuer Produkte mit digitalen Elementen vorbereiten', - description='Lifecycle-Security, Vulnerability Handling, Support-/Patch-Management, Security-Dokumentation und Konformitaetsvorbereitung fuer digitale Produkte konkretisieren.', - priority='CRITICAL', effort='LARGE', measure_type='DOCUMENTARY', target_phase='Phase 1 – Governance', owner_role='Compliance Manager', reason='CRA Applicability aktiv.', - )) - if matrix['ai_act']['applicable']: - created_titles.append(ProductSecurityService._get_or_create_measure( - session, - title='AI Governance und AI-Act-Readiness aufbauen', - description='AI-Systeminventar, Risikoklassifizierung, Dokumentation, Logging, Human Oversight und Provider-/Modell-Governance definieren.', - priority='HIGH', effort='LARGE', measure_type='ORGANIZATIONAL', target_phase='Phase 5 – Strukturmassnahmen', owner_role='Compliance Manager', reason='AI Act Applicability aktiv.', - )) - if matrix['iec62443']['applicable']: - created_titles.append(ProductSecurityService._get_or_create_measure( - session, - title='OT-/IEC-62443-orientierte Security Controls und Segmentierung definieren', - description='Zonen, Conduits, Security Levels, Integrator-/Supplier-Sicht und industrielle Nachweise fuer OT-/IACS-Kontexte erarbeiten.', - priority='HIGH', effort='LARGE', measure_type='TECHNICAL', target_phase='Phase 5 – Strukturmassnahmen', owner_role='Security Officer', reason='IEC 62443 Applicability aktiv.', - )) - if matrix['iso_sae_21434']['applicable']: - created_titles.append(ProductSecurityService._get_or_create_measure( - session, - title='Automotive Cybersecurity Engineering nach ISO/SAE 21434 vorbereiten', - description='TARA, Cybersecurity Goals, Lifecycle-Traceability, Supplier-Einbindung und Field-Monitoring fuer Fahrzeug-/E/E-Kontexte strukturieren.', - priority='HIGH', effort='LARGE', measure_type='ORGANIZATIONAL', target_phase='Phase 5 – Strukturmassnahmen', owner_role='Security Officer', reason='ISO/SAE 21434 Applicability aktiv.', - )) - if getattr(tenant, 'develops_digital_products', False) and not getattr(tenant, 'psirt_defined', False): - created_titles.append(ProductSecurityService._get_or_create_measure( - session, - title='PSIRT-/Vulnerability-Handling-Prozess etablieren', - description='Schwachstellenaufnahme, Triage, Advisory-/Disclosure-Prozess, Kundenkommunikation und Patch-Steuerung definieren.', - priority='CRITICAL', effort='MEDIUM', measure_type='ORGANIZATIONAL', target_phase='Phase 4 – Quick Wins', owner_role='Security Officer', reason='Digitale Produkte ohne definierten PSIRT-Prozess.', - )) - return [title for title in created_titles if title] - - @staticmethod - def _get_or_create_measure(session, **defaults): - measure, _ = session.generated_measures.get_or_create(title=defaults['title'], defaults=defaults) - return measure.title - - @staticmethod - def _calc_percent(session, **filters): - qs = session.answers.filter(question__question_kind=AssessmentQuestion.Kind.MATURITY, **filters).exclude(selected_option__is_na=True) - total = qs.count() - if total == 0: - return 0 - return min(100, int((sum(item.score for item in qs) / (total * 5)) * 100)) - - @staticmethod - def calculate_framework_readiness(session): - return { - 'cra_readiness_percent': ProductSecurityService._calc_percent(session, question__applies_to_cra=True), - 'ai_act_readiness_percent': ProductSecurityService._calc_percent(session, question__applies_to_ai_act=True), - 'iec62443_readiness_percent': ProductSecurityService._calc_percent(session, question__applies_to_iec62443=True), - 'iso_sae_21434_readiness_percent': ProductSecurityService._calc_percent(session, question__applies_to_iso_sae_21434=True), - } - - @staticmethod - def _threat_model_coverage(product): - releases = max(product.releases.count(), 1) - approved_count = product.threat_models.filter(status=ThreatModel.Status.APPROVED).count() - all_count = product.threat_models.count() - return min(100, int(((approved_count * 1.0) + (all_count * 0.35)) / releases * 100)) - - @staticmethod - def _psirt_readiness(product): - score = 20 - open_cases = product.psirt_cases.exclude(status=PSIRTCase.Status.CLOSED).count() - published_advisories = product.advisories.filter(status=SecurityAdvisory.Status.PUBLISHED).count() - if product.psirt_cases.exists(): - score += 25 - if published_advisories: - score += 20 - if product.vulnerabilities.exclude(status__in=[Vulnerability.Status.FIXED, Vulnerability.Status.ACCEPTED]).count() == 0: - score += 20 - if product.components.filter(has_sbom=True).exists(): - score += 15 - if open_cases == 0: - score += 10 - return min(score, 100) - - @staticmethod - def sync_snapshot_for_tenant(tenant, session=None): - matrix = ProductSecurityService.get_regime_matrix(tenant) - readiness = ProductSecurityService.calculate_framework_readiness(session) if session else { - 'cra_readiness_percent': 0, - 'ai_act_readiness_percent': 0, - 'iec62443_readiness_percent': 0, - 'iso_sae_21434_readiness_percent': 0, - } - for product in Product.objects.filter(tenant=tenant): - open_vulns = product.vulnerabilities.exclude(status__in=[Vulnerability.Status.FIXED, Vulnerability.Status.ACCEPTED]) - ProductSecuritySnapshot.objects.update_or_create( - tenant=tenant, - product=product, - defaults={ - 'cra_applicable': matrix['cra']['applicable'], - 'ai_act_applicable': matrix['ai_act']['applicable'], - 'iec62443_applicable': matrix['iec62443']['applicable'], - 'iso_sae_21434_applicable': matrix['iso_sae_21434']['applicable'], - **readiness, - 'threat_model_coverage_percent': ProductSecurityService._threat_model_coverage(product), - 'psirt_readiness_percent': ProductSecurityService._psirt_readiness(product), - 'open_vulnerability_count': open_vulns.count(), - 'critical_vulnerability_count': open_vulns.filter(severity=Vulnerability.Severity.CRITICAL).count(), - 'summary': matrix['summary'], - }, - ) - ProductSecurityService.generate_product_roadmap(product) - - @staticmethod - def generate_product_roadmap(product): - snapshot = product.snapshots.first() - roadmap, _ = ProductSecurityRoadmap.objects.update_or_create( - tenant=product.tenant, - product=product, - defaults={ - 'title': f'Product Security Roadmap – {product.name}', - 'summary': 'Aus Product-Security-Regimen, Threat Modeling, Releases und Schwachstellen abgeleitete Roadmap.', - 'generated_from_snapshot': snapshot, - }, - ) - roadmap.tasks.all().delete() - - def add_task(phase, title, description, priority='HIGH', owner='Product Security Lead', due=45, dependency=''): - ProductSecurityRoadmapTask.objects.create( - tenant=product.tenant, - roadmap=roadmap, - phase=phase, - title=title, - description=description, - priority=priority, - owner_role=owner, - due_in_days=due, - dependency_text=dependency, - ) - - add_task( - ProductSecurityRoadmapTask.Phase.GOVERNANCE, - 'Produkt-Scope, Verantwortlichkeiten und Security Sign-off festigen', - 'Produktverantwortung, Release-Governance und Security-Sign-off pro Produkt und Release verbindlich machen.', - priority='CRITICAL', - due=21, - ) - if product.has_digital_elements: - add_task( - ProductSecurityRoadmapTask.Phase.COMPLIANCE, - 'CRA-Lifecycle-Readiness und Support-/Patch-Strategie konkretisieren', - 'Supportfenster, Patch-Prozess, Security-Dokumentation und Kundenkommunikation fuer digitale Produkte nachschärfen.', - priority='CRITICAL', - owner='Compliance Manager', - due=30, - ) - if product.includes_ai: - add_task( - ProductSecurityRoadmapTask.Phase.COMPLIANCE, - 'AI Governance, Risikoklassifizierung und Monitoring vertiefen', - 'AI-Systeminventar, Provider-Register, Modellbeobachtung und Human Oversight definieren.', - owner='AI Governance Lead', - due=35, - ) - if product.ot_iacs_context: - add_task( - ProductSecurityRoadmapTask.Phase.MODELING, - 'OT-/IEC-62443-Architekturbewertung durchführen', - 'Zonen, Conduits, Segmentierung und Security Level fuer industrielle Kontexte konkretisieren.', - owner='OT Security Lead', - due=45, - ) - if product.automotive_context: - add_task( - ProductSecurityRoadmapTask.Phase.MODELING, - 'TARA und Cybersecurity Goals aufsetzen', - 'ISO/SAE-21434-nahe TARA fuer Produkt/Release und kritische Komponenten durchführen.', - owner='Automotive Security Lead', - due=40, - ) - if product.threat_models.count() == 0: - add_task( - ProductSecurityRoadmapTask.Phase.MODELING, - 'Threat Model für aktuelles Release erstellen', - 'Mindestens ein Threat Model pro aktivem/nahem Release etablieren.', - dependency='Governance/Security Sign-off sollte zuerst gesetzt sein.', - ) - elif product.threat_models.filter(status=ThreatModel.Status.APPROVED).count() == 0: - add_task( - ProductSecurityRoadmapTask.Phase.MODELING, - 'Bestehende Threat Models reviewen und freigeben', - 'Vorhandene Threat Models fachlich reviewen und in Release-Gates integrieren.', - priority='HIGH', - due=20, - ) - if product.taras.count() == 0: - add_task( - ProductSecurityRoadmapTask.Phase.MODELING, - 'TARA / Risikoentscheidung dokumentieren', - 'Threat Scenarios in konkrete Risikoentscheidungen und Maßnahmen überführen.', - due=28, - ) - - open_vulns = list(product.vulnerabilities.exclude(status__in=[Vulnerability.Status.FIXED, Vulnerability.Status.ACCEPTED]).order_by('severity', 'title')) - if open_vulns: - top_vuln = open_vulns[0] - ProductSecurityRoadmapTask.objects.create( - tenant=product.tenant, - roadmap=roadmap, - phase=ProductSecurityRoadmapTask.Phase.RESPONSE, - related_release=top_vuln.release, - related_vulnerability=top_vuln, - title='Kritische offene Schwachstellen priorisiert beheben', - description=f'Priorisierte Behandlung der offenen Schwachstellen, beginnend mit {top_vuln.title}.', - priority='CRITICAL' if top_vuln.severity == Vulnerability.Severity.CRITICAL else 'HIGH', - owner_role='PSIRT Lead', - due_in_days=14 if top_vuln.severity == Vulnerability.Severity.CRITICAL else 30, - dependency_text='Abhängig von Triage, Fix-Planung und Testfreigabe.', - ) - if product.psirt_cases.count() == 0: - add_task( - ProductSecurityRoadmapTask.Phase.RESPONSE, - 'PSIRT-Case-Workflow einführen', - 'Case IDs, Triage, Disclosure-Fristen und Kundenkommunikation für Product Security Incidents einführen.', - owner='PSIRT Lead', - due=21, - ) - if product.components.filter(has_sbom=False).exists(): - add_task( - ProductSecurityRoadmapTask.Phase.DELIVERY, - 'SBOM-Abdeckung für Komponenten und Releases erhöhen', - 'Komponenten ohne SBOM identifizieren und Build-/Release-Prozess ergänzen.', - owner='DevSecOps Lead', - due=30, - ) - if product.releases.filter(status__in=[ProductRelease.Status.ACTIVE, ProductRelease.Status.MAINTENANCE]).exists() and not product.advisories.exists(): - add_task( - ProductSecurityRoadmapTask.Phase.RESPONSE, - 'Security Advisory / Kundentransparenz vorbereiten', - 'Vorlage und Ablauf für Security Advisories und Release Notes mit Security-Bezug aufsetzen.', - owner='Product Manager', - due=35, - ) - return roadmap - - @staticmethod - def tenant_posture(tenant): - products = Product.objects.for_tenant(tenant) - snapshots = ProductSecuritySnapshot.objects.for_tenant(tenant) - open_vulns = Vulnerability.objects.for_tenant(tenant).exclude(status__in=[Vulnerability.Status.FIXED, Vulnerability.Status.ACCEPTED]) - return { - 'products': products.count(), - 'active_releases': ProductRelease.objects.for_tenant(tenant).filter(status=ProductRelease.Status.ACTIVE).count(), - 'threat_models': ThreatModel.objects.for_tenant(tenant).count(), - 'taras': TARA.objects.for_tenant(tenant).count(), - 'open_vulnerabilities': open_vulns.count(), - 'critical_open_vulnerabilities': open_vulns.filter(severity=Vulnerability.Severity.CRITICAL).count(), - 'psirt_cases_open': PSIRTCase.objects.for_tenant(tenant).exclude(status=PSIRTCase.Status.CLOSED).count(), - 'published_advisories': SecurityAdvisory.objects.for_tenant(tenant).filter(status=SecurityAdvisory.Status.PUBLISHED).count(), - 'avg_threat_model_coverage': int(sum(item.threat_model_coverage_percent for item in snapshots) / snapshots.count()) if snapshots.exists() else 0, - 'avg_psirt_readiness': int(sum(item.psirt_readiness_percent for item in snapshots) / snapshots.count()) if snapshots.exists() else 0, - } diff --git a/apps/product_security/services_rust.py b/apps/product_security/services_rust.py deleted file mode 100644 index b6ab504..0000000 --- a/apps/product_security/services_rust.py +++ /dev/null @@ -1,1045 +0,0 @@ -import json -from dataclasses import dataclass -from urllib.error import HTTPError -from urllib.request import Request, urlopen - -from django.conf import settings - - -@dataclass(frozen=True) -class ProductSecurityRelatedRef: - id: int | None - name: str - - @property - def pk(self) -> int | None: - return self.id - - @property - def title(self) -> str: - return self.name - - def __str__(self) -> str: - return self.name - - -@dataclass(frozen=True) -class ProductSecurityRelatedCount: - value: int - - def count(self) -> int: - return self.value - - -@dataclass(frozen=True) -class ProductSecurityProductListItem: - id: int - tenant: object - tenant_id: int - family: ProductSecurityRelatedRef | None - family_id: int | None - name: str - code: str - description: str - has_digital_elements: bool - includes_ai: bool - ot_iacs_context: bool - automotive_context: bool - support_window_months: int - releases: ProductSecurityRelatedCount - threat_models: ProductSecurityRelatedCount - taras: ProductSecurityRelatedCount - vulnerabilities: ProductSecurityRelatedCount - psirt_cases: ProductSecurityRelatedCount - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - -@dataclass(frozen=True) -class ProductSecuritySnapshotListItem: - id: int - tenant: object - tenant_id: int - product: ProductSecurityRelatedRef - product_id: int - cra_applicable: bool - ai_act_applicable: bool - iec62443_applicable: bool - iso_sae_21434_applicable: bool - cra_readiness_percent: int - ai_act_readiness_percent: int - iec62443_readiness_percent: int - iso_sae_21434_readiness_percent: int - threat_model_coverage_percent: int - psirt_readiness_percent: int - open_vulnerability_count: int - critical_vulnerability_count: int - summary: str - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - -@dataclass(frozen=True) -class ProductSecurityReleaseItem: - id: int - tenant: object - tenant_id: int - product_id: int - version: str - status: str - status_label: str - release_date: str | None - support_end_date: str | None - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - def get_status_display(self) -> str: - return self.status_label - - -@dataclass(frozen=True) -class ProductSecurityComponentItem: - id: int - tenant: object - tenant_id: int - product_id: int - supplier: ProductSecurityRelatedRef | None - supplier_id: int | None - name: str - component_type: str - component_type_label: str - version: str - is_open_source: bool - has_sbom: bool - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - def get_component_type_display(self) -> str: - return self.component_type_label - - -@dataclass(frozen=True) -class ProductSecurityThreatModelItem: - id: int - tenant: object - tenant_id: int - product_id: int - release: ProductSecurityRelatedRef | None - release_id: int | None - name: str - methodology: str - summary: str - status: str - status_label: str - scenarios: ProductSecurityRelatedCount - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - def get_status_display(self) -> str: - return self.status_label - - -@dataclass(frozen=True) -class ProductSecurityTaraItem: - id: int - tenant: object - tenant_id: int - product_id: int - release: ProductSecurityRelatedRef | None - release_id: int | None - scenario: ProductSecurityRelatedRef | None - scenario_id: int | None - name: str - summary: str - attack_feasibility: int - impact_score: int - risk_score: int - status: str - status_label: str - treatment_decision: str - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - def get_status_display(self) -> str: - return self.status_label - - -@dataclass(frozen=True) -class ProductSecurityVulnerabilityItem: - id: int - tenant: object - tenant_id: int - product_id: int - release: ProductSecurityRelatedRef | None - release_id: int | None - component: ProductSecurityRelatedRef | None - component_id: int | None - title: str - cve: str - severity: str - severity_label: str - status: str - status_label: str - remediation_due: str | None - summary: str - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - def get_severity_display(self) -> str: - return self.severity_label - - def get_status_display(self) -> str: - return self.status_label - - -@dataclass(frozen=True) -class ProductSecurityAiSystemItem: - id: int - tenant: object - tenant_id: int - product: ProductSecurityRelatedRef | None - product_id: int | None - name: str - use_case: str - provider: str - risk_classification: str - risk_classification_label: str - in_scope: bool - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - def get_risk_classification_display(self) -> str: - return self.risk_classification_label - - -@dataclass(frozen=True) -class ProductSecurityPsirtCaseItem: - id: int - tenant: object - tenant_id: int - product_id: int - release: ProductSecurityRelatedRef | None - release_id: int | None - vulnerability: ProductSecurityRelatedRef | None - vulnerability_id: int | None - case_id: str - title: str - severity: str - severity_label: str - status: str - status_label: str - disclosure_due: str | None - summary: str - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - def get_severity_display(self) -> str: - return self.severity_label - - def get_status_display(self) -> str: - return self.status_label - - -@dataclass(frozen=True) -class ProductSecurityAdvisoryItem: - id: int - tenant: object - tenant_id: int - product_id: int - release: ProductSecurityRelatedRef | None - release_id: int | None - psirt_case: ProductSecurityRelatedRef | None - psirt_case_id: int | None - advisory_id: str - title: str - status: str - status_label: str - published_on: str | None - summary: str - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - def get_status_display(self) -> str: - return self.status_label - - -@dataclass(frozen=True) -class ProductSecurityRoadmapItem: - id: int - tenant: object - tenant_id: int - product_id: int - title: str - summary: str - generated_from_snapshot_id: int | None - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - -@dataclass(frozen=True) -class ProductSecurityRoadmapTaskItem: - id: int - tenant: object - tenant_id: int - roadmap_id: int - related_release: ProductSecurityRelatedRef | None - related_release_id: int | None - related_vulnerability: ProductSecurityRelatedRef | None - related_vulnerability_id: int | None - phase: str - phase_label: str - title: str - description: str - priority: str - owner_role: str - due_in_days: int - dependency_text: str - status: str - status_label: str - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - def get_phase_display(self) -> str: - return self.phase_label - - def get_status_display(self) -> str: - return self.status_label - - -@dataclass(frozen=True) -class ProductSecurityOverviewBridgeResult: - products: list[ProductSecurityProductListItem] - snapshots: list[ProductSecuritySnapshotListItem] - matrix: dict - posture: dict - - -@dataclass(frozen=True) -class ProductSecurityDetailBridgeResult: - product: ProductSecurityProductListItem - releases: list[ProductSecurityReleaseItem] - components: list[ProductSecurityComponentItem] - threat_models: list[ProductSecurityThreatModelItem] - threat_scenarios: int - taras: list[ProductSecurityTaraItem] - vulnerabilities: list[ProductSecurityVulnerabilityItem] - ai_systems: list[ProductSecurityAiSystemItem] - psirt_cases: list[ProductSecurityPsirtCaseItem] - advisories: list[ProductSecurityAdvisoryItem] - snapshot: ProductSecuritySnapshotListItem | None - roadmap: ProductSecurityRoadmapItem | None - roadmap_tasks: list[ProductSecurityRoadmapTaskItem] - - -@dataclass(frozen=True) -class ProductSecurityRoadmapBridgeResult: - product: ProductSecurityProductListItem - roadmap: ProductSecurityRoadmapItem - tasks: list[ProductSecurityRoadmapTaskItem] - snapshot: ProductSecuritySnapshotListItem | None - - -@dataclass(frozen=True) -class ProductSecurityRoadmapTaskUpdateBridgeResult: - product_id: int - roadmap_id: int - task: ProductSecurityRoadmapTaskItem - - -@dataclass(frozen=True) -class ProductSecurityVulnerabilityUpdateBridgeResult: - product_id: int - vulnerability: ProductSecurityVulnerabilityItem - - -class ProductSecurityRustClient: - @staticmethod - def _base_url() -> str: - base = (getattr(settings, 'RUST_BACKEND_URL', '') or '').strip() - return base.rstrip('/') - - @staticmethod - def fetch_overview(request, tenant, timeout: int = 8) -> ProductSecurityOverviewBridgeResult: - base = ProductSecurityRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = ProductSecurityRustClient._authenticated_request( - request, - tenant, - f'{base}/api/v1/product-security/overview', - ) - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - - tenant_id = ProductSecurityRustClient._int_field(payload, 'tenant_id') - if tenant_id != int(tenant.id): - raise RuntimeError('Rust-Product-Security-Uebersicht gehoert nicht zum angefragten Tenant.') - - return ProductSecurityOverviewBridgeResult( - products=[ - ProductSecurityRustClient._product_from_payload(item, tenant) - for item in payload.get('products', []) - if isinstance(item, dict) - ], - snapshots=[ - ProductSecurityRustClient._snapshot_from_payload(item, tenant) - for item in payload.get('snapshots', []) - if isinstance(item, dict) - ], - matrix=payload.get('matrix') if isinstance(payload.get('matrix'), dict) else {}, - posture=payload.get('posture') if isinstance(payload.get('posture'), dict) else {}, - ) - - @staticmethod - def fetch_detail(request, tenant, product_id: int, timeout: int = 8) -> ProductSecurityDetailBridgeResult: - base = ProductSecurityRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = ProductSecurityRustClient._authenticated_request( - request, - tenant, - f'{base}/api/v1/product-security/products/{int(product_id)}', - ) - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - - product_payload = payload.get('product') - if not isinstance(product_payload, dict): - raise RuntimeError('Rust-Product-Security-Detail enthaelt kein Produkt.') - - product = ProductSecurityRustClient._product_from_payload(product_payload, tenant) - if product.tenant_id != int(tenant.id) or product.id != int(product_id): - raise RuntimeError('Rust-Product-Security-Detail gehoert nicht zum angefragten Produkt.') - - snapshot_payload = payload.get('snapshot') - roadmap_payload = payload.get('roadmap') - return ProductSecurityDetailBridgeResult( - product=product, - releases=ProductSecurityRustClient._items(payload, 'releases', tenant, ProductSecurityRustClient._release_from_payload), - components=ProductSecurityRustClient._items(payload, 'components', tenant, ProductSecurityRustClient._component_from_payload), - threat_models=ProductSecurityRustClient._items(payload, 'threat_models', tenant, ProductSecurityRustClient._threat_model_from_payload), - threat_scenarios=ProductSecurityRustClient._int_field(payload, 'threat_scenarios'), - taras=ProductSecurityRustClient._items(payload, 'taras', tenant, ProductSecurityRustClient._tara_from_payload), - vulnerabilities=ProductSecurityRustClient._items(payload, 'vulnerabilities', tenant, ProductSecurityRustClient._vulnerability_from_payload), - ai_systems=ProductSecurityRustClient._items(payload, 'ai_systems', tenant, ProductSecurityRustClient._ai_system_from_payload), - psirt_cases=ProductSecurityRustClient._items(payload, 'psirt_cases', tenant, ProductSecurityRustClient._psirt_case_from_payload), - advisories=ProductSecurityRustClient._items(payload, 'advisories', tenant, ProductSecurityRustClient._advisory_from_payload), - snapshot=ProductSecurityRustClient._snapshot_from_payload(snapshot_payload, tenant) if isinstance(snapshot_payload, dict) else None, - roadmap=ProductSecurityRustClient._roadmap_from_payload(roadmap_payload, tenant) if isinstance(roadmap_payload, dict) else None, - roadmap_tasks=ProductSecurityRustClient._items(payload, 'roadmap_tasks', tenant, ProductSecurityRustClient._roadmap_task_from_payload), - ) - - @staticmethod - def fetch_roadmap(request, tenant, product_id: int, timeout: int = 8) -> ProductSecurityRoadmapBridgeResult: - base = ProductSecurityRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = ProductSecurityRustClient._authenticated_request( - request, - tenant, - f'{base}/api/v1/product-security/products/{int(product_id)}/roadmap', - ) - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - - product_payload = payload.get('product') - roadmap_payload = payload.get('roadmap') - if not isinstance(product_payload, dict) or not isinstance(roadmap_payload, dict): - raise RuntimeError('Rust-Product-Security-Roadmap enthaelt kein Produkt oder keine Roadmap.') - - product = ProductSecurityRustClient._product_from_payload(product_payload, tenant) - if product.tenant_id != int(tenant.id) or product.id != int(product_id): - raise RuntimeError('Rust-Product-Security-Roadmap gehoert nicht zum angefragten Produkt.') - - snapshot_payload = payload.get('snapshot') - return ProductSecurityRoadmapBridgeResult( - product=product, - roadmap=ProductSecurityRustClient._roadmap_from_payload(roadmap_payload, tenant), - tasks=ProductSecurityRustClient._items(payload, 'tasks', tenant, ProductSecurityRustClient._roadmap_task_from_payload), - snapshot=ProductSecurityRustClient._snapshot_from_payload(snapshot_payload, tenant) if isinstance(snapshot_payload, dict) else None, - ) - - @staticmethod - def update_roadmap_task( - request, - tenant, - task_id: int, - data: dict, - timeout: int = 8, - ) -> ProductSecurityRoadmapTaskUpdateBridgeResult | None: - base = ProductSecurityRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = ProductSecurityRustClient._authenticated_json_request( - request, - tenant, - f'{base}/api/v1/product-security/roadmap-tasks/{int(task_id)}', - { - 'status': data.get('status'), - 'priority': data.get('priority') or '', - 'owner_role': data.get('owner_role') or '', - 'due_in_days': data.get('due_in_days'), - 'dependency_text': data.get('dependency_text') or '', - }, - method='PATCH', - ) - try: - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - except HTTPError as exc: - if exc.code == 404: - return None - raise - - task_payload = payload.get('task') or {} - if not isinstance(task_payload, dict): - raise RuntimeError('Rust-Product-Security-Roadmaptask-Update hat kein task-Objekt geliefert.') - - return ProductSecurityRoadmapTaskUpdateBridgeResult( - product_id=ProductSecurityRustClient._int_field(payload, 'product_id'), - roadmap_id=ProductSecurityRustClient._int_field(payload, 'roadmap_id'), - task=ProductSecurityRustClient._roadmap_task_from_payload(task_payload, tenant), - ) - - @staticmethod - def update_vulnerability( - request, - tenant, - vulnerability_id: int, - data: dict, - timeout: int = 8, - ) -> ProductSecurityVulnerabilityUpdateBridgeResult | None: - base = ProductSecurityRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = ProductSecurityRustClient._authenticated_json_request( - request, - tenant, - f'{base}/api/v1/product-security/vulnerabilities/{int(vulnerability_id)}', - { - 'severity': data.get('severity'), - 'status': data.get('status'), - 'remediation_due': ProductSecurityRustClient._date_to_payload(data.get('remediation_due')), - 'summary': data.get('summary') or '', - }, - method='PATCH', - ) - try: - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - except HTTPError as exc: - if exc.code == 404: - return None - raise - - vulnerability_payload = payload.get('vulnerability') or {} - if not isinstance(vulnerability_payload, dict): - raise RuntimeError('Rust-Product-Security-Vulnerability-Update hat kein vulnerability-Objekt geliefert.') - - return ProductSecurityVulnerabilityUpdateBridgeResult( - product_id=ProductSecurityRustClient._int_field(payload, 'product_id'), - vulnerability=ProductSecurityRustClient._vulnerability_from_payload(vulnerability_payload, tenant), - ) - - @staticmethod - def _product_from_payload(item: dict, tenant) -> ProductSecurityProductListItem: - family_id = ProductSecurityRustClient._optional_int_field(item, 'family_id') - family_name = str(item.get('family_name') or '').strip() - return ProductSecurityProductListItem( - id=ProductSecurityRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=ProductSecurityRustClient._int_field(item, 'tenant_id'), - family=ProductSecurityRelatedRef(family_id, family_name) if family_name else None, - family_id=family_id, - name=str(item.get('name') or ''), - code=str(item.get('code') or ''), - description=str(item.get('description') or ''), - has_digital_elements=bool(item.get('has_digital_elements')), - includes_ai=bool(item.get('includes_ai')), - ot_iacs_context=bool(item.get('ot_iacs_context')), - automotive_context=bool(item.get('automotive_context')), - support_window_months=ProductSecurityRustClient._int_field(item, 'support_window_months'), - releases=ProductSecurityRelatedCount(ProductSecurityRustClient._int_field(item, 'release_count')), - threat_models=ProductSecurityRelatedCount(ProductSecurityRustClient._int_field(item, 'threat_model_count')), - taras=ProductSecurityRelatedCount(ProductSecurityRustClient._int_field(item, 'tara_count')), - vulnerabilities=ProductSecurityRelatedCount(ProductSecurityRustClient._int_field(item, 'vulnerability_count')), - psirt_cases=ProductSecurityRelatedCount(ProductSecurityRustClient._int_field(item, 'psirt_case_count')), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - - @staticmethod - def _snapshot_from_payload(item: dict, tenant) -> ProductSecuritySnapshotListItem: - product_id = ProductSecurityRustClient._int_field(item, 'product_id') - product_name = str(item.get('product_name') or '').strip() - return ProductSecuritySnapshotListItem( - id=ProductSecurityRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=ProductSecurityRustClient._int_field(item, 'tenant_id'), - product=ProductSecurityRelatedRef(product_id, product_name), - product_id=product_id, - cra_applicable=bool(item.get('cra_applicable')), - ai_act_applicable=bool(item.get('ai_act_applicable')), - iec62443_applicable=bool(item.get('iec62443_applicable')), - iso_sae_21434_applicable=bool(item.get('iso_sae_21434_applicable')), - cra_readiness_percent=ProductSecurityRustClient._int_field(item, 'cra_readiness_percent'), - ai_act_readiness_percent=ProductSecurityRustClient._int_field(item, 'ai_act_readiness_percent'), - iec62443_readiness_percent=ProductSecurityRustClient._int_field(item, 'iec62443_readiness_percent'), - iso_sae_21434_readiness_percent=ProductSecurityRustClient._int_field(item, 'iso_sae_21434_readiness_percent'), - threat_model_coverage_percent=ProductSecurityRustClient._int_field(item, 'threat_model_coverage_percent'), - psirt_readiness_percent=ProductSecurityRustClient._int_field(item, 'psirt_readiness_percent'), - open_vulnerability_count=ProductSecurityRustClient._int_field(item, 'open_vulnerability_count'), - critical_vulnerability_count=ProductSecurityRustClient._int_field(item, 'critical_vulnerability_count'), - summary=str(item.get('summary') or ''), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - - @staticmethod - def _release_from_payload(item: dict, tenant) -> ProductSecurityReleaseItem: - return ProductSecurityReleaseItem( - id=ProductSecurityRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=ProductSecurityRustClient._int_field(item, 'tenant_id'), - product_id=ProductSecurityRustClient._int_field(item, 'product_id'), - version=str(item.get('version') or ''), - status=str(item.get('status') or ''), - status_label=str(item.get('status_label') or item.get('status') or ''), - release_date=ProductSecurityRustClient._optional_str_field(item, 'release_date'), - support_end_date=ProductSecurityRustClient._optional_str_field(item, 'support_end_date'), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - - @staticmethod - def _component_from_payload(item: dict, tenant) -> ProductSecurityComponentItem: - supplier_id = ProductSecurityRustClient._optional_int_field(item, 'supplier_id') - supplier_name = str(item.get('supplier_name') or '').strip() - return ProductSecurityComponentItem( - id=ProductSecurityRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=ProductSecurityRustClient._int_field(item, 'tenant_id'), - product_id=ProductSecurityRustClient._int_field(item, 'product_id'), - supplier=ProductSecurityRelatedRef(supplier_id, supplier_name) if supplier_name else None, - supplier_id=supplier_id, - name=str(item.get('name') or ''), - component_type=str(item.get('component_type') or ''), - component_type_label=str(item.get('component_type_label') or item.get('component_type') or ''), - version=str(item.get('version') or ''), - is_open_source=bool(item.get('is_open_source')), - has_sbom=bool(item.get('has_sbom')), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - - @staticmethod - def _threat_model_from_payload(item: dict, tenant) -> ProductSecurityThreatModelItem: - release_id = ProductSecurityRustClient._optional_int_field(item, 'release_id') - release_version = str(item.get('release_version') or '').strip() - return ProductSecurityThreatModelItem( - id=ProductSecurityRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=ProductSecurityRustClient._int_field(item, 'tenant_id'), - product_id=ProductSecurityRustClient._int_field(item, 'product_id'), - release=ProductSecurityRelatedRef(release_id, release_version) if release_version else None, - release_id=release_id, - name=str(item.get('name') or ''), - methodology=str(item.get('methodology') or ''), - summary=str(item.get('summary') or ''), - status=str(item.get('status') or ''), - status_label=str(item.get('status_label') or item.get('status') or ''), - scenarios=ProductSecurityRelatedCount(ProductSecurityRustClient._int_field(item, 'scenario_count')), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - - @staticmethod - def _tara_from_payload(item: dict, tenant) -> ProductSecurityTaraItem: - release_id = ProductSecurityRustClient._optional_int_field(item, 'release_id') - release_version = str(item.get('release_version') or '').strip() - scenario_id = ProductSecurityRustClient._optional_int_field(item, 'scenario_id') - scenario_title = str(item.get('scenario_title') or '').strip() - return ProductSecurityTaraItem( - id=ProductSecurityRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=ProductSecurityRustClient._int_field(item, 'tenant_id'), - product_id=ProductSecurityRustClient._int_field(item, 'product_id'), - release=ProductSecurityRelatedRef(release_id, release_version) if release_version else None, - release_id=release_id, - scenario=ProductSecurityRelatedRef(scenario_id, scenario_title) if scenario_title else None, - scenario_id=scenario_id, - name=str(item.get('name') or ''), - summary=str(item.get('summary') or ''), - attack_feasibility=ProductSecurityRustClient._int_field(item, 'attack_feasibility'), - impact_score=ProductSecurityRustClient._int_field(item, 'impact_score'), - risk_score=ProductSecurityRustClient._int_field(item, 'risk_score'), - status=str(item.get('status') or ''), - status_label=str(item.get('status_label') or item.get('status') or ''), - treatment_decision=str(item.get('treatment_decision') or ''), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - - @staticmethod - def _vulnerability_from_payload(item: dict, tenant) -> ProductSecurityVulnerabilityItem: - release_id = ProductSecurityRustClient._optional_int_field(item, 'release_id') - release_version = str(item.get('release_version') or '').strip() - component_id = ProductSecurityRustClient._optional_int_field(item, 'component_id') - component_name = str(item.get('component_name') or '').strip() - return ProductSecurityVulnerabilityItem( - id=ProductSecurityRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=ProductSecurityRustClient._int_field(item, 'tenant_id'), - product_id=ProductSecurityRustClient._int_field(item, 'product_id'), - release=ProductSecurityRelatedRef(release_id, release_version) if release_version else None, - release_id=release_id, - component=ProductSecurityRelatedRef(component_id, component_name) if component_name else None, - component_id=component_id, - title=str(item.get('title') or ''), - cve=str(item.get('cve') or ''), - severity=str(item.get('severity') or ''), - severity_label=str(item.get('severity_label') or item.get('severity') or ''), - status=str(item.get('status') or ''), - status_label=str(item.get('status_label') or item.get('status') or ''), - remediation_due=ProductSecurityRustClient._optional_str_field(item, 'remediation_due'), - summary=str(item.get('summary') or ''), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - - @staticmethod - def _ai_system_from_payload(item: dict, tenant) -> ProductSecurityAiSystemItem: - product_id = ProductSecurityRustClient._optional_int_field(item, 'product_id') - product_name = str(item.get('product_name') or '').strip() - return ProductSecurityAiSystemItem( - id=ProductSecurityRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=ProductSecurityRustClient._int_field(item, 'tenant_id'), - product=ProductSecurityRelatedRef(product_id, product_name) if product_name else None, - product_id=product_id, - name=str(item.get('name') or ''), - use_case=str(item.get('use_case') or ''), - provider=str(item.get('provider') or ''), - risk_classification=str(item.get('risk_classification') or ''), - risk_classification_label=str(item.get('risk_classification_label') or item.get('risk_classification') or ''), - in_scope=bool(item.get('in_scope')), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - - @staticmethod - def _psirt_case_from_payload(item: dict, tenant) -> ProductSecurityPsirtCaseItem: - release_id = ProductSecurityRustClient._optional_int_field(item, 'release_id') - release_version = str(item.get('release_version') or '').strip() - vulnerability_id = ProductSecurityRustClient._optional_int_field(item, 'vulnerability_id') - vulnerability_title = str(item.get('vulnerability_title') or '').strip() - return ProductSecurityPsirtCaseItem( - id=ProductSecurityRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=ProductSecurityRustClient._int_field(item, 'tenant_id'), - product_id=ProductSecurityRustClient._int_field(item, 'product_id'), - release=ProductSecurityRelatedRef(release_id, release_version) if release_version else None, - release_id=release_id, - vulnerability=ProductSecurityRelatedRef(vulnerability_id, vulnerability_title) if vulnerability_title else None, - vulnerability_id=vulnerability_id, - case_id=str(item.get('case_id') or ''), - title=str(item.get('title') or ''), - severity=str(item.get('severity') or ''), - severity_label=str(item.get('severity_label') or item.get('severity') or ''), - status=str(item.get('status') or ''), - status_label=str(item.get('status_label') or item.get('status') or ''), - disclosure_due=ProductSecurityRustClient._optional_str_field(item, 'disclosure_due'), - summary=str(item.get('summary') or ''), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - - @staticmethod - def _advisory_from_payload(item: dict, tenant) -> ProductSecurityAdvisoryItem: - release_id = ProductSecurityRustClient._optional_int_field(item, 'release_id') - release_version = str(item.get('release_version') or '').strip() - psirt_case_id = ProductSecurityRustClient._optional_int_field(item, 'psirt_case_id') - psirt_case_identifier = str(item.get('psirt_case_identifier') or '').strip() - return ProductSecurityAdvisoryItem( - id=ProductSecurityRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=ProductSecurityRustClient._int_field(item, 'tenant_id'), - product_id=ProductSecurityRustClient._int_field(item, 'product_id'), - release=ProductSecurityRelatedRef(release_id, release_version) if release_version else None, - release_id=release_id, - psirt_case=ProductSecurityRelatedRef(psirt_case_id, psirt_case_identifier) if psirt_case_identifier else None, - psirt_case_id=psirt_case_id, - advisory_id=str(item.get('advisory_id') or ''), - title=str(item.get('title') or ''), - status=str(item.get('status') or ''), - status_label=str(item.get('status_label') or item.get('status') or ''), - published_on=ProductSecurityRustClient._optional_str_field(item, 'published_on'), - summary=str(item.get('summary') or ''), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - - @staticmethod - def _roadmap_from_payload(item: dict, tenant) -> ProductSecurityRoadmapItem: - return ProductSecurityRoadmapItem( - id=ProductSecurityRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=ProductSecurityRustClient._int_field(item, 'tenant_id'), - product_id=ProductSecurityRustClient._int_field(item, 'product_id'), - title=str(item.get('title') or ''), - summary=str(item.get('summary') or ''), - generated_from_snapshot_id=ProductSecurityRustClient._optional_int_field(item, 'generated_from_snapshot_id'), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - - @staticmethod - def _roadmap_task_from_payload(item: dict, tenant) -> ProductSecurityRoadmapTaskItem: - release_id = ProductSecurityRustClient._optional_int_field(item, 'related_release_id') - release_version = str(item.get('related_release_version') or '').strip() - vulnerability_id = ProductSecurityRustClient._optional_int_field(item, 'related_vulnerability_id') - vulnerability_title = str(item.get('related_vulnerability_title') or '').strip() - return ProductSecurityRoadmapTaskItem( - id=ProductSecurityRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=ProductSecurityRustClient._int_field(item, 'tenant_id'), - roadmap_id=ProductSecurityRustClient._int_field(item, 'roadmap_id'), - related_release=ProductSecurityRelatedRef(release_id, release_version) if release_version else None, - related_release_id=release_id, - related_vulnerability=ProductSecurityRelatedRef(vulnerability_id, vulnerability_title) if vulnerability_title else None, - related_vulnerability_id=vulnerability_id, - phase=str(item.get('phase') or ''), - phase_label=str(item.get('phase_label') or item.get('phase') or ''), - title=str(item.get('title') or ''), - description=str(item.get('description') or ''), - priority=str(item.get('priority') or ''), - owner_role=str(item.get('owner_role') or ''), - due_in_days=ProductSecurityRustClient._int_field(item, 'due_in_days'), - dependency_text=str(item.get('dependency_text') or ''), - status=str(item.get('status') or ''), - status_label=str(item.get('status_label') or item.get('status') or ''), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - - @staticmethod - def _items(payload: dict, key: str, tenant, parser): - return [ - parser(item, tenant) - for item in payload.get(key, []) - if isinstance(item, dict) - ] - - @staticmethod - def _authenticated_request(request, tenant, url: str) -> Request: - user = getattr(request, 'user', None) - user_id = getattr(user, 'id', None) - if not user_id: - raise RuntimeError('Product-Security-Rust-Bridge braucht einen authentifizierten User.') - - rust_request = Request(url) - rust_request.add_header('Accept', 'application/json') - rust_request.add_header('X-ISCY-Tenant-ID', str(tenant.id)) - rust_request.add_header('X-ISCY-User-ID', str(user_id)) - user_email = (getattr(user, 'email', '') or '').strip() - if user_email: - rust_request.add_header('X-ISCY-User-Email', user_email) - return rust_request - - @staticmethod - def _authenticated_json_request(request, tenant, url: str, payload: dict, method: str) -> Request: - rust_request = ProductSecurityRustClient._authenticated_request(request, tenant, url) - rust_request.data = json.dumps(payload).encode('utf-8') - rust_request.method = method - rust_request.add_header('Content-Type', 'application/json') - return rust_request - - @staticmethod - def _int_field(payload: dict, key: str) -> int: - return int(payload.get(key) or 0) - - @staticmethod - def _optional_int_field(payload: dict, key: str) -> int | None: - value = payload.get(key) - if value in (None, ''): - return None - return int(value) - - @staticmethod - def _optional_str_field(payload: dict, key: str) -> str | None: - value = payload.get(key) - if value in (None, ''): - return None - return str(value) - - @staticmethod - def _date_to_payload(value) -> str: - if not value: - return '' - if hasattr(value, 'isoformat'): - return value.isoformat() - return str(value) - - -class ProductSecurityBridge: - @staticmethod - def fetch_overview(request, tenant): - if tenant is None: - return None - - backend = str(getattr(settings, 'PRODUCT_SECURITY_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not ProductSecurityRustClient._base_url(): - if ProductSecurityBridge._strict_rust_mode(): - raise RuntimeError('Rust product security backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return ProductSecurityRustClient.fetch_overview(request, tenant) - except Exception as exc: - if ProductSecurityBridge._strict_rust_mode(): - raise RuntimeError('Rust product security backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def fetch_detail(request, tenant, product_id: int): - if tenant is None: - return None - - backend = str(getattr(settings, 'PRODUCT_SECURITY_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not ProductSecurityRustClient._base_url(): - if ProductSecurityBridge._strict_rust_mode(): - raise RuntimeError('Rust product security backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return ProductSecurityRustClient.fetch_detail(request, tenant, product_id) - except Exception as exc: - if ProductSecurityBridge._strict_rust_mode(): - raise RuntimeError('Rust product security backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def fetch_roadmap(request, tenant, product_id: int): - if tenant is None: - return None - - backend = str(getattr(settings, 'PRODUCT_SECURITY_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not ProductSecurityRustClient._base_url(): - if ProductSecurityBridge._strict_rust_mode(): - raise RuntimeError('Rust product security backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return ProductSecurityRustClient.fetch_roadmap(request, tenant, product_id) - except Exception as exc: - if ProductSecurityBridge._strict_rust_mode(): - raise RuntimeError('Rust product security backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def update_roadmap_task(request, tenant, task_id: int, data: dict): - if tenant is None: - return None - - backend = str(getattr(settings, 'PRODUCT_SECURITY_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not ProductSecurityRustClient._base_url(): - if ProductSecurityBridge._strict_rust_mode(): - raise RuntimeError('Rust product security backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return ProductSecurityRustClient.update_roadmap_task(request, tenant, task_id, data) - except Exception as exc: - if ProductSecurityBridge._strict_rust_mode(): - raise RuntimeError('Rust product security backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def update_vulnerability(request, tenant, vulnerability_id: int, data: dict): - if tenant is None: - return None - - backend = str(getattr(settings, 'PRODUCT_SECURITY_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not ProductSecurityRustClient._base_url(): - if ProductSecurityBridge._strict_rust_mode(): - raise RuntimeError('Rust product security backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return ProductSecurityRustClient.update_vulnerability(request, tenant, vulnerability_id, data) - except Exception as exc: - if ProductSecurityBridge._strict_rust_mode(): - raise RuntimeError('Rust product security backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def _strict_rust_mode() -> bool: - return bool(getattr(settings, 'RUST_STRICT_MODE', False)) diff --git a/apps/product_security/tests.py b/apps/product_security/tests.py deleted file mode 100644 index 4c0f3b0..0000000 --- a/apps/product_security/tests.py +++ /dev/null @@ -1,677 +0,0 @@ -import json -from unittest.mock import Mock, patch - -from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings -from django.urls import reverse - -from apps.organizations.models import Tenant -from apps.product_security.models import Product, ProductFamily, ProductSecuritySnapshot, Vulnerability -from apps.product_security.services import ProductSecurityService - - -User = get_user_model() - - -@override_settings(PRODUCT_SECURITY_BACKEND='local', RUST_BACKEND_URL='') -class ProductSecurityViewTests(TestCase): - def setUp(self): - self.tenant_a = Tenant.objects.create( - name="Tenant A", - slug="tenant-a-ps", - country="DE", - develops_digital_products=True, - ) - self.tenant_b = Tenant.objects.create( - name="Tenant B", - slug="tenant-b-ps", - country="DE", - develops_digital_products=True, - ) - - self.user_a = User.objects.create_user( - username="product-security-a", - password="testpass123", - tenant=self.tenant_a, - ) - self.user_b = User.objects.create_user( - username="product-security-b", - password="testpass123", - tenant=self.tenant_b, - ) - - self.family_a = ProductFamily.objects.create(tenant=self.tenant_a, name="Familie A") - self.family_b = ProductFamily.objects.create(tenant=self.tenant_b, name="Familie B") - - self.product_a = Product.objects.create( - tenant=self.tenant_a, - family=self.family_a, - name="Produkt A", - has_digital_elements=True, - ) - self.product_b = Product.objects.create( - tenant=self.tenant_b, - family=self.family_b, - name="Produkt B", - has_digital_elements=True, - ) - - self.snapshot_a = ProductSecuritySnapshot.objects.create( - tenant=self.tenant_a, - product=self.product_a, - cra_applicable=True, - cra_readiness_percent=72, - threat_model_coverage_percent=40, - psirt_readiness_percent=55, - open_vulnerability_count=2, - critical_vulnerability_count=1, - summary="Snapshot Tenant A", - ) - self.snapshot_b = ProductSecuritySnapshot.objects.create( - tenant=self.tenant_b, - product=self.product_b, - cra_applicable=True, - cra_readiness_percent=33, - summary="Snapshot Tenant B", - ) - self.vulnerability_a = Vulnerability.objects.create( - tenant=self.tenant_a, - product=self.product_a, - title="Tenant A Vulnerability", - cve="CVE-2026-4242", - severity=Vulnerability.Severity.HIGH, - status=Vulnerability.Status.OPEN, - remediation_due="2026-06-01", - summary="Tenant A vulnerability summary", - ) - self.vulnerability_b = Vulnerability.objects.create( - tenant=self.tenant_b, - product=self.product_b, - title="Tenant B Vulnerability", - severity=Vulnerability.Severity.CRITICAL, - status=Vulnerability.Status.OPEN, - summary="Tenant B vulnerability summary", - ) - - def test_product_list_only_shows_current_tenant_products_and_snapshots(self): - self.client.force_login(self.user_a) - - response = self.client.get(reverse("product_security:list")) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context["product_security_source"], "django") - self.assertEqual(list(response.context["products"]), [self.product_a]) - self.assertEqual(list(response.context["snapshots"]), [self.snapshot_a]) - self.assertContains(response, "Produkt A") - self.assertNotContains(response, "Produkt B") - - @patch("apps.product_security.services_rust.urlopen") - def test_product_list_can_use_rust_bridge(self, mock_urlopen): - self._mock_rust_response(mock_urlopen, self._rust_payload()) - self.client.force_login(self.user_a) - - with self.settings(PRODUCT_SECURITY_BACKEND="rust_service", RUST_BACKEND_URL="http://rust-backend:9000"): - response = self.client.get(reverse("product_security:list")) - - self.assertEqual(response.status_code, 200) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual(rust_request.full_url, "http://rust-backend:9000/api/v1/product-security/overview") - self.assertEqual(rust_request.get_header("X-iscy-tenant-id"), str(self.tenant_a.id)) - self.assertEqual(response.context["product_security_source"], "rust_service") - self.assertEqual(len(response.context["products"]), 1) - self.assertEqual(response.context["posture"]["products"], 1) - self.assertContains(response, "Rust Produkt") - self.assertContains(response, "Rust Product Security ist aktiv") - - @patch("apps.product_security.services_rust.urlopen", side_effect=OSError("backend down")) - def test_product_list_falls_back_to_django_when_rust_unavailable_in_non_strict_mode(self, _mock_urlopen): - self.client.force_login(self.user_a) - - with self.settings( - PRODUCT_SECURITY_BACKEND="rust_service", - RUST_BACKEND_URL="http://rust-backend:9000", - RUST_STRICT_MODE=False, - ): - response = self.client.get(reverse("product_security:list")) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context["product_security_source"], "django") - self.assertContains(response, "Produkt A") - - def test_product_list_raises_in_strict_mode_without_rust_backend_url(self): - self.client.force_login(self.user_a) - - with self.settings(PRODUCT_SECURITY_BACKEND="rust_service", RUST_BACKEND_URL="", RUST_STRICT_MODE=True): - with self.assertRaises(RuntimeError): - self.client.get(reverse("product_security:list")) - - def test_product_detail_blocks_foreign_tenant_access(self): - self.client.force_login(self.user_a) - - response = self.client.get(reverse("product_security:detail", args=[self.product_b.pk])) - - self.assertEqual(response.status_code, 404) - - def test_product_detail_generates_roadmap_for_current_tenant(self): - self.client.force_login(self.user_a) - - response = self.client.get(reverse("product_security:detail", args=[self.product_a.pk])) - - self.assertEqual(response.status_code, 200) - roadmap = self.product_a.roadmaps.first() - self.assertIsNotNone(roadmap) - self.assertGreater(roadmap.tasks.count(), 0) - self.assertContains(response, "Product-Security-Roadmap") - - @patch("apps.product_security.services_rust.urlopen") - def test_product_detail_can_use_rust_bridge(self, mock_urlopen): - self._mock_rust_response(mock_urlopen, self._rust_detail_payload()) - self.client.force_login(self.user_a) - - with self.settings(PRODUCT_SECURITY_BACKEND="rust_service", RUST_BACKEND_URL="http://rust-backend:9000"): - response = self.client.get(reverse("product_security:detail", args=[self.product_a.pk])) - - self.assertEqual(response.status_code, 200) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual( - rust_request.full_url, - f"http://rust-backend:9000/api/v1/product-security/products/{self.product_a.id}", - ) - self.assertEqual(response.context["product_security_source"], "rust_service") - self.assertContains(response, "Rust Firmware") - self.assertContains(response, "Rust Threat Model") - self.assertContains(response, "Rust Roadmap Task") - - def test_product_roadmap_blocks_foreign_tenant_access(self): - self.client.force_login(self.user_a) - - response = self.client.get(reverse("product_security:roadmap", args=[self.product_b.pk])) - - self.assertEqual(response.status_code, 404) - - def test_product_roadmap_renders_generated_tasks(self): - self.client.force_login(self.user_a) - - response = self.client.get(reverse("product_security:roadmap", args=[self.product_a.pk])) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, "Governance") - self.assertContains(response, "Produkt-Scope, Verantwortlichkeiten und Security Sign-off festigen") - - @patch("apps.product_security.services_rust.urlopen") - def test_product_roadmap_can_use_rust_bridge(self, mock_urlopen): - self._mock_rust_response(mock_urlopen, self._rust_roadmap_payload()) - self.client.force_login(self.user_a) - - with self.settings(PRODUCT_SECURITY_BACKEND="rust_service", RUST_BACKEND_URL="http://rust-backend:9000"): - response = self.client.get(reverse("product_security:roadmap", args=[self.product_a.pk])) - - self.assertEqual(response.status_code, 200) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual( - rust_request.full_url, - f"http://rust-backend:9000/api/v1/product-security/products/{self.product_a.id}/roadmap", - ) - self.assertEqual(response.context["product_security_source"], "rust_service") - self.assertContains(response, "Rust Product Roadmap") - self.assertContains(response, "Rust Roadmap Task") - - def test_product_roadmap_task_update_uses_local_backend(self): - roadmap = ProductSecurityService.generate_product_roadmap(self.product_a) - task = roadmap.tasks.first() - self.client.force_login(self.user_a) - - response = self.client.post( - reverse("product_security:roadmap-task-edit", args=[task.pk]), - { - "status": "DONE", - "priority": "MEDIUM", - "owner_role": "Product Security Office", - "due_in_days": "9", - "dependency_text": "Reviewed locally", - }, - ) - - self.assertRedirects( - response, - reverse("product_security:roadmap", args=[self.product_a.pk]), - fetch_redirect_response=False, - ) - task.refresh_from_db() - self.assertEqual(task.status, "DONE") - self.assertEqual(task.priority, "MEDIUM") - self.assertEqual(task.owner_role, "Product Security Office") - self.assertEqual(task.due_in_days, 9) - self.assertEqual(task.dependency_text, "Reviewed locally") - - @patch("apps.product_security.services_rust.urlopen") - def test_product_roadmap_task_update_can_use_rust_bridge(self, mock_urlopen): - roadmap = ProductSecurityService.generate_product_roadmap(self.product_a) - task = roadmap.tasks.first() - task_payload = self._rust_roadmap_task_payload() - task_payload.update({ - "id": task.id, - "roadmap_id": roadmap.id, - "status": "DONE", - "status_label": "Erledigt", - "priority": "MEDIUM", - "owner_role": "Product Security Office", - "due_in_days": 9, - "dependency_text": "Reviewed in Rust", - }) - self._mock_rust_response(mock_urlopen, { - "api_version": "v1", - "product_id": self.product_a.id, - "roadmap_id": roadmap.id, - "task": task_payload, - }) - self.client.force_login(self.user_a) - - with self.settings(PRODUCT_SECURITY_BACKEND="rust_service", RUST_BACKEND_URL="http://rust-backend:9000"): - response = self.client.post( - reverse("product_security:roadmap-task-edit", args=[task.pk]), - { - "status": "DONE", - "priority": "MEDIUM", - "owner_role": "Product Security Office", - "due_in_days": "9", - "dependency_text": "Reviewed in Rust", - }, - ) - - self.assertRedirects( - response, - reverse("product_security:roadmap", args=[self.product_a.pk]), - fetch_redirect_response=False, - ) - rust_request = mock_urlopen.call_args.args[0] - body = json.loads(rust_request.data.decode("utf-8")) - self.assertEqual( - rust_request.full_url, - f"http://rust-backend:9000/api/v1/product-security/roadmap-tasks/{task.id}", - ) - self.assertEqual(rust_request.get_method(), "PATCH") - self.assertEqual(body["status"], "DONE") - self.assertEqual(body["owner_role"], "Product Security Office") - self.assertEqual(body["due_in_days"], 9) - - def test_product_roadmap_task_update_blocks_foreign_tenant_access(self): - roadmap = ProductSecurityService.generate_product_roadmap(self.product_b) - task = roadmap.tasks.first() - self.client.force_login(self.user_a) - - response = self.client.post( - reverse("product_security:roadmap-task-edit", args=[task.pk]), - { - "status": "DONE", - "priority": "MEDIUM", - "owner_role": "Product Security Office", - "due_in_days": "9", - "dependency_text": "Nope", - }, - ) - - self.assertEqual(response.status_code, 404) - - def test_product_vulnerability_update_uses_local_backend(self): - self.client.force_login(self.user_a) - - response = self.client.post( - reverse("product_security:vulnerability-edit", args=[self.vulnerability_a.pk]), - { - "severity": "MEDIUM", - "status": "MITIGATED", - "remediation_due": "2026-06-15", - "summary": "Mitigated locally", - }, - ) - - self.assertRedirects( - response, - reverse("product_security:detail", args=[self.product_a.pk]), - fetch_redirect_response=False, - ) - self.vulnerability_a.refresh_from_db() - self.assertEqual(self.vulnerability_a.severity, "MEDIUM") - self.assertEqual(self.vulnerability_a.status, "MITIGATED") - self.assertEqual(self.vulnerability_a.remediation_due.isoformat(), "2026-06-15") - self.assertEqual(self.vulnerability_a.summary, "Mitigated locally") - - @patch("apps.product_security.services_rust.urlopen") - def test_product_vulnerability_update_can_use_rust_bridge(self, mock_urlopen): - vulnerability_payload = self._rust_detail_payload()["vulnerabilities"][0] - vulnerability_payload.update({ - "id": self.vulnerability_a.id, - "product_id": self.product_a.id, - "severity": "MEDIUM", - "severity_label": "Mittel", - "status": "MITIGATED", - "status_label": "Mitigiert", - "remediation_due": "2026-06-15", - "summary": "Mitigated in Rust", - }) - self._mock_rust_response(mock_urlopen, { - "api_version": "v1", - "product_id": self.product_a.id, - "vulnerability": vulnerability_payload, - }) - self.client.force_login(self.user_a) - - with self.settings(PRODUCT_SECURITY_BACKEND="rust_service", RUST_BACKEND_URL="http://rust-backend:9000"): - response = self.client.post( - reverse("product_security:vulnerability-edit", args=[self.vulnerability_a.pk]), - { - "severity": "MEDIUM", - "status": "MITIGATED", - "remediation_due": "2026-06-15", - "summary": "Mitigated in Rust", - }, - ) - - self.assertRedirects( - response, - reverse("product_security:detail", args=[self.product_a.pk]), - fetch_redirect_response=False, - ) - rust_request = mock_urlopen.call_args.args[0] - body = json.loads(rust_request.data.decode("utf-8")) - self.assertEqual( - rust_request.full_url, - f"http://rust-backend:9000/api/v1/product-security/vulnerabilities/{self.vulnerability_a.id}", - ) - self.assertEqual(rust_request.get_method(), "PATCH") - self.assertEqual(body["severity"], "MEDIUM") - self.assertEqual(body["status"], "MITIGATED") - self.assertEqual(body["remediation_due"], "2026-06-15") - self.assertEqual(body["summary"], "Mitigated in Rust") - - def test_product_vulnerability_update_blocks_foreign_tenant_access(self): - self.client.force_login(self.user_a) - - response = self.client.post( - reverse("product_security:vulnerability-edit", args=[self.vulnerability_b.pk]), - { - "severity": "LOW", - "status": "ACCEPTED", - "remediation_due": "", - "summary": "Nope", - }, - ) - - self.assertEqual(response.status_code, 404) - - def _rust_payload(self): - return { - "api_version": "v1", - "tenant_id": self.tenant_a.id, - "matrix": { - "cra": { - "applicable": True, - "label": "CRA", - "reason": "Relevant fuer Produkte mit digitalen Elementen.", - }, - "ai_act": { - "applicable": False, - "label": "AI Act", - "reason": "Kein AI-Scope.", - }, - "iec62443": { - "applicable": False, - "label": "IEC 62443", - "reason": "Kein OT-Scope.", - }, - "iso_sae_21434": { - "applicable": False, - "label": "ISO/SAE 21434", - "reason": "Kein Automotive-Scope.", - }, - "summary": "Rust Product Security ist aktiv.", - }, - "posture": { - "products": 1, - "active_releases": 2, - "threat_models": 3, - "taras": 4, - "open_vulnerabilities": 5, - "critical_open_vulnerabilities": 1, - "psirt_cases_open": 2, - "published_advisories": 1, - "avg_threat_model_coverage": 40, - "avg_psirt_readiness": 55, - }, - "products": [{ - "id": self.product_a.id, - "tenant_id": self.tenant_a.id, - "family_id": self.family_a.id, - "family_name": "Rust Familie", - "name": "Rust Produkt", - "code": "rust-produkt", - "description": "Rust product overview", - "has_digital_elements": True, - "includes_ai": False, - "ot_iacs_context": False, - "automotive_context": False, - "support_window_months": 24, - "release_count": 2, - "threat_model_count": 3, - "tara_count": 4, - "vulnerability_count": 5, - "psirt_case_count": 2, - "created_at": "2026-04-19T10:00:00Z", - "updated_at": "2026-04-19T11:00:00Z", - }], - "snapshots": [{ - "id": self.snapshot_a.id, - "tenant_id": self.tenant_a.id, - "product_id": self.product_a.id, - "product_name": "Rust Produkt", - "cra_applicable": True, - "ai_act_applicable": False, - "iec62443_applicable": False, - "iso_sae_21434_applicable": False, - "cra_readiness_percent": 72, - "ai_act_readiness_percent": 0, - "iec62443_readiness_percent": 0, - "iso_sae_21434_readiness_percent": 0, - "threat_model_coverage_percent": 40, - "psirt_readiness_percent": 55, - "open_vulnerability_count": 2, - "critical_vulnerability_count": 1, - "summary": "Rust Snapshot", - "created_at": "2026-04-19T10:00:00Z", - "updated_at": "2026-04-19T11:00:00Z", - }], - } - - def _rust_detail_payload(self): - payload = self._rust_payload() - product = payload["products"][0] - snapshot = payload["snapshots"][0] - return { - "api_version": "v1", - "product": product, - "releases": [{ - "id": 200, - "tenant_id": self.tenant_a.id, - "product_id": self.product_a.id, - "version": "1.0", - "status": "ACTIVE", - "status_label": "Aktiv", - "release_date": "2026-04-01", - "support_end_date": "2028-04-01", - "created_at": "2026-04-19T10:00:00Z", - "updated_at": "2026-04-19T11:00:00Z", - }], - "components": [{ - "id": 250, - "tenant_id": self.tenant_a.id, - "product_id": self.product_a.id, - "supplier_id": None, - "supplier_name": None, - "name": "Rust Firmware", - "component_type": "FIRMWARE", - "component_type_label": "Firmware", - "version": "1.0.3", - "is_open_source": False, - "has_sbom": True, - "created_at": "2026-04-19T10:00:00Z", - "updated_at": "2026-04-19T11:00:00Z", - }], - "threat_models": [{ - "id": 300, - "tenant_id": self.tenant_a.id, - "product_id": self.product_a.id, - "release_id": 200, - "release_version": "1.0", - "name": "Rust Threat Model", - "methodology": "STRIDE", - "summary": "Rust threat summary", - "status": "APPROVED", - "status_label": "Freigegeben", - "scenario_count": 1, - "created_at": "2026-04-19T10:00:00Z", - "updated_at": "2026-04-19T11:00:00Z", - }], - "threat_scenarios": 1, - "taras": [{ - "id": 400, - "tenant_id": self.tenant_a.id, - "product_id": self.product_a.id, - "release_id": 200, - "release_version": "1.0", - "scenario_id": 301, - "scenario_title": "Rust scenario", - "name": "Rust TARA", - "summary": "Rust TARA summary", - "attack_feasibility": 3, - "impact_score": 4, - "risk_score": 12, - "status": "OPEN", - "status_label": "Offen", - "treatment_decision": "Mitigate", - "created_at": "2026-04-19T10:00:00Z", - "updated_at": "2026-04-19T11:00:00Z", - }], - "vulnerabilities": [{ - "id": 500, - "tenant_id": self.tenant_a.id, - "product_id": self.product_a.id, - "release_id": 200, - "release_version": "1.0", - "component_id": 250, - "component_name": "Rust Firmware", - "title": "Rust Critical Finding", - "cve": "CVE-2026-0001", - "severity": "CRITICAL", - "severity_label": "Kritisch", - "status": "OPEN", - "status_label": "Offen", - "remediation_due": "2026-05-18", - "summary": "Rust vulnerability summary", - "created_at": "2026-04-19T10:00:00Z", - "updated_at": "2026-04-19T11:00:00Z", - }], - "ai_systems": [{ - "id": 260, - "tenant_id": self.tenant_a.id, - "product_id": self.product_a.id, - "product_name": "Rust Produkt", - "name": "Rust AI Assistant", - "use_case": "Support triage", - "provider": "Internal", - "risk_classification": "LIMITED", - "risk_classification_label": "Begrenztes Risiko", - "in_scope": True, - "created_at": "2026-04-19T10:00:00Z", - "updated_at": "2026-04-19T11:00:00Z", - }], - "psirt_cases": [{ - "id": 600, - "tenant_id": self.tenant_a.id, - "product_id": self.product_a.id, - "release_id": 200, - "release_version": "1.0", - "vulnerability_id": 500, - "vulnerability_title": "Rust Critical Finding", - "case_id": "RUST-PSIRT-1", - "title": "Rust PSIRT Case", - "severity": "CRITICAL", - "severity_label": "Kritisch", - "status": "TRIAGE", - "status_label": "Triage", - "disclosure_due": "2026-05-20", - "summary": "Rust PSIRT summary", - "created_at": "2026-04-19T10:00:00Z", - "updated_at": "2026-04-19T11:00:00Z", - }], - "advisories": [{ - "id": 700, - "tenant_id": self.tenant_a.id, - "product_id": self.product_a.id, - "release_id": 200, - "release_version": "1.0", - "psirt_case_id": 600, - "psirt_case_identifier": "RUST-PSIRT-1", - "advisory_id": "RUST-ADV-1", - "title": "Rust Advisory", - "status": "PUBLISHED", - "status_label": "Veröffentlicht", - "published_on": "2026-05-21", - "summary": "Rust advisory summary", - "created_at": "2026-04-19T10:00:00Z", - "updated_at": "2026-04-19T11:00:00Z", - }], - "snapshot": snapshot, - "roadmap": { - "id": 900, - "tenant_id": self.tenant_a.id, - "product_id": self.product_a.id, - "title": "Rust Product Roadmap", - "summary": "Rust roadmap summary", - "generated_from_snapshot_id": self.snapshot_a.id, - "created_at": "2026-04-19T10:00:00Z", - "updated_at": "2026-04-19T11:00:00Z", - }, - "roadmap_tasks": [self._rust_roadmap_task_payload()], - } - - def _rust_roadmap_payload(self): - detail = self._rust_detail_payload() - return { - "api_version": "v1", - "product": detail["product"], - "roadmap": detail["roadmap"], - "tasks": detail["roadmap_tasks"], - "snapshot": detail["snapshot"], - } - - def _rust_roadmap_task_payload(self): - return { - "id": 901, - "tenant_id": self.tenant_a.id, - "roadmap_id": 900, - "related_release_id": 200, - "related_release_version": "1.0", - "related_vulnerability_id": 500, - "related_vulnerability_title": "Rust Critical Finding", - "phase": "GOVERNANCE", - "phase_label": "Governance", - "title": "Rust Roadmap Task", - "description": "Task delivered from Rust", - "priority": "HIGH", - "owner_role": "Product Security Lead", - "due_in_days": 30, - "dependency_text": "", - "status": "OPEN", - "status_label": "Offen", - "created_at": "2026-04-19T10:00:00Z", - "updated_at": "2026-04-19T11:00:00Z", - } - - def _mock_rust_response(self, mock_urlopen, payload): - response_mock = Mock() - response_mock.read.return_value = json.dumps(payload).encode("utf-8") - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - mock_urlopen.return_value = response_ctx diff --git a/apps/product_security/urls.py b/apps/product_security/urls.py deleted file mode 100644 index a7af6b6..0000000 --- a/apps/product_security/urls.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.urls import path -from .views import ( - ProductDetailView, - ProductListView, - ProductRoadmapView, - ProductSecurityRoadmapTaskUpdateView, - ProductSecurityVulnerabilityUpdateView, -) - -app_name = 'product_security' - -urlpatterns = [ - path('', ProductListView.as_view(), name='list'), - path('/', ProductDetailView.as_view(), name='detail'), - path('/roadmap/', ProductRoadmapView.as_view(), name='roadmap'), - path( - 'roadmap/tasks//edit/', - ProductSecurityRoadmapTaskUpdateView.as_view(), - name='roadmap-task-edit', - ), - path( - 'vulnerabilities//edit/', - ProductSecurityVulnerabilityUpdateView.as_view(), - name='vulnerability-edit', - ), -] diff --git a/apps/product_security/views.py b/apps/product_security/views.py deleted file mode 100644 index 2491d2b..0000000 --- a/apps/product_security/views.py +++ /dev/null @@ -1,198 +0,0 @@ -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpResponseRedirect -from django.shortcuts import get_object_or_404 -from django.urls import reverse -from django.views.generic import DetailView, ListView, TemplateView, UpdateView - -from .forms import ProductSecurityRoadmapTaskUpdateForm, ProductSecurityVulnerabilityUpdateForm -from .models import Product, ProductSecurityRoadmapTask, ProductSecuritySnapshot, Vulnerability -from .services_rust import ProductSecurityBridge -from .services import ProductSecurityService - - -class ProductListView(LoginRequiredMixin, ListView): - model = Product - template_name = 'product_security/product_list.html' - context_object_name = 'products' - - def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) - overview = ProductSecurityBridge.fetch_overview(self.request, tenant) - self._product_security_overview = overview - if overview: - return overview.products - return Product.objects.for_tenant(tenant).select_related('family') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - tenant = getattr(self.request, 'tenant', None) - overview = getattr(self, '_product_security_overview', None) - if overview: - context['matrix'] = overview.matrix - context['snapshots'] = overview.snapshots - context['posture'] = overview.posture - context['product_security_source'] = 'rust_service' - return context - - context['matrix'] = ProductSecurityService.get_regime_matrix(tenant) if tenant else None - context['snapshots'] = ProductSecuritySnapshot.objects.for_tenant(tenant).select_related('product')[:10] if tenant else [] - context['posture'] = ProductSecurityService.tenant_posture(tenant) if tenant else None - context['product_security_source'] = 'django' - return context - - -class ProductDetailView(LoginRequiredMixin, DetailView): - model = Product - template_name = 'product_security/product_detail.html' - context_object_name = 'product' - - def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) - return Product.objects.for_tenant(tenant).select_related('family') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - product = self.object - ProductSecurityService.generate_product_roadmap(product) - detail = ProductSecurityBridge.fetch_detail(self.request, product.tenant, product.id) - if detail: - context['object'] = detail.product - context['product'] = detail.product - context['components'] = detail.components - context['releases'] = detail.releases - context['threat_models'] = detail.threat_models - context['threat_scenarios'] = detail.threat_scenarios - context['taras'] = detail.taras - context['vulnerabilities'] = detail.vulnerabilities - context['ai_systems'] = detail.ai_systems - context['psirt_cases'] = detail.psirt_cases - context['advisories'] = detail.advisories - context['snapshot'] = detail.snapshot - context['roadmap'] = detail.roadmap - context['roadmap_tasks'] = detail.roadmap_tasks - context['product_security_source'] = 'rust_service' - return context - - context['components'] = product.components.all().select_related('supplier') - context['releases'] = product.releases.all() - context['threat_models'] = product.threat_models.all().prefetch_related('scenarios') - context['threat_scenarios'] = sum(model.scenarios.count() for model in context['threat_models']) - context['taras'] = product.taras.all().select_related('scenario', 'release') - context['vulnerabilities'] = product.vulnerabilities.all().select_related('component', 'release') - context['ai_systems'] = product.ai_systems.all() - context['psirt_cases'] = product.psirt_cases.all().select_related('release', 'vulnerability') - context['advisories'] = product.advisories.all().select_related('release', 'psirt_case') - context['snapshot'] = product.snapshots.first() - context['roadmap'] = product.roadmaps.first() - context['roadmap_tasks'] = context['roadmap'].tasks.all() if context['roadmap'] else [] - context['product_security_source'] = 'django' - return context - - -class ProductRoadmapView(LoginRequiredMixin, TemplateView): - template_name = 'product_security/roadmap_detail.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - tenant = getattr(self.request, 'tenant', None) - product = get_object_or_404(Product.objects.for_tenant(tenant), pk=self.kwargs['pk']) - roadmap = ProductSecurityService.generate_product_roadmap(product) - detail = ProductSecurityBridge.fetch_roadmap(self.request, product.tenant, product.id) - if detail: - phase_order = [ - ('GOVERNANCE', 'Governance'), - ('MODELING', 'Threat Modeling / TARA'), - ('DELIVERY', 'Secure Delivery'), - ('RESPONSE', 'PSIRT / Response'), - ('COMPLIANCE', 'Regulatory Readiness'), - ] - grouped = { - (phase_code, phase_label): [ - task for task in detail.tasks if task.phase == phase_code - ] - for phase_code, phase_label in phase_order - } - context.update({ - 'product': detail.product, - 'roadmap': detail.roadmap, - 'grouped_tasks': grouped, - 'snapshot': detail.snapshot, - 'product_security_source': 'rust_service', - }) - return context - - tasks = roadmap.tasks.all().order_by('phase', 'priority', 'title') - grouped = {} - for phase_code, phase_label in roadmap.tasks.model.Phase.choices: - grouped[(phase_code, phase_label)] = [task for task in tasks if task.phase == phase_code] - context.update({ - 'product': product, - 'roadmap': roadmap, - 'grouped_tasks': grouped, - 'snapshot': product.snapshots.first(), - 'product_security_source': 'django', - }) - return context - - -class ProductSecurityRoadmapTaskUpdateView(LoginRequiredMixin, UpdateView): - model = ProductSecurityRoadmapTask - form_class = ProductSecurityRoadmapTaskUpdateForm - template_name = 'product_security/roadmap_task_form.html' - context_object_name = 'task' - - def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) - return ProductSecurityRoadmapTask.objects.for_tenant(tenant).select_related('roadmap__product') - - def get_success_url(self): - return reverse('product_security:roadmap', kwargs={'pk': self.object.roadmap.product_id}) - - def form_valid(self, form): - rust_result = ProductSecurityBridge.update_roadmap_task( - self.request, - getattr(self.request, 'tenant', None), - self.object.pk, - form.cleaned_data, - ) - if rust_result is not None: - self.object.status = rust_result.task.status - self.object.priority = rust_result.task.priority - self.object.owner_role = rust_result.task.owner_role - self.object.due_in_days = rust_result.task.due_in_days - self.object.dependency_text = rust_result.task.dependency_text - return HttpResponseRedirect( - reverse('product_security:roadmap', kwargs={'pk': rust_result.product_id}) - ) - return super().form_valid(form) - - -class ProductSecurityVulnerabilityUpdateView(LoginRequiredMixin, UpdateView): - model = Vulnerability - form_class = ProductSecurityVulnerabilityUpdateForm - template_name = 'product_security/vulnerability_form.html' - context_object_name = 'vulnerability' - - def get_queryset(self): - tenant = getattr(self.request, 'tenant', None) - return Vulnerability.objects.for_tenant(tenant).select_related('product', 'release', 'component') - - def get_success_url(self): - return reverse('product_security:detail', kwargs={'pk': self.object.product_id}) - - def form_valid(self, form): - rust_result = ProductSecurityBridge.update_vulnerability( - self.request, - getattr(self.request, 'tenant', None), - self.object.pk, - form.cleaned_data, - ) - if rust_result is not None: - self.object.severity = rust_result.vulnerability.severity - self.object.status = rust_result.vulnerability.status - self.object.remediation_due = rust_result.vulnerability.remediation_due - self.object.summary = rust_result.vulnerability.summary - return HttpResponseRedirect( - reverse('product_security:detail', kwargs={'pk': rust_result.product_id}) - ) - return super().form_valid(form) diff --git a/apps/reports/__init__.py b/apps/reports/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/reports/admin.py b/apps/reports/admin.py deleted file mode 100644 index dcae430..0000000 --- a/apps/reports/admin.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.contrib import admin -from .models import ReportSnapshot - - -@admin.register(ReportSnapshot) -class ReportSnapshotAdmin(admin.ModelAdmin): - list_display = ('title', 'tenant', 'iso_readiness_percent', 'created_at') - search_fields = ('title', 'tenant__name') diff --git a/apps/reports/apps.py b/apps/reports/apps.py deleted file mode 100644 index a79748c..0000000 --- a/apps/reports/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class ReportsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.reports' diff --git a/apps/reports/migrations/0001_initial.py b/apps/reports/migrations/0001_initial.py deleted file mode 100644 index 25f4873..0000000 --- a/apps/reports/migrations/0001_initial.py +++ /dev/null @@ -1,46 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-09 14:39 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('organizations', '0001_initial'), - ('wizard', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='ReportSnapshot', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('title', models.CharField(max_length=255)), - ('executive_summary', models.TextField(blank=True)), - ('applicability_result', models.CharField(blank=True, max_length=255)), - ('iso_readiness_percent', models.PositiveIntegerField(default=0)), - ('nis2_readiness_percent', models.PositiveIntegerField(default=0)), - ('cra_readiness_percent', models.PositiveIntegerField(default=0)), - ('ai_act_readiness_percent', models.PositiveIntegerField(default=0)), - ('iec62443_readiness_percent', models.PositiveIntegerField(default=0)), - ('iso_sae_21434_readiness_percent', models.PositiveIntegerField(default=0)), - ('regulatory_matrix_json', models.JSONField(blank=True, default=dict)), - ('product_security_json', models.JSONField(blank=True, default=dict)), - ('top_gaps_json', models.JSONField(blank=True, default=list)), - ('top_measures_json', models.JSONField(blank=True, default=list)), - ('roadmap_summary', models.JSONField(blank=True, default=list)), - ('domain_scores_json', models.JSONField(blank=True, default=list)), - ('next_steps_json', models.JSONField(blank=True, default=dict)), - ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_snapshots', to='wizard.assessmentsession')), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_snapshots', to='organizations.tenant')), - ], - options={ - 'ordering': ['-created_at'], - }, - ), - ] diff --git a/apps/reports/migrations/0002_reportsnapshot_compliance_versions_json_and_more.py b/apps/reports/migrations/0002_reportsnapshot_compliance_versions_json_and_more.py deleted file mode 100644 index 970af08..0000000 --- a/apps/reports/migrations/0002_reportsnapshot_compliance_versions_json_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-13 18:15 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('reports', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='reportsnapshot', - name='compliance_versions_json', - field=models.JSONField(blank=True, default=dict), - ), - migrations.AddField( - model_name='reportsnapshot', - name='kritis_readiness_percent', - field=models.PositiveIntegerField(default=0), - ), - ] diff --git a/apps/reports/migrations/__init__.py b/apps/reports/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/reports/models.py b/apps/reports/models.py deleted file mode 100644 index a269bfe..0000000 --- a/apps/reports/models.py +++ /dev/null @@ -1,35 +0,0 @@ -from django.db import models -from apps.core.models import TenantRelationValidationMixin, TimeStampedModel - - -class ReportSnapshot(TenantRelationValidationMixin, TimeStampedModel): - tenant_relation_fields = { - 'session': 'tenant_id', - } - - tenant = models.ForeignKey('organizations.Tenant', on_delete=models.CASCADE, related_name='report_snapshots') - session = models.ForeignKey('wizard.AssessmentSession', on_delete=models.CASCADE, related_name='report_snapshots') - title = models.CharField(max_length=255) - executive_summary = models.TextField(blank=True) - applicability_result = models.CharField(max_length=255, blank=True) - iso_readiness_percent = models.PositiveIntegerField(default=0) - nis2_readiness_percent = models.PositiveIntegerField(default=0) - kritis_readiness_percent = models.PositiveIntegerField(default=0) - cra_readiness_percent = models.PositiveIntegerField(default=0) - ai_act_readiness_percent = models.PositiveIntegerField(default=0) - iec62443_readiness_percent = models.PositiveIntegerField(default=0) - iso_sae_21434_readiness_percent = models.PositiveIntegerField(default=0) - regulatory_matrix_json = models.JSONField(default=dict, blank=True) - compliance_versions_json = models.JSONField(default=dict, blank=True) - product_security_json = models.JSONField(default=dict, blank=True) - top_gaps_json = models.JSONField(default=list, blank=True) - top_measures_json = models.JSONField(default=list, blank=True) - roadmap_summary = models.JSONField(default=list, blank=True) - domain_scores_json = models.JSONField(default=list, blank=True) - next_steps_json = models.JSONField(default=dict, blank=True) - - class Meta: - ordering = ['-created_at'] - - def __str__(self): - return self.title diff --git a/apps/reports/pdf_export.py b/apps/reports/pdf_export.py deleted file mode 100644 index 4e098f0..0000000 --- a/apps/reports/pdf_export.py +++ /dev/null @@ -1,239 +0,0 @@ -"""V20: Professioneller PDF-Report-Export (Audit-ready).""" -import io -from datetime import date - -from reportlab.lib import colors -from reportlab.lib.pagesizes import A4 -from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle -from reportlab.lib.units import mm, cm -from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, HRFlowable -from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT - -from apps.organizations.sector_catalog import get_sector_definition -from apps.risks.services import RiskMatrixService -from apps.evidence.models import RequirementEvidenceNeed - - -# ── Farben ── -BLUE = colors.HexColor('#1e40af') -BLUE_LIGHT = colors.HexColor('#dbeafe') -RED = colors.HexColor('#dc2626') -ORANGE = colors.HexColor('#ea580c') -AMBER = colors.HexColor('#f59e0b') -GREEN = colors.HexColor('#22c55e') -GRAY = colors.HexColor('#64748b') -GRAY_LIGHT = colors.HexColor('#f1f5f9') -BLACK = colors.HexColor('#0f172a') -WHITE = colors.white - - -def _styles(): - ss = getSampleStyleSheet() - ss.add(ParagraphStyle('DocTitle', parent=ss['Heading1'], fontSize=20, textColor=BLUE, spaceAfter=6)) - ss.add(ParagraphStyle('DocSubtitle', parent=ss['Normal'], fontSize=10, textColor=GRAY, spaceAfter=14)) - ss.add(ParagraphStyle('SectionHead', parent=ss['Heading2'], fontSize=13, textColor=BLUE, spaceBefore=16, spaceAfter=6)) - ss.add(ParagraphStyle('BodyText2', parent=ss['Normal'], fontSize=9, leading=13, textColor=BLACK)) - ss.add(ParagraphStyle('SmallGray', parent=ss['Normal'], fontSize=8, textColor=GRAY)) - ss.add(ParagraphStyle('CellText', parent=ss['Normal'], fontSize=8, leading=10, textColor=BLACK)) - ss.add(ParagraphStyle('CellBold', parent=ss['Normal'], fontSize=8, leading=10, textColor=BLACK, fontName='Helvetica-Bold')) - return ss - - -def _header_footer(canvas_obj, doc, tenant_name, report_title): - canvas_obj.saveState() - canvas_obj.setFont('Helvetica', 7) - canvas_obj.setFillColor(GRAY) - canvas_obj.drawString(2 * cm, A4[1] - 1.2 * cm, f'{tenant_name} – {report_title}') - canvas_obj.drawRightString(A4[0] - 2 * cm, A4[1] - 1.2 * cm, f'Erstellt: {date.today().strftime("%d.%m.%Y")}') - canvas_obj.drawString(2 * cm, 1.2 * cm, 'Vertraulich – Nur fuer internen Gebrauch') - canvas_obj.drawRightString(A4[0] - 2 * cm, 1.2 * cm, f'Seite {doc.page}') - canvas_obj.setStrokeColor(colors.HexColor('#dbe4f0')) - canvas_obj.line(2 * cm, A4[1] - 1.4 * cm, A4[0] - 2 * cm, A4[1] - 1.4 * cm) - canvas_obj.line(2 * cm, 1.5 * cm, A4[0] - 2 * cm, 1.5 * cm) - canvas_obj.restoreState() - - -def _kpi_table(data, col_widths=None): - """Baut eine formatierte KPI-Tabelle.""" - style = [ - ('BACKGROUND', (0, 0), (-1, 0), BLUE), - ('TEXTCOLOR', (0, 0), (-1, 0), WHITE), - ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), - ('FONTSIZE', (0, 0), (-1, 0), 8), - ('FONTSIZE', (0, 1), (-1, -1), 8), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'TOP'), - ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#dbe4f0')), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [WHITE, GRAY_LIGHT]), - ('TOPPADDING', (0, 0), (-1, -1), 4), - ('BOTTOMPADDING', (0, 0), (-1, -1), 4), - ('LEFTPADDING', (0, 0), (-1, -1), 6), - ] - t = Table(data, colWidths=col_widths, repeatRows=1) - t.setStyle(TableStyle(style)) - return t - - -def generate_audit_report_pdf(report, session, tenant): - """Generiert einen professionellen PDF-Report.""" - buffer = io.BytesIO() - ss = _styles() - sector = get_sector_definition(tenant.sector) - report_title = f'ISCY Assessment Report – {tenant.name}' - - doc = SimpleDocTemplate( - buffer, pagesize=A4, - topMargin=2 * cm, bottomMargin=2 * cm, - leftMargin=2 * cm, rightMargin=2 * cm, - title=report_title, - ) - story = [] - - # ── Titelseite ── - story.append(Spacer(1, 3 * cm)) - story.append(Paragraph(report_title, ss['DocTitle'])) - story.append(Paragraph(f'{tenant.name} · Sektor: {sector.label} · {date.today().strftime("%d.%m.%Y")}', ss['DocSubtitle'])) - story.append(HRFlowable(width='100%', thickness=1, color=BLUE, spaceAfter=12)) - if report.executive_summary: - story.append(Paragraph(report.executive_summary, ss['BodyText2'])) - story.append(Spacer(1, 1 * cm)) - - # KPI-Box - kpi_data = [ - ['Kennzahl', 'Wert'], - ['ISO-27001-Readiness', f'{report.iso_readiness_percent}%'], - ['NIS2-Readiness', f'{report.nis2_readiness_percent}%' if report.nis2_readiness_percent else 'n/a'], - ['KRITIS-Readiness', f'{report.kritis_readiness_percent}%' if report.kritis_readiness_percent else 'n/a'], - ['Betroffenheitsindikation', report.applicability_result or '–'], - ['Sektor', sector.label], - ['Sektorbezug', sector.indicative_classification], - ] - story.append(_kpi_table(kpi_data, col_widths=[200, 280])) - if report.compliance_versions_json: - version_rows = [['Framework', 'Version', 'Quelle']] - for item in report.compliance_versions_json.values(): - version_rows.append([ - item.get('framework', ''), - f'{item.get("program_name", "ISCY")} v{item.get("version", "")}', - item.get('title', ''), - ]) - story.append(Spacer(1, 6 * mm)) - story.append(_kpi_table(version_rows, col_widths=[80, 110, 290])) - story.append(PageBreak()) - - # ── Domain Scores ── - story.append(Paragraph('Reifegrad nach Domaene', ss['SectionHead'])) - if report.domain_scores_json: - ds_data = [['Domaene', 'Readiness %', 'Reifegrad']] - for ds in report.domain_scores_json: - ds_data.append([ds.get('domain', ''), f'{ds.get("score_percent", 0)}%', ds.get('maturity_level', '')]) - story.append(_kpi_table(ds_data, col_widths=[200, 80, 200])) - else: - story.append(Paragraph('Keine Domaen-Scores verfuegbar.', ss['SmallGray'])) - story.append(Spacer(1, 8 * mm)) - - # ── Top Gaps ── - story.append(Paragraph('Wichtigste Gaps', ss['SectionHead'])) - if report.top_gaps_json: - gap_data = [['Gap', 'Schwere']] - for gap in report.top_gaps_json[:10]: - gap_data.append([ - Paragraph(gap.get('title', ''), ss['CellText']), - gap.get('severity', ''), - ]) - story.append(_kpi_table(gap_data, col_widths=[360, 120])) - else: - story.append(Paragraph('Keine Gaps identifiziert.', ss['SmallGray'])) - story.append(Spacer(1, 8 * mm)) - - # ── Top Massnahmen ── - story.append(Paragraph('Priorisierte Massnahmen', ss['SectionHead'])) - if report.top_measures_json: - m_data = [['Massnahme', 'Prioritaet', 'Phase']] - for m in report.top_measures_json[:10]: - m_data.append([ - Paragraph(m.get('title', ''), ss['CellText']), - m.get('priority', ''), - m.get('target_phase', ''), - ]) - story.append(_kpi_table(m_data, col_widths=[240, 80, 160])) - story.append(PageBreak()) - - # ── Roadmap ── - story.append(Paragraph('Umsetzungsroadmap', ss['SectionHead'])) - if report.roadmap_summary: - r_data = [['Phase', 'Wochen', 'Ziel']] - for phase in report.roadmap_summary: - r_data.append([ - phase.get('name', ''), - str(phase.get('duration_weeks', '')), - Paragraph(phase.get('objective', ''), ss['CellText']), - ]) - story.append(_kpi_table(r_data, col_widths=[140, 50, 290])) - story.append(Spacer(1, 8 * mm)) - - # ── Naechste Schritte ── - story.append(Paragraph('Empfohlene naechste Schritte', ss['SectionHead'])) - if report.next_steps_json: - for period, label in [('next_30_days', '30 Tage'), ('next_60_days', '60 Tage'), ('next_90_days', '90 Tage')]: - items = report.next_steps_json.get(period, []) - if items: - story.append(Paragraph(f'Innerhalb von {label}:', ss['BodyText2'])) - for item in items: - story.append(Paragraph(f' • {item.get("title", "")} ({item.get("priority", "")})', ss['BodyText2'])) - story.append(Spacer(1, 4 * mm)) - - story.append(PageBreak()) - story.append(Paragraph('Evidenzpflichten mit Mapping- und Quellenbezug', ss['SectionHead'])) - evidence_needs = RequirementEvidenceNeed.objects.filter( - tenant=tenant, - session=session, - ).select_related('requirement__mapping_version', 'requirement__primary_source')[:12] - if evidence_needs: - evidence_rows = [['Requirement', 'Status', 'Mapping-Version', 'Quelle']] - for need in evidence_needs: - requirement = need.requirement - mapping_version = need.requirement_mapping_version or '-' - source_citation = need.requirement_source_citation or '-' - evidence_rows.append([ - f'{requirement.framework} {requirement.code}', - need.get_status_display(), - Paragraph(mapping_version, ss['CellText']), - Paragraph(source_citation, ss['CellText']), - ]) - story.append(_kpi_table(evidence_rows, col_widths=[90, 70, 140, 180])) - else: - story.append(Paragraph('Keine Evidenzpflichten fuer diese Session verfuegbar.', ss['SmallGray'])) - - # ── Risk Heatmap (text) ── - story.append(Paragraph('Risikomatrix-Zusammenfassung', ss['SectionHead'])) - from apps.risks.models import Risk - risks = Risk.objects.filter(tenant=tenant) - if risks.exists(): - summary = RiskMatrixService.summary(risks) - risk_data = [ - ['Level', 'Anzahl'], - ['Kritisch', str(summary['critical'])], - ['Hoch', str(summary['high'])], - ['Mittel', str(summary['medium'])], - ['Niedrig', str(summary['low'])], - ['Gesamt', str(summary['total'])], - ] - story.append(_kpi_table(risk_data, col_widths=[200, 80])) - else: - story.append(Paragraph('Keine Risiken im Register erfasst.', ss['SmallGray'])) - - # ── Disclaimer ── - story.append(Spacer(1, 2 * cm)) - story.append(HRFlowable(width='100%', thickness=0.5, color=colors.HexColor('#dbe4f0'), spaceAfter=8)) - story.append(Paragraph( - 'Dieses Dokument ist das Ergebnis einer strukturierten Bewertung und ersetzt keine rechtliche Pruefung. ' - 'Die dargestellten Ergebnisse sind als Indikation und Dokumentationsgrundlage zu verstehen.', - ss['SmallGray'], - )) - - def on_page(canvas_obj, doc_obj): - _header_footer(canvas_obj, doc_obj, tenant.name, 'ISCY Assessment Report') - - doc.build(story, onFirstPage=on_page, onLaterPages=on_page) - return buffer.getvalue() diff --git a/apps/reports/services.py b/apps/reports/services.py deleted file mode 100644 index 9f32a8f..0000000 --- a/apps/reports/services.py +++ /dev/null @@ -1,257 +0,0 @@ -import json -from dataclasses import dataclass, field -from urllib.error import HTTPError -from urllib.request import Request, urlopen - -from django.conf import settings - - -@dataclass(frozen=True) -class ReportTenantRef: - id: int - name: str - - -@dataclass(frozen=True) -class ReportSnapshotListItem: - id: int - tenant: ReportTenantRef - tenant_id: int - session_id: int - title: str - applicability_result: str - iso_readiness_percent: int - nis2_readiness_percent: int - created_at: str - updated_at: str - - -class EmptyRelatedManager: - def first(self): - return None - - -@dataclass(frozen=True) -class ReportSessionRef: - id: int - roadmap_plans: EmptyRelatedManager = field(default_factory=EmptyRelatedManager) - - -@dataclass(frozen=True) -class ReportSnapshotDetailItem: - id: int - tenant: object - tenant_id: int - session: ReportSessionRef - session_id: int - title: str - executive_summary: str - applicability_result: str - iso_readiness_percent: int - nis2_readiness_percent: int - kritis_readiness_percent: int - cra_readiness_percent: int - ai_act_readiness_percent: int - iec62443_readiness_percent: int - iso_sae_21434_readiness_percent: int - regulatory_matrix_json: dict - compliance_versions_json: dict - product_security_json: dict - top_gaps_json: list - top_measures_json: list - roadmap_summary: list - domain_scores_json: list - next_steps_json: dict - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - -class ReportRustClient: - @staticmethod - def _base_url() -> str: - base = (getattr(settings, 'RUST_BACKEND_URL', '') or '').strip() - return base.rstrip('/') - - @staticmethod - def fetch_snapshots(request, tenant, timeout: int = 8) -> list[ReportSnapshotListItem]: - base = ReportRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - user = getattr(request, 'user', None) - user_id = getattr(user, 'id', None) - if not user_id: - raise RuntimeError('Report-Rust-Bridge braucht einen authentifizierten User.') - - rust_request = Request(f'{base}/api/v1/reports/snapshots') - rust_request.add_header('Accept', 'application/json') - rust_request.add_header('X-ISCY-Tenant-ID', str(tenant.id)) - rust_request.add_header('X-ISCY-User-ID', str(user_id)) - user_email = (getattr(user, 'email', '') or '').strip() - if user_email: - rust_request.add_header('X-ISCY-User-Email', user_email) - - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - - tenant_id = int(payload.get('tenant_id') or 0) - if tenant_id != int(tenant.id): - raise RuntimeError('Rust-Reportliste gehoert nicht zum angefragten Tenant.') - - tenant_ref = ReportTenantRef(id=int(tenant.id), name=str(tenant.name)) - return [ - ReportSnapshotListItem( - id=ReportRustClient._int_field(item, 'id'), - tenant=tenant_ref, - tenant_id=ReportRustClient._int_field(item, 'tenant_id'), - session_id=ReportRustClient._int_field(item, 'session_id'), - title=str(item.get('title') or ''), - applicability_result=str(item.get('applicability_result') or ''), - iso_readiness_percent=ReportRustClient._int_field(item, 'iso_readiness_percent'), - nis2_readiness_percent=ReportRustClient._int_field(item, 'nis2_readiness_percent'), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - for item in payload.get('reports', []) - if isinstance(item, dict) - ] - - @staticmethod - def fetch_snapshot_detail(request, tenant, report_id: int, timeout: int = 8): - base = ReportRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = ReportRustClient._authenticated_request( - request, - tenant, - f'{base}/api/v1/reports/snapshots/{int(report_id)}', - ) - try: - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - except HTTPError as exc: - if exc.code == 404: - return None - raise - - report = payload.get('report') or {} - if not isinstance(report, dict): - raise RuntimeError('Rust-Reportdetail hat kein report-Objekt geliefert.') - tenant_id = ReportRustClient._int_field(report, 'tenant_id') - if tenant_id != int(tenant.id): - raise RuntimeError('Rust-Reportdetail gehoert nicht zum angefragten Tenant.') - - session_id = ReportRustClient._int_field(report, 'session_id') - return ReportSnapshotDetailItem( - id=ReportRustClient._int_field(report, 'id'), - tenant=tenant, - tenant_id=tenant_id, - session=ReportSessionRef(id=session_id), - session_id=session_id, - title=str(report.get('title') or ''), - executive_summary=str(report.get('executive_summary') or ''), - applicability_result=str(report.get('applicability_result') or ''), - iso_readiness_percent=ReportRustClient._int_field(report, 'iso_readiness_percent'), - nis2_readiness_percent=ReportRustClient._int_field(report, 'nis2_readiness_percent'), - kritis_readiness_percent=ReportRustClient._int_field(report, 'kritis_readiness_percent'), - cra_readiness_percent=ReportRustClient._int_field(report, 'cra_readiness_percent'), - ai_act_readiness_percent=ReportRustClient._int_field(report, 'ai_act_readiness_percent'), - iec62443_readiness_percent=ReportRustClient._int_field(report, 'iec62443_readiness_percent'), - iso_sae_21434_readiness_percent=ReportRustClient._int_field( - report, - 'iso_sae_21434_readiness_percent', - ), - regulatory_matrix_json=ReportRustClient._dict_field(report, 'regulatory_matrix_json'), - compliance_versions_json=ReportRustClient._dict_field(report, 'compliance_versions_json'), - product_security_json=ReportRustClient._dict_field(report, 'product_security_json'), - top_gaps_json=ReportRustClient._list_field(report, 'top_gaps_json'), - top_measures_json=ReportRustClient._list_field(report, 'top_measures_json'), - roadmap_summary=ReportRustClient._list_field(report, 'roadmap_summary'), - domain_scores_json=ReportRustClient._list_field(report, 'domain_scores_json'), - next_steps_json=ReportRustClient._dict_field(report, 'next_steps_json'), - created_at=str(report.get('created_at') or ''), - updated_at=str(report.get('updated_at') or ''), - ) - - @staticmethod - def _authenticated_request(request, tenant, url: str) -> Request: - user = getattr(request, 'user', None) - user_id = getattr(user, 'id', None) - if not user_id: - raise RuntimeError('Report-Rust-Bridge braucht einen authentifizierten User.') - - rust_request = Request(url) - rust_request.add_header('Accept', 'application/json') - rust_request.add_header('X-ISCY-Tenant-ID', str(tenant.id)) - rust_request.add_header('X-ISCY-User-ID', str(user_id)) - user_email = (getattr(user, 'email', '') or '').strip() - if user_email: - rust_request.add_header('X-ISCY-User-Email', user_email) - return rust_request - - @staticmethod - def _int_field(payload: dict, key: str) -> int: - return int(payload.get(key) or 0) - - @staticmethod - def _dict_field(payload: dict, key: str) -> dict: - value = payload.get(key) - return value if isinstance(value, dict) else {} - - @staticmethod - def _list_field(payload: dict, key: str) -> list: - value = payload.get(key) - return value if isinstance(value, list) else [] - - -class ReportSnapshotBridge: - @staticmethod - def fetch_list(request, tenant): - if tenant is None: - return None - - backend = str(getattr(settings, 'REPORT_SNAPSHOT_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not ReportRustClient._base_url(): - if ReportSnapshotBridge._strict_rust_mode(): - raise RuntimeError('Rust report snapshot backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return ReportRustClient.fetch_snapshots(request, tenant) - except Exception as exc: - if ReportSnapshotBridge._strict_rust_mode(): - raise RuntimeError('Rust report snapshot backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def fetch_detail(request, tenant, report_id: int): - if tenant is None: - return None - - backend = str(getattr(settings, 'REPORT_SNAPSHOT_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not ReportRustClient._base_url(): - if ReportSnapshotBridge._strict_rust_mode(): - raise RuntimeError('Rust report snapshot backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return ReportRustClient.fetch_snapshot_detail(request, tenant, report_id) - except Exception as exc: - if ReportSnapshotBridge._strict_rust_mode(): - raise RuntimeError('Rust report snapshot backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def _strict_rust_mode() -> bool: - return bool(getattr(settings, 'RUST_STRICT_MODE', False)) diff --git a/apps/reports/tests.py b/apps/reports/tests.py deleted file mode 100644 index 38c564c..0000000 --- a/apps/reports/tests.py +++ /dev/null @@ -1,247 +0,0 @@ -import json -from unittest.mock import Mock, patch - -from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings -from django.urls import reverse - -from apps.organizations.models import Tenant -from apps.reports.models import ReportSnapshot -from apps.wizard.models import AssessmentSession - - -User = get_user_model() - - -@override_settings(REPORT_SNAPSHOT_BACKEND="local", RUST_BACKEND_URL="") -class ReportViewTests(TestCase): - def setUp(self): - self.tenant_a = Tenant.objects.create(name="Tenant A", slug="tenant-a", country="DE") - self.tenant_b = Tenant.objects.create(name="Tenant B", slug="tenant-b", country="DE") - - self.user_a = User.objects.create_user( - username="tenant-a-user", - password="testpass123", - tenant=self.tenant_a, - ) - self.user_b = User.objects.create_user( - username="tenant-b-user", - password="testpass123", - tenant=self.tenant_b, - ) - - self.session_a = AssessmentSession.objects.create( - tenant=self.tenant_a, - started_by=self.user_a, - applicability_result="relevant", - executive_summary="Kurzfassung Tenant A", - ) - self.session_b = AssessmentSession.objects.create( - tenant=self.tenant_b, - started_by=self.user_b, - applicability_result="not_relevant", - executive_summary="Kurzfassung Tenant B", - ) - - self.report_a = ReportSnapshot.objects.create( - tenant=self.tenant_a, - session=self.session_a, - title="Report A", - executive_summary="Executive Summary A", - applicability_result="relevant", - compliance_versions_json={ - "ISO27001": {"version": "2022"}, - "NIS2": {"version": "2024"}, - }, - domain_scores_json=[ - {"domain": "Governance", "score_percent": 82, "maturity_level": "Managed"} - ], - top_measures_json=[{"title": "MFA einführen", "priority": "HIGH"}], - roadmap_summary=[{"name": "Phase 1", "duration_weeks": 6, "objective": "Basis schaffen"}], - next_steps_json={"dependencies": []}, - ) - self.report_b = ReportSnapshot.objects.create( - tenant=self.tenant_b, - session=self.session_b, - title="Report B", - executive_summary="Executive Summary B", - applicability_result="not_relevant", - ) - - def test_report_list_only_shows_current_tenant(self): - self.client.force_login(self.user_a) - - response = self.client.get(reverse("reports:list")) - - self.assertEqual(response.status_code, 200) - reports = list(response.context["reports"]) - self.assertEqual(reports, [self.report_a]) - self.assertEqual(response.context["report_snapshot_source"], "django") - - @patch("apps.reports.services.urlopen") - def test_report_list_can_use_rust_snapshot_bridge(self, mock_urlopen): - response_mock = Mock() - response_mock.read.return_value = json.dumps({ - "api_version": "v1", - "tenant_id": self.tenant_a.id, - "reports": [ - { - "id": self.report_a.id, - "tenant_id": self.tenant_a.id, - "session_id": self.session_a.id, - "title": "Rust Report A", - "applicability_result": "relevant", - "iso_readiness_percent": 81, - "nis2_readiness_percent": 76, - "created_at": "2026-04-18T10:00:00Z", - "updated_at": "2026-04-18T11:00:00Z", - } - ], - }).encode("utf-8") - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - mock_urlopen.return_value = response_ctx - self.client.force_login(self.user_a) - - with self.settings(REPORT_SNAPSHOT_BACKEND="rust_service", RUST_BACKEND_URL="http://rust-backend:9000"): - response = self.client.get(reverse("reports:list")) - - self.assertEqual(response.status_code, 200) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual(rust_request.full_url, "http://rust-backend:9000/api/v1/reports/snapshots") - self.assertEqual(rust_request.get_header("X-iscy-tenant-id"), str(self.tenant_a.id)) - self.assertEqual(rust_request.get_header("X-iscy-user-id"), str(self.user_a.id)) - reports = list(response.context["reports"]) - self.assertEqual(response.context["report_snapshot_source"], "rust_service") - self.assertEqual(len(reports), 1) - self.assertEqual(reports[0].title, "Rust Report A") - self.assertEqual(reports[0].tenant.name, "Tenant A") - self.assertEqual(reports[0].iso_readiness_percent, 81) - - @patch("apps.reports.services.urlopen", side_effect=OSError("backend down")) - def test_report_list_falls_back_to_django_when_rust_unavailable_in_non_strict_mode(self, _mock_urlopen): - self.client.force_login(self.user_a) - - with self.settings( - REPORT_SNAPSHOT_BACKEND="rust_service", - RUST_BACKEND_URL="http://rust-backend:9000", - RUST_STRICT_MODE=False, - ): - response = self.client.get(reverse("reports:list")) - - self.assertEqual(response.status_code, 200) - reports = list(response.context["reports"]) - self.assertEqual(response.context["report_snapshot_source"], "django") - self.assertEqual(reports, [self.report_a]) - - def test_report_list_raises_in_strict_mode_without_rust_backend_url(self): - self.client.force_login(self.user_a) - - with self.settings(REPORT_SNAPSHOT_BACKEND="rust_service", RUST_BACKEND_URL="", RUST_STRICT_MODE=True): - with self.assertRaises(RuntimeError): - self.client.get(reverse("reports:list")) - - @patch("apps.reports.services.urlopen") - def test_report_detail_can_use_rust_snapshot_bridge(self, mock_urlopen): - response_mock = Mock() - response_mock.read.return_value = json.dumps({ - "api_version": "v1", - "report": { - "id": self.report_a.id, - "tenant_id": self.tenant_a.id, - "session_id": self.session_a.id, - "title": "Rust Detail Report", - "executive_summary": "Rust Executive Summary", - "applicability_result": "relevant", - "iso_readiness_percent": 88, - "nis2_readiness_percent": 79, - "kritis_readiness_percent": 33, - "cra_readiness_percent": 34, - "ai_act_readiness_percent": 35, - "iec62443_readiness_percent": 36, - "iso_sae_21434_readiness_percent": 37, - "regulatory_matrix_json": { - "summary": "Rust matrix", - "nis2": {"label": "NIS2", "applicable": True, "reason": "Tenant relevant"}, - }, - "compliance_versions_json": { - "ISO27001": { - "framework": "ISO27001", - "version": "2022", - "title": "ISO 27001", - "requirement_count": 93, - "source_count": 1, - } - }, - "product_security_json": {"sbom_required": True}, - "top_gaps_json": [{"title": "Rust Gap", "severity": "HIGH"}], - "top_measures_json": [{"title": "Rust Measure", "priority": "HIGH"}], - "roadmap_summary": [{"name": "Rust Phase", "duration_weeks": 4, "objective": "Bridge"}], - "domain_scores_json": [{"domain": "Governance", "score_percent": 88, "maturity_level": "Managed"}], - "next_steps_json": {"dependencies": []}, - "created_at": "2026-04-18T10:00:00Z", - "updated_at": "2026-04-18T11:00:00Z", - }, - }).encode("utf-8") - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - mock_urlopen.return_value = response_ctx - self.client.force_login(self.user_a) - - with self.settings(REPORT_SNAPSHOT_BACKEND="rust_service", RUST_BACKEND_URL="http://rust-backend:9000"): - response = self.client.get(reverse("reports:detail", args=[self.report_a.pk])) - - self.assertEqual(response.status_code, 200) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual( - rust_request.full_url, - f"http://rust-backend:9000/api/v1/reports/snapshots/{self.report_a.pk}", - ) - self.assertEqual(rust_request.get_header("X-iscy-tenant-id"), str(self.tenant_a.id)) - self.assertEqual(rust_request.get_header("X-iscy-user-id"), str(self.user_a.id)) - report = response.context["report"] - self.assertEqual(response.context["report_snapshot_source"], "rust_service") - self.assertEqual(report.title, "Rust Detail Report") - self.assertEqual(report.iso_readiness_percent, 88) - self.assertEqual(report.tenant.name, "Tenant A") - self.assertContains(response, "Rust Executive Summary") - - @patch("apps.reports.services.urlopen", side_effect=OSError("backend down")) - def test_report_detail_falls_back_to_django_when_rust_unavailable_in_non_strict_mode(self, _mock_urlopen): - self.client.force_login(self.user_a) - - with self.settings( - REPORT_SNAPSHOT_BACKEND="rust_service", - RUST_BACKEND_URL="http://rust-backend:9000", - RUST_STRICT_MODE=False, - ): - response = self.client.get(reverse("reports:detail", args=[self.report_a.pk])) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context["report_snapshot_source"], "django") - self.assertEqual(response.context["report"], self.report_a) - - def test_report_detail_blocks_foreign_tenant_access(self): - self.client.force_login(self.user_a) - - response = self.client.get(reverse("reports:detail", args=[self.report_b.pk])) - - self.assertEqual(response.status_code, 404) - - def test_report_pdf_is_generated_for_own_tenant(self): - self.client.force_login(self.user_a) - - response = self.client.get(reverse("reports:pdf", args=[self.report_a.pk])) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response["Content-Type"], "application/pdf") - self.assertTrue(response.content.startswith(b"%PDF")) - - def test_report_pdf_blocks_foreign_tenant_access(self): - self.client.force_login(self.user_a) - - response = self.client.get(reverse("reports:pdf", args=[self.report_b.pk])) - - self.assertEqual(response.status_code, 404) diff --git a/apps/reports/urls.py b/apps/reports/urls.py deleted file mode 100644 index 44023e8..0000000 --- a/apps/reports/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.urls import path -from .views import ReportDetailView, ReportListView, ReportPdfView, ReportProPdfView - -app_name = 'reports' - -urlpatterns = [ - path('', ReportListView.as_view(), name='list'), - path('/', ReportDetailView.as_view(), name='detail'), - path('/pdf/', ReportPdfView.as_view(), name='pdf'), - path('/pdf-pro/', ReportProPdfView.as_view(), name='pdf_pro'), -] diff --git a/apps/reports/views.py b/apps/reports/views.py deleted file mode 100644 index 1b5c4e5..0000000 --- a/apps/reports/views.py +++ /dev/null @@ -1,186 +0,0 @@ -import io -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpResponse -from django.shortcuts import get_object_or_404 -from django.views.generic import DetailView, ListView, View -from apps.core.mixins import TenantAccessMixin -from reportlab.lib import colors -from reportlab.lib.pagesizes import A4, landscape -from reportlab.lib.styles import getSampleStyleSheet -from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle -from .models import ReportSnapshot -from .services import ReportSnapshotBridge -from apps.evidence.models import RequirementEvidenceNeed -from apps.evidence.services import EvidenceNeedService -from apps.wizard.models import GeneratedMeasure - - -class ReportListView(TenantAccessMixin, ListView): - model = ReportSnapshot - template_name = 'reports/report_list.html' - context_object_name = 'reports' - - def get_queryset(self): - rust_reports = ReportSnapshotBridge.fetch_list(self.request, self.get_tenant()) - if rust_reports is not None: - self.report_snapshot_source = 'rust_service' - return rust_reports - self.report_snapshot_source = 'django' - return super().get_queryset().select_related('tenant') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['report_snapshot_source'] = getattr(self, 'report_snapshot_source', 'django') - return context - - -class ReportDetailView(TenantAccessMixin, DetailView): - model = ReportSnapshot - template_name = 'reports/report_detail.html' - context_object_name = 'report' - - def get_object(self, queryset=None): - report_id = self.kwargs.get(self.pk_url_kwarg) - rust_report = ReportSnapshotBridge.fetch_detail(self.request, self.get_tenant(), report_id) - if rust_report is not None: - self.report_snapshot_source = 'rust_service' - self.check_tenant_access(rust_report) - return rust_report - self.report_snapshot_source = 'django' - return super().get_object(queryset) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - report = self.object - session_id = getattr(report, 'session_id', None) or getattr(report.session, 'id', None) - context['evidence_needs'] = RequirementEvidenceNeed.objects.filter( - tenant=report.tenant, - session_id=session_id, - ).select_related('requirement__mapping_version', 'requirement__primary_source')[:20] - measures = list( - GeneratedMeasure.objects.filter(session_id=session_id) - .select_related('domain', 'question') - .all()[:12] - ) - context['measure_evidence_rows'] = [ - {'measure': measure, 'summary': EvidenceNeedService.measure_need_summary(measure)} - for measure in measures - ] - context['report_snapshot_source'] = getattr(self, 'report_snapshot_source', 'django') - return context - - -class ReportPdfView(TenantAccessMixin, View): - tenant_filter_field = 'tenant' - - def get(self, request, pk): - report = get_object_or_404( - self.filter_queryset_for_tenant( - ReportSnapshot.objects.select_related('tenant', 'session') - ), - pk=pk, - ) - evidence_needs = RequirementEvidenceNeed.objects.filter( - tenant=report.tenant, - session=report.session, - ).select_related('requirement__mapping_version', 'requirement__primary_source')[:12] - buffer = io.BytesIO() - doc = SimpleDocTemplate(buffer, pagesize=landscape(A4), title=report.title) - styles = getSampleStyleSheet() - story = [] - story.append(Paragraph(report.title, styles['Title'])) - story.append(Paragraph(f'Mandant: {report.tenant.name}', styles['Normal'])) - story.append(Paragraph(f'Betroffenheit: {report.applicability_result}', styles['Normal'])) - story.append(Paragraph(f'Länder / Präsenz: {report.tenant.countries_display}', styles['Normal'])) - story.append(Paragraph(f'Sektor: {report.tenant.sector_label} ({report.tenant.sector_profile.nis2_group})', styles['Normal'])) - if report.compliance_versions_json: - version_labels = ", ".join( - f"{key} {value.get('version', '')}" - for key, value in report.compliance_versions_json.items() - ) - story.append(Paragraph(f"Mapping-Versionen: {version_labels}", styles['Normal'])) - story.append(Paragraph(report.tenant.sector_profile.downstream_impact, styles['BodyText'])) - story.append(Spacer(1, 12)) - story.append(Paragraph('Executive Summary', styles['Heading2'])) - story.append(Paragraph(report.executive_summary or '-', styles['BodyText'])) - story.append(Spacer(1, 12)) - story.append(Paragraph('Domänenscores / Heatmap', styles['Heading2'])) - table_data = [['Domäne', 'Score', 'Reifegrad', 'Ampel']] - score_colors = [] - for item in report.domain_scores_json: - score = int(item.get('score_percent', 0)) - if score <= 20: - ampel = 'Rot'; c = colors.HexColor('#c62828') - elif score <= 40: - ampel = 'Orange'; c = colors.HexColor('#ef6c00') - elif score <= 60: - ampel = 'Gelb'; c = colors.HexColor('#f9a825') - elif score <= 80: - ampel = 'Blau'; c = colors.HexColor('#1565c0') - else: - ampel = 'Grün'; c = colors.HexColor('#2e7d32') - table_data.append([item.get('domain', ''), f"{score}%", item.get('maturity_level', ''), ampel]) - score_colors.append(c) - table = Table(table_data, repeatRows=1) - style = TableStyle([('BACKGROUND', (0,0), (-1,0), colors.HexColor('#0b3d91')), ('TEXTCOLOR', (0,0), (-1,0), colors.white), ('GRID', (0,0), (-1,-1), 0.5, colors.grey), ('ROWBACKGROUNDS', (0,1), (-1,-1), [colors.whitesmoke, colors.HexColor('#eef4fb')])]) - for idx, color in enumerate(score_colors, start=1): - style.add('BACKGROUND', (3, idx), (3, idx), color) - style.add('TEXTCOLOR', (3, idx), (3, idx), colors.white) - table.setStyle(style) - story.append(table) - story.append(Spacer(1, 12)) - story.append(Paragraph('Top Maßnahmen', styles['Heading2'])) - for item in report.top_measures_json: - story.append(Paragraph(f"• {item.get('title')} ({item.get('priority', '-')})", styles['BodyText'])) - if evidence_needs: - story.append(Spacer(1, 12)) - story.append(Paragraph('Evidenzpflichten je Requirement', styles['Heading2'])) - for need in evidence_needs: - story.append(Paragraph(f"• {need.requirement.framework} {need.requirement.code}: {need.get_status_display()} – {need.description}", styles['BodyText'])) - if need.requirement_mapping_version: - story.append(Paragraph(f" Mapping-Version: {need.requirement_mapping_version}", styles['BodyText'])) - if need.requirement_source_citation: - story.append(Paragraph(f" Quelle: {need.requirement_source_citation}", styles['BodyText'])) - story.append(Spacer(1, 12)) - story.append(Paragraph('Maßnahmen mit Nachweispflichten', styles['Heading2'])) - for measure in report.session.generated_measures.select_related('domain')[:8]: - summary = EvidenceNeedService.measure_need_summary(measure) - story.append(Paragraph(f"• {measure.title}: offen {summary['open']}, teilweise {summary['partial']}, abgedeckt {summary['covered']}", styles['BodyText'])) - deps = report.next_steps_json.get('dependencies', []) - if deps: - story.append(Spacer(1, 12)) - story.append(Paragraph('Wesentliche Abhängigkeiten', styles['Heading2'])) - for dep in deps[:10]: - story.append(Paragraph(f"• {dep.get('predecessor')} → {dep.get('successor')} ({dep.get('type')})", styles['BodyText'])) - story.append(Spacer(1, 12)) - story.append(Paragraph('Roadmap', styles['Heading2'])) - for item in report.roadmap_summary: - story.append(Paragraph(f"• {item.get('name')} – {item.get('duration_weeks', '-') } Wochen", styles['BodyText'])) - if item.get('objective'): - story.append(Paragraph(item['objective'], styles['Normal'])) - doc.build(story) - pdf = buffer.getvalue() - buffer.close() - response = HttpResponse(content_type='application/pdf') - response['Content-Disposition'] = f'attachment; filename="report-{report.pk}.pdf"' - response.write(pdf) - return response - - -class ReportProPdfView(TenantAccessMixin, View): - """V20: Professioneller audit-ready PDF-Report.""" - tenant_filter_field = 'tenant' - - def get(self, request, pk): - report = get_object_or_404( - self.filter_queryset_for_tenant( - ReportSnapshot.objects.select_related('tenant', 'session') - ), - pk=pk, - ) - from .pdf_export import generate_audit_report_pdf - pdf_bytes = generate_audit_report_pdf(report, report.session, report.tenant) - response = HttpResponse(content_type='application/pdf') - response['Content-Disposition'] = f'attachment; filename="ISMS-Report-{report.tenant.name}-{report.pk}.pdf"' - response.write(pdf_bytes) - return response diff --git a/apps/requirements_app/__init__.py b/apps/requirements_app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/requirements_app/admin.py b/apps/requirements_app/admin.py deleted file mode 100644 index 145af88..0000000 --- a/apps/requirements_app/admin.py +++ /dev/null @@ -1,30 +0,0 @@ -from django.contrib import admin -from .models import MappingVersion, RegulatorySource, Requirement, RequirementQuestionMapping - - -@admin.register(MappingVersion) -class MappingVersionAdmin(admin.ModelAdmin): - list_display = ('framework', 'version', 'status', 'program_name', 'effective_on') - list_filter = ('framework', 'status') - search_fields = ('framework', 'version', 'title', 'program_name') - - -@admin.register(RegulatorySource) -class RegulatorySourceAdmin(admin.ModelAdmin): - list_display = ('framework', 'code', 'authority', 'source_type', 'mapping_version') - list_filter = ('framework', 'source_type', 'authority') - search_fields = ('code', 'title', 'citation', 'url') - - -@admin.register(Requirement) -class RequirementAdmin(admin.ModelAdmin): - list_display = ('framework', 'code', 'title', 'domain', 'mapping_version', 'is_active') - list_filter = ('framework', 'domain', 'is_active', 'coverage_level') - search_fields = ('code', 'title', 'description', 'legal_reference') - - -@admin.register(RequirementQuestionMapping) -class RequirementQuestionMappingAdmin(admin.ModelAdmin): - list_display = ('requirement', 'question', 'mapping_version', 'strength') - list_filter = ('requirement__framework', 'strength') - search_fields = ('requirement__code', 'question__code', 'rationale') diff --git a/apps/requirements_app/apps.py b/apps/requirements_app/apps.py deleted file mode 100644 index 972616b..0000000 --- a/apps/requirements_app/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class RequirementsAppConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.requirements_app' diff --git a/apps/requirements_app/management/__init__.py b/apps/requirements_app/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/requirements_app/management/commands/__init__.py b/apps/requirements_app/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/requirements_app/management/commands/seed_requirements.py b/apps/requirements_app/management/commands/seed_requirements.py deleted file mode 100644 index 09c88fd..0000000 --- a/apps/requirements_app/management/commands/seed_requirements.py +++ /dev/null @@ -1,729 +0,0 @@ -"""Seed versioned ISO 27001, NIS2, KRITIS and CRA requirements plus mapping traceability.""" - -from datetime import date - -from django.core.management.base import BaseCommand - -from apps.catalog.models import AssessmentQuestion -from apps.requirements_app.models import ( - MappingVersion, - RegulatorySource, - Requirement, - RequirementQuestionMapping, -) - - -class Command(BaseCommand): - help = 'Seed versioned regulatory requirements, sources and question mappings for ISCY.' - - def handle(self, *args, **options): - versions = self._seed_versions() - sources = self._seed_sources(versions) - requirements = self._seed_requirements(versions, sources) - self._seed_question_mappings(versions, requirements) - - counts = {fw: Requirement.objects.filter(framework=fw).count() for fw, _ in Requirement.Framework.choices} - mapping_count = RequirementQuestionMapping.objects.count() - source_count = RegulatorySource.objects.count() - summary = ', '.join(f'{fw}={count}' for fw, count in counts.items()) - self.stdout.write(self.style.SUCCESS(f'Requirements seeded: {summary}; sources={source_count}; question_mappings={mapping_count}.')) - - def _seed_versions(self): - definitions = [ - { - 'framework': Requirement.Framework.ISO27001, - 'slug': 'iscy-iso27001-2022-v2-0', - 'title': 'ISCY Mapping Baseline ISO/IEC 27001:2022 und ISO/IEC 27002:2022', - 'version': '2.0', - 'effective_on': date(2026, 3, 13), - 'notes': 'Versionierte Referenzbasis fuer Annex-A-Controls und deren Nutzung in ISCY.', - }, - { - 'framework': Requirement.Framework.NIS2, - 'slug': 'iscy-nis2-eu-de-v2-0', - 'title': 'ISCY Mapping Baseline NIS2 EU/DE', - 'version': '2.0', - 'effective_on': date(2026, 3, 13), - 'notes': 'Basierend auf Richtlinie (EU) 2022/2555, Durchfuehrungsverordnung (EU) 2024/2690 und aktueller BSI-Information zur Umsetzung in Deutschland.', - }, - { - 'framework': Requirement.Framework.KRITIS, - 'slug': 'iscy-kritis-de-v2-0', - 'title': 'ISCY Mapping Baseline KRITIS Deutschland', - 'version': '2.0', - 'effective_on': date(2026, 3, 13), - 'notes': 'Basierend auf BSIG, BSI-KritisV und BSI-Informationen fuer KRITIS-Betreiber.', - }, - { - 'framework': Requirement.Framework.CRA, - 'slug': 'iscy-cra-eu-v2-0', - 'title': 'ISCY Mapping Baseline Cyber Resilience Act', - 'version': '2.0', - 'effective_on': date(2026, 3, 13), - 'notes': 'Basierend auf Verordnung (EU) 2024/2847 und der offiziellen EU-Kommunikation zum Cyber Resilience Act.', - }, - ] - versions = {} - for definition in definitions: - version, _ = MappingVersion.objects.update_or_create( - slug=definition['slug'], - defaults={ - 'framework': definition['framework'], - 'title': definition['title'], - 'version': definition['version'], - 'program_name': 'ISCY', - 'status': MappingVersion.Status.ACTIVE, - 'effective_on': definition['effective_on'], - 'notes': definition['notes'], - }, - ) - versions[definition['framework']] = version - MappingVersion.objects.filter(framework=definition['framework']).exclude(pk=version.pk).update(status=MappingVersion.Status.SUPERSEDED) - return versions - - def _seed_sources(self, versions): - source_defs = { - Requirement.Framework.ISO27001: [ - { - 'code': 'ISO27001-2022', - 'title': 'ISO/IEC 27001:2022 Information security, cybersecurity and privacy protection', - 'authority': 'ISO', - 'citation': 'ISO/IEC 27001:2022', - 'url': 'https://www.iso.org/standard/27001', - 'source_type': RegulatorySource.SourceType.STANDARD, - 'published_on': date(2022, 10, 25), - 'effective_on': date(2022, 10, 25), - }, - { - 'code': 'ISO27002-2022', - 'title': 'ISO/IEC 27002:2022 Information security controls', - 'authority': 'ISO', - 'citation': 'ISO/IEC 27002:2022', - 'url': 'https://www.iso.org/standard/75652.html', - 'source_type': RegulatorySource.SourceType.STANDARD, - 'published_on': date(2022, 2, 15), - 'effective_on': date(2022, 2, 15), - }, - ], - Requirement.Framework.NIS2: [ - { - 'code': 'NIS2-DIR', - 'title': 'Directive (EU) 2022/2555', - 'authority': 'EUR-Lex', - 'citation': 'Richtlinie (EU) 2022/2555', - 'url': 'https://eur-lex.europa.eu/eli/dir/2022/2555/oj', - 'source_type': RegulatorySource.SourceType.PRIMARY, - 'published_on': date(2022, 12, 27), - 'effective_on': date(2023, 1, 16), - }, - { - 'code': 'NIS2-IR-2024-2690', - 'title': 'Implementing Regulation (EU) 2024/2690', - 'authority': 'EUR-Lex', - 'citation': 'Durchfuehrungsverordnung (EU) 2024/2690', - 'url': 'https://eur-lex.europa.eu/eli/reg_impl/2024/2690/oj', - 'source_type': RegulatorySource.SourceType.PRIMARY, - 'published_on': date(2024, 10, 17), - 'effective_on': date(2024, 10, 18), - }, - { - 'code': 'BSI-NIS2-2025', - 'title': 'BSI Information NIS2-Registrierung und Umsetzung', - 'authority': 'BSI', - 'citation': 'BSI Informationsseite, Stand nach Inkrafttreten 06.12.2025', - 'url': 'https://mip2.bsi.bund.de/de/info-nis2-registrierung/', - 'source_type': RegulatorySource.SourceType.OFFICIAL_GUIDANCE, - 'published_on': date(2025, 12, 6), - 'effective_on': date(2025, 12, 6), - }, - ], - Requirement.Framework.KRITIS: [ - { - 'code': 'BSIG-8A', - 'title': 'BSIG § 8a Sicherheit in der Informationstechnik Kritischer Infrastrukturen', - 'authority': 'Gesetze im Internet', - 'citation': 'BSIG § 8a', - 'url': 'https://www.gesetze-im-internet.de/bsig_2009/__8a.html', - 'source_type': RegulatorySource.SourceType.PRIMARY, - }, - { - 'code': 'BSI-KRITISV', - 'title': 'BSI-Kritisverordnung', - 'authority': 'Gesetze im Internet', - 'citation': 'BSI-KritisV', - 'url': 'https://www.gesetze-im-internet.de/bsi-kritisv/', - 'source_type': RegulatorySource.SourceType.PRIMARY, - }, - { - 'code': 'BSI-KRITIS', - 'title': 'BSI KRITIS Betreiber Informationen', - 'authority': 'BSI', - 'citation': 'BSI KRITIS Betreiber', - 'url': 'https://www.bsi.bund.de/DE/Themen/KRITIS/kritis_node.html', - 'source_type': RegulatorySource.SourceType.OFFICIAL_GUIDANCE, - }, - ], - Requirement.Framework.CRA: [ - { - 'code': 'CRA-REG-2024-2847', - 'title': 'Regulation (EU) 2024/2847 Cyber Resilience Act', - 'authority': 'EUR-Lex', - 'citation': 'Verordnung (EU) 2024/2847', - 'url': 'https://eur-lex.europa.eu/eli/reg/2024/2847/oj', - 'source_type': RegulatorySource.SourceType.PRIMARY, - 'published_on': date(2024, 11, 20), - 'effective_on': date(2024, 12, 10), - }, - { - 'code': 'EU-CRA-POLICY', - 'title': 'European Commission Cyber Resilience Act policy page', - 'authority': 'European Commission', - 'citation': 'EU Digital Strategy Cyber Resilience Act', - 'url': 'https://digital-strategy.ec.europa.eu/en/policies/cyber-resilience-act', - 'source_type': RegulatorySource.SourceType.OFFICIAL_GUIDANCE, - }, - ], - } - sources = {} - for framework, definitions in source_defs.items(): - version = versions[framework] - sources[framework] = {} - for definition in definitions: - source, _ = RegulatorySource.objects.update_or_create( - mapping_version=version, - framework=framework, - code=definition['code'], - defaults={ - 'title': definition['title'], - 'authority': definition['authority'], - 'citation': definition.get('citation', ''), - 'url': definition.get('url', ''), - 'source_type': definition['source_type'], - 'published_on': definition.get('published_on'), - 'effective_on': definition.get('effective_on'), - 'notes': definition.get('notes', ''), - }, - ) - sources[framework][definition['code']] = source - return sources - - def _seed_requirements(self, versions, sources): - definitions = [ - { - 'framework': Requirement.Framework.ISO27001, - 'code': 'A.5.1', - 'title': 'Informationssicherheitsrichtlinien', - 'domain': 'Governance', - 'description': 'Richtlinien fuer Informationssicherheit muessen definiert, genehmigt und kommuniziert werden.', - 'evidence_guidance': 'Freigegebene IS-Policy, Kommunikationsnachweis', - 'sector_package': 'ALL', - 'legal_reference': 'Annex A.5.1', - 'mapped_controls': ['A.5.1'], - 'mapping_rationale': 'Baseline-Control fuer Governance, Policies und nachgelagerte regulatorische Mappings in ISCY.', - 'source_code': 'ISO27001-2022', - }, - { - 'framework': Requirement.Framework.ISO27001, - 'code': 'A.5.2', - 'title': 'Rollen und Verantwortlichkeiten', - 'domain': 'Governance', - 'description': 'Rollen und Verantwortlichkeiten fuer Informationssicherheit muessen zugewiesen und kommuniziert werden.', - 'evidence_guidance': 'RACI-Matrix, Stellenbeschreibungen', - 'sector_package': 'ALL', - 'legal_reference': 'Annex A.5.2', - 'mapped_controls': ['A.5.2'], - 'mapping_rationale': 'Governance-Control fuer regulatorische Verantwortlichkeiten und Management-Zuordnung.', - 'source_code': 'ISO27001-2022', - }, - { - 'framework': Requirement.Framework.ISO27001, - 'code': 'A.5.9', - 'title': 'Inventar der Informationen und zugehoeriger Assets', - 'domain': 'Asset-Management', - 'description': 'Ein Inventar der Informationen und zugehoeriger Assets muss gefuehrt werden.', - 'evidence_guidance': 'Asset-Register, Prozessregister', - 'sector_package': 'ALL', - 'legal_reference': 'Annex A.5.9', - 'mapped_controls': ['A.5.9'], - 'mapping_rationale': 'Querschnitts-Control fuer NIS2, KRITIS und CRA-bezogene Asset- und Scope-Transparenz.', - 'source_code': 'ISO27001-2022', - }, - { - 'framework': Requirement.Framework.ISO27001, - 'code': 'A.5.19', - 'title': 'Informationssicherheit in Lieferantenbeziehungen', - 'domain': 'Lieferkette', - 'description': 'Anforderungen an die Sicherheit muessen in Lieferantenbeziehungen beruecksichtigt werden.', - 'evidence_guidance': 'Lieferantenbewertung, Vertragsklauseln', - 'sector_package': 'ALL', - 'legal_reference': 'Annex A.5.19-A.5.23', - 'mapped_controls': ['A.5.19', 'A.5.20', 'A.5.22', 'A.5.23'], - 'mapping_rationale': 'Wird von ISCY fuer NIS2- und KRITIS-Lieferkettenmappings wiederverwendet.', - 'source_code': 'ISO27002-2022', - }, - { - 'framework': Requirement.Framework.ISO27001, - 'code': 'A.5.24', - 'title': 'Incident Management Planung', - 'domain': 'Incident', - 'description': 'Vorgehensweisen fuer das Management von Informationssicherheitsereignissen muessen geplant werden.', - 'evidence_guidance': 'IR-Plan, Eskalationsmatrix', - 'sector_package': 'ALL', - 'legal_reference': 'Annex A.5.24-A.5.28', - 'mapped_controls': ['A.5.24', 'A.5.25', 'A.5.26', 'A.5.27', 'A.5.28'], - 'mapping_rationale': 'Kontrollfamilie fuer Vorfallmanagement, Meldewege und Lessons Learned.', - 'source_code': 'ISO27002-2022', - }, - { - 'framework': Requirement.Framework.ISO27001, - 'code': 'A.5.31', - 'title': 'Rechtliche und regulatorische Anforderungen', - 'domain': 'Governance', - 'description': 'Anwendbare Anforderungen muessen identifiziert und dokumentiert werden.', - 'evidence_guidance': 'Rechtsregister, Compliance-Status', - 'sector_package': 'ALL', - 'legal_reference': 'Annex A.5.31', - 'mapped_controls': ['A.5.31'], - 'mapping_rationale': 'Schafft die fachliche Basis fuer versionierte Regulatory-Mappings in ISCY.', - 'source_code': 'ISO27001-2022', - }, - { - 'framework': Requirement.Framework.ISO27001, - 'code': 'A.8.25', - 'title': 'Sichere Entwicklung', - 'domain': 'SDLC', - 'description': 'Regeln fuer die sichere Entwicklung von Software muessen etabliert werden.', - 'evidence_guidance': 'Secure-SDLC-Richtlinie, Code-Review-Nachweis', - 'sector_package': 'DIGITAL', - 'legal_reference': 'Annex A.8.25-A.8.32', - 'mapped_controls': ['A.8.25', 'A.8.29', 'A.8.32'], - 'mapping_rationale': 'Wird direkt fuer CRA- und NIS2-Mappings zur sicheren Entwicklung genutzt.', - 'source_code': 'ISO27002-2022', - }, - { - 'framework': Requirement.Framework.NIS2, - 'code': 'NIS2-21-2a', - 'title': 'Risikoanalyse und Sicherheit von Netz- und Informationssystemen', - 'domain': 'Governance', - 'description': 'Betroffene Einrichtungen muessen ein Konzept fuer Risikoanalyse und Sicherheit ihrer Netz- und Informationssysteme einfuehren.', - 'evidence_guidance': 'Risikoanalyse-Methodik, Risikobericht', - 'sector_package': 'ALL', - 'legal_reference': 'Art. 21 Abs. 2 lit. a NIS2', - 'mapped_controls': ['A.5.1', 'A.5.2', 'A.5.9', 'A.5.31'], - 'mapping_rationale': 'ISCY stuetzt diese Pflicht ueber Governance-, Scope- und Asset-Fragen; die Zuordnung ist in der Mapping-Version dokumentiert.', - 'source_code': 'NIS2-DIR', - }, - { - 'framework': Requirement.Framework.NIS2, - 'code': 'NIS2-21-2b', - 'title': 'Wirksamkeitsbewertung von Risikomanagementmassnahmen', - 'domain': 'Governance', - 'description': 'Die Wirksamkeit der getroffenen Massnahmen muss bewertet und nachvollziehbar gemacht werden.', - 'evidence_guidance': 'Wirksamkeitsbericht, KPI-Dashboard', - 'sector_package': 'ALL', - 'legal_reference': 'Art. 21 Abs. 2 lit. b NIS2', - 'mapped_controls': ['A.5.27', 'A.5.37'], - 'mapping_rationale': 'Bewertung ueber Reviews, Tests und Lessons Learned statt ueber einen losen Framework-Bool in der Frage.', - 'source_code': 'NIS2-DIR', - }, - { - 'framework': Requirement.Framework.NIS2, - 'code': 'NIS2-21-2c', - 'title': 'Sicherheit der Lieferkette', - 'domain': 'Lieferkette', - 'description': 'Sicherheitsaspekte in der Lieferkette und in Dienstleisterbeziehungen muessen beruecksichtigt werden.', - 'evidence_guidance': 'Lieferantenbewertung, Risikoanalyse Lieferkette', - 'sector_package': 'ALL', - 'legal_reference': 'Art. 21 Abs. 2 lit. c NIS2', - 'mapped_controls': ['A.5.19', 'A.5.20', 'A.5.22', 'A.5.23'], - 'mapping_rationale': 'Versionierte Lieferkettenpflicht inklusive Cloud- und Third-Party-Sicht.', - 'source_code': 'NIS2-DIR', - }, - { - 'framework': Requirement.Framework.NIS2, - 'code': 'NIS2-21-2d', - 'title': 'Sicherheit bei Erwerb, Entwicklung und Wartung', - 'domain': 'SDLC', - 'description': 'Sicherheit bei Erwerb, Entwicklung und Wartung von Netz- und Informationssystemen einschliesslich Schwachstellenbehandlung.', - 'evidence_guidance': 'SDLC-Richtlinie, Schwachstellenmanagement', - 'sector_package': 'ALL', - 'legal_reference': 'Art. 21 Abs. 2 lit. d NIS2', - 'mapped_controls': ['A.8.25', 'A.8.29', 'A.8.32', 'A.8.8'], - 'mapping_rationale': 'ISCY mappt diese Pflicht explizit auf SDLC- und Vulnerability-Fragen.', - 'source_code': 'NIS2-DIR', - }, - { - 'framework': Requirement.Framework.NIS2, - 'code': 'NIS2-21-2e', - 'title': 'Konzepte und Verfahren zur Bewertung der Wirksamkeit', - 'domain': 'Governance', - 'description': 'Konzepte und Verfahren fuer die Bewertung der Wirksamkeit von Cybersicherheitsmassnahmen muessen bestehen.', - 'evidence_guidance': 'Audit-Bericht, Review-Protokolle', - 'sector_package': 'ALL', - 'legal_reference': 'Art. 21 Abs. 2 lit. e NIS2', - 'mapped_controls': ['A.5.27', 'A.5.37'], - 'mapping_rationale': 'Ergaenzt lit. b um dokumentierte Bewertungs- und Review-Verfahren.', - 'source_code': 'NIS2-DIR', - }, - { - 'framework': Requirement.Framework.NIS2, - 'code': 'NIS2-21-2f', - 'title': 'Cyberhygiene und Schulung', - 'domain': 'Awareness', - 'description': 'Grundlegende Verfahren der Cyberhygiene und Schulungen muessen sichergestellt werden.', - 'evidence_guidance': 'Schulungsnachweise, Awareness-Berichte', - 'sector_package': 'ALL', - 'legal_reference': 'Art. 21 Abs. 2 lit. f NIS2', - 'mapped_controls': ['A.6.3', 'A.8.7', 'A.8.8', 'A.8.9'], - 'mapping_rationale': 'ISCY bewertet diese Pflicht ueber Awareness- und Cyberhygiene-Fragen.', - 'source_code': 'NIS2-DIR', - }, - { - 'framework': Requirement.Framework.NIS2, - 'code': 'NIS2-21-2g', - 'title': 'Kryptografie und Verschluesselung', - 'domain': 'Kryptografie', - 'description': 'Konzepte und Verfahren fuer den Einsatz von Kryptografie und gegebenenfalls Verschluesselung muessen bestehen.', - 'evidence_guidance': 'Kryptografie-Policy, Verschluesselungsnachweis', - 'sector_package': 'ALL', - 'legal_reference': 'Art. 21 Abs. 2 lit. g NIS2', - 'mapped_controls': ['A.8.24'], - 'mapping_rationale': 'Versionierte Ableitung auf definierte Krypto- und Uebertragungsfragen.', - 'source_code': 'NIS2-DIR', - }, - { - 'framework': Requirement.Framework.NIS2, - 'code': 'NIS2-21-2h', - 'title': 'Personalsicherheit, Zugangssteuerung und Asset-Management', - 'domain': 'IAM', - 'description': 'Personalsicherheit, Zugangssteuerung und Asset-Management muessen angemessen adressiert werden.', - 'evidence_guidance': 'HR-Security-Policy, Zugangsrichtlinie', - 'sector_package': 'ALL', - 'legal_reference': 'Art. 21 Abs. 2 lit. h NIS2', - 'mapped_controls': ['A.5.9', 'A.5.15', 'A.5.16', 'A.5.18', 'A.7.2'], - 'mapping_rationale': 'Abgedeckt ueber Lifecycle-, Asset- und Zugriffskontrollfragen.', - 'source_code': 'NIS2-DIR', - }, - { - 'framework': Requirement.Framework.NIS2, - 'code': 'NIS2-21-2i', - 'title': 'Multi-Faktor-Authentifizierung', - 'domain': 'IAM', - 'description': 'Verwendung von MFA oder kontinuierlicher Authentifizierung fuer angemessene Systeme und Konten.', - 'evidence_guidance': 'MFA-Nachweis, Konfigurationsdokumentation', - 'sector_package': 'ALL', - 'legal_reference': 'Art. 21 Abs. 2 lit. i NIS2', - 'mapped_controls': ['A.8.5'], - 'mapping_rationale': 'Direkte Abbildung ueber die MFA-Frage statt unspezifischer Sammelbewertung.', - 'source_code': 'NIS2-DIR', - }, - { - 'framework': Requirement.Framework.NIS2, - 'code': 'NIS2-21-2j', - 'title': 'Sichere Kommunikation', - 'domain': 'Kryptografie', - 'description': 'Gesicherte Sprach-, Video- und Textkommunikation sowie Notfallkommunikation muessen sichergestellt werden.', - 'evidence_guidance': 'Kommunikationsrichtlinie, Tool-Inventar', - 'sector_package': 'ALL', - 'legal_reference': 'Art. 21 Abs. 2 lit. j NIS2', - 'mapped_controls': ['A.8.24', 'A.5.24'], - 'mapping_rationale': 'ISCY nutzt dafuer Krypto- und Incident-Kommunikationsfragen mit versionierter Begruendung.', - 'source_code': 'NIS2-DIR', - }, - { - 'framework': Requirement.Framework.NIS2, - 'code': 'NIS2-23', - 'title': 'Meldepflichten bei erheblichen Sicherheitsvorfaellen', - 'domain': 'Incident', - 'description': 'Erhebliche Sicherheitsvorfaelle muessen fristgerecht ueber definierte Meldewege gemeldet werden.', - 'evidence_guidance': 'Meldeprozess, Kontaktregister BSI/CSIRT', - 'sector_package': 'ALL', - 'legal_reference': 'Art. 23 NIS2', - 'mapped_controls': ['A.5.24', 'A.5.25', 'A.5.26'], - 'mapping_rationale': 'Versionierte Zuordnung auf Incident-Response-, Notifikations- und Uebungsfragen.', - 'source_code': 'NIS2-DIR', - }, - { - 'framework': Requirement.Framework.KRITIS, - 'code': 'KRITIS-8A-OTV', - 'title': 'Angemessene organisatorische und technische Vorkehrungen', - 'domain': 'Governance', - 'description': 'KRITIS-Betreiber muessen angemessene organisatorische und technische Vorkehrungen zur Vermeidung von Stoerungen treffen.', - 'evidence_guidance': 'Sicherheitskonzept, Governance- und Betriebsnachweise', - 'sector_package': 'CRITICAL_INFRA', - 'legal_reference': 'BSIG § 8a', - 'mapped_controls': ['A.5.1', 'A.5.2', 'A.5.31', 'A.8.15', 'A.8.24'], - 'mapping_rationale': 'ISCY stuetzt KRITIS-Grundschutz ueber Governance-, Detection- und Resilienzfragen.', - 'source_code': 'BSIG-8A', - }, - { - 'framework': Requirement.Framework.KRITIS, - 'code': 'KRITIS-8A-NACHWEIS', - 'title': 'Regelmaessige Nachweise und Pruefungen', - 'domain': 'Nachweisfuehrung', - 'description': 'KRITIS-Betreiber muessen geeignete Nachweise ueber ihre Vorkehrungen fuehren und regelmaessig pruefen lassen.', - 'evidence_guidance': 'Pruefbericht, Auditnachweise, Management-Review', - 'sector_package': 'CRITICAL_INFRA', - 'legal_reference': 'BSIG § 8a', - 'mapped_controls': ['A.5.27', 'A.5.37'], - 'mapping_rationale': 'Versionierte Zuordnung auf Reviews, Tests und dokumentierte Wirksamkeitsnachweise.', - 'source_code': 'BSIG-8A', - }, - { - 'framework': Requirement.Framework.KRITIS, - 'code': 'KRITIS-8B-KONTAKT', - 'title': 'Kontaktstelle und Erreichbarkeit', - 'domain': 'Incident', - 'description': 'KRITIS-Betreiber muessen eine geeignete Kontaktstelle und belastbare Erreichbarkeit gegenueber dem BSI sicherstellen.', - 'evidence_guidance': 'Kontaktstellenregister, Eskalationsmatrix', - 'sector_package': 'CRITICAL_INFRA', - 'legal_reference': 'BSIG § 8b', - 'mapped_controls': ['A.5.24', 'A.5.25'], - 'mapping_rationale': 'Abbildung ueber Incident-Response- und Kommunikationsfragen in ISCY.', - 'source_code': 'BSI-KRITIS', - }, - { - 'framework': Requirement.Framework.KRITIS, - 'code': 'KRITIS-8B-MELDUNG', - 'title': 'Meldewege fuer Stoerungen und erhebliche IT-Vorfaelle', - 'domain': 'Incident', - 'description': 'Stoerungen und erhebliche IT-Vorfaelle muessen ueber belastbare Prozesse an das BSI gemeldet werden koennen.', - 'evidence_guidance': 'Meldeprozess, Uebungsnachweis, SIEM/Logging-Konzept', - 'sector_package': 'CRITICAL_INFRA', - 'legal_reference': 'BSIG § 8b', - 'mapped_controls': ['A.5.24', 'A.5.26', 'A.8.15'], - 'mapping_rationale': 'Versionierte Zuordnung auf Meldewege, Erkennung und Uebungsfaehigkeit.', - 'source_code': 'BSI-KRITIS', - }, - { - 'framework': Requirement.Framework.CRA, - 'code': 'CRA-ANNEX-I-PRIMARY', - 'title': 'Secure by Design und Secure by Default', - 'domain': 'Product Security', - 'description': 'Produkte mit digitalen Elementen muessen grundlegende Cybersecurity-Anforderungen ueber Design, Entwicklung und Voreinstellungen erfuellen.', - 'evidence_guidance': 'Security Requirements, Architekturentscheidungen, Default-Konfigurationen', - 'sector_package': 'DIGITAL', - 'legal_reference': 'CRA Art. 13, Annex I Part I', - 'mapped_controls': ['A.8.25', 'A.8.29'], - 'mapping_rationale': 'ISCY mappt CRA-Kernanforderungen auf Product-Security- und SDLC-Fragen.', - 'source_code': 'CRA-REG-2024-2847', - }, - { - 'framework': Requirement.Framework.CRA, - 'code': 'CRA-ANNEX-I-COMP', - 'title': 'Komponenten- und Lifecycle-Transparenz', - 'domain': 'Product Security', - 'description': 'Komponenten, Abhaengigkeiten, Support- und Lifecycle-Informationen muessen nachvollziehbar gepflegt werden.', - 'evidence_guidance': 'SBOM, Komponentenregister, Support-/EOL-Policy', - 'sector_package': 'DIGITAL', - 'legal_reference': 'CRA Annex I Part II', - 'mapped_controls': ['A.5.9', 'A.8.25'], - 'mapping_rationale': 'Wird in ISCY ueber SBOM-, Support- und Komponentenfragen versioniert abgebildet.', - 'source_code': 'CRA-REG-2024-2847', - }, - { - 'framework': Requirement.Framework.CRA, - 'code': 'CRA-ANNEX-I-VULN', - 'title': 'Vulnerability Handling und koordinierte Offenlegung', - 'domain': 'PSIRT', - 'description': 'Schwachstellen muessen aufgenommen, priorisiert, behoben und gegenueber Kunden koordiniert kommuniziert werden.', - 'evidence_guidance': 'PSIRT-Prozess, Advisorys, Patch-Tracking', - 'sector_package': 'DIGITAL', - 'legal_reference': 'CRA Annex I Part II', - 'mapped_controls': ['A.8.8', 'A.8.25'], - 'mapping_rationale': 'Direkte Zuordnung auf PSIRT- und Vulnerability-Handling-Fragen.', - 'source_code': 'CRA-REG-2024-2847', - }, - { - 'framework': Requirement.Framework.CRA, - 'code': 'CRA-POSTMARKET', - 'title': 'Post-Market Security und Support', - 'domain': 'Product Security', - 'description': 'Support-, Patch- und End-of-Life-Prozesse muessen ueber den Produktlebenszyklus definiert sein.', - 'evidence_guidance': 'Support-Policy, Release-/Patch-Prozess', - 'sector_package': 'DIGITAL', - 'legal_reference': 'CRA Art. 13, Annex I Part II', - 'mapped_controls': ['A.8.8', 'A.8.25'], - 'mapping_rationale': 'ISCY ordnet dies auf Support-, Tracking- und Release-Gate-Fragen zu.', - 'source_code': 'CRA-REG-2024-2847', - }, - { - 'framework': Requirement.Framework.AI_ACT, - 'code': 'AI-QMS', - 'title': 'AI Governance / QMS', - 'domain': 'AI Governance', - 'description': 'Fuer relevante AI-Systeme muss eine belastbare Governance- und QMS-Struktur vorhanden sein.', - 'evidence_guidance': 'AI-Register, Governance-Dokumente, Rollen', - 'sector_package': 'DIGITAL', - 'legal_reference': 'EU AI Act Governance/QMS', - 'mapped_controls': ['AI_INVENTORY', 'AI_RISK_CLASS', 'AI_MONITORING'], - 'mapping_rationale': 'Bestehendes AI-Act-Framework bleibt in ISCY erhalten.', - }, - { - 'framework': Requirement.Framework.AI_ACT, - 'code': 'AI-RISK-CLASS', - 'title': 'AI-Risikoklassifizierung', - 'domain': 'AI Governance', - 'description': 'AI-Systeme und Use Cases muessen klassifiziert und dokumentiert werden.', - 'evidence_guidance': 'Klassifizierungsdokumente, Use-Case-Register', - 'sector_package': 'DIGITAL', - 'legal_reference': 'EU AI Act Risikoklassifizierung', - 'mapped_controls': ['AI_RISK_CLASS'], - 'mapping_rationale': 'Bestehende AI-Act-Readiness bleibt erhalten.', - }, - { - 'framework': Requirement.Framework.AI_ACT, - 'code': 'AI-HUMAN-OVERSIGHT', - 'title': 'Human Oversight und Monitoring', - 'domain': 'AI Governance', - 'description': 'Oversight, Monitoring und Logging muessen fuer relevante AI-Funktionen geregelt werden.', - 'evidence_guidance': 'Oversight-Prozess, Logs, Monitoring-Nachweise', - 'sector_package': 'DIGITAL', - 'legal_reference': 'EU AI Act Human Oversight', - 'mapped_controls': ['AI_MONITORING'], - 'mapping_rationale': 'Bestehende AI-Act-Readiness bleibt erhalten.', - }, - { - 'framework': Requirement.Framework.IEC62443, - 'code': 'IEC62443-ZONES', - 'title': 'Zonen / Conduits / Segmentierung', - 'domain': 'Industrial Security', - 'description': 'OT-/IACS-Systeme sollten in Zonen und Kommunikationsbeziehungen segmentiert werden.', - 'evidence_guidance': 'Zonen-/Conduit-Modell, Netzplan', - 'sector_package': 'CRITICAL_INFRA', - 'legal_reference': 'IEC 62443 Zonen/Conduits', - 'mapped_controls': ['OT_ZONES'], - 'mapping_rationale': 'Bestehendes IEC-62443-Framework bleibt erhalten.', - }, - { - 'framework': Requirement.Framework.IEC62443, - 'code': 'IEC62443-COMP', - 'title': 'Komponenten- und Integrator-Sicherheit', - 'domain': 'Industrial Security', - 'description': 'Komponenten, Integrator- und Supplier-Verantwortungen sind sicherheitsbezogen zu steuern.', - 'evidence_guidance': 'Komponentenregister, Integrationsvorgaben', - 'sector_package': 'CRITICAL_INFRA', - 'legal_reference': 'IEC 62443 Komponenten/Integrator', - 'mapped_controls': ['OT_COMPONENTS'], - 'mapping_rationale': 'Bestehendes IEC-62443-Framework bleibt erhalten.', - }, - { - 'framework': Requirement.Framework.IEC62443, - 'code': 'IEC62443-SL', - 'title': 'Security Levels und industrielle Schutzanforderungen', - 'domain': 'Industrial Security', - 'description': 'Sicherheitsniveaus und industrielle Schutzanforderungen sind zu definieren.', - 'evidence_guidance': 'Security-Level-Konzept, Schutzbedarfsanalyse', - 'sector_package': 'CRITICAL_INFRA', - 'legal_reference': 'IEC 62443 Security Levels', - 'mapped_controls': ['OT_LEVELS'], - 'mapping_rationale': 'Bestehendes IEC-62443-Framework bleibt erhalten.', - }, - { - 'framework': Requirement.Framework.ISO_SAE_21434, - 'code': '21434-TARA', - 'title': 'Threat Analysis and Risk Assessment', - 'domain': 'Automotive Security', - 'description': 'Eine TARA ist fuer relevante Fahrzeug-/E/E-/Software-Kontexte erforderlich.', - 'evidence_guidance': 'TARA-Artefakte, Risikoentscheidungen', - 'sector_package': 'DIGITAL', - 'legal_reference': 'ISO/SAE 21434 TARA', - 'mapped_controls': ['AUTO_TARA'], - 'mapping_rationale': 'Bestehendes ISO/SAE-21434-Framework bleibt erhalten.', - }, - { - 'framework': Requirement.Framework.ISO_SAE_21434, - 'code': '21434-TRACE', - 'title': 'Cybersecurity-Traceability', - 'domain': 'Automotive Security', - 'description': 'Cybersecurity-Ziele, Anforderungen, Tests und Nachweise muessen rueckverfolgbar sein.', - 'evidence_guidance': 'Traceability-Matrix, Testnachweise', - 'sector_package': 'DIGITAL', - 'legal_reference': 'ISO/SAE 21434 Traceability', - 'mapped_controls': ['AUTO_TRACE'], - 'mapping_rationale': 'Bestehendes ISO/SAE-21434-Framework bleibt erhalten.', - }, - { - 'framework': Requirement.Framework.ISO_SAE_21434, - 'code': '21434-FIELD', - 'title': 'Field Monitoring und Updateability', - 'domain': 'Automotive Security', - 'description': 'Betrieb, Monitoring, Incident Handling und Updates sind ueber den Lebenszyklus abzusichern.', - 'evidence_guidance': 'Field-Monitoring-Prozess, Update-Konzept', - 'sector_package': 'DIGITAL', - 'legal_reference': 'ISO/SAE 21434 Field Monitoring', - 'mapped_controls': ['AUTO_FIELD'], - 'mapping_rationale': 'Bestehendes ISO/SAE-21434-Framework bleibt erhalten.', - }, - ] - - requirements = {} - for definition in definitions: - framework = definition['framework'] - requirement, _ = Requirement.objects.update_or_create( - framework=framework, - code=definition['code'], - defaults={ - 'title': definition['title'], - 'domain': definition['domain'], - 'description': definition['description'], - 'guidance': f'Versioniertes {framework}-Mapping in ISCY.', - 'is_active': True, - 'evidence_required': True, - 'evidence_guidance': definition['evidence_guidance'], - 'evidence_examples': 'Policy, Screenshot, Export, Review-Protokoll, Ticket oder Auditnachweis.', - 'sector_package': definition['sector_package'], - 'legal_reference': definition['legal_reference'], - 'mapped_controls': definition['mapped_controls'], - 'mapping_rationale': definition['mapping_rationale'], - 'coverage_level': Requirement.Coverage.PRIMARY, - 'mapping_version': versions.get(framework), - 'primary_source': sources.get(framework, {}).get(definition.get('source_code', '')), - }, - ) - requirements[definition['code']] = requirement - return requirements - - def _seed_question_mappings(self, versions, requirements): - question_mappings = { - 'NIS2-21-2a': ['GOV_SCOPE', 'GOV_ROLES', 'GOV_POLICY', 'GOV_LEGAL', 'PROC_REGISTER'], - 'NIS2-21-2b': ['GOV_MGMT_REVIEW', 'DOC_REVIEWS', 'INC_LESSONS'], - 'NIS2-21-2c': ['SUP_REGISTER', 'SUP_CONTRACTS', 'SUP_MONITORING', 'CLOUD_INVENTORY'], - 'NIS2-21-2d': ['SDLC_SECURE', 'SDLC_TEST', 'SDLC_CHANGE', 'CYBER_PATCH'], - 'NIS2-21-2e': ['GOV_MGMT_REVIEW', 'DOC_REVIEWS', 'BCM_TEST'], - 'NIS2-21-2f': ['CYBER_PATCH', 'CYBER_HARDENING', 'CYBER_MALWARE', 'AWARE_TRAINING'], - 'NIS2-21-2g': ['CRYPTO_POLICY', 'CRYPTO_TRANSIT', 'CRYPTO_REST'], - 'NIS2-21-2h': ['PROC_REGISTER', 'PROC_OWNER', 'IAM_LIFECYCLE', 'IAM_ACCESS_REVIEW', 'PHYS_ACCESS'], - 'NIS2-21-2i': ['IAM_MFA'], - 'NIS2-21-2j': ['CRYPTO_TRANSIT', 'INC_RESPONSE'], - 'NIS2-23': ['INC_RESPONSE', 'INC_NOTIFY', 'INC_DRILL'], - 'KRITIS-8A-OTV': ['GOV_POLICY', 'GOV_ROLES', 'GOV_LEGAL', 'DETECT_LOGGING', 'INC_RESPONSE', 'BCM_BACKUP'], - 'KRITIS-8A-NACHWEIS': ['GOV_MGMT_REVIEW', 'DOC_REVIEWS', 'BCM_TEST', 'INC_DRILL'], - 'KRITIS-8B-KONTAKT': ['INC_RESPONSE', 'INC_NOTIFY', 'DOC_COMMUNICATION'], - 'KRITIS-8B-MELDUNG': ['INC_NOTIFY', 'DETECT_LOGGING', 'DETECT_SIEM', 'INC_DRILL'], - 'CRA-ANNEX-I-PRIMARY': ['PSM_SECURE_BY_DESIGN', 'PSM_RELEASE_GATES', 'SDLC_SECURE', 'SDLC_TEST'], - 'CRA-ANNEX-I-COMP': ['PSM_SBOM', 'PSM_SUPPORT'], - 'CRA-ANNEX-I-VULN': ['PSIRT_PROCESS', 'PSIRT_DISCLOSURE', 'PSIRT_TRACKING', 'CYBER_PATCH'], - 'CRA-POSTMARKET': ['PSM_SUPPORT', 'PSIRT_TRACKING', 'PSM_RELEASE_GATES'], - } - questions = { - question.code: question - for question in AssessmentQuestion.objects.filter(code__in={code for codes in question_mappings.values() for code in codes}) - } - for requirement_code, question_codes in question_mappings.items(): - requirement = requirements.get(requirement_code) - if not requirement: - continue - for question_code in question_codes: - question = questions.get(question_code) - if not question: - continue - RequirementQuestionMapping.objects.update_or_create( - requirement=requirement, - mapping_version=versions[requirement.framework], - question=question, - defaults={ - 'strength': RequirementQuestionMapping.Strength.PRIMARY, - 'rationale': f'ISCY nutzt die Frage {question.code} als versionierten Mapping-Baustein fuer {requirement.code}.', - }, - ) diff --git a/apps/requirements_app/migrations/0001_initial.py b/apps/requirements_app/migrations/0001_initial.py deleted file mode 100644 index 6a620ce..0000000 --- a/apps/requirements_app/migrations/0001_initial.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-09 14:39 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='Requirement', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('framework', models.CharField(choices=[('ISO27001', 'ISO 27001'), ('NIS2', 'NIS2'), ('CRA', 'Cyber Resilience Act'), ('AI_ACT', 'EU AI Act'), ('IEC62443', 'IEC 62443'), ('ISO_SAE_21434', 'ISO/SAE 21434')], max_length=32)), - ('code', models.CharField(max_length=64)), - ('title', models.CharField(max_length=255)), - ('domain', models.CharField(max_length=255)), - ('description', models.TextField()), - ('guidance', models.TextField(blank=True)), - ('is_active', models.BooleanField(default=True)), - ('evidence_required', models.BooleanField(default=True)), - ('evidence_guidance', models.TextField(blank=True)), - ('evidence_examples', models.TextField(blank=True)), - ('sector_package', models.CharField(blank=True, help_text='Optionales Paket wie DIGITAL, FINANCE, CRITICAL_INFRA, ALL', max_length=64)), - ], - options={ - 'ordering': ['framework', 'code'], - 'unique_together': {('framework', 'code')}, - }, - ), - ] diff --git a/apps/requirements_app/migrations/0002_mappingversion_requirement_coverage_level_and_more.py b/apps/requirements_app/migrations/0002_mappingversion_requirement_coverage_level_and_more.py deleted file mode 100644 index 27bd6b4..0000000 --- a/apps/requirements_app/migrations/0002_mappingversion_requirement_coverage_level_and_more.py +++ /dev/null @@ -1,109 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-13 18:15 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('catalog', '0001_initial'), - ('requirements_app', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='MappingVersion', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('framework', models.CharField(max_length=32)), - ('slug', models.SlugField(unique=True)), - ('title', models.CharField(max_length=255)), - ('version', models.CharField(max_length=32)), - ('program_name', models.CharField(default='ISCY', max_length=64)), - ('status', models.CharField(choices=[('DRAFT', 'Entwurf'), ('ACTIVE', 'Aktiv'), ('SUPERSEDED', 'Ersetzt')], default='ACTIVE', max_length=16)), - ('effective_on', models.DateField(blank=True, null=True)), - ('notes', models.TextField(blank=True)), - ], - options={ - 'ordering': ['framework', '-effective_on', '-created_at'], - }, - ), - migrations.AddField( - model_name='requirement', - name='coverage_level', - field=models.CharField(choices=[('PRIMARY', 'Primär'), ('SUPPORTING', 'Unterstützend'), ('DERIVED', 'Abgeleitet')], default='PRIMARY', max_length=16), - ), - migrations.AddField( - model_name='requirement', - name='legal_reference', - field=models.CharField(blank=True, max_length=128), - ), - migrations.AddField( - model_name='requirement', - name='mapped_controls', - field=models.JSONField(blank=True, default=list), - ), - migrations.AddField( - model_name='requirement', - name='mapping_rationale', - field=models.TextField(blank=True), - ), - migrations.AlterField( - model_name='requirement', - name='framework', - field=models.CharField(choices=[('ISO27001', 'ISO 27001'), ('NIS2', 'NIS2'), ('KRITIS', 'KRITIS'), ('CRA', 'Cyber Resilience Act'), ('AI_ACT', 'EU AI Act'), ('IEC62443', 'IEC 62443'), ('ISO_SAE_21434', 'ISO/SAE 21434')], max_length=32), - ), - migrations.AddField( - model_name='requirement', - name='mapping_version', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requirements', to='requirements_app.mappingversion'), - ), - migrations.CreateModel( - name='RegulatorySource', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('framework', models.CharField(choices=[('ISO27001', 'ISO 27001'), ('NIS2', 'NIS2'), ('KRITIS', 'KRITIS'), ('CRA', 'Cyber Resilience Act'), ('AI_ACT', 'EU AI Act'), ('IEC62443', 'IEC 62443'), ('ISO_SAE_21434', 'ISO/SAE 21434')], max_length=32)), - ('code', models.CharField(max_length=64)), - ('title', models.CharField(max_length=255)), - ('authority', models.CharField(max_length=128)), - ('citation', models.CharField(blank=True, max_length=255)), - ('url', models.URLField(blank=True)), - ('source_type', models.CharField(choices=[('PRIMARY', 'Primärquelle'), ('OFFICIAL_GUIDANCE', 'Offizielle Guidance'), ('STANDARD', 'Standard')], default='PRIMARY', max_length=32)), - ('published_on', models.DateField(blank=True, null=True)), - ('effective_on', models.DateField(blank=True, null=True)), - ('notes', models.TextField(blank=True)), - ('mapping_version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sources', to='requirements_app.mappingversion')), - ], - options={ - 'ordering': ['framework', 'code'], - 'unique_together': {('mapping_version', 'framework', 'code')}, - }, - ), - migrations.AddField( - model_name='requirement', - name='primary_source', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requirements', to='requirements_app.regulatorysource'), - ), - migrations.CreateModel( - name='RequirementQuestionMapping', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('strength', models.CharField(choices=[('PRIMARY', 'Primär'), ('SUPPORTING', 'Unterstützend')], default='PRIMARY', max_length=16)), - ('rationale', models.TextField(blank=True)), - ('mapping_version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='question_mappings', to='requirements_app.mappingversion')), - ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requirement_mappings', to='catalog.assessmentquestion')), - ('requirement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='question_mappings', to='requirements_app.requirement')), - ], - options={ - 'ordering': ['requirement__framework', 'requirement__code', 'question__sort_order'], - 'unique_together': {('requirement', 'mapping_version', 'question')}, - }, - ), - ] diff --git a/apps/requirements_app/migrations/__init__.py b/apps/requirements_app/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/requirements_app/models.py b/apps/requirements_app/models.py deleted file mode 100644 index 44206ab..0000000 --- a/apps/requirements_app/models.py +++ /dev/null @@ -1,110 +0,0 @@ -from django.db import models -from apps.core.models import TimeStampedModel - - -class MappingVersion(TimeStampedModel): - class Status(models.TextChoices): - DRAFT = 'DRAFT', 'Entwurf' - ACTIVE = 'ACTIVE', 'Aktiv' - SUPERSEDED = 'SUPERSEDED', 'Ersetzt' - - framework = models.CharField(max_length=32) - slug = models.SlugField(unique=True) - title = models.CharField(max_length=255) - version = models.CharField(max_length=32) - program_name = models.CharField(max_length=64, default='ISCY') - status = models.CharField(max_length=16, choices=Status.choices, default=Status.ACTIVE) - effective_on = models.DateField(null=True, blank=True) - notes = models.TextField(blank=True) - - class Meta: - ordering = ['framework', '-effective_on', '-created_at'] - - def __str__(self): - return f'{self.program_name} {self.framework} {self.version}' - - -class Requirement(TimeStampedModel): - class Framework(models.TextChoices): - ISO27001 = 'ISO27001', 'ISO 27001' - NIS2 = 'NIS2', 'NIS2' - KRITIS = 'KRITIS', 'KRITIS' - CRA = 'CRA', 'Cyber Resilience Act' - AI_ACT = 'AI_ACT', 'EU AI Act' - IEC62443 = 'IEC62443', 'IEC 62443' - ISO_SAE_21434 = 'ISO_SAE_21434', 'ISO/SAE 21434' - - class Coverage(models.TextChoices): - PRIMARY = 'PRIMARY', 'Primär' - SUPPORTING = 'SUPPORTING', 'Unterstützend' - DERIVED = 'DERIVED', 'Abgeleitet' - - framework = models.CharField(max_length=32, choices=Framework.choices) - code = models.CharField(max_length=64) - title = models.CharField(max_length=255) - domain = models.CharField(max_length=255) - description = models.TextField() - guidance = models.TextField(blank=True) - is_active = models.BooleanField(default=True) - evidence_required = models.BooleanField(default=True) - evidence_guidance = models.TextField(blank=True) - evidence_examples = models.TextField(blank=True) - sector_package = models.CharField(max_length=64, blank=True, help_text='Optionales Paket wie DIGITAL, FINANCE, CRITICAL_INFRA, ALL') - legal_reference = models.CharField(max_length=128, blank=True) - mapped_controls = models.JSONField(default=list, blank=True) - mapping_rationale = models.TextField(blank=True) - coverage_level = models.CharField(max_length=16, choices=Coverage.choices, default=Coverage.PRIMARY) - mapping_version = models.ForeignKey('requirements_app.MappingVersion', on_delete=models.SET_NULL, null=True, blank=True, related_name='requirements') - primary_source = models.ForeignKey('requirements_app.RegulatorySource', on_delete=models.SET_NULL, null=True, blank=True, related_name='requirements') - - class Meta: - unique_together = ('framework', 'code') - ordering = ['framework', 'code'] - - def __str__(self): - return f'{self.framework} - {self.code}' - - -class RegulatorySource(TimeStampedModel): - class SourceType(models.TextChoices): - PRIMARY = 'PRIMARY', 'Primärquelle' - OFFICIAL_GUIDANCE = 'OFFICIAL_GUIDANCE', 'Offizielle Guidance' - STANDARD = 'STANDARD', 'Standard' - - framework = models.CharField(max_length=32, choices=Requirement.Framework.choices) - mapping_version = models.ForeignKey(MappingVersion, on_delete=models.CASCADE, related_name='sources') - code = models.CharField(max_length=64) - title = models.CharField(max_length=255) - authority = models.CharField(max_length=128) - citation = models.CharField(max_length=255, blank=True) - url = models.URLField(blank=True) - source_type = models.CharField(max_length=32, choices=SourceType.choices, default=SourceType.PRIMARY) - published_on = models.DateField(null=True, blank=True) - effective_on = models.DateField(null=True, blank=True) - notes = models.TextField(blank=True) - - class Meta: - unique_together = ('mapping_version', 'framework', 'code') - ordering = ['framework', 'code'] - - def __str__(self): - return f'{self.framework} {self.code}' - - -class RequirementQuestionMapping(TimeStampedModel): - class Strength(models.TextChoices): - PRIMARY = 'PRIMARY', 'Primär' - SUPPORTING = 'SUPPORTING', 'Unterstützend' - - requirement = models.ForeignKey(Requirement, on_delete=models.CASCADE, related_name='question_mappings') - mapping_version = models.ForeignKey(MappingVersion, on_delete=models.CASCADE, related_name='question_mappings') - question = models.ForeignKey('catalog.AssessmentQuestion', on_delete=models.CASCADE, related_name='requirement_mappings') - strength = models.CharField(max_length=16, choices=Strength.choices, default=Strength.PRIMARY) - rationale = models.TextField(blank=True) - - class Meta: - unique_together = ('requirement', 'mapping_version', 'question') - ordering = ['requirement__framework', 'requirement__code', 'question__sort_order'] - - def __str__(self): - return f'{self.requirement.code} -> {self.question.code}' diff --git a/apps/requirements_app/services.py b/apps/requirements_app/services.py deleted file mode 100644 index ca783f7..0000000 --- a/apps/requirements_app/services.py +++ /dev/null @@ -1,64 +0,0 @@ -from apps.catalog.models import AssessmentQuestion - -from .models import MappingVersion, Requirement, RequirementQuestionMapping - - -class RegulatoryMappingService: - FRAMEWORKS_FOR_REPORT = ( - Requirement.Framework.ISO27001, - Requirement.Framework.NIS2, - Requirement.Framework.KRITIS, - Requirement.Framework.CRA, - ) - - @staticmethod - def get_active_versions(frameworks=None): - queryset = MappingVersion.objects.filter(status=MappingVersion.Status.ACTIVE) - if frameworks: - queryset = queryset.filter(framework__in=frameworks) - return queryset.order_by('framework', '-effective_on', '-created_at') - - @staticmethod - def build_version_snapshot(frameworks=None): - versions = {} - for version in RegulatoryMappingService.get_active_versions(frameworks).prefetch_related('sources', 'requirements'): - versions[version.framework] = { - 'framework': version.framework, - 'title': version.title, - 'version': version.version, - 'program_name': version.program_name, - 'effective_on': version.effective_on.isoformat() if version.effective_on else '', - 'source_count': version.sources.count(), - 'requirement_count': version.requirements.count(), - 'notes': version.notes, - } - return versions - - @staticmethod - def calculate_framework_readiness(session, framework: str) -> int: - question_ids = list( - RequirementQuestionMapping.objects.filter( - mapping_version__status=MappingVersion.Status.ACTIVE, - requirement__framework=framework, - question__question_kind=AssessmentQuestion.Kind.MATURITY, - ) - .values_list('question_id', flat=True) - .distinct() - ) - if not question_ids: - return 0 - - answers = { - answer.question_id: answer - for answer in session.answers.filter(question_id__in=question_ids).select_related('selected_option') - } - total = 0 - score = 0 - for question_id in question_ids: - answer = answers.get(question_id) - if answer and answer.selected_option and answer.selected_option.is_na: - continue - total += 1 - score += answer.score if answer else 0 - - return min(100, int((score / (total * 5)) * 100)) if total else 0 diff --git a/apps/requirements_app/services_rust.py b/apps/requirements_app/services_rust.py deleted file mode 100644 index 17847ca..0000000 --- a/apps/requirements_app/services_rust.py +++ /dev/null @@ -1,248 +0,0 @@ -import json -from dataclasses import dataclass -from datetime import date, datetime -from urllib.request import Request, urlopen - -from django.conf import settings - - -@dataclass(frozen=True) -class RequirementMappingVersionBridgeItem: - id: int - framework: str - slug: str - title: str - version: str - program_name: str - status: str - status_label: str - effective_on: date | None - notes: str - source_count: int - requirement_count: int - created_at: datetime | None - updated_at: datetime | None - - @property - def pk(self) -> int: - return self.id - - def get_status_display(self) -> str: - return self.status_label - - -@dataclass(frozen=True) -class RequirementRegulatorySourceBridgeItem: - id: int - framework: str - code: str - title: str - authority: str - citation: str - url: str - source_type: str - - @property - def pk(self) -> int: - return self.id - - -@dataclass(frozen=True) -class RequirementBridgeItem: - id: int - framework: str - framework_label: str - code: str - title: str - domain: str - description: str - guidance: str - is_active: bool - evidence_required: bool - evidence_guidance: str - evidence_examples: str - sector_package: str - legal_reference: str - coverage_level: str - coverage_level_label: str - mapping_version: RequirementMappingVersionBridgeItem | None - primary_source: RequirementRegulatorySourceBridgeItem | None - created_at: datetime | None - updated_at: datetime | None - - @property - def pk(self) -> int: - return self.id - - def get_framework_display(self) -> str: - return self.framework_label - - def get_coverage_level_display(self) -> str: - return self.coverage_level_label - - -@dataclass(frozen=True) -class RequirementLibraryBridgeResult: - requirements: list[RequirementBridgeItem] - mapping_versions: dict[str, dict] - - -class RequirementRustClient: - @staticmethod - def _base_url() -> str: - base = (getattr(settings, 'RUST_BACKEND_URL', '') or '').strip() - return base.rstrip('/') - - @staticmethod - def fetch_library(request, timeout: int = 8) -> RequirementLibraryBridgeResult: - base = RequirementRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = RequirementRustClient._authenticated_request(request, f'{base}/api/v1/requirements') - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - - versions = [ - RequirementRustClient._mapping_version_from_payload(item) - for item in payload.get('mapping_versions', []) - if isinstance(item, dict) - ] - return RequirementLibraryBridgeResult( - requirements=[ - RequirementRustClient._requirement_from_payload(item) - for item in payload.get('requirements', []) - if isinstance(item, dict) - ], - mapping_versions={ - version.framework: { - 'framework': version.framework, - 'title': version.title, - 'version': version.version, - 'program_name': version.program_name, - 'effective_on': version.effective_on.isoformat() if version.effective_on else '', - 'source_count': version.source_count, - 'requirement_count': version.requirement_count, - 'notes': version.notes, - } - for version in versions - }, - ) - - @staticmethod - def _requirement_from_payload(item: dict) -> RequirementBridgeItem: - mapping_payload = item.get('mapping_version') - source_payload = item.get('primary_source') - return RequirementBridgeItem( - id=int(item.get('id') or 0), - framework=str(item.get('framework') or ''), - framework_label=str(item.get('framework_label') or ''), - code=str(item.get('code') or ''), - title=str(item.get('title') or ''), - domain=str(item.get('domain') or ''), - description=str(item.get('description') or ''), - guidance=str(item.get('guidance') or ''), - is_active=bool(item.get('is_active')), - evidence_required=bool(item.get('evidence_required')), - evidence_guidance=str(item.get('evidence_guidance') or ''), - evidence_examples=str(item.get('evidence_examples') or ''), - sector_package=str(item.get('sector_package') or ''), - legal_reference=str(item.get('legal_reference') or ''), - coverage_level=str(item.get('coverage_level') or ''), - coverage_level_label=str(item.get('coverage_level_label') or ''), - mapping_version=RequirementRustClient._mapping_version_from_payload(mapping_payload) - if isinstance(mapping_payload, dict) - else None, - primary_source=RequirementRustClient._source_from_payload(source_payload) - if isinstance(source_payload, dict) - else None, - created_at=RequirementRustClient._datetime_field(item.get('created_at')), - updated_at=RequirementRustClient._datetime_field(item.get('updated_at')), - ) - - @staticmethod - def _mapping_version_from_payload(item: dict) -> RequirementMappingVersionBridgeItem: - return RequirementMappingVersionBridgeItem( - id=int(item.get('id') or 0), - framework=str(item.get('framework') or ''), - slug=str(item.get('slug') or ''), - title=str(item.get('title') or ''), - version=str(item.get('version') or ''), - program_name=str(item.get('program_name') or ''), - status=str(item.get('status') or ''), - status_label=str(item.get('status_label') or ''), - effective_on=RequirementRustClient._date_field(item.get('effective_on')), - notes=str(item.get('notes') or ''), - source_count=int(item.get('source_count') or 0), - requirement_count=int(item.get('requirement_count') or 0), - created_at=RequirementRustClient._datetime_field(item.get('created_at')), - updated_at=RequirementRustClient._datetime_field(item.get('updated_at')), - ) - - @staticmethod - def _source_from_payload(item: dict) -> RequirementRegulatorySourceBridgeItem: - return RequirementRegulatorySourceBridgeItem( - id=int(item.get('id') or 0), - framework=str(item.get('framework') or ''), - code=str(item.get('code') or ''), - title=str(item.get('title') or ''), - authority=str(item.get('authority') or ''), - citation=str(item.get('citation') or ''), - url=str(item.get('url') or ''), - source_type=str(item.get('source_type') or ''), - ) - - @staticmethod - def _authenticated_request(request, url: str) -> Request: - user = getattr(request, 'user', None) - user_id = getattr(user, 'id', None) - tenant_id = getattr(getattr(user, 'tenant', None), 'id', None) - if not user_id or not tenant_id: - raise RuntimeError('Requirement-Rust-Bridge braucht authentifizierten Tenant-Kontext.') - - rust_request = Request(url) - rust_request.add_header('Accept', 'application/json') - rust_request.add_header('X-ISCY-Tenant-ID', str(tenant_id)) - rust_request.add_header('X-ISCY-User-ID', str(user_id)) - user_email = (getattr(user, 'email', '') or '').strip() - if user_email: - rust_request.add_header('X-ISCY-User-Email', user_email) - return rust_request - - @staticmethod - def _date_field(value) -> date | None: - value = str(value or '').strip() - if not value: - return None - return date.fromisoformat(value[:10]) - - @staticmethod - def _datetime_field(value) -> datetime | None: - value = str(value or '').strip() - if not value: - return None - return datetime.fromisoformat(value.replace('Z', '+00:00')) - - -class RequirementBridge: - @staticmethod - def fetch_library(request): - backend = str(getattr(settings, 'REQUIREMENTS_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not RequirementRustClient._base_url(): - if RequirementBridge._strict_rust_mode(): - raise RuntimeError('Rust requirements backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return RequirementRustClient.fetch_library(request) - except Exception as exc: - if RequirementBridge._strict_rust_mode(): - raise RuntimeError('Rust requirements backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def _strict_rust_mode() -> bool: - return bool(getattr(settings, 'RUST_STRICT_MODE', False)) diff --git a/apps/requirements_app/tests.py b/apps/requirements_app/tests.py deleted file mode 100644 index 1355b04..0000000 --- a/apps/requirements_app/tests.py +++ /dev/null @@ -1,176 +0,0 @@ -import json -from unittest.mock import Mock, patch - -from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings -from django.urls import reverse - -from apps.organizations.models import Tenant - -from .models import MappingVersion, RegulatorySource, Requirement - - -User = get_user_model() - - -@override_settings(REQUIREMENTS_BACKEND='local', RUST_BACKEND_URL='') -class RequirementViewTests(TestCase): - def setUp(self): - self.tenant = Tenant.objects.create(name='Tenant A', slug='tenant-a', country='DE') - self.user = User.objects.create_user( - username='requirements-user', - email='requirements@example.test', - password='testpass123', - tenant=self.tenant, - ) - self.version = MappingVersion.objects.create( - framework=Requirement.Framework.ISO27001, - slug='iso27001-2022', - title='ISO 27001 Mapping', - version='2022', - program_name='ISCY', - status=MappingVersion.Status.ACTIVE, - ) - self.source = RegulatorySource.objects.create( - framework=Requirement.Framework.ISO27001, - mapping_version=self.version, - code='A.5.17', - title='Authentication Information', - authority='ISO', - source_type=RegulatorySource.SourceType.STANDARD, - ) - self.requirement = Requirement.objects.create( - framework=Requirement.Framework.ISO27001, - code='A.5.17', - title='Authentication Information', - domain='Identity', - description='Protect authentication information', - evidence_required=True, - legal_reference='ISO/IEC 27001:2022 A.5.17', - mapping_version=self.version, - primary_source=self.source, - ) - - def test_requirement_view_uses_local_library_by_default(self): - self.client.force_login(self.user) - - response = self.client.get(reverse('requirements:list')) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['requirements_source'], 'django') - self.assertContains(response, 'Authentication Information') - self.assertContains(response, 'ISO27001') - - @patch('apps.requirements_app.services_rust.urlopen') - def test_requirement_view_can_use_rust_bridge(self, mock_urlopen): - self._mock_rust_response(mock_urlopen, self._rust_payload()) - self.client.force_login(self.user) - - with self.settings(REQUIREMENTS_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.get(reverse('requirements:list')) - - self.assertEqual(response.status_code, 200) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual(rust_request.full_url, 'http://rust-backend:9000/api/v1/requirements') - self.assertEqual(rust_request.get_header('X-iscy-tenant-id'), str(self.tenant.id)) - self.assertEqual(response.context['requirements_source'], 'rust_service') - self.assertContains(response, 'Rust Authentication') - self.assertContains(response, 'ISO27001') - self.assertEqual(response.context['mapping_versions']['ISO27001']['version'], '2022') - - @patch('apps.requirements_app.services_rust.urlopen', side_effect=OSError('backend down')) - def test_requirement_view_falls_back_to_django_when_rust_unavailable_in_non_strict_mode(self, _mock_urlopen): - self.client.force_login(self.user) - - with self.settings( - REQUIREMENTS_BACKEND='rust_service', - RUST_BACKEND_URL='http://rust-backend:9000', - RUST_STRICT_MODE=False, - ): - response = self.client.get(reverse('requirements:list')) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['requirements_source'], 'django') - self.assertContains(response, 'Authentication Information') - - def test_requirement_view_raises_in_strict_mode_without_rust_backend_url(self): - self.client.force_login(self.user) - - with self.settings(REQUIREMENTS_BACKEND='rust_service', RUST_BACKEND_URL='', RUST_STRICT_MODE=True): - with self.assertRaises(RuntimeError): - self.client.get(reverse('requirements:list')) - - def _rust_payload(self): - return { - 'api_version': 'v1', - 'mapping_versions': [{ - 'id': self.version.id, - 'framework': 'ISO27001', - 'slug': 'iso27001-2022', - 'title': 'ISO 27001 Mapping', - 'version': '2022', - 'program_name': 'ISCY', - 'status': 'ACTIVE', - 'status_label': 'Aktiv', - 'effective_on': '2026-01-01', - 'notes': 'Active mapping', - 'source_count': 1, - 'requirement_count': 1, - 'created_at': '2026-04-19T10:00:00Z', - 'updated_at': '2026-04-19T11:00:00Z', - }], - 'requirements': [{ - 'id': self.requirement.id, - 'framework': 'ISO27001', - 'framework_label': 'ISO 27001', - 'code': 'A.5.17', - 'title': 'Rust Authentication', - 'domain': 'Identity', - 'description': 'Protect authentication information', - 'guidance': 'Use MFA', - 'is_active': True, - 'evidence_required': True, - 'evidence_guidance': 'MFA policy', - 'evidence_examples': 'Policy', - 'sector_package': 'ALL', - 'legal_reference': 'ISO/IEC 27001:2022 A.5.17', - 'coverage_level': 'PRIMARY', - 'coverage_level_label': 'Primaer', - 'mapping_version': { - 'id': self.version.id, - 'framework': 'ISO27001', - 'slug': 'iso27001-2022', - 'title': 'ISO 27001 Mapping', - 'version': '2022', - 'program_name': 'ISCY', - 'status': 'ACTIVE', - 'status_label': 'Aktiv', - 'effective_on': '2026-01-01', - 'notes': 'Active mapping', - 'source_count': 1, - 'requirement_count': 1, - 'created_at': '2026-04-19T10:00:00Z', - 'updated_at': '2026-04-19T11:00:00Z', - }, - 'primary_source': { - 'id': self.source.id, - 'framework': 'ISO27001', - 'code': 'A.5.17', - 'title': 'Authentication Information', - 'authority': 'ISO', - 'citation': '', - 'url': '', - 'source_type': 'STANDARD', - }, - 'created_at': '2026-04-19T10:00:00Z', - 'updated_at': '2026-04-19T11:00:00Z', - }], - } - - def _mock_rust_response(self, mock_urlopen, payload): - response_mock = Mock() - response_mock.read.return_value = json.dumps(payload).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - mock_urlopen.return_value = response_ctx diff --git a/apps/requirements_app/urls.py b/apps/requirements_app/urls.py deleted file mode 100644 index 838c727..0000000 --- a/apps/requirements_app/urls.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.urls import path -from .views import RequirementListView - -app_name = 'requirements' - -urlpatterns = [ - path('', RequirementListView.as_view(), name='list'), -] diff --git a/apps/requirements_app/views.py b/apps/requirements_app/views.py deleted file mode 100644 index 74edaee..0000000 --- a/apps/requirements_app/views.py +++ /dev/null @@ -1,31 +0,0 @@ -from django.contrib.auth.mixins import LoginRequiredMixin -from django.views.generic import ListView -from .models import Requirement -from .services import RegulatoryMappingService -from .services_rust import RequirementBridge - - -class RequirementListView(LoginRequiredMixin, ListView): - model = Requirement - template_name = 'requirements/requirement_list.html' - context_object_name = 'requirements' - paginate_by = 50 - - def get_queryset(self): - rust_library = RequirementBridge.fetch_library(self.request) - if rust_library is not None: - self.requirements_source = 'rust_service' - self.mapping_versions = rust_library.mapping_versions - return rust_library.requirements - - self.requirements_source = 'django' - return Requirement.objects.select_related('mapping_version', 'primary_source').order_by('framework', 'code') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - if getattr(self, 'requirements_source', 'django') == 'rust_service': - context['mapping_versions'] = self.mapping_versions - else: - context['mapping_versions'] = RegulatoryMappingService.build_version_snapshot() - context['requirements_source'] = getattr(self, 'requirements_source', 'django') - return context diff --git a/apps/risks/__init__.py b/apps/risks/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/risks/admin.py b/apps/risks/admin.py deleted file mode 100644 index c20bdc5..0000000 --- a/apps/risks/admin.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.contrib import admin -from .models import Risk, RiskCategory - - -@admin.register(RiskCategory) -class RiskCategoryAdmin(admin.ModelAdmin): - list_display = ('name', 'tenant', 'sort_order') - list_filter = ('tenant',) - - -@admin.register(Risk) -class RiskAdmin(admin.ModelAdmin): - list_display = ('title', 'tenant', 'impact', 'likelihood', 'score', 'risk_level', 'status', 'owner') - list_filter = ('tenant', 'impact', 'likelihood', 'status', 'treatment_strategy') - search_fields = ('title', 'description') diff --git a/apps/risks/apps.py b/apps/risks/apps.py deleted file mode 100644 index 6e8bf54..0000000 --- a/apps/risks/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class RisksConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.risks' diff --git a/apps/risks/forms.py b/apps/risks/forms.py deleted file mode 100644 index 639c6a4..0000000 --- a/apps/risks/forms.py +++ /dev/null @@ -1,30 +0,0 @@ -from django import forms -from apps.core.forms import TenantScopedModelForm -from .models import Risk - - -class RiskForm(TenantScopedModelForm): - tenant_scoped_fields = { - 'category': 'tenant', - 'process': 'tenant', - 'asset': 'tenant', - 'owner': 'tenant', - } - - class Meta: - model = Risk - fields = [ - 'title', 'description', 'category', 'process', 'asset', 'owner', - 'threat', 'vulnerability', 'impact', 'likelihood', - 'residual_impact', 'residual_likelihood', - 'status', 'treatment_strategy', 'treatment_plan', 'treatment_due_date', - 'review_date', - ] - widgets = { - 'description': forms.Textarea(attrs={'rows': 3}), - 'threat': forms.Textarea(attrs={'rows': 2}), - 'vulnerability': forms.Textarea(attrs={'rows': 2}), - 'treatment_plan': forms.Textarea(attrs={'rows': 3}), - 'treatment_due_date': forms.DateInput(attrs={'type': 'date'}), - 'review_date': forms.DateInput(attrs={'type': 'date'}), - } diff --git a/apps/risks/migrations/0001_initial.py b/apps/risks/migrations/0001_initial.py deleted file mode 100644 index ee06682..0000000 --- a/apps/risks/migrations/0001_initial.py +++ /dev/null @@ -1,67 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-09 14:39 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('assets_app', '0001_initial'), - ('organizations', '0001_initial'), - ('processes', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='RiskCategory', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=128)), - ('description', models.TextField(blank=True)), - ('sort_order', models.PositiveIntegerField(default=0)), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='risk_categories', to='organizations.tenant')), - ], - options={ - 'verbose_name_plural': 'Risk categories', - 'ordering': ['sort_order', 'name'], - }, - ), - migrations.CreateModel( - name='Risk', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('title', models.CharField(max_length=255)), - ('description', models.TextField()), - ('threat', models.TextField(blank=True, help_text='Bedrohungsbeschreibung')), - ('vulnerability', models.TextField(blank=True, help_text='Schwachstelle die ausgenutzt werden kann')), - ('impact', models.IntegerField(choices=[(1, '1 – Unerheblich'), (2, '2 – Gering'), (3, '3 – Mittel'), (4, '4 – Hoch'), (5, '5 – Kritisch')], default=3)), - ('likelihood', models.IntegerField(choices=[(1, '1 – Unwahrscheinlich'), (2, '2 – Selten'), (3, '3 – Moeglich'), (4, '4 – Wahrscheinlich'), (5, '5 – Sehr wahrscheinlich')], default=3)), - ('residual_impact', models.IntegerField(blank=True, choices=[(1, '1 – Unerheblich'), (2, '2 – Gering'), (3, '3 – Mittel'), (4, '4 – Hoch'), (5, '5 – Kritisch')], null=True)), - ('residual_likelihood', models.IntegerField(blank=True, choices=[(1, '1 – Unwahrscheinlich'), (2, '2 – Selten'), (3, '3 – Moeglich'), (4, '4 – Wahrscheinlich'), (5, '5 – Sehr wahrscheinlich')], null=True)), - ('status', models.CharField(choices=[('IDENTIFIED', 'Identifiziert'), ('ANALYZING', 'In Analyse'), ('TREATING', 'In Behandlung'), ('ACCEPTED', 'Akzeptiert'), ('MITIGATED', 'Gemindert'), ('TRANSFERRED', 'Transferiert'), ('AVOIDED', 'Vermieden'), ('CLOSED', 'Geschlossen')], default='IDENTIFIED', max_length=16)), - ('treatment_strategy', models.CharField(blank=True, choices=[('MITIGATE', 'Mindern'), ('ACCEPT', 'Akzeptieren'), ('TRANSFER', 'Transferieren'), ('AVOID', 'Vermeiden')], max_length=16)), - ('treatment_plan', models.TextField(blank=True)), - ('treatment_due_date', models.DateField(blank=True, null=True)), - ('accepted_at', models.DateTimeField(blank=True, null=True)), - ('review_date', models.DateField(blank=True, null=True)), - ('accepted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='accepted_risks', to=settings.AUTH_USER_MODEL)), - ('asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='risks', to='assets_app.informationasset')), - ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_risks', to=settings.AUTH_USER_MODEL)), - ('process', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='risks', to='processes.process')), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='risks', to='organizations.tenant')), - ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='risks', to='risks.riskcategory')), - ], - options={ - 'ordering': ['-impact', '-likelihood', 'title'], - }, - ), - ] diff --git a/apps/risks/migrations/__init__.py b/apps/risks/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/risks/models.py b/apps/risks/models.py deleted file mode 100644 index fedc790..0000000 --- a/apps/risks/models.py +++ /dev/null @@ -1,105 +0,0 @@ -"""V20: Erweitertes Risikoregister mit 5x5 Matrix.""" -from django.db import models -from apps.core.models import TenantRelationValidationMixin, TimeStampedModel - - -class RiskCategory(TenantRelationValidationMixin, TimeStampedModel): - tenant = models.ForeignKey('organizations.Tenant', on_delete=models.CASCADE, related_name='risk_categories') - name = models.CharField(max_length=128) - description = models.TextField(blank=True) - sort_order = models.PositiveIntegerField(default=0) - - class Meta: - ordering = ['sort_order', 'name'] - verbose_name_plural = 'Risk categories' - - def __str__(self): - return self.name - - -class Risk(TenantRelationValidationMixin, TimeStampedModel): - tenant_relation_fields = { - 'category': 'tenant_id', - 'process': 'tenant_id', - 'asset': 'tenant_id', - 'owner': 'tenant_id', - 'accepted_by': 'tenant_id', - } - - IMPACT_CHOICES = [(1, '1 – Unerheblich'), (2, '2 – Gering'), (3, '3 – Mittel'), (4, '4 – Hoch'), (5, '5 – Kritisch')] - LIKELIHOOD_CHOICES = [(1, '1 – Unwahrscheinlich'), (2, '2 – Selten'), (3, '3 – Moeglich'), (4, '4 – Wahrscheinlich'), (5, '5 – Sehr wahrscheinlich')] - - class Status(models.TextChoices): - IDENTIFIED = 'IDENTIFIED', 'Identifiziert' - ANALYZING = 'ANALYZING', 'In Analyse' - TREATING = 'TREATING', 'In Behandlung' - ACCEPTED = 'ACCEPTED', 'Akzeptiert' - MITIGATED = 'MITIGATED', 'Gemindert' - TRANSFERRED = 'TRANSFERRED', 'Transferiert' - AVOIDED = 'AVOIDED', 'Vermieden' - CLOSED = 'CLOSED', 'Geschlossen' - - class Treatment(models.TextChoices): - MITIGATE = 'MITIGATE', 'Mindern' - ACCEPT = 'ACCEPT', 'Akzeptieren' - TRANSFER = 'TRANSFER', 'Transferieren' - AVOID = 'AVOID', 'Vermeiden' - - tenant = models.ForeignKey('organizations.Tenant', on_delete=models.CASCADE, related_name='risks') - category = models.ForeignKey(RiskCategory, on_delete=models.SET_NULL, null=True, blank=True, related_name='risks') - process = models.ForeignKey('processes.Process', on_delete=models.SET_NULL, null=True, blank=True, related_name='risks') - asset = models.ForeignKey('assets_app.InformationAsset', on_delete=models.SET_NULL, null=True, blank=True, related_name='risks') - owner = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='owned_risks') - title = models.CharField(max_length=255) - description = models.TextField() - threat = models.TextField(blank=True, help_text='Bedrohungsbeschreibung') - vulnerability = models.TextField(blank=True, help_text='Schwachstelle die ausgenutzt werden kann') - - # 5x5 Matrix: Impact x Likelihood - impact = models.IntegerField(choices=IMPACT_CHOICES, default=3) - likelihood = models.IntegerField(choices=LIKELIHOOD_CHOICES, default=3) - - # Residual risk after treatment - residual_impact = models.IntegerField(choices=IMPACT_CHOICES, null=True, blank=True) - residual_likelihood = models.IntegerField(choices=LIKELIHOOD_CHOICES, null=True, blank=True) - - status = models.CharField(max_length=16, choices=Status.choices, default=Status.IDENTIFIED) - treatment_strategy = models.CharField(max_length=16, choices=Treatment.choices, blank=True) - treatment_plan = models.TextField(blank=True) - treatment_due_date = models.DateField(null=True, blank=True) - accepted_by = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='accepted_risks') - accepted_at = models.DateTimeField(null=True, blank=True) - review_date = models.DateField(null=True, blank=True) - - class Meta: - ordering = ['-impact', '-likelihood', 'title'] - - @property - def score(self): - return self.impact * self.likelihood - - @property - def residual_score(self): - if self.residual_impact and self.residual_likelihood: - return self.residual_impact * self.residual_likelihood - return None - - @property - def risk_level(self): - s = self.score - if s >= 20: return 'CRITICAL' - if s >= 12: return 'HIGH' - if s >= 6: return 'MEDIUM' - return 'LOW' - - @property - def risk_level_label(self): - return {'CRITICAL': 'Kritisch', 'HIGH': 'Hoch', 'MEDIUM': 'Mittel', 'LOW': 'Niedrig'}.get(self.risk_level, '–') - - # Legacy compatibility - @property - def severity(self): - return self.impact - - def __str__(self): - return self.title diff --git a/apps/risks/services.py b/apps/risks/services.py deleted file mode 100644 index 87c214b..0000000 --- a/apps/risks/services.py +++ /dev/null @@ -1,509 +0,0 @@ -"""V20: Risk-Matrix-Service fuer 5x5 Heatmap.""" -from collections import defaultdict -import json -from dataclasses import dataclass -from datetime import date, datetime -from urllib.error import HTTPError -from urllib.request import Request, urlopen - -from django.conf import settings - -from .models import Risk - - -IMPACT_LABELS = dict(Risk.IMPACT_CHOICES) -LIKELIHOOD_LABELS = dict(Risk.LIKELIHOOD_CHOICES) -STATUS_LABELS = dict(Risk.Status.choices) -TREATMENT_LABELS = dict(Risk.Treatment.choices) - - -@dataclass(frozen=True) -class RiskRelatedRef: - id: int | None - name: str - - def __str__(self) -> str: - return self.name - - -@dataclass(frozen=True) -class RiskBridgeItem: - id: int - tenant: object - tenant_id: int - category: RiskRelatedRef | None - category_id: int | None - process: RiskRelatedRef | None - process_id: int | None - asset: RiskRelatedRef | None - asset_id: int | None - owner: RiskRelatedRef | None - owner_id: int | None - title: str - description: str - threat: str - vulnerability: str - impact: int - impact_label: str - likelihood: int - likelihood_label: str - residual_impact: int | None - residual_impact_label: str | None - residual_likelihood: int | None - residual_likelihood_label: str | None - status: str - status_label: str - treatment_strategy: str - treatment_strategy_label: str - treatment_plan: str - treatment_due_date: date | None - accepted_by: RiskRelatedRef | None - accepted_by_id: int | None - accepted_at: datetime | None - review_date: date | None - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - @property - def score(self) -> int: - return self.impact * self.likelihood - - @property - def residual_score(self) -> int | None: - if self.residual_impact and self.residual_likelihood: - return self.residual_impact * self.residual_likelihood - return None - - @property - def risk_level(self) -> str: - score = self.score - if score >= 20: - return 'CRITICAL' - if score >= 12: - return 'HIGH' - if score >= 6: - return 'MEDIUM' - return 'LOW' - - @property - def risk_level_label(self) -> str: - return {'CRITICAL': 'Kritisch', 'HIGH': 'Hoch', 'MEDIUM': 'Mittel', 'LOW': 'Niedrig'}.get( - self.risk_level, - '–', - ) - - @property - def severity(self) -> int: - return self.impact - - def get_impact_display(self) -> str: - return self.impact_label or IMPACT_LABELS.get(self.impact, str(self.impact)) - - def get_likelihood_display(self) -> str: - return self.likelihood_label or LIKELIHOOD_LABELS.get(self.likelihood, str(self.likelihood)) - - def get_status_display(self) -> str: - return self.status_label or STATUS_LABELS.get(self.status, self.status) - - def get_treatment_strategy_display(self) -> str: - return self.treatment_strategy_label or TREATMENT_LABELS.get(self.treatment_strategy, self.treatment_strategy) - - -class RiskRustClient: - @staticmethod - def _base_url() -> str: - base = (getattr(settings, 'RUST_BACKEND_URL', '') or '').strip() - return base.rstrip('/') - - @staticmethod - def fetch_risks(request, tenant, timeout: int = 8) -> list[RiskBridgeItem]: - base = RiskRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = RiskRustClient._authenticated_request(request, tenant, f'{base}/api/v1/risks') - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - - tenant_id = RiskRustClient._int_field(payload, 'tenant_id') - if tenant_id != int(tenant.id): - raise RuntimeError('Rust-Risikoliste gehoert nicht zum angefragten Tenant.') - - return [ - RiskRustClient._risk_from_payload(item, tenant) - for item in payload.get('risks', []) - if isinstance(item, dict) - ] - - @staticmethod - def fetch_risk_detail(request, tenant, risk_id: int, timeout: int = 8): - base = RiskRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = RiskRustClient._authenticated_request(request, tenant, f'{base}/api/v1/risks/{int(risk_id)}') - try: - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - except HTTPError as exc: - if exc.code == 404: - return None - raise - - risk = payload.get('risk') or {} - if not isinstance(risk, dict): - raise RuntimeError('Rust-Risikodetail hat kein risk-Objekt geliefert.') - risk_tenant_id = RiskRustClient._int_field(risk, 'tenant_id') - if risk_tenant_id != int(tenant.id): - raise RuntimeError('Rust-Risikodetail gehoert nicht zum angefragten Tenant.') - return RiskRustClient._risk_from_payload(risk, tenant) - - @staticmethod - def create_risk(request, tenant, payload: dict, timeout: int = 8): - base = RiskRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = RiskRustClient._authenticated_json_request( - request, - tenant, - f'{base}/api/v1/risks', - 'POST', - payload, - ) - with urlopen(rust_request, timeout=timeout) as response: - response_payload = json.loads(response.read().decode('utf-8')) - - risk = response_payload.get('risk') or {} - if not isinstance(risk, dict): - raise RuntimeError('Rust-Risikoanlage hat kein risk-Objekt geliefert.') - risk_tenant_id = RiskRustClient._int_field(risk, 'tenant_id') - if risk_tenant_id != int(tenant.id): - raise RuntimeError('Rust-Risikoanlage gehoert nicht zum angefragten Tenant.') - return RiskRustClient._risk_from_payload(risk, tenant) - - @staticmethod - def update_risk(request, tenant, risk_id: int, payload: dict, timeout: int = 8): - base = RiskRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = RiskRustClient._authenticated_json_request( - request, - tenant, - f'{base}/api/v1/risks/{int(risk_id)}', - 'PATCH', - payload, - ) - with urlopen(rust_request, timeout=timeout) as response: - response_payload = json.loads(response.read().decode('utf-8')) - - risk = response_payload.get('risk') or {} - if not isinstance(risk, dict): - raise RuntimeError('Rust-Risikoaktualisierung hat kein risk-Objekt geliefert.') - risk_tenant_id = RiskRustClient._int_field(risk, 'tenant_id') - if risk_tenant_id != int(tenant.id): - raise RuntimeError('Rust-Risikoaktualisierung gehoert nicht zum angefragten Tenant.') - return RiskRustClient._risk_from_payload(risk, tenant) - - @staticmethod - def payload_from_form(form) -> dict: - cleaned_data = form.cleaned_data - - def pk(field_name: str) -> int | None: - value = cleaned_data.get(field_name) - return getattr(value, 'pk', None) if value is not None else None - - def date_value(field_name: str) -> str | None: - value = cleaned_data.get(field_name) - return value.isoformat() if value else None - - return { - 'category_id': pk('category'), - 'process_id': pk('process'), - 'asset_id': pk('asset'), - 'owner_id': pk('owner'), - 'title': cleaned_data.get('title') or '', - 'description': cleaned_data.get('description') or '', - 'threat': cleaned_data.get('threat') or '', - 'vulnerability': cleaned_data.get('vulnerability') or '', - 'impact': cleaned_data.get('impact') or 3, - 'likelihood': cleaned_data.get('likelihood') or 3, - 'residual_impact': cleaned_data.get('residual_impact'), - 'residual_likelihood': cleaned_data.get('residual_likelihood'), - 'status': cleaned_data.get('status') or Risk.Status.IDENTIFIED, - 'treatment_strategy': cleaned_data.get('treatment_strategy') or '', - 'treatment_plan': cleaned_data.get('treatment_plan') or '', - 'treatment_due_date': date_value('treatment_due_date'), - 'review_date': date_value('review_date'), - } - - @staticmethod - def _risk_from_payload(item: dict, tenant) -> RiskBridgeItem: - category_id = RiskRustClient._optional_int_field(item, 'category_id') - process_id = RiskRustClient._optional_int_field(item, 'process_id') - asset_id = RiskRustClient._optional_int_field(item, 'asset_id') - owner_id = RiskRustClient._optional_int_field(item, 'owner_id') - accepted_by_id = RiskRustClient._optional_int_field(item, 'accepted_by_id') - category_name = str(item.get('category_name') or '').strip() - process_name = str(item.get('process_name') or '').strip() - asset_name = str(item.get('asset_name') or '').strip() - owner_display = str(item.get('owner_display') or '').strip() - accepted_by_display = str(item.get('accepted_by_display') or '').strip() - impact = RiskRustClient._int_field(item, 'impact') - likelihood = RiskRustClient._int_field(item, 'likelihood') - status = str(item.get('status') or Risk.Status.IDENTIFIED) - treatment_strategy = str(item.get('treatment_strategy') or '') - return RiskBridgeItem( - id=RiskRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=RiskRustClient._int_field(item, 'tenant_id'), - category=RiskRelatedRef(category_id, category_name) if category_name else None, - category_id=category_id, - process=RiskRelatedRef(process_id, process_name) if process_name else None, - process_id=process_id, - asset=RiskRelatedRef(asset_id, asset_name) if asset_name else None, - asset_id=asset_id, - owner=RiskRelatedRef(owner_id, owner_display) if owner_display else None, - owner_id=owner_id, - title=str(item.get('title') or ''), - description=str(item.get('description') or ''), - threat=str(item.get('threat') or ''), - vulnerability=str(item.get('vulnerability') or ''), - impact=impact, - impact_label=str(item.get('impact_label') or IMPACT_LABELS.get(impact, impact)), - likelihood=likelihood, - likelihood_label=str(item.get('likelihood_label') or LIKELIHOOD_LABELS.get(likelihood, likelihood)), - residual_impact=RiskRustClient._optional_int_field(item, 'residual_impact'), - residual_impact_label=RiskRustClient._optional_str_field(item, 'residual_impact_label'), - residual_likelihood=RiskRustClient._optional_int_field(item, 'residual_likelihood'), - residual_likelihood_label=RiskRustClient._optional_str_field(item, 'residual_likelihood_label'), - status=status, - status_label=str(item.get('status_label') or STATUS_LABELS.get(status, status)), - treatment_strategy=treatment_strategy, - treatment_strategy_label=str( - item.get('treatment_strategy_label') or TREATMENT_LABELS.get(treatment_strategy, treatment_strategy) - ), - treatment_plan=str(item.get('treatment_plan') or ''), - treatment_due_date=RiskRustClient._date_field(item, 'treatment_due_date'), - accepted_by=RiskRelatedRef(accepted_by_id, accepted_by_display) if accepted_by_display else None, - accepted_by_id=accepted_by_id, - accepted_at=RiskRustClient._datetime_field(item, 'accepted_at'), - review_date=RiskRustClient._date_field(item, 'review_date'), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - - @staticmethod - def _authenticated_request(request, tenant, url: str, *, method: str = 'GET', data: bytes | None = None) -> Request: - user = getattr(request, 'user', None) - user_id = getattr(user, 'id', None) - if not user_id: - raise RuntimeError('Risk-Rust-Bridge braucht einen authentifizierten User.') - - rust_request = Request(url, data=data, method=method) - rust_request.add_header('Accept', 'application/json') - if data is not None: - rust_request.add_header('Content-Type', 'application/json') - rust_request.add_header('X-ISCY-Tenant-ID', str(tenant.id)) - rust_request.add_header('X-ISCY-User-ID', str(user_id)) - user_email = (getattr(user, 'email', '') or '').strip() - if user_email: - rust_request.add_header('X-ISCY-User-Email', user_email) - return rust_request - - @staticmethod - def _authenticated_json_request(request, tenant, url: str, method: str, payload: dict) -> Request: - data = json.dumps(payload).encode('utf-8') - return RiskRustClient._authenticated_request(request, tenant, url, method=method, data=data) - - @staticmethod - def _int_field(payload: dict, key: str) -> int: - return int(payload.get(key) or 0) - - @staticmethod - def _optional_int_field(payload: dict, key: str) -> int | None: - value = payload.get(key) - if value in (None, ''): - return None - return int(value) - - @staticmethod - def _optional_str_field(payload: dict, key: str) -> str | None: - value = str(payload.get(key) or '').strip() - return value or None - - @staticmethod - def _date_field(payload: dict, key: str) -> date | None: - value = str(payload.get(key) or '').strip() - if not value: - return None - return date.fromisoformat(value[:10]) - - @staticmethod - def _datetime_field(payload: dict, key: str) -> datetime | None: - value = str(payload.get(key) or '').strip() - if not value: - return None - normalized = value.replace('Z', '+00:00') - return datetime.fromisoformat(normalized) - - -class RiskRegisterBridge: - @staticmethod - def fetch_list(request, tenant): - if tenant is None: - return None - - backend = str(getattr(settings, 'RISK_REGISTER_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not RiskRustClient._base_url(): - if RiskRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust risk register backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return RiskRustClient.fetch_risks(request, tenant) - except Exception as exc: - if RiskRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust risk register backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def fetch_detail(request, tenant, risk_id: int): - if tenant is None: - return None - - backend = str(getattr(settings, 'RISK_REGISTER_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not RiskRustClient._base_url(): - if RiskRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust risk register backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return RiskRustClient.fetch_risk_detail(request, tenant, risk_id) - except Exception as exc: - if RiskRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust risk register backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def create_from_form(request, tenant, form): - if not RiskRegisterBridge._writes_enabled(tenant): - return None - - payload = RiskRustClient.payload_from_form(form) - try: - return RiskRustClient.create_risk(request, tenant, payload) - except Exception as exc: - if RiskRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust risk register write backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def update_from_form(request, tenant, risk_id: int, form): - if not RiskRegisterBridge._writes_enabled(tenant): - return None - - payload = RiskRustClient.payload_from_form(form) - try: - return RiskRustClient.update_risk(request, tenant, risk_id, payload) - except Exception as exc: - if RiskRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust risk register write backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def _writes_enabled(tenant) -> bool: - if tenant is None: - return False - - backend = str(getattr(settings, 'RISK_REGISTER_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return False - - if not RiskRustClient._base_url(): - if RiskRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust risk register backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return False - return True - - @staticmethod - def _strict_rust_mode() -> bool: - return bool(getattr(settings, 'RUST_STRICT_MODE', False)) - - -class RiskMatrixService: - """Baut eine 5x5 Risikomatrix mit Faerbung und Risikozaehlung.""" - - LEVEL_MAP = { - (5, 5): 'critical', (5, 4): 'critical', (4, 5): 'critical', - (5, 3): 'high', (4, 4): 'high', (3, 5): 'high', (4, 3): 'high', (3, 4): 'high', - (5, 2): 'high', (2, 5): 'high', - (5, 1): 'medium', (1, 5): 'medium', (4, 2): 'medium', (2, 4): 'medium', - (3, 3): 'medium', (3, 2): 'medium', (2, 3): 'medium', - (4, 1): 'medium', (1, 4): 'medium', - (3, 1): 'low', (1, 3): 'low', (2, 2): 'low', (2, 1): 'low', (1, 2): 'low', - (1, 1): 'low', - } - - COLORS = { - 'critical': '#dc2626', - 'high': '#ea580c', - 'medium': '#f59e0b', - 'low': '#22c55e', - } - - BG_COLORS = { - 'critical': '#fef2f2', - 'high': '#fff7ed', - 'medium': '#fffbeb', - 'low': '#f0fdf4', - } - - @staticmethod - def build_matrix(risks): - """Baut die 5x5 Matrix aus einer QuerySet von Risks. - Returns: list of rows (likelihood 5->1), each row = list of cells. - """ - count_map = defaultdict(list) - for risk in risks: - count_map[(risk.impact, risk.likelihood)].append(risk) - - matrix = [] - for likelihood in range(5, 0, -1): - row = [] - for impact in range(1, 6): - level = RiskMatrixService.LEVEL_MAP.get((impact, likelihood), 'low') - cell_risks = count_map.get((impact, likelihood), []) - row.append({ - 'impact': impact, - 'likelihood': likelihood, - 'level': level, - 'color': RiskMatrixService.COLORS[level], - 'bg_color': RiskMatrixService.BG_COLORS[level], - 'count': len(cell_risks), - 'risks': cell_risks, - 'score': impact * likelihood, - }) - matrix.append({'likelihood': likelihood, 'cells': row}) - return matrix - - @staticmethod - def summary(risks): - """Zaehlt Risiken nach Level.""" - summary = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0, 'total': 0} - for risk in risks: - level = RiskMatrixService.LEVEL_MAP.get((risk.impact, risk.likelihood), 'low') - summary[level] += 1 - summary['total'] += 1 - return summary diff --git a/apps/risks/tests.py b/apps/risks/tests.py deleted file mode 100644 index 305ae11..0000000 --- a/apps/risks/tests.py +++ /dev/null @@ -1,301 +0,0 @@ -import json -from unittest.mock import Mock, patch - -from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings -from django.urls import reverse - -from apps.assets_app.models import InformationAsset -from apps.organizations.models import Tenant -from apps.processes.models import Process -from apps.risks.models import Risk, RiskCategory - - -User = get_user_model() - - -@override_settings(RISK_REGISTER_BACKEND='local', RUST_BACKEND_URL='') -class RiskViewTests(TestCase): - def setUp(self): - self.tenant_a = Tenant.objects.create(name='Tenant A', slug='tenant-a', country='DE') - self.tenant_b = Tenant.objects.create(name='Tenant B', slug='tenant-b', country='DE') - self.user_a = User.objects.create_user( - username='tenant-a-user', - email='risk-user@example.test', - password='testpass123', - tenant=self.tenant_a, - first_name='Ada', - last_name='Lovelace', - ) - self.user_b = User.objects.create_user( - username='tenant-b-user', - password='testpass123', - tenant=self.tenant_b, - ) - self.category = RiskCategory.objects.create(tenant=self.tenant_a, name='Cyber Risk') - self.process = Process.objects.create(tenant=self.tenant_a, name='Incident Intake') - self.asset = InformationAsset.objects.create(tenant=self.tenant_a, name='Customer Portal') - self.risk_a = Risk.objects.create( - tenant=self.tenant_a, - category=self.category, - process=self.process, - asset=self.asset, - owner=self.user_a, - title='Credential Phishing', - description='Credential theft can disrupt SOC operations', - threat='Phishing campaign', - vulnerability='Weak MFA coverage', - impact=5, - likelihood=4, - residual_impact=3, - residual_likelihood=2, - status=Risk.Status.TREATING, - treatment_strategy=Risk.Treatment.MITIGATE, - treatment_plan='Roll out phishing-resistant MFA', - ) - self.risk_b = Risk.objects.create( - tenant=self.tenant_b, - owner=self.user_b, - title='Foreign Risk', - description='Foreign tenant risk', - impact=5, - likelihood=5, - ) - - def test_risk_list_only_shows_current_tenant(self): - self.client.force_login(self.user_a) - - response = self.client.get(reverse('risks:list')) - - self.assertEqual(response.status_code, 200) - risks = list(response.context['risks']) - self.assertEqual(risks, [self.risk_a]) - self.assertEqual(response.context['risk_register_source'], 'django') - self.assertEqual(response.context['summary']['critical'], 1) - self.assertContains(response, 'Credential Phishing') - self.assertNotContains(response, 'Foreign Risk') - - @patch('apps.risks.services.urlopen') - def test_risk_list_can_use_rust_register_bridge(self, mock_urlopen): - response_mock = Mock() - response_mock.read.return_value = json.dumps({ - 'api_version': 'v1', - 'tenant_id': self.tenant_a.id, - 'risks': [ - self._rust_risk_payload(title='Rust Credential Phishing'), - ], - }).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - mock_urlopen.return_value = response_ctx - self.client.force_login(self.user_a) - - with self.settings(RISK_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.get(reverse('risks:list')) - - self.assertEqual(response.status_code, 200) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual(rust_request.full_url, 'http://rust-backend:9000/api/v1/risks') - self.assertEqual(rust_request.get_header('X-iscy-tenant-id'), str(self.tenant_a.id)) - self.assertEqual(rust_request.get_header('X-iscy-user-id'), str(self.user_a.id)) - self.assertEqual(rust_request.get_header('X-iscy-user-email'), 'risk-user@example.test') - risks = list(response.context['risks']) - self.assertEqual(response.context['risk_register_source'], 'rust_service') - self.assertEqual(len(risks), 1) - self.assertEqual(risks[0].title, 'Rust Credential Phishing') - self.assertEqual(risks[0].tenant.name, 'Tenant A') - self.assertEqual(risks[0].get_status_display(), 'In Behandlung') - self.assertEqual(response.context['summary']['critical'], 1) - self.assertContains(response, 'Rust Credential Phishing') - self.assertContains(response, 'Ada Lovelace') - - @patch('apps.risks.services.urlopen') - def test_risk_detail_can_use_rust_register_bridge(self, mock_urlopen): - response_mock = Mock() - response_mock.read.return_value = json.dumps({ - 'api_version': 'v1', - 'risk': self._rust_risk_payload(description='Rust detail risk'), - }).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - mock_urlopen.return_value = response_ctx - self.client.force_login(self.user_a) - - with self.settings(RISK_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.get(reverse('risks:detail', args=[self.risk_a.pk])) - - self.assertEqual(response.status_code, 200) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual(rust_request.full_url, f'http://rust-backend:9000/api/v1/risks/{self.risk_a.pk}') - risk = response.context['risk'] - self.assertEqual(response.context['risk_register_source'], 'rust_service') - self.assertEqual(risk.description, 'Rust detail risk') - self.assertEqual(risk.owner.name, 'Ada Lovelace') - self.assertEqual(risk.score, 20) - self.assertEqual(risk.residual_score, 6) - self.assertContains(response, 'Rust detail risk') - self.assertContains(response, 'Customer Portal') - - @patch('apps.risks.services.urlopen') - def test_risk_create_can_use_rust_register_bridge(self, mock_urlopen): - response_mock = Mock() - response_mock.read.return_value = json.dumps({ - 'api_version': 'v1', - 'risk': self._rust_risk_payload(id=99, title='Rust Created Risk'), - }).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - mock_urlopen.return_value = response_ctx - self.client.force_login(self.user_a) - - with self.settings(RISK_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.post(reverse('risks:create'), data={ - 'title': 'Rust Created Risk', - 'description': 'Created through Rust', - 'category': self.category.pk, - 'process': self.process.pk, - 'asset': self.asset.pk, - 'owner': self.user_a.pk, - 'threat': 'Credential stuffing', - 'vulnerability': 'Weak lockout', - 'impact': 4, - 'likelihood': 3, - 'residual_impact': 2, - 'residual_likelihood': 2, - 'status': Risk.Status.ANALYZING, - 'treatment_strategy': Risk.Treatment.MITIGATE, - 'treatment_plan': 'Harden login controls', - 'treatment_due_date': '2026-06-01', - 'review_date': '2026-06-15', - }) - - self.assertEqual(response.status_code, 302) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual(rust_request.full_url, 'http://rust-backend:9000/api/v1/risks') - self.assertEqual(rust_request.get_method(), 'POST') - self.assertEqual(rust_request.get_header('X-iscy-tenant-id'), str(self.tenant_a.id)) - payload = json.loads(rust_request.data.decode('utf-8')) - self.assertEqual(payload['title'], 'Rust Created Risk') - self.assertEqual(payload['category_id'], self.category.pk) - self.assertEqual(payload['process_id'], self.process.pk) - self.assertEqual(payload['asset_id'], self.asset.pk) - self.assertEqual(payload['owner_id'], self.user_a.pk) - self.assertEqual(payload['impact'], 4) - self.assertFalse(Risk.objects.filter(title='Rust Created Risk').exists()) - - @patch('apps.risks.services.urlopen') - def test_risk_update_can_use_rust_register_bridge(self, mock_urlopen): - response_mock = Mock() - response_mock.read.return_value = json.dumps({ - 'api_version': 'v1', - 'risk': self._rust_risk_payload(title='Rust Updated Risk', status='CLOSED', status_label='Geschlossen'), - }).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - mock_urlopen.return_value = response_ctx - self.client.force_login(self.user_a) - - with self.settings(RISK_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.post(reverse('risks:edit', args=[self.risk_a.pk]), data={ - 'title': 'Rust Updated Risk', - 'description': self.risk_a.description, - 'category': '', - 'process': '', - 'asset': '', - 'owner': '', - 'threat': self.risk_a.threat, - 'vulnerability': self.risk_a.vulnerability, - 'impact': 2, - 'likelihood': 4, - 'residual_impact': '', - 'residual_likelihood': '', - 'status': Risk.Status.CLOSED, - 'treatment_strategy': '', - 'treatment_plan': '', - 'treatment_due_date': '', - 'review_date': '', - }) - - self.assertEqual(response.status_code, 302) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual(rust_request.full_url, f'http://rust-backend:9000/api/v1/risks/{self.risk_a.pk}') - self.assertEqual(rust_request.get_method(), 'PATCH') - payload = json.loads(rust_request.data.decode('utf-8')) - self.assertEqual(payload['title'], 'Rust Updated Risk') - self.assertIsNone(payload['category_id']) - self.assertIsNone(payload['process_id']) - self.assertIsNone(payload['asset_id']) - self.assertIsNone(payload['owner_id']) - self.assertEqual(payload['status'], Risk.Status.CLOSED) - self.assertEqual(Risk.objects.get(pk=self.risk_a.pk).title, 'Credential Phishing') - - @patch('apps.risks.services.urlopen', side_effect=OSError('backend down')) - def test_risk_list_falls_back_to_django_when_rust_unavailable_in_non_strict_mode(self, _mock_urlopen): - self.client.force_login(self.user_a) - - with self.settings( - RISK_REGISTER_BACKEND='rust_service', - RUST_BACKEND_URL='http://rust-backend:9000', - RUST_STRICT_MODE=False, - ): - response = self.client.get(reverse('risks:list')) - - self.assertEqual(response.status_code, 200) - risks = list(response.context['risks']) - self.assertEqual(response.context['risk_register_source'], 'django') - self.assertEqual(risks, [self.risk_a]) - - def test_risk_list_raises_in_strict_mode_without_rust_backend_url(self): - self.client.force_login(self.user_a) - - with self.settings(RISK_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='', RUST_STRICT_MODE=True): - with self.assertRaises(RuntimeError): - self.client.get(reverse('risks:list')) - - def _rust_risk_payload(self, **overrides): - payload = { - 'id': self.risk_a.id, - 'tenant_id': self.tenant_a.id, - 'category_id': self.category.id, - 'category_name': 'Cyber Risk', - 'process_id': self.process.id, - 'process_name': 'Incident Intake', - 'asset_id': self.asset.id, - 'asset_name': 'Customer Portal', - 'owner_id': self.user_a.id, - 'owner_display': 'Ada Lovelace', - 'title': 'Credential Phishing', - 'description': 'Credential theft can disrupt SOC operations', - 'threat': 'Phishing campaign', - 'vulnerability': 'Weak MFA coverage', - 'impact': 5, - 'impact_label': '5 – Kritisch', - 'likelihood': 4, - 'likelihood_label': '4 – Wahrscheinlich', - 'residual_impact': 3, - 'residual_impact_label': '3 – Mittel', - 'residual_likelihood': 2, - 'residual_likelihood_label': '2 – Selten', - 'status': 'TREATING', - 'status_label': 'In Behandlung', - 'treatment_strategy': 'MITIGATE', - 'treatment_strategy_label': 'Mindern', - 'treatment_plan': 'Roll out phishing-resistant MFA', - 'treatment_due_date': '2026-04-30', - 'accepted_by_id': None, - 'accepted_by_display': None, - 'accepted_at': None, - 'review_date': '2026-05-01', - 'score': 20, - 'residual_score': 6, - 'risk_level': 'CRITICAL', - 'risk_level_label': 'Kritisch', - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - } - payload.update(overrides) - return payload diff --git a/apps/risks/urls.py b/apps/risks/urls.py deleted file mode 100644 index 60c0df3..0000000 --- a/apps/risks/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.urls import path -from .views import RiskCreateView, RiskDetailView, RiskListView, RiskUpdateView - -app_name = 'risks' - -urlpatterns = [ - path('', RiskListView.as_view(), name='list'), - path('new/', RiskCreateView.as_view(), name='create'), - path('/', RiskDetailView.as_view(), name='detail'), - path('/edit/', RiskUpdateView.as_view(), name='edit'), -] diff --git a/apps/risks/views.py b/apps/risks/views.py deleted file mode 100644 index dbf43f0..0000000 --- a/apps/risks/views.py +++ /dev/null @@ -1,88 +0,0 @@ -from django.shortcuts import redirect -from django.urls import reverse_lazy -from django.views.generic import CreateView, DetailView, ListView, UpdateView -from apps.core.mixins import TenantAccessMixin, TenantCreateMixin -from .forms import RiskForm -from .models import Risk -from .services import RiskMatrixService, RiskRegisterBridge - - -class RiskListView(TenantAccessMixin, ListView): - model = Risk - template_name = 'risks/risk_list.html' - context_object_name = 'risks' - - def get_queryset(self): - rust_risks = RiskRegisterBridge.fetch_list(self.request, self.get_tenant()) - if rust_risks is not None: - self.risk_register_source = 'rust_service' - return rust_risks - - self.risk_register_source = 'django' - return super().get_queryset().select_related('owner', 'category', 'process', 'asset') - - def get_context_data(self, **kwargs): - ctx = super().get_context_data(**kwargs) - risks = list(ctx.get('risks', [])) - ctx['risk_register_source'] = getattr(self, 'risk_register_source', 'django') - ctx['matrix'] = RiskMatrixService.build_matrix(risks) - ctx['summary'] = RiskMatrixService.summary(risks) - ctx['impact_labels'] = ['Unerheblich', 'Gering', 'Mittel', 'Hoch', 'Kritisch'] - ctx['likelihood_labels'] = ['Unwahrscheinlich', 'Selten', 'Moeglich', 'Wahrscheinlich', 'Sehr wahrscheinlich'] - return ctx - - -class RiskDetailView(TenantAccessMixin, DetailView): - model = Risk - template_name = 'risks/risk_detail.html' - context_object_name = 'risk' - - def get_object(self, queryset=None): - rust_risk = RiskRegisterBridge.fetch_detail( - self.request, - self.get_tenant(), - self.kwargs.get(self.pk_url_kwarg), - ) - if rust_risk is not None: - self.risk_register_source = 'rust_service' - self.check_tenant_access(rust_risk) - return rust_risk - - self.risk_register_source = 'django' - return super().get_object(queryset) - - def get_context_data(self, **kwargs): - ctx = super().get_context_data(**kwargs) - ctx['risk_register_source'] = getattr(self, 'risk_register_source', 'django') - return ctx - - -class RiskCreateView(TenantCreateMixin, CreateView): - model = Risk - form_class = RiskForm - template_name = 'risks/risk_form.html' - success_url = reverse_lazy('risks:list') - - def form_valid(self, form): - rust_risk = RiskRegisterBridge.create_from_form(self.request, self.get_tenant(), form) - if rust_risk is not None: - return redirect(str(self.success_url)) - return super().form_valid(form) - - -class RiskUpdateView(TenantAccessMixin, UpdateView): - model = Risk - form_class = RiskForm - template_name = 'risks/risk_form.html' - success_url = reverse_lazy('risks:list') - - def form_valid(self, form): - rust_risk = RiskRegisterBridge.update_from_form( - self.request, - self.get_tenant(), - form.instance.pk, - form, - ) - if rust_risk is not None: - return redirect(str(self.success_url)) - return super().form_valid(form) diff --git a/apps/roadmap/__init__.py b/apps/roadmap/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/roadmap/admin.py b/apps/roadmap/admin.py deleted file mode 100644 index f2b806a..0000000 --- a/apps/roadmap/admin.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.contrib import admin -from .models import RoadmapPlan, RoadmapPhase, RoadmapTask, RoadmapTaskDependency - -admin.site.register(RoadmapPlan) -admin.site.register(RoadmapPhase) -admin.site.register(RoadmapTask) -admin.site.register(RoadmapTaskDependency) diff --git a/apps/roadmap/apps.py b/apps/roadmap/apps.py deleted file mode 100644 index ae001b3..0000000 --- a/apps/roadmap/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class RoadmapConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.roadmap' diff --git a/apps/roadmap/forms.py b/apps/roadmap/forms.py deleted file mode 100644 index e5e9c13..0000000 --- a/apps/roadmap/forms.py +++ /dev/null @@ -1,13 +0,0 @@ -from django import forms -from .models import RoadmapTask - - -class RoadmapTaskUpdateForm(forms.ModelForm): - class Meta: - model = RoadmapTask - fields = ['status', 'planned_start', 'due_date', 'owner_role', 'notes'] - widgets = { - 'planned_start': forms.DateInput(attrs={'type': 'date'}), - 'due_date': forms.DateInput(attrs={'type': 'date'}), - 'notes': forms.Textarea(attrs={'rows': 3}), - } diff --git a/apps/roadmap/migrations/0001_initial.py b/apps/roadmap/migrations/0001_initial.py deleted file mode 100644 index 4c9e6da..0000000 --- a/apps/roadmap/migrations/0001_initial.py +++ /dev/null @@ -1,91 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-09 14:39 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('organizations', '0001_initial'), - ('wizard', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='RoadmapPlan', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('title', models.CharField(max_length=255)), - ('summary', models.TextField(blank=True)), - ('overall_priority', models.CharField(blank=True, max_length=32)), - ('planned_start', models.DateField(blank=True, null=True)), - ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='roadmap_plans', to='wizard.assessmentsession')), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='roadmap_plans', to='organizations.tenant')), - ], - options={ - 'ordering': ['-created_at'], - }, - ), - migrations.CreateModel( - name='RoadmapPhase', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=255)), - ('sort_order', models.PositiveIntegerField(default=0)), - ('objective', models.TextField(blank=True)), - ('duration_weeks', models.PositiveIntegerField(default=2)), - ('planned_start', models.DateField(blank=True, null=True)), - ('planned_end', models.DateField(blank=True, null=True)), - ('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='phases', to='roadmap.roadmapplan')), - ], - options={ - 'ordering': ['sort_order'], - }, - ), - migrations.CreateModel( - name='RoadmapTask', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('title', models.CharField(max_length=255)), - ('description', models.TextField(blank=True)), - ('priority', models.CharField(blank=True, max_length=32)), - ('owner_role', models.CharField(blank=True, max_length=64)), - ('due_in_days', models.PositiveIntegerField(default=30)), - ('dependency_text', models.TextField(blank=True)), - ('status', models.CharField(choices=[('OPEN', 'Offen'), ('PLANNED', 'Geplant'), ('IN_PROGRESS', 'In Umsetzung'), ('BLOCKED', 'Blockiert'), ('DONE', 'Erledigt')], default='OPEN', max_length=16)), - ('planned_start', models.DateField(blank=True, null=True)), - ('due_date', models.DateField(blank=True, null=True)), - ('notes', models.TextField(blank=True)), - ('measure', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='roadmap_tasks', to='wizard.generatedmeasure')), - ('phase', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='roadmap.roadmapphase')), - ], - options={ - 'ordering': ['priority', 'title'], - }, - ), - migrations.CreateModel( - name='RoadmapTaskDependency', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('dependency_type', models.CharField(choices=[('FS', 'Finish-to-Start'), ('SS', 'Start-to-Start')], default='FS', max_length=2)), - ('rationale', models.CharField(blank=True, max_length=255)), - ('predecessor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='outgoing_dependencies', to='roadmap.roadmaptask')), - ('successor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='incoming_dependencies', to='roadmap.roadmaptask')), - ], - options={ - 'ordering': ['predecessor_id', 'successor_id'], - 'unique_together': {('predecessor', 'successor')}, - }, - ), - ] diff --git a/apps/roadmap/migrations/__init__.py b/apps/roadmap/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/roadmap/models.py b/apps/roadmap/models.py deleted file mode 100644 index 88b6f50..0000000 --- a/apps/roadmap/models.py +++ /dev/null @@ -1,100 +0,0 @@ -from django.db import models -from apps.core.models import TenantRelationValidationMixin, TimeStampedModel - - -class RoadmapPlan(TenantRelationValidationMixin, TimeStampedModel): - tenant_relation_fields = { - 'session': 'tenant_id', - } - - tenant = models.ForeignKey('organizations.Tenant', on_delete=models.CASCADE, related_name='roadmap_plans') - session = models.ForeignKey('wizard.AssessmentSession', on_delete=models.CASCADE, related_name='roadmap_plans') - title = models.CharField(max_length=255) - summary = models.TextField(blank=True) - overall_priority = models.CharField(max_length=32, blank=True) - planned_start = models.DateField(null=True, blank=True) - - class Meta: - ordering = ['-created_at'] - - def __str__(self): - return self.title - - -class RoadmapPhase(TenantRelationValidationMixin, TimeStampedModel): - tenant_source = 'plan__tenant_id' - - plan = models.ForeignKey(RoadmapPlan, on_delete=models.CASCADE, related_name='phases') - name = models.CharField(max_length=255) - sort_order = models.PositiveIntegerField(default=0) - objective = models.TextField(blank=True) - duration_weeks = models.PositiveIntegerField(default=2) - planned_start = models.DateField(null=True, blank=True) - planned_end = models.DateField(null=True, blank=True) - - class Meta: - ordering = ['sort_order'] - - def __str__(self): - return self.name - - -class RoadmapTask(TenantRelationValidationMixin, TimeStampedModel): - tenant_source = 'phase__plan__tenant_id' - tenant_relation_fields = { - 'measure': 'session__tenant_id', - } - - class Status(models.TextChoices): - OPEN = 'OPEN', 'Offen' - PLANNED = 'PLANNED', 'Geplant' - IN_PROGRESS = 'IN_PROGRESS', 'In Umsetzung' - BLOCKED = 'BLOCKED', 'Blockiert' - DONE = 'DONE', 'Erledigt' - - phase = models.ForeignKey(RoadmapPhase, on_delete=models.CASCADE, related_name='tasks') - measure = models.ForeignKey('wizard.GeneratedMeasure', on_delete=models.SET_NULL, null=True, blank=True, related_name='roadmap_tasks') - title = models.CharField(max_length=255) - description = models.TextField(blank=True) - priority = models.CharField(max_length=32, blank=True) - owner_role = models.CharField(max_length=64, blank=True) - due_in_days = models.PositiveIntegerField(default=30) - dependency_text = models.TextField(blank=True) - status = models.CharField(max_length=16, choices=Status.choices, default=Status.OPEN) - planned_start = models.DateField(null=True, blank=True) - due_date = models.DateField(null=True, blank=True) - notes = models.TextField(blank=True) - - class Meta: - ordering = ['priority', 'title'] - - def __str__(self): - return self.title - - @property - def dependencies(self): - return self.incoming_dependencies.select_related('predecessor').all() - - -class RoadmapTaskDependency(TenantRelationValidationMixin, TimeStampedModel): - tenant_source = 'predecessor__phase__plan__tenant_id' - tenant_relation_fields = { - 'predecessor': 'phase__plan__tenant_id', - 'successor': 'phase__plan__tenant_id', - } - - class DependencyType(models.TextChoices): - FINISH_TO_START = 'FS', 'Finish-to-Start' - START_TO_START = 'SS', 'Start-to-Start' - - predecessor = models.ForeignKey(RoadmapTask, on_delete=models.CASCADE, related_name='outgoing_dependencies') - successor = models.ForeignKey(RoadmapTask, on_delete=models.CASCADE, related_name='incoming_dependencies') - dependency_type = models.CharField(max_length=2, choices=DependencyType.choices, default=DependencyType.FINISH_TO_START) - rationale = models.CharField(max_length=255, blank=True) - - class Meta: - unique_together = ('predecessor', 'successor') - ordering = ['predecessor_id', 'successor_id'] - - def __str__(self): - return f'{self.predecessor} -> {self.successor}' diff --git a/apps/roadmap/services.py b/apps/roadmap/services.py deleted file mode 100644 index 1eb3795..0000000 --- a/apps/roadmap/services.py +++ /dev/null @@ -1,526 +0,0 @@ -import json -from dataclasses import dataclass, field -from datetime import date, datetime -from urllib.error import HTTPError -from urllib.request import Request, urlopen - -from django.conf import settings - -from apps.roadmap.models import RoadmapTask, RoadmapTaskDependency - - -class RoadmapRelatedList: - def __init__(self, items=None, known_count: int | None = None): - self._items = list(items or []) - self._known_count = known_count - - def all(self): - return list(self._items) - - def count(self): - if self._known_count is not None: - return self._known_count - return len(self._items) - - def prefetch_related(self, *_args): - return self - - def select_related(self, *_args): - return self - - def add(self, item): - self._items.append(item) - self._known_count = None - - def __iter__(self): - return iter(self._items) - - def __len__(self): - return self.count() - - def __getitem__(self, index): - return self._items[index] - - -@dataclass -class RoadmapPlanBridgeItem: - id: int - tenant: object - tenant_id: int - tenant_name: str - session_id: int - title: str - summary: str - overall_priority: str - planned_start: date | None - phase_count: int - task_count: int - open_task_count: int - created_at: datetime | None - updated_at: datetime | None - phases: RoadmapRelatedList = field(default_factory=RoadmapRelatedList) - - @property - def pk(self) -> int: - return self.id - - -@dataclass -class RoadmapPhaseBridgeItem: - id: int - plan: RoadmapPlanBridgeItem - plan_id: int - name: str - sort_order: int - objective: str - duration_weeks: int - planned_start: date | None - planned_end: date | None - task_count: int - created_at: datetime | None - updated_at: datetime | None - tasks: RoadmapRelatedList = field(default_factory=RoadmapRelatedList) - - @property - def pk(self) -> int: - return self.id - - -@dataclass -class RoadmapTaskBridgeItem: - id: int - phase: RoadmapPhaseBridgeItem - phase_id: int - phase_name: str - measure_id: int | None - title: str - description: str - priority: str - owner_role: str - due_in_days: int - dependency_text: str - status: str - status_label: str - planned_start: date | None - due_date: date | None - notes: str - incoming_dependency_count: int - created_at: datetime | None - updated_at: datetime | None - incoming_dependencies: RoadmapRelatedList = field(default_factory=RoadmapRelatedList) - - @property - def pk(self) -> int: - return self.id - - def get_status_display(self) -> str: - return self.status_label - - -@dataclass -class RoadmapTaskDependencyBridgeItem: - id: int - predecessor: RoadmapTaskBridgeItem | None - predecessor_id: int - predecessor_title: str - successor: RoadmapTaskBridgeItem | None - successor_id: int - successor_title: str - dependency_type: str - dependency_type_label: str - rationale: str - created_at: datetime | None - updated_at: datetime | None - - @property - def pk(self) -> int: - return self.id - - def get_dependency_type_display(self) -> str: - return self.dependency_type_label - - -@dataclass -class RoadmapPlanBridgeDetail: - plan: RoadmapPlanBridgeItem - tasks: list[RoadmapTaskBridgeItem] - dependencies: list[RoadmapTaskDependencyBridgeItem] - - -@dataclass -class RoadmapTaskUpdateBridgeResult: - plan_id: int - task: RoadmapTaskBridgeItem - - -class RoadmapRustClient: - @staticmethod - def _base_url() -> str: - base = (getattr(settings, 'RUST_BACKEND_URL', '') or '').strip() - return base.rstrip('/') - - @staticmethod - def fetch_plans(request, tenant, timeout: int = 8) -> list[RoadmapPlanBridgeItem]: - base = RoadmapRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = RoadmapRustClient._authenticated_request( - request, - tenant, - f'{base}/api/v1/roadmap/plans', - ) - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - - tenant_id = RoadmapRustClient._int_field(payload, 'tenant_id') - if tenant_id != int(tenant.id): - raise RuntimeError('Rust-Roadmapliste gehoert nicht zum angefragten Tenant.') - - return [ - RoadmapRustClient._plan_from_payload(item, tenant) - for item in payload.get('plans', []) - if isinstance(item, dict) - ] - - @staticmethod - def fetch_plan_detail(request, tenant, plan_id: int, timeout: int = 8) -> RoadmapPlanBridgeDetail | None: - base = RoadmapRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = RoadmapRustClient._authenticated_request( - request, - tenant, - f'{base}/api/v1/roadmap/plans/{int(plan_id)}', - ) - try: - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - except HTTPError as exc: - if exc.code == 404: - return None - raise - - plan_payload = payload.get('plan') or {} - if not isinstance(plan_payload, dict): - raise RuntimeError('Rust-Roadmapdetail hat kein plan-Objekt geliefert.') - plan = RoadmapRustClient._plan_from_payload(plan_payload, tenant) - if plan.tenant_id != int(tenant.id): - raise RuntimeError('Rust-Roadmapdetail gehoert nicht zum angefragten Tenant.') - - phase_by_id: dict[int, RoadmapPhaseBridgeItem] = {} - for item in payload.get('phases', []): - if not isinstance(item, dict): - continue - phase = RoadmapRustClient._phase_from_payload(item, plan) - phase_by_id[phase.id] = phase - plan.phases.add(phase) - - tasks: list[RoadmapTaskBridgeItem] = [] - task_by_id: dict[int, RoadmapTaskBridgeItem] = {} - for item in payload.get('tasks', []): - if not isinstance(item, dict): - continue - phase_id = RoadmapRustClient._int_field(item, 'phase_id') - phase = phase_by_id.get(phase_id) - if phase is None: - continue - task = RoadmapRustClient._task_from_payload(item, phase) - tasks.append(task) - task_by_id[task.id] = task - phase.tasks.add(task) - - dependencies: list[RoadmapTaskDependencyBridgeItem] = [] - for item in payload.get('dependencies', []): - if not isinstance(item, dict): - continue - dependency = RoadmapRustClient._dependency_from_payload(item, task_by_id) - dependencies.append(dependency) - if dependency.successor is not None: - dependency.successor.incoming_dependencies.add(dependency) - - return RoadmapPlanBridgeDetail(plan=plan, tasks=tasks, dependencies=dependencies) - - @staticmethod - def update_task(request, tenant, task_id: int, data: dict, timeout: int = 8) -> RoadmapTaskUpdateBridgeResult | None: - base = RoadmapRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = RoadmapRustClient._authenticated_json_request( - request, - tenant, - f'{base}/api/v1/roadmap/tasks/{int(task_id)}', - { - 'status': data.get('status'), - 'planned_start': RoadmapRustClient._date_to_payload(data.get('planned_start')), - 'due_date': RoadmapRustClient._date_to_payload(data.get('due_date')), - 'owner_role': data.get('owner_role') or '', - 'notes': data.get('notes') or '', - }, - method='PATCH', - ) - try: - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - except HTTPError as exc: - if exc.code == 404: - return None - raise - - plan_id = RoadmapRustClient._int_field(payload, 'plan_id') - task_payload = payload.get('task') or {} - if not isinstance(task_payload, dict): - raise RuntimeError('Rust-Roadmaptask-Update hat kein task-Objekt geliefert.') - - plan = RoadmapPlanBridgeItem( - id=plan_id, - tenant=tenant, - tenant_id=int(tenant.id), - tenant_name=getattr(tenant, 'name', ''), - session_id=0, - title='', - summary='', - overall_priority='', - planned_start=None, - phase_count=0, - task_count=0, - open_task_count=0, - created_at=None, - updated_at=None, - ) - phase = RoadmapPhaseBridgeItem( - id=RoadmapRustClient._int_field(task_payload, 'phase_id'), - plan=plan, - plan_id=plan_id, - name=str(task_payload.get('phase_name') or ''), - sort_order=0, - objective='', - duration_weeks=0, - planned_start=None, - planned_end=None, - task_count=0, - created_at=None, - updated_at=None, - ) - task = RoadmapRustClient._task_from_payload(task_payload, phase) - return RoadmapTaskUpdateBridgeResult(plan_id=plan_id, task=task) - - @staticmethod - def _plan_from_payload(item: dict, tenant) -> RoadmapPlanBridgeItem: - phase_count = RoadmapRustClient._int_field(item, 'phase_count') - return RoadmapPlanBridgeItem( - id=RoadmapRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=RoadmapRustClient._int_field(item, 'tenant_id'), - tenant_name=str(item.get('tenant_name') or getattr(tenant, 'name', '')), - session_id=RoadmapRustClient._int_field(item, 'session_id'), - title=str(item.get('title') or ''), - summary=str(item.get('summary') or ''), - overall_priority=str(item.get('overall_priority') or ''), - planned_start=RoadmapRustClient._date_field(item, 'planned_start'), - phase_count=phase_count, - task_count=RoadmapRustClient._int_field(item, 'task_count'), - open_task_count=RoadmapRustClient._int_field(item, 'open_task_count'), - created_at=RoadmapRustClient._datetime_field(item, 'created_at'), - updated_at=RoadmapRustClient._datetime_field(item, 'updated_at'), - phases=RoadmapRelatedList(known_count=phase_count), - ) - - @staticmethod - def _phase_from_payload(item: dict, plan: RoadmapPlanBridgeItem) -> RoadmapPhaseBridgeItem: - task_count = RoadmapRustClient._int_field(item, 'task_count') - return RoadmapPhaseBridgeItem( - id=RoadmapRustClient._int_field(item, 'id'), - plan=plan, - plan_id=RoadmapRustClient._int_field(item, 'plan_id'), - name=str(item.get('name') or ''), - sort_order=RoadmapRustClient._int_field(item, 'sort_order'), - objective=str(item.get('objective') or ''), - duration_weeks=RoadmapRustClient._int_field(item, 'duration_weeks'), - planned_start=RoadmapRustClient._date_field(item, 'planned_start'), - planned_end=RoadmapRustClient._date_field(item, 'planned_end'), - task_count=task_count, - created_at=RoadmapRustClient._datetime_field(item, 'created_at'), - updated_at=RoadmapRustClient._datetime_field(item, 'updated_at'), - tasks=RoadmapRelatedList(known_count=task_count), - ) - - @staticmethod - def _task_from_payload(item: dict, phase: RoadmapPhaseBridgeItem) -> RoadmapTaskBridgeItem: - status = str(item.get('status') or RoadmapTask.Status.OPEN) - return RoadmapTaskBridgeItem( - id=RoadmapRustClient._int_field(item, 'id'), - phase=phase, - phase_id=RoadmapRustClient._int_field(item, 'phase_id'), - phase_name=str(item.get('phase_name') or phase.name), - measure_id=RoadmapRustClient._optional_int_field(item, 'measure_id'), - title=str(item.get('title') or ''), - description=str(item.get('description') or ''), - priority=str(item.get('priority') or ''), - owner_role=str(item.get('owner_role') or ''), - due_in_days=RoadmapRustClient._int_field(item, 'due_in_days'), - dependency_text=str(item.get('dependency_text') or ''), - status=status, - status_label=str(item.get('status_label') or dict(RoadmapTask.Status.choices).get(status, status)), - planned_start=RoadmapRustClient._date_field(item, 'planned_start'), - due_date=RoadmapRustClient._date_field(item, 'due_date'), - notes=str(item.get('notes') or ''), - incoming_dependency_count=RoadmapRustClient._int_field(item, 'incoming_dependency_count'), - created_at=RoadmapRustClient._datetime_field(item, 'created_at'), - updated_at=RoadmapRustClient._datetime_field(item, 'updated_at'), - ) - - @staticmethod - def _dependency_from_payload( - item: dict, - task_by_id: dict[int, RoadmapTaskBridgeItem], - ) -> RoadmapTaskDependencyBridgeItem: - predecessor_id = RoadmapRustClient._int_field(item, 'predecessor_id') - successor_id = RoadmapRustClient._int_field(item, 'successor_id') - dependency_type = str(item.get('dependency_type') or RoadmapTaskDependency.DependencyType.FINISH_TO_START) - return RoadmapTaskDependencyBridgeItem( - id=RoadmapRustClient._int_field(item, 'id'), - predecessor=task_by_id.get(predecessor_id), - predecessor_id=predecessor_id, - predecessor_title=str(item.get('predecessor_title') or ''), - successor=task_by_id.get(successor_id), - successor_id=successor_id, - successor_title=str(item.get('successor_title') or ''), - dependency_type=dependency_type, - dependency_type_label=str( - item.get('dependency_type_label') - or dict(RoadmapTaskDependency.DependencyType.choices).get(dependency_type, dependency_type) - ), - rationale=str(item.get('rationale') or ''), - created_at=RoadmapRustClient._datetime_field(item, 'created_at'), - updated_at=RoadmapRustClient._datetime_field(item, 'updated_at'), - ) - - @staticmethod - def _authenticated_request(request, tenant, url: str) -> Request: - user = getattr(request, 'user', None) - user_id = getattr(user, 'id', None) - if not user_id: - raise RuntimeError('Roadmap-Rust-Bridge braucht einen authentifizierten User.') - - rust_request = Request(url) - rust_request.add_header('Accept', 'application/json') - rust_request.add_header('X-ISCY-Tenant-ID', str(tenant.id)) - rust_request.add_header('X-ISCY-User-ID', str(user_id)) - user_email = (getattr(user, 'email', '') or '').strip() - if user_email: - rust_request.add_header('X-ISCY-User-Email', user_email) - return rust_request - - @staticmethod - def _authenticated_json_request(request, tenant, url: str, payload: dict, method: str) -> Request: - rust_request = RoadmapRustClient._authenticated_request(request, tenant, url) - rust_request.data = json.dumps(payload).encode('utf-8') - rust_request.method = method - rust_request.add_header('Content-Type', 'application/json') - return rust_request - - @staticmethod - def _date_to_payload(value) -> str: - if not value: - return '' - if hasattr(value, 'isoformat'): - return value.isoformat() - return str(value) - - @staticmethod - def _int_field(payload: dict, key: str) -> int: - return int(payload.get(key) or 0) - - @staticmethod - def _optional_int_field(payload: dict, key: str) -> int | None: - value = payload.get(key) - if value in (None, ''): - return None - return int(value) - - @staticmethod - def _date_field(payload: dict, key: str) -> date | None: - value = str(payload.get(key) or '').strip() - if not value: - return None - return date.fromisoformat(value[:10]) - - @staticmethod - def _datetime_field(payload: dict, key: str) -> datetime | None: - value = str(payload.get(key) or '').strip() - if not value: - return None - normalized = value.replace('Z', '+00:00') - return datetime.fromisoformat(normalized) - - -class RoadmapRegisterBridge: - @staticmethod - def fetch_list(request, tenant): - if tenant is None: - return None - - backend = str(getattr(settings, 'ROADMAP_REGISTER_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not RoadmapRustClient._base_url(): - if RoadmapRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust roadmap register backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return RoadmapRustClient.fetch_plans(request, tenant) - except Exception as exc: - if RoadmapRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust roadmap register backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def fetch_detail(request, tenant, plan_id): - if tenant is None or not plan_id: - return None - - backend = str(getattr(settings, 'ROADMAP_REGISTER_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not RoadmapRustClient._base_url(): - if RoadmapRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust roadmap register backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return RoadmapRustClient.fetch_plan_detail(request, tenant, int(plan_id)) - except Exception as exc: - if RoadmapRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust roadmap register backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def update_task(request, tenant, task_id, data): - if tenant is None or not task_id: - return None - - backend = str(getattr(settings, 'ROADMAP_REGISTER_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not RoadmapRustClient._base_url(): - if RoadmapRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust roadmap register backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return RoadmapRustClient.update_task(request, tenant, int(task_id), data) - except Exception as exc: - if RoadmapRegisterBridge._strict_rust_mode(): - raise RuntimeError('Rust roadmap register backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def _strict_rust_mode() -> bool: - return bool(getattr(settings, 'RUST_STRICT_MODE', False)) diff --git a/apps/roadmap/tests.py b/apps/roadmap/tests.py deleted file mode 100644 index 8020b4f..0000000 --- a/apps/roadmap/tests.py +++ /dev/null @@ -1,333 +0,0 @@ -import json -from datetime import date -from unittest.mock import Mock, patch - -from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings -from django.urls import reverse - -from apps.organizations.models import Tenant -from apps.roadmap.models import RoadmapPhase, RoadmapPlan, RoadmapTask, RoadmapTaskDependency -from apps.wizard.models import AssessmentSession - - -User = get_user_model() - - -@override_settings(ROADMAP_REGISTER_BACKEND='local', RUST_BACKEND_URL='') -class RoadmapViewTests(TestCase): - def setUp(self): - self.tenant_a = Tenant.objects.create(name='Tenant A', slug='tenant-a', country='DE') - self.tenant_b = Tenant.objects.create(name='Tenant B', slug='tenant-b', country='DE') - self.user_a = User.objects.create_user( - username='tenant-a-roadmap', - email='roadmap-user@example.test', - password='testpass123', - tenant=self.tenant_a, - first_name='Ada', - last_name='Lovelace', - ) - self.user_b = User.objects.create_user( - username='tenant-b-roadmap', - password='testpass123', - tenant=self.tenant_b, - ) - self.session_a = AssessmentSession.objects.create( - tenant=self.tenant_a, - started_by=self.user_a, - assessment_type=AssessmentSession.Type.FULL, - status=AssessmentSession.Status.COMPLETED, - ) - self.session_b = AssessmentSession.objects.create( - tenant=self.tenant_b, - started_by=self.user_b, - assessment_type=AssessmentSession.Type.FULL, - ) - self.plan_a = RoadmapPlan.objects.create( - tenant=self.tenant_a, - session=self.session_a, - title='Security Roadmap', - summary='Bring controls to audit readiness.', - overall_priority='HIGH', - planned_start=date(2026, 5, 1), - ) - self.phase_a = RoadmapPhase.objects.create( - plan=self.plan_a, - name='Governance', - sort_order=1, - objective='Create ownership and policies.', - duration_weeks=2, - planned_start=date(2026, 5, 1), - planned_end=date(2026, 5, 14), - ) - self.task_a = RoadmapTask.objects.create( - phase=self.phase_a, - title='Policy aktualisieren', - description='Update security policy.', - priority='HIGH', - owner_role='CISO', - status=RoadmapTask.Status.OPEN, - planned_start=date(2026, 5, 1), - due_date=date(2026, 5, 7), - ) - self.task_b = RoadmapTask.objects.create( - phase=self.phase_a, - title='MFA ausrollen', - description='Roll out MFA.', - priority='CRITICAL', - owner_role='IAM Lead', - status=RoadmapTask.Status.IN_PROGRESS, - planned_start=date(2026, 5, 8), - due_date=date(2026, 5, 14), - ) - RoadmapTaskDependency.objects.create( - predecessor=self.task_a, - successor=self.task_b, - dependency_type=RoadmapTaskDependency.DependencyType.FINISH_TO_START, - rationale='Policy gates rollout.', - ) - self.plan_b = RoadmapPlan.objects.create( - tenant=self.tenant_b, - session=self.session_b, - title='Foreign Roadmap', - summary='Foreign tenant plan.', - ) - - def test_roadmap_list_only_shows_current_tenant(self): - self.client.force_login(self.user_a) - - response = self.client.get(reverse('roadmap:list')) - - self.assertEqual(response.status_code, 200) - self.assertEqual(list(response.context['plans']), [self.plan_a]) - self.assertEqual(response.context['roadmap_register_source'], 'django') - self.assertContains(response, 'Security Roadmap') - self.assertNotContains(response, 'Foreign Roadmap') - - @patch('apps.roadmap.services.urlopen') - def test_roadmap_list_can_use_rust_register_bridge(self, mock_urlopen): - self._mock_rust_response(mock_urlopen, { - 'api_version': 'v1', - 'tenant_id': self.tenant_a.id, - 'plans': [self._rust_plan_payload(title='Rust Roadmap')], - }) - self.client.force_login(self.user_a) - - with self.settings(ROADMAP_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.get(reverse('roadmap:list')) - - self.assertEqual(response.status_code, 200) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual(rust_request.full_url, 'http://rust-backend:9000/api/v1/roadmap/plans') - self.assertEqual(rust_request.get_header('X-iscy-tenant-id'), str(self.tenant_a.id)) - self.assertEqual(rust_request.get_header('X-iscy-user-id'), str(self.user_a.id)) - self.assertEqual(response.context['roadmap_register_source'], 'rust_service') - self.assertEqual(list(response.context['plans'])[0].title, 'Rust Roadmap') - self.assertContains(response, 'Rust Roadmap') - - @patch('apps.roadmap.services.urlopen') - def test_roadmap_detail_can_use_rust_register_bridge(self, mock_urlopen): - self._mock_rust_response(mock_urlopen, self._rust_detail_payload()) - self.client.force_login(self.user_a) - - with self.settings(ROADMAP_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.get(reverse('roadmap:detail', kwargs={'pk': self.plan_a.id})) - - self.assertEqual(response.status_code, 200) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual(rust_request.full_url, f'http://rust-backend:9000/api/v1/roadmap/plans/{self.plan_a.id}') - self.assertEqual(response.context['roadmap_register_source'], 'rust_service') - self.assertEqual(response.context['plan'].title, 'Rust Security Roadmap') - self.assertEqual(response.context['total_tasks'], 2) - self.assertContains(response, 'Rust Security Roadmap') - self.assertContains(response, 'Policy aktualisieren') - - @patch('apps.roadmap.services.urlopen') - def test_roadmap_kanban_can_use_rust_register_bridge(self, mock_urlopen): - self._mock_rust_response(mock_urlopen, self._rust_detail_payload()) - self.client.force_login(self.user_a) - - with self.settings(ROADMAP_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.get(reverse('roadmap:kanban', kwargs={'pk': self.plan_a.id})) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['roadmap_register_source'], 'rust_service') - self.assertContains(response, 'Policy aktualisieren') - self.assertContains(response, '1 Abhängigkeit') - - @patch('apps.roadmap.services.urlopen') - def test_roadmap_task_update_can_use_rust_register_bridge(self, mock_urlopen): - payload = self._rust_detail_payload()['tasks'][0] - payload.update({ - 'status': 'DONE', - 'status_label': 'Erledigt', - 'planned_start': '2026-05-02', - 'due_date': '2026-05-08', - 'owner_role': 'CISO Office', - 'notes': 'Closed in Rust', - }) - self._mock_rust_response(mock_urlopen, { - 'api_version': 'v1', - 'plan_id': self.plan_a.id, - 'task': payload, - }) - self.client.force_login(self.user_a) - - with self.settings(ROADMAP_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.post( - reverse('roadmap:task-edit', kwargs={'pk': self.task_a.id}), - { - 'status': 'DONE', - 'planned_start': '2026-05-02', - 'due_date': '2026-05-08', - 'owner_role': 'CISO Office', - 'notes': 'Closed in Rust', - }, - ) - - self.assertRedirects( - response, - reverse('roadmap:detail', kwargs={'pk': self.plan_a.id}), - fetch_redirect_response=False, - ) - rust_request = mock_urlopen.call_args.args[0] - body = json.loads(rust_request.data.decode('utf-8')) - self.assertEqual(rust_request.full_url, f'http://rust-backend:9000/api/v1/roadmap/tasks/{self.task_a.id}') - self.assertEqual(rust_request.get_method(), 'PATCH') - self.assertEqual(body['status'], 'DONE') - self.assertEqual(body['owner_role'], 'CISO Office') - - @patch('apps.roadmap.services.urlopen') - def test_roadmap_pdf_can_use_rust_export_data(self, mock_urlopen): - self._mock_rust_response(mock_urlopen, self._rust_detail_payload()) - self.client.force_login(self.user_a) - - with self.settings(ROADMAP_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.get(reverse('roadmap:pdf', kwargs={'pk': self.plan_a.id})) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response['Content-Type'], 'application/pdf') - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual(rust_request.full_url, f'http://rust-backend:9000/api/v1/roadmap/plans/{self.plan_a.id}') - - @patch('apps.roadmap.services.urlopen', side_effect=OSError('backend down')) - def test_roadmap_list_falls_back_to_django_when_rust_unavailable_in_non_strict_mode(self, _mock_urlopen): - self.client.force_login(self.user_a) - - with self.settings( - ROADMAP_REGISTER_BACKEND='rust_service', - RUST_BACKEND_URL='http://rust-backend:9000', - RUST_STRICT_MODE=False, - ): - response = self.client.get(reverse('roadmap:list')) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['roadmap_register_source'], 'django') - self.assertEqual(list(response.context['plans']), [self.plan_a]) - - def test_roadmap_list_raises_in_strict_mode_without_rust_backend_url(self): - self.client.force_login(self.user_a) - - with self.settings(ROADMAP_REGISTER_BACKEND='rust_service', RUST_BACKEND_URL='', RUST_STRICT_MODE=True): - with self.assertRaises(RuntimeError): - self.client.get(reverse('roadmap:list')) - - def _mock_rust_response(self, mock_urlopen, payload): - response_mock = Mock() - response_mock.read.return_value = json.dumps(payload).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - mock_urlopen.return_value = response_ctx - - def _rust_plan_payload(self, **overrides): - payload = { - 'id': self.plan_a.id, - 'tenant_id': self.tenant_a.id, - 'tenant_name': 'Tenant A', - 'session_id': self.session_a.id, - 'title': 'Security Roadmap', - 'summary': 'Bring controls to audit readiness.', - 'overall_priority': 'HIGH', - 'planned_start': '2026-05-01', - 'phase_count': 1, - 'task_count': 2, - 'open_task_count': 2, - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - } - payload.update(overrides) - return payload - - def _rust_detail_payload(self): - return { - 'api_version': 'v1', - 'plan': self._rust_plan_payload(title='Rust Security Roadmap'), - 'phases': [{ - 'id': self.phase_a.id, - 'plan_id': self.plan_a.id, - 'name': 'Governance', - 'sort_order': 1, - 'objective': 'Create ownership and policies.', - 'duration_weeks': 2, - 'planned_start': '2026-05-01', - 'planned_end': '2026-05-14', - 'task_count': 2, - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - }], - 'tasks': [ - { - 'id': self.task_a.id, - 'phase_id': self.phase_a.id, - 'phase_name': 'Governance', - 'measure_id': None, - 'title': 'Policy aktualisieren', - 'description': 'Update security policy.', - 'priority': 'HIGH', - 'owner_role': 'CISO', - 'due_in_days': 14, - 'dependency_text': '', - 'status': 'OPEN', - 'status_label': 'Offen', - 'planned_start': '2026-05-01', - 'due_date': '2026-05-07', - 'notes': '', - 'incoming_dependency_count': 0, - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - }, - { - 'id': self.task_b.id, - 'phase_id': self.phase_a.id, - 'phase_name': 'Governance', - 'measure_id': None, - 'title': 'MFA ausrollen', - 'description': 'Roll out MFA.', - 'priority': 'CRITICAL', - 'owner_role': 'IAM Lead', - 'due_in_days': 21, - 'dependency_text': 'Policy first', - 'status': 'IN_PROGRESS', - 'status_label': 'In Umsetzung', - 'planned_start': '2026-05-08', - 'due_date': '2026-05-14', - 'notes': 'Pilot started', - 'incoming_dependency_count': 1, - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - }, - ], - 'dependencies': [{ - 'id': 5001, - 'predecessor_id': self.task_a.id, - 'predecessor_title': 'Policy aktualisieren', - 'successor_id': self.task_b.id, - 'successor_title': 'MFA ausrollen', - 'dependency_type': 'FS', - 'dependency_type_label': 'Finish-to-Start', - 'rationale': 'Policy gates rollout.', - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - }], - } diff --git a/apps/roadmap/urls.py b/apps/roadmap/urls.py deleted file mode 100644 index 3bdd4c1..0000000 --- a/apps/roadmap/urls.py +++ /dev/null @@ -1,13 +0,0 @@ -from django.urls import path -from .views import RoadmapKanbanView, RoadmapPdfView, RoadmapPngView, RoadmapPlanDetailView, RoadmapPlanListView, RoadmapTaskUpdateView - -app_name = 'roadmap' - -urlpatterns = [ - path('', RoadmapPlanListView.as_view(), name='list'), - path('/', RoadmapPlanDetailView.as_view(), name='detail'), - path('/kanban/', RoadmapKanbanView.as_view(), name='kanban'), - path('/pdf/', RoadmapPdfView.as_view(), name='pdf'), - path('/png/', RoadmapPngView.as_view(), name='png'), - path('tasks//edit/', RoadmapTaskUpdateView.as_view(), name='task-edit'), -] diff --git a/apps/roadmap/views.py b/apps/roadmap/views.py deleted file mode 100644 index aeed39e..0000000 --- a/apps/roadmap/views.py +++ /dev/null @@ -1,503 +0,0 @@ -from collections import OrderedDict -from collections import Counter -from datetime import date -import io - -from django.http import HttpResponse, HttpResponseRedirect -from django.shortcuts import get_object_or_404 -from django.urls import reverse -from django.views.generic import DetailView, ListView, TemplateView, UpdateView, View -from apps.core.mixins import TenantAccessMixin -from apps.reports.models import ReportSnapshot -from reportlab.lib import colors -from reportlab.lib.pagesizes import landscape, A4 -from reportlab.pdfgen import canvas -from PIL import Image, ImageDraw - -from apps.wizard.services import WizardService -from .forms import RoadmapTaskUpdateForm -from .models import RoadmapPlan, RoadmapTask, RoadmapTaskDependency -from .services import RoadmapRegisterBridge - - -PHASE_COLORS = [ - '#2563eb', '#3b82f6', '#10b981', '#84cc16', '#f59e0b', '#8b5cf6', '#ec4899' -] -THEMES = { - 'ocean': {'shell': 'roadmap-theme-ocean', 'accent': '#2563eb'}, - 'forest': {'shell': 'roadmap-theme-forest', 'accent': '#15803d'}, - 'slate': {'shell': 'roadmap-theme-slate', 'accent': '#475569'}, -} - - -def _safe_date(value, fallback): - return value or fallback - - -def _timeline_bounds(plan, phases): - starts = [p.planned_start for p in phases if p.planned_start] - ends = [p.planned_end for p in phases if p.planned_end] - if starts and ends: - min_start = min(starts) - max_end = max(ends) - else: - min_start = plan.planned_start or date.today() - max_end = min_start - total_days = max((max_end - min_start).days + 1, 1) - return min_start, max_end, total_days - - -def _build_segments(min_start, max_end, total_days, zoom='month'): - segments = [] - current = date(min_start.year, min_start.month, 1) - while current <= max_end: - if current.month == 12: - next_month = date(current.year + 1, 1, 1) - else: - next_month = date(current.year, current.month + 1, 1) - quarter_key = (current.year, ((current.month - 1) // 3) + 1) - if zoom == 'quarter': - if segments and segments[-1]['quarter_key'] == quarter_key: - seg_end = min(next_month, max_end) - width_days = max((seg_end - current).days, 1) - segments[-1]['width'] += round((width_days / total_days) * 100, 2) - else: - seg_end = min(next_month, max_end) - width_days = max((seg_end - current).days, 1) - segments.append({ - 'label': f'Q{quarter_key[1]} {quarter_key[0]}', - 'width': round((width_days / total_days) * 100, 2), - 'quarter_key': quarter_key, - }) - else: - seg_end = min(next_month, max_end) - width_days = max((seg_end - current).days, 1) - segments.append({ - 'label': current.strftime('%b %Y'), - 'width': round((width_days / total_days) * 100, 2), - 'quarter_key': quarter_key, - }) - current = next_month - return segments - - -def _status_counts(tasks): - counts = Counter(task.status for task in tasks) - return [ - {'status': status, 'total': counts[status]} - for status, _label in RoadmapTask.Status.choices - if counts[status] - ] - - -def _latest_report_for_plan(plan): - tenant = getattr(plan, 'tenant', None) - session_id = getattr(plan, 'session_id', None) - if tenant is None or not session_id: - return None - return ReportSnapshot.objects.filter(tenant=tenant, session_id=session_id).first() - - -class RoadmapPlanListView(TenantAccessMixin, ListView): - model = RoadmapPlan - template_name = 'roadmap/plan_list.html' - context_object_name = 'plans' - - def get_queryset(self): - tenant = WizardService.get_default_tenant(self.request.user) - rust_plans = RoadmapRegisterBridge.fetch_list(self.request, tenant) - if rust_plans is not None: - self.roadmap_register_source = 'rust_service' - return rust_plans - - self.roadmap_register_source = 'django' - return RoadmapPlan.objects.filter(tenant=tenant).prefetch_related('phases__tasks') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['roadmap_register_source'] = getattr(self, 'roadmap_register_source', 'django') - return context - - -class RoadmapPlanDetailView(TenantAccessMixin, DetailView): - model = RoadmapPlan - template_name = 'roadmap/plan_detail.html' - context_object_name = 'plan' - - def get_object(self, queryset=None): - rust_detail = RoadmapRegisterBridge.fetch_detail( - self.request, - self.get_tenant(), - self.kwargs.get(self.pk_url_kwarg), - ) - if rust_detail is not None: - self.roadmap_register_source = 'rust_service' - self.roadmap_detail = rust_detail - self.check_tenant_access(rust_detail.plan) - return rust_detail.plan - - self.roadmap_register_source = 'django' - return super().get_object(queryset) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - plan = self.object - current_view = self.request.GET.get('view', 'executive') - if current_view not in {'executive', 'delivery', 'combined', 'boardroom'}: - current_view = 'executive' - current_zoom = self.request.GET.get('zoom', 'month') - if current_zoom not in {'month', 'quarter'}: - current_zoom = 'month' - current_theme = self.request.GET.get('theme', 'ocean') - if current_theme not in THEMES: - current_theme = 'ocean' - - phases = list(plan.phases.prefetch_related('tasks__incoming_dependencies__predecessor').all()) - min_start, max_end, total_days = _timeline_bounds(plan, phases) - - month_segments = _build_segments(min_start, max_end, total_days, current_zoom) - if current_zoom == 'quarter': - grid = [] - offset = 0.0 - for segment in month_segments[:-1]: - offset += segment['width'] - grid.append(round(offset, 2)) - else: - grid = [round((idx / max(len(month_segments), 1)) * 100, 2) for idx in range(1, len(month_segments))] - - timeline_rows = [] - for idx, phase in enumerate(phases): - start = _safe_date(phase.planned_start, min_start) - end = _safe_date(phase.planned_end, start) - offset = ((start - min_start).days / total_days) * 100 - width = max((((end - start).days + 1) / total_days) * 100, 7) - tasks = sorted(list(phase.tasks.all()), key=lambda t: (t.due_date or end, t.priority or '', t.title)) - milestones = [] - visible_milestones = [task for task in tasks if task.due_date][:5] - for m_idx, task in enumerate(visible_milestones): - left = ((task.due_date - min_start).days / total_days) * 100 - milestones.append({ - 'left': max(2.0, min(round(left, 2), 98.0)), - 'title': task.title, - 'short_title': task.title[:26] + ('…' if len(task.title) > 26 else ''), - 'date': task.due_date, - 'priority': task.priority, - 'slot': 'top' if m_idx % 2 == 0 else 'bottom', - }) - timeline_rows.append({ - 'phase': phase, - 'left': round(offset, 2), - 'width': round(width, 2), - 'color': PHASE_COLORS[idx % len(PHASE_COLORS)], - 'task_count': len(tasks), - 'milestones': milestones, - 'key_tasks': tasks[:3], - 'tasks': tasks, - }) - - rust_detail = getattr(self, 'roadmap_detail', None) - if rust_detail is not None: - tasks = sorted(rust_detail.tasks, key=lambda task: (task.due_date or date.max, task.priority or '', task.id)) - dependency_rows = rust_detail.dependencies[:40] - report = _latest_report_for_plan(plan) - else: - tasks = list( - RoadmapTask.objects.filter(phase__plan=plan) - .select_related('phase') - .prefetch_related('incoming_dependencies__predecessor') - .order_by('due_date', 'priority') - ) - dependency_rows = list(RoadmapTaskDependency.objects.filter(successor__phase__plan=plan).select_related('predecessor', 'successor')[:40]) - report = plan.session.report_snapshots.first() - - grouped = OrderedDict((choice, []) for choice in RoadmapTask.Status.choices) - for task in tasks: - grouped[(task.status, task.get_status_display())].append(task) - - heatmap_rows = report.domain_scores_json if report else [] - - - boardroom_top_phases = sorted(timeline_rows, key=lambda row: (-row['task_count'], row['phase'].sort_order))[:4] - critical_tasks = [task for task in tasks if (task.priority or '').upper() in {'CRITICAL', 'HIGH'}][:6] - milestone_feed = [] - for row in timeline_rows: - for milestone in row['milestones'][:2]: - # Calculate horizontal position for flag markers - left_pct = round(((milestone['date'] - min_start).days / total_days) * 100, 1) if milestone.get('date') else 50 - left_pct = max(3, min(left_pct, 92)) # Keep flags within visible area - milestone_feed.append({ - 'phase': row['phase'].name, - 'title': milestone['title'], - 'date': milestone['date'], - 'priority': milestone['priority'] or 'medium', - 'left_pct': left_pct, - }) - milestone_feed = sorted(milestone_feed, key=lambda item: item['date'])[:8] - - context.update({ - 'timeline_rows': timeline_rows, - 'timeline_grid': grid, - 'timeline_month_segments': month_segments, - 'timeline_start': min_start, - 'timeline_end': max_end, - 'timeline_total_weeks': max(1, round(total_days / 7)), - 'kanban_columns': grouped, - 'status_counts': _status_counts(tasks), - 'dependency_rows': dependency_rows, - 'heatmap_rows': heatmap_rows, - 'report': report, - 'total_tasks': len(tasks), - 'total_dependencies': len(dependency_rows), - 'current_view': current_view, - 'current_zoom': current_zoom, - 'current_theme': current_theme, - 'theme_class': THEMES[current_theme]['shell'], - 'boardroom_top_phases': boardroom_top_phases, - 'critical_tasks': critical_tasks, - 'milestone_feed': milestone_feed, - 'roadmap_register_source': getattr(self, 'roadmap_register_source', 'django'), - }) - return context - - -class RoadmapTaskUpdateView(TenantAccessMixin, UpdateView): - model = RoadmapTask - form_class = RoadmapTaskUpdateForm - template_name = 'roadmap/task_form.html' - tenant_filter_field = 'phase__plan__tenant' - - def get_success_url(self): - return reverse('roadmap:detail', kwargs={'pk': self.object.phase.plan_id}) - - def form_valid(self, form): - rust_result = RoadmapRegisterBridge.update_task( - self.request, - self.get_tenant(), - self.object.pk, - form.cleaned_data, - ) - if rust_result is not None: - self.object.status = rust_result.task.status - self.object.planned_start = rust_result.task.planned_start - self.object.due_date = rust_result.task.due_date - self.object.owner_role = rust_result.task.owner_role - self.object.notes = rust_result.task.notes - return HttpResponseRedirect(reverse('roadmap:detail', kwargs={'pk': rust_result.plan_id})) - return super().form_valid(form) - - -class RoadmapKanbanView(TenantAccessMixin, TemplateView): - template_name = 'roadmap/kanban.html' - tenant_filter_field = 'tenant' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - rust_detail = RoadmapRegisterBridge.fetch_detail(self.request, self.get_tenant(), self.kwargs['pk']) - if rust_detail is not None: - self.roadmap_register_source = 'rust_service' - plan = rust_detail.plan - self.check_tenant_access(plan) - tasks = sorted(rust_detail.tasks, key=lambda task: (task.due_date or date.max, task.priority or '', task.id)) - else: - self.roadmap_register_source = 'django' - plan = get_object_or_404(self.filter_queryset_for_tenant(RoadmapPlan.objects.all()), pk=self.kwargs['pk']) - tasks = list( - RoadmapTask.objects.filter(phase__plan=plan) - .select_related('phase') - .prefetch_related('incoming_dependencies__predecessor') - .order_by('due_date', 'priority') - ) - grouped = OrderedDict((choice, []) for choice in RoadmapTask.Status.choices) - for task in tasks: - grouped[(task.status, task.get_status_display())].append(task) - context['plan'] = plan - context['kanban_columns'] = grouped - context['roadmap_register_source'] = getattr(self, 'roadmap_register_source', 'django') - return context - - -class RoadmapPdfView(TenantAccessMixin, View): - tenant_filter_field = 'tenant' - - def get(self, request, pk): - rust_detail = RoadmapRegisterBridge.fetch_detail(request, self.get_tenant(), pk) - if rust_detail is not None: - plan = rust_detail.plan - self.check_tenant_access(plan) - phases = list(plan.phases.all()) - export_tasks = sorted(rust_detail.tasks, key=lambda task: (task.priority or '', task.due_date or date.max, task.id)) - else: - plan = get_object_or_404(self.filter_queryset_for_tenant(RoadmapPlan.objects.prefetch_related('phases__tasks')), pk=pk) - phases = list(plan.phases.all()) - export_tasks = list( - RoadmapTask.objects.filter(phase__plan=plan) - .prefetch_related('incoming_dependencies__predecessor') - .order_by('priority', 'due_date')[:14] - ) - buffer = io.BytesIO() - pdf = canvas.Canvas(buffer, pagesize=landscape(A4)) - width, height = landscape(A4) - margin = 30 - pdf.setTitle(f'Roadmap-{plan.pk}') - pdf.setFont('Helvetica-Bold', 18) - pdf.drawString(margin, height - 30, plan.title) - pdf.setFont('Helvetica', 9) - pdf.drawString(margin, height - 45, plan.summary[:140]) - - min_start, max_end, total_days = _timeline_bounds(plan, phases) - - top = height - 95 - left = margin + 145 - right = width - margin - track_width = right - left - lane_height = 34 - header_y = top + 20 - - pdf.setFont('Helvetica-Bold', 10) - pdf.drawString(margin, header_y, 'Phase') - for i in range(0, 9): - x = left + (track_width * i / 8) - pdf.setStrokeColor(colors.HexColor('#d7e3f4')) - pdf.line(x, top + 10, x, top - (lane_height * len(phases)) - 10) - if i < 8: - pdf.setFillColor(colors.HexColor('#4f6b8a')) - pdf.setFont('Helvetica', 7) - label_date = min_start if i == 0 else min_start.fromordinal(min_start.toordinal() + int(total_days * i / 8)) - pdf.drawString(x + 2, top + 14, label_date.strftime('%d.%m.%y')) - - for idx, phase in enumerate(phases): - row_y = top - idx * lane_height - color = colors.HexColor(PHASE_COLORS[idx % len(PHASE_COLORS)]) - start = _safe_date(phase.planned_start, min_start) - end = _safe_date(phase.planned_end, start) - offset = ((start - min_start).days / total_days) * track_width - width_bar = max((((end - start).days + 1) / total_days) * track_width, 84) - pdf.setFillColor(colors.black) - pdf.setFont('Helvetica-Bold', 8) - pdf.drawString(margin, row_y + 6, phase.name[:26]) - pdf.setFont('Helvetica', 7) - pdf.drawString(margin, row_y - 3, f'{phase.duration_weeks} Wochen') - pdf.setFillColor(colors.HexColor('#edf3fb')) - pdf.roundRect(left, row_y - 10, track_width, 20, 6, fill=1, stroke=0) - pdf.setFillColor(color) - pdf.roundRect(left + offset, row_y - 10, width_bar, 20, 6, fill=1, stroke=0) - pdf.setFillColor(colors.white) - pdf.setFont('Helvetica-Bold', 8) - pdf.drawString(left + offset + 6, row_y + 2, phase.name[:35]) - pdf.setFillColor(colors.HexColor('#dc3545')) - for task in list(phase.tasks.all())[:3]: - if not task.due_date: - continue - mx = left + (((task.due_date - min_start).days / total_days) * track_width) - pdf.circle(mx, row_y, 3, fill=1, stroke=0) - - text_y = top - (lane_height * len(phases)) - 35 - pdf.setFillColor(colors.black) - pdf.setFont('Helvetica-Bold', 11) - pdf.drawString(margin, text_y, 'Priorisierte Aufgaben und Abhängigkeiten') - pdf.setFont('Helvetica', 8) - text_y -= 14 - for task in export_tasks[:14]: - deps = ', '.join(dep.predecessor.title[:18] for dep in task.incoming_dependencies.all()[:2]) or '-' - line = f'• {task.title[:45]} | {task.priority or "-"} | fällig: {task.due_date.strftime("%d.%m.%Y") if task.due_date else "-"} | abhängig von: {deps}' - pdf.drawString(margin, text_y, line[:150]) - text_y -= 12 - if text_y < 40: - pdf.showPage() - text_y = height - 40 - pdf.setFont('Helvetica', 8) - - pdf.showPage() - pdf.save() - response = HttpResponse(buffer.getvalue(), content_type='application/pdf') - response['Content-Disposition'] = f'attachment; filename="roadmap-{plan.pk}.pdf"' - return response - - -class RoadmapPngView(TenantAccessMixin, View): - tenant_filter_field = 'tenant' - - def get(self, request, pk): - rust_detail = RoadmapRegisterBridge.fetch_detail(request, self.get_tenant(), pk) - if rust_detail is not None: - plan = rust_detail.plan - self.check_tenant_access(plan) - phases = list(plan.phases.all()) - else: - plan = get_object_or_404(self.filter_queryset_for_tenant(RoadmapPlan.objects.prefetch_related('phases__tasks')), pk=pk) - phases = list(plan.phases.all()) - min_start, max_end, total_days = _timeline_bounds(plan, phases) - width, height = 2000, 1080 - image = Image.new('RGB', (width, height), '#f0f4fa') - draw = ImageDraw.Draw(image) - margin = 56 - left = 400 - right = width - margin - top = 170 - track_width = right - left - lane_height = 108 - - # Subtle background gradient effect - for y in range(height): - r = int(240 + (248 - 240) * y / height) - g = int(244 + (250 - 244) * y / height) - b = int(250 + (252 - 250) * y / height) - draw.line([(0, y), (width, y)], fill=(r, g, b)) - - # Title area - draw.rounded_rectangle((margin - 10, 28, width - margin + 10, 130), radius=28, fill='#ffffff', outline='#dbe4f0') - draw.text((margin + 16, 44), plan.title, fill='#0f172a') - draw.text((margin + 16, 86), f'{plan.tenant.name} · {plan.summary[:100]}', fill='#64748b') - - # Month header bubbles - months = _build_segments(min_start, max_end, total_days, 'month') - x = left - for segment in months: - seg_w = int(track_width * (segment['width'] / 100)) - draw.rounded_rectangle((x, top - 52, x + seg_w - 4, top - 10), radius=16, fill='#ffffff', outline='#e2e8f0') - draw.text((x + 14, top - 40), segment['label'], fill='#475569') - x += seg_w - - for idx, phase in enumerate(phases): - y = top + idx * lane_height - color = PHASE_COLORS[idx % len(PHASE_COLORS)] - start = _safe_date(phase.planned_start, min_start) - end = _safe_date(phase.planned_end, start) - offset = ((start - min_start).days / total_days) * track_width - bar_w = max((((end - start).days + 1) / total_days) * track_width, 140) - - # Lane card - draw.rounded_rectangle((margin, y, right + 10, y + 88), radius=24, fill='#ffffff', outline='#e2e8f0') - - # Accent stripe - draw.rounded_rectangle((margin + 14, y + 14, margin + 21, y + 74), radius=5, fill=color) - - # Phase label - draw.text((margin + 36, y + 16), f'Phase {idx + 1}', fill='#94a3b8') - draw.text((margin + 36, y + 40), phase.name[:28], fill='#0f172a') - draw.text((margin + 36, y + 62), f'{phase.duration_weeks}W · {phase.tasks.count()} Tasks', fill='#94a3b8') - - # Track background - draw.rounded_rectangle((left, y + 20, right, y + 68), radius=18, fill='#f1f5f9', outline='#e2e8f0') - - # Phase bar - bar_end = min(left + offset + bar_w, right) - draw.rounded_rectangle((left + offset, y + 20, bar_end, y + 68), radius=18, fill=color) - - # Bar text - draw.text((left + offset + 20, y + 36), phase.name[:32], fill='#ffffff') - - # Milestone flag markers (not red dots) - for task in list(phase.tasks.all())[:4]: - if task.due_date: - mx = left + (((task.due_date - min_start).days / total_days) * track_width) - # Flag triangle - draw.polygon([(mx, y + 16), (mx + 12, y + 10), (mx + 12, y + 22)], fill=color, outline='#ffffff') - # Stem - draw.line([(mx, y + 22), (mx, y + 32)], fill='#94a3b8', width=2) - - out = io.BytesIO() - image.save(out, format='PNG', quality=95) - response = HttpResponse(out.getvalue(), content_type='image/png') - response['Content-Disposition'] = f'attachment; filename="roadmap-{plan.pk}.png"' - return response diff --git a/apps/vulnerability_intelligence/__init__.py b/apps/vulnerability_intelligence/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/vulnerability_intelligence/admin.py b/apps/vulnerability_intelligence/admin.py deleted file mode 100644 index 3e0850b..0000000 --- a/apps/vulnerability_intelligence/admin.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.contrib import admin - -from .models import CVEAssessment, CVERecord - - -@admin.register(CVERecord) -class CVERecordAdmin(admin.ModelAdmin): - list_display = ('cve_id', 'severity', 'cvss_score', 'epss_score', 'in_kev_catalog', 'published_at', 'modified_at') - search_fields = ('cve_id', 'description') - list_filter = ('severity', 'source', 'in_kev_catalog') - - -@admin.register(CVEAssessment) -class CVEAssessmentAdmin(admin.ModelAdmin): - list_display = ('cve', 'tenant', 'product', 'deterministic_priority', 'in_kev_catalog', 'nis2_relevant', 'llm_status', 'related_risk') - search_fields = ('cve__cve_id', 'product__name', 'business_context') - list_filter = ('deterministic_priority', 'llm_status', 'exposure', 'asset_criticality', 'in_kev_catalog', 'nis2_relevant', 'exploit_maturity') diff --git a/apps/vulnerability_intelligence/apps.py b/apps/vulnerability_intelligence/apps.py deleted file mode 100644 index 00cbd9b..0000000 --- a/apps/vulnerability_intelligence/apps.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.apps import AppConfig - - -class VulnerabilityIntelligenceConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.vulnerability_intelligence' - verbose_name = 'Vulnerability Intelligence' diff --git a/apps/vulnerability_intelligence/forms.py b/apps/vulnerability_intelligence/forms.py deleted file mode 100644 index 748af8b..0000000 --- a/apps/vulnerability_intelligence/forms.py +++ /dev/null @@ -1,71 +0,0 @@ -from django import forms - -from apps.product_security.models import Component, Product, ProductRelease -from .models import CVEAssessment - - -AUTO_BOOL_CHOICES = ( - ('', 'Automatisch / aus Feed oder Tenant ableiten'), - ('true', 'Ja'), - ('false', 'Nein'), -) - - -class CVELookupForm(forms.Form): - cve_id = forms.CharField(label='CVE-ID', max_length=32, help_text='Beispiel: CVE-2025-12345') - product = forms.ModelChoiceField(queryset=Product.objects.none(), required=False, label='Produkt') - release = forms.ModelChoiceField(queryset=ProductRelease.objects.none(), required=False, label='Release') - component = forms.ModelChoiceField(queryset=Component.objects.none(), required=False, label='Komponente') - exposure = forms.ChoiceField(choices=CVEAssessment.Exposure.choices, initial=CVEAssessment.Exposure.UNKNOWN, label='Exponierung') - asset_criticality = forms.ChoiceField(choices=CVEAssessment.AssetCriticality.choices, initial=CVEAssessment.AssetCriticality.MEDIUM, label='Geschäftskritikalität') - epss_score = forms.DecimalField(required=False, max_digits=5, decimal_places=4, min_value=0, max_value=1, label='EPSS-Score', help_text='Optionaler EPSS-Wert zwischen 0 und 1.') - in_kev_catalog = forms.TypedChoiceField( - required=False, - choices=AUTO_BOOL_CHOICES, - coerce=lambda value: {'true': True, 'false': False}.get(value, None), - empty_value=None, - label='In CISA KEV / Known Exploited gelistet', - ) - exploit_maturity = forms.ChoiceField(choices=CVEAssessment.ExploitMaturity.choices, initial=CVEAssessment.ExploitMaturity.UNKNOWN, label='Exploit-Reife') - affects_critical_service = forms.BooleanField(required=False, label='Beeinflusst kritischen Dienst / wesentliche Leistung') - nis2_relevant = forms.TypedChoiceField( - required=False, - choices=AUTO_BOOL_CHOICES, - coerce=lambda value: {'true': True, 'false': False}.get(value, None), - empty_value=None, - label='NIS2-relevanter Fall', - ) - nis2_impact_summary = forms.CharField(widget=forms.Textarea(attrs={'rows': 2}), required=False, label='NIS2-Auswirkung') - repository_name = forms.CharField(required=False, max_length=255, label='Repository') - repository_url = forms.URLField(required=False, label='Repository-URL') - git_ref = forms.CharField(required=False, max_length=128, label='Git-Ref / Branch / Tag / Commit') - source_package = forms.CharField(required=False, max_length=255, label='Paket / Artefakt') - source_package_version = forms.CharField(required=False, max_length=128, label='Paket-Version') - regulatory_tags = forms.CharField(required=False, label='Regulatorische Tags', help_text='Kommagetrennt, z. B. NIS2, CRA, ISO27001') - business_context = forms.CharField(widget=forms.Textarea(attrs={'rows': 3}), required=False, label='Geschäftskontext') - existing_controls = forms.CharField(widget=forms.Textarea(attrs={'rows': 3}), required=False, label='Vorhandene Controls') - auto_create_risk = forms.BooleanField(required=False, initial=True, label='Initiales Risiko automatisch erzeugen/aktualisieren') - run_llm = forms.BooleanField(required=False, initial=True, label='Lokale LLM-Analyse ausführen (Rust-Service)') - - def __init__(self, *args, **kwargs): - tenant = kwargs.pop('tenant', None) - super().__init__(*args, **kwargs) - if tenant is not None: - self.fields['product'].queryset = Product.objects.for_tenant(tenant).order_by('name') - self.fields['release'].queryset = ProductRelease.objects.for_tenant(tenant).select_related('product').order_by('product__name', 'version') - self.fields['component'].queryset = Component.objects.for_tenant(tenant).select_related('product').order_by('product__name', 'name') - self.fields['nis2_relevant'].initial = 'true' if getattr(tenant, 'nis2_relevant', False) else '' - - def clean_regulatory_tags(self): - raw = self.cleaned_data.get('regulatory_tags', '') - return [tag.strip().upper() for tag in raw.split(',') if tag.strip()] - - -class LLMRuntimeTestForm(forms.Form): - prompt = forms.CharField( - label='Test-Prompt', - required=False, - widget=forms.Textarea(attrs={'rows': 5}), - initial='Return exactly this JSON: {"status":"ok","message":"local-llm-ready"}', - help_text='Kleiner technischer Test für die konfigurierte lokale LLM-Runtime.', - ) diff --git a/apps/vulnerability_intelligence/management/__init__.py b/apps/vulnerability_intelligence/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/vulnerability_intelligence/management/commands/__init__.py b/apps/vulnerability_intelligence/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/vulnerability_intelligence/management/commands/_rust_canary.py b/apps/vulnerability_intelligence/management/commands/_rust_canary.py deleted file mode 100644 index d9c4619..0000000 --- a/apps/vulnerability_intelligence/management/commands/_rust_canary.py +++ /dev/null @@ -1,34 +0,0 @@ -import shlex -import shutil -import subprocess - -from django.core.management.base import CommandError - - -def run_rust_canary(*, subcommand: str, args: list[str], cwd: str = '.') -> str: - """Run Rust canary CLI and return stdout for backward-compatible Django wrappers.""" - if shutil.which('iscy-canary'): - cmd = ['iscy-canary', subcommand, *args] - else: - cmd = [ - 'cargo', - 'run', - '--manifest-path', - 'rust/iscy-backend/Cargo.toml', - '--bin', - 'iscy-canary', - '--', - subcommand, - *args, - ] - - result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) - if result.returncode != 0: - stderr = (result.stderr or '').strip() - stdout = (result.stdout or '').strip() - detail = stderr or stdout or f'Exit-Code {result.returncode}' - raise CommandError( - f"Rust-Canary-Kommando fehlgeschlagen: {shlex.join(cmd)}\n{detail}" - ) - - return (result.stdout or '').strip() diff --git a/apps/vulnerability_intelligence/management/commands/check_local_llm.py b/apps/vulnerability_intelligence/management/commands/check_local_llm.py deleted file mode 100644 index 512f87a..0000000 --- a/apps/vulnerability_intelligence/management/commands/check_local_llm.py +++ /dev/null @@ -1,42 +0,0 @@ -from django.conf import settings -from django.core.management.base import BaseCommand, CommandError - -from apps.vulnerability_intelligence.services import LocalLLMUnavailable, get_local_llm_provider, get_local_llm_runtime_info - - -class Command(BaseCommand): - help = "Checks whether the configured local LLM backend can be loaded and optionally performs a tiny generation." - - def add_arguments(self, parser): - parser.add_argument('--generate', action='store_true', help='Run a tiny test generation') - - def handle(self, *args, **options): - try: - runtime = get_local_llm_runtime_info() - except LocalLLMUnavailable as exc: - raise CommandError(str(exc)) from exc - if not getattr(settings, 'LOCAL_LLM_ENABLED', False): - raise CommandError('LOCAL_LLM_ENABLED ist nicht aktiv.') - if not runtime.model_exists: - raise CommandError(f'LLM-Modell fehlt: {runtime.model_path}') - if not runtime.import_ok and runtime.error: - raise CommandError(f'LLM-Backend ist nicht nutzbar: {runtime.error}') - - try: - provider = get_local_llm_provider() - except LocalLLMUnavailable as exc: - raise CommandError(str(exc)) from exc - except Exception as exc: - raise CommandError(f'Lokales LLM konnte nicht geladen werden: {exc}') from exc - - self.stdout.write(self.style.SUCCESS( - f"LLM geladen: {runtime.model_name} @ {runtime.model_path} | n_ctx={runtime.n_ctx}, threads={runtime.n_threads}, gpu_layers={runtime.n_gpu_layers}" - )) - self.stdout.write(runtime.note) - - if options['generate']: - try: - result = provider.runtime_test() - self.stdout.write(self.style.SUCCESS(f"Generierung erfolgreich: {result['raw_text'][:160]}")) - except Exception as exc: - raise CommandError(f'LLM geladen, aber Generierung fehlgeschlagen: {exc}') from exc diff --git a/apps/vulnerability_intelligence/management/commands/import_cve_context_csv.py b/apps/vulnerability_intelligence/management/commands/import_cve_context_csv.py deleted file mode 100644 index 6b86219..0000000 --- a/apps/vulnerability_intelligence/management/commands/import_cve_context_csv.py +++ /dev/null @@ -1,137 +0,0 @@ -import csv - -from django.core.management.base import BaseCommand, CommandError - -from apps.organizations.models import Tenant -from apps.product_security.models import Component, Product, ProductRelease -from apps.vulnerability_intelligence.models import CVEAssessment -from apps.vulnerability_intelligence.services import CVERiskEnrichmentService - - -def _parse_bool(value, default=False): - if value is None or str(value).strip() == '': - return default - return str(value).strip().lower() in {'1', 'true', 'yes', 'ja', 'y'} - - -class Command(BaseCommand): - help = 'Imports tenant-specific CVE assessment context from CSV, suitable for Git/SBOM/scanner pipeline outputs.' - - def add_arguments(self, parser): - parser.add_argument('tenant_slug', help='Tenant slug') - parser.add_argument('file', help='Path to the CSV file') - parser.add_argument('--user-id', type=int, required=True, help='User ID for audit logging') - - def handle(self, *args, **options): - try: - tenant = Tenant.objects.get(slug=options['tenant_slug']) - except Tenant.DoesNotExist as exc: - raise CommandError(f"Tenant nicht gefunden: {options['tenant_slug']}") from exc - - user_model = Tenant._meta.apps.get_model('accounts', 'User') - try: - user = user_model.objects.get(pk=options['user_id']) - except user_model.DoesNotExist as exc: - raise CommandError(f"User nicht gefunden: {options['user_id']}") from exc - - created = 0 - updated = 0 - try: - with open(options['file'], 'r', encoding='utf-8-sig', newline='') as handle: - reader = csv.DictReader(handle) - for row in reader: - cve_id = str(row.get('cve_id') or row.get('cve') or '').strip().upper() - if not cve_id: - continue - product = self._resolve_product(tenant, row.get('product')) - release = self._resolve_release(tenant, product, row.get('release')) - component = self._resolve_component(tenant, product, row.get('component')) - assessment, was_created = self._upsert_assessment( - tenant=tenant, - user=user, - cve_id=cve_id, - product=product, - release=release, - component=component, - row=row, - ) - created += int(was_created) - updated += int(not was_created) - except FileNotFoundError as exc: - raise CommandError(f"Datei nicht gefunden: {options['file']}") from exc - except Exception as exc: - raise CommandError(f'CSV-Import fehlgeschlagen: {exc}') from exc - - self.stdout.write(self.style.SUCCESS( - f'CSV-Kontext importiert. Neu: {created} | aktualisiert: {updated}' - )) - - def _upsert_assessment(self, *, tenant, user, cve_id, product, release, component, row): - existing = CVEAssessment.objects.filter( - tenant=tenant, - cve__cve_id=cve_id, - product=product, - release=release, - component=component, - ).first() - assessment = CVERiskEnrichmentService.create_or_update_assessment( - tenant=tenant, - user=user, - cve_id=cve_id, - product=product, - release=release, - component=component, - exposure=(row.get('exposure') or CVEAssessment.Exposure.UNKNOWN).strip().upper(), - asset_criticality=(row.get('asset_criticality') or CVEAssessment.AssetCriticality.MEDIUM).strip().upper(), - epss_score=(row.get('epss_score') or None), - in_kev_catalog=self._optional_bool(row.get('in_kev_catalog')), - exploit_maturity=(row.get('exploit_maturity') or CVEAssessment.ExploitMaturity.UNKNOWN).strip().upper(), - affects_critical_service=_parse_bool(row.get('affects_critical_service')), - nis2_relevant=self._optional_bool(row.get('nis2_relevant')), - nis2_impact_summary=(row.get('nis2_impact_summary') or '').strip(), - repository_name=(row.get('repository_name') or '').strip(), - repository_url=(row.get('repository_url') or '').strip(), - git_ref=(row.get('git_ref') or '').strip(), - source_package=(row.get('source_package') or '').strip(), - source_package_version=(row.get('source_package_version') or '').strip(), - regulatory_tags=self._split_tags(row.get('regulatory_tags')), - business_context=(row.get('business_context') or '').strip(), - existing_controls=(row.get('existing_controls') or '').strip(), - auto_create_risk=_parse_bool(row.get('auto_create_risk'), default=True), - run_llm=_parse_bool(row.get('run_llm')), - ) - return assessment, existing is None - - def _resolve_product(self, tenant, name): - value = (name or '').strip() - if not value: - return None - return Product.objects.for_tenant(tenant).filter(name=value).first() - - def _resolve_release(self, tenant, product, version): - value = (version or '').strip() - if not value: - return None - queryset = ProductRelease.objects.for_tenant(tenant) - if product is not None: - queryset = queryset.filter(product=product) - return queryset.filter(version=value).first() - - def _resolve_component(self, tenant, product, name): - value = (name or '').strip() - if not value: - return None - queryset = Component.objects.for_tenant(tenant) - if product is not None: - queryset = queryset.filter(product=product) - return queryset.filter(name=value).first() - - def _optional_bool(self, value): - if value is None or str(value).strip() == '': - return None - return _parse_bool(value) - - def _split_tags(self, value): - if not value: - return [] - return [tag.strip().upper() for tag in str(value).split(',') if tag.strip()] diff --git a/apps/vulnerability_intelligence/management/commands/import_epss_feed.py b/apps/vulnerability_intelligence/management/commands/import_epss_feed.py deleted file mode 100644 index 715a120..0000000 --- a/apps/vulnerability_intelligence/management/commands/import_epss_feed.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.core.management.base import BaseCommand, CommandError - -from apps.vulnerability_intelligence.services import LocalIntelligenceFeedService - - -class Command(BaseCommand): - help = 'Imports a local EPSS CSV feed and propagates EPSS-based rescoring to existing CVE assessments.' - - def add_arguments(self, parser): - parser.add_argument('file', help='Path to the EPSS CSV file') - - def handle(self, *args, **options): - file_path = options['file'] - try: - result = LocalIntelligenceFeedService.import_epss(file_path) - except FileNotFoundError as exc: - raise CommandError(f'Datei nicht gefunden: {file_path}') from exc - except Exception as exc: - raise CommandError(f'EPSS-Import fehlgeschlagen: {exc}') from exc - - self.stdout.write(self.style.SUCCESS( - f"EPSS importiert. Aktualisierte CVEs: {result['updated_records']} | berührte CVE-IDs: {result['touched_cves']}" - )) diff --git a/apps/vulnerability_intelligence/management/commands/import_kev_catalog.py b/apps/vulnerability_intelligence/management/commands/import_kev_catalog.py deleted file mode 100644 index 41670f5..0000000 --- a/apps/vulnerability_intelligence/management/commands/import_kev_catalog.py +++ /dev/null @@ -1,33 +0,0 @@ -from django.core.management.base import BaseCommand, CommandError - -from apps.vulnerability_intelligence.services import LocalIntelligenceFeedService - - -class Command(BaseCommand): - help = 'Imports a local CISA KEV JSON catalog and propagates KEV-based rescoring to existing CVE assessments.' - - def add_arguments(self, parser): - parser.add_argument('file', help='Path to the KEV JSON file') - parser.add_argument( - '--reset-missing', - action='store_true', - help='Mark previously imported KEV CVEs as no longer in KEV when they are missing from the current file.', - ) - - def handle(self, *args, **options): - file_path = options['file'] - try: - result = LocalIntelligenceFeedService.import_kev( - file_path, - reset_missing=options['reset_missing'], - ) - except FileNotFoundError as exc: - raise CommandError(f'Datei nicht gefunden: {file_path}') from exc - except Exception as exc: - raise CommandError(f'KEV-Import fehlgeschlagen: {exc}') from exc - - self.stdout.write(self.style.SUCCESS( - f"KEV importiert. Aktualisierte CVEs: {result['updated_records']} | berührte CVE-IDs: {result['touched_cves']}" - )) - if result['reset_missing']: - self.stdout.write('Nicht mehr enthaltene KEV-Einträge wurden zurückgesetzt.') diff --git a/apps/vulnerability_intelligence/management/commands/import_nvd_cves.py b/apps/vulnerability_intelligence/management/commands/import_nvd_cves.py deleted file mode 100644 index 8e7e8d7..0000000 --- a/apps/vulnerability_intelligence/management/commands/import_nvd_cves.py +++ /dev/null @@ -1,38 +0,0 @@ -from django.core.management.base import BaseCommand - -from ._rust_canary import run_rust_canary - - -class Command(BaseCommand): - help = 'Imports CVE collections from NVD API v2.0 via Rust canary CLI.' - - def add_arguments(self, parser): - parser.add_argument('--cve-tag', default='', help='Optional NVD cveTag filter (e.g. disputed)') - parser.add_argument('--cpe-name', default='', help='Optional cpeName filter') - parser.add_argument('--has-kev', action='store_true', help='Import only NVD entries with KEV flag') - parser.add_argument('--last-mod-start-date', default='', help='Optional NVD lastModStartDate (ISO-8601)') - parser.add_argument('--last-mod-end-date', default='', help='Optional NVD lastModEndDate (ISO-8601)') - parser.add_argument('--results-per-page', type=int, default=2000, help='NVD page size') - parser.add_argument('--max-pages', type=int, default=1, help='How many pages should be pulled') - parser.add_argument('--skip-healthcheck', action='store_true', help='Skip Rust backend health check') - - def handle(self, *args, **options): - cmd_args = [ - '--results-per-page', str(max(1, options['results_per_page'])), - '--max-pages', str(max(1, options['max_pages'])), - ] - if options['cve_tag']: - cmd_args.extend(['--cve-tag', options['cve_tag']]) - if options['cpe_name']: - cmd_args.extend(['--cpe-name', options['cpe_name']]) - if options['has_kev']: - cmd_args.append('--has-kev') - if options['last_mod_start_date']: - cmd_args.extend(['--last-mod-start-date', options['last_mod_start_date']]) - if options['last_mod_end_date']: - cmd_args.extend(['--last-mod-end-date', options['last_mod_end_date']]) - if options['skip_healthcheck']: - cmd_args.append('--skip-healthcheck') - - output = run_rust_canary(subcommand='import-collection', args=cmd_args) - self.stdout.write(self.style.SUCCESS(f'NVD-CVE-Import (Rust) abgeschlossen: {output}')) diff --git a/apps/vulnerability_intelligence/management/commands/import_nvd_cves_canary.py b/apps/vulnerability_intelligence/management/commands/import_nvd_cves_canary.py deleted file mode 100644 index 89bc4ae..0000000 --- a/apps/vulnerability_intelligence/management/commands/import_nvd_cves_canary.py +++ /dev/null @@ -1,18 +0,0 @@ -from django.core.management.base import BaseCommand - -from ._rust_canary import run_rust_canary - - -class Command(BaseCommand): - help = 'Rust-only import mode for explicit CVE IDs (compatibility wrapper).' - - def add_arguments(self, parser): - parser.add_argument('cve_ids', nargs='+', help='CVE IDs, e.g. CVE-2026-1234') - parser.add_argument('--skip-healthcheck', action='store_true', help='Skip Rust backend health check') - - def handle(self, *args, **options): - cmd_args = list(options['cve_ids']) - if options['skip_healthcheck']: - cmd_args.append('--skip-healthcheck') - output = run_rust_canary(subcommand='import', args=cmd_args) - self.stdout.write(self.style.SUCCESS(f'Rust-Import (Canary Wrapper) abgeschlossen: {output}')) diff --git a/apps/vulnerability_intelligence/management/commands/import_nvd_cves_via_rust.py b/apps/vulnerability_intelligence/management/commands/import_nvd_cves_via_rust.py deleted file mode 100644 index 63d2fef..0000000 --- a/apps/vulnerability_intelligence/management/commands/import_nvd_cves_via_rust.py +++ /dev/null @@ -1,18 +0,0 @@ -from django.core.management.base import BaseCommand - -from ._rust_canary import run_rust_canary - - -class Command(BaseCommand): - help = 'Imports explicit CVE IDs via Rust canary CLI (compatibility wrapper).' - - def add_arguments(self, parser): - parser.add_argument('cve_ids', nargs='+', help='CVE IDs, e.g. CVE-2026-1234') - parser.add_argument('--skip-healthcheck', action='store_true', help='Skip pre-flight health check against Rust backend') - - def handle(self, *args, **options): - cmd_args = list(options['cve_ids']) - if options['skip_healthcheck']: - cmd_args.append('--skip-healthcheck') - output = run_rust_canary(subcommand='import', args=cmd_args) - self.stdout.write(self.style.SUCCESS(f'Rust-Bridge Import abgeschlossen: {output}')) diff --git a/apps/vulnerability_intelligence/management/commands/report_nvd_canary_parity.py b/apps/vulnerability_intelligence/management/commands/report_nvd_canary_parity.py deleted file mode 100644 index 6e5335c..0000000 --- a/apps/vulnerability_intelligence/management/commands/report_nvd_canary_parity.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.core.management.base import BaseCommand - -from ._rust_canary import run_rust_canary - - -class Command(BaseCommand): - help = 'Generates JSON/CSV parity report using Rust canary CLI (compatibility wrapper).' - - def add_arguments(self, parser): - parser.add_argument('cve_ids', nargs='+', help='CVE IDs to compare') - parser.add_argument('--out-dir', default='reports/canary', help='Output directory for reports') - - def handle(self, *args, **options): - cmd_args = ['--out-dir', options['out_dir'], *options['cve_ids']] - output = run_rust_canary(subcommand='parity', args=cmd_args) - self.stdout.write(self.style.SUCCESS(output or 'Canary-Parity-Report erstellt.')) diff --git a/apps/vulnerability_intelligence/management/commands/report_nvd_canary_trend.py b/apps/vulnerability_intelligence/management/commands/report_nvd_canary_trend.py deleted file mode 100644 index 1a3b6e4..0000000 --- a/apps/vulnerability_intelligence/management/commands/report_nvd_canary_trend.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.core.management.base import BaseCommand - -from ._rust_canary import run_rust_canary - - -class Command(BaseCommand): - help = 'Aggregates parity reports and computes trend/gate metrics via Rust canary CLI.' - - def add_arguments(self, parser): - parser.add_argument('--reports-dir', default='reports/canary', help='Directory with nvd_canary_parity_*.json files') - parser.add_argument('--window', type=int, default=30, help='How many latest reports should be analyzed') - parser.add_argument('--max-mismatch-rate', type=float, default=0.5, help='Mismatch-rate threshold in percent') - parser.add_argument('--enforce-gate', action='store_true', help='Fail when last mismatch-rate exceeds threshold') - - def handle(self, *args, **options): - cmd_args = [ - '--reports-dir', options['reports_dir'], - '--window', str(max(1, options['window'])), - '--max-mismatch-rate', str(options['max_mismatch_rate']), - ] - if options['enforce_gate']: - cmd_args.append('--enforce-gate') - output = run_rust_canary(subcommand='trend', args=cmd_args) - self.stdout.write(self.style.SUCCESS(output or 'Canary-Trend geschrieben.')) diff --git a/apps/vulnerability_intelligence/management/commands/sync_nvd_recent.py b/apps/vulnerability_intelligence/management/commands/sync_nvd_recent.py deleted file mode 100644 index c8ed933..0000000 --- a/apps/vulnerability_intelligence/management/commands/sync_nvd_recent.py +++ /dev/null @@ -1,34 +0,0 @@ -from django.core.management.base import BaseCommand - -from ._rust_canary import run_rust_canary - - -class Command(BaseCommand): - help = 'Syncs recently modified CVEs from NVD API via Rust canary CLI (default: last 24 hours).' - - def add_arguments(self, parser): - parser.add_argument('--hours', type=int, default=24, help='Lookback window in hours (default: 24)') - parser.add_argument('--has-kev', action='store_true', help='Only sync CVEs tagged with KEV in NVD') - parser.add_argument('--cve-tag', default='', help='Optional NVD cveTag filter') - parser.add_argument('--cpe-name', default='', help='Optional cpeName filter') - parser.add_argument('--results-per-page', type=int, default=2000, help='NVD page size') - parser.add_argument('--max-pages', type=int, default=2, help='Maximum pages to pull per run') - parser.add_argument('--skip-healthcheck', action='store_true', help='Skip Rust backend health check') - - def handle(self, *args, **options): - cmd_args = [ - '--hours', str(max(1, options['hours'])), - '--results-per-page', str(max(1, options['results_per_page'])), - '--max-pages', str(max(1, options['max_pages'])), - ] - if options['has_kev']: - cmd_args.append('--has-kev') - if options['cve_tag']: - cmd_args.extend(['--cve-tag', options['cve_tag']]) - if options['cpe_name']: - cmd_args.extend(['--cpe-name', options['cpe_name']]) - if options['skip_healthcheck']: - cmd_args.append('--skip-healthcheck') - - output = run_rust_canary(subcommand='sync-recent', args=cmd_args) - self.stdout.write(self.style.SUCCESS(f'NVD-Recent-Sync (Rust) abgeschlossen: {output}')) diff --git a/apps/vulnerability_intelligence/migrations/0001_initial.py b/apps/vulnerability_intelligence/migrations/0001_initial.py deleted file mode 100644 index 19e05f6..0000000 --- a/apps/vulnerability_intelligence/migrations/0001_initial.py +++ /dev/null @@ -1,82 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-09 17:25 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('organizations', '0001_initial'), - ('product_security', '0002_productsecuritysnapshot_critical_vulnerability_count_and_more'), - ('risks', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='CVERecord', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('cve_id', models.CharField(max_length=32, unique=True)), - ('source', models.CharField(default='NVD', max_length=32)), - ('description', models.TextField(blank=True)), - ('cvss_score', models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True)), - ('cvss_vector', models.CharField(blank=True, max_length=255)), - ('severity', models.CharField(choices=[('CRITICAL', 'Kritisch'), ('HIGH', 'Hoch'), ('MEDIUM', 'Mittel'), ('LOW', 'Niedrig'), ('UNKNOWN', 'Unbekannt')], default='UNKNOWN', max_length=16)), - ('weakness_ids_json', models.JSONField(blank=True, default=list)), - ('references_json', models.JSONField(blank=True, default=list)), - ('configurations_json', models.JSONField(blank=True, default=list)), - ('raw_json', models.JSONField(blank=True, default=dict)), - ('published_at', models.DateTimeField(blank=True, null=True)), - ('modified_at', models.DateTimeField(blank=True, null=True)), - ], - options={ - 'ordering': ['-published_at', 'cve_id'], - }, - ), - migrations.CreateModel( - name='CVEAssessment', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('exposure', models.CharField(choices=[('INTERNET', 'Internet-exponiert'), ('INTERNAL', 'Nur intern'), ('CUSTOMER', 'Beim Kunden / ausgeliefert'), ('UNKNOWN', 'Unklar')], default='UNKNOWN', max_length=16)), - ('asset_criticality', models.CharField(choices=[('CRITICAL', 'Kritisch'), ('HIGH', 'Hoch'), ('MEDIUM', 'Mittel'), ('LOW', 'Niedrig')], default='MEDIUM', max_length=16)), - ('business_context', models.TextField(blank=True)), - ('existing_controls', models.TextField(blank=True)), - ('deterministic_priority', models.CharField(default='MEDIUM', max_length=16)), - ('deterministic_due_days', models.PositiveIntegerField(default=30)), - ('llm_backend', models.CharField(default='LLAMA_CPP', max_length=32)), - ('llm_model_name', models.CharField(blank=True, max_length=128)), - ('llm_status', models.CharField(choices=[('DISABLED', 'Nicht aktiviert'), ('PENDING', 'Ausstehend'), ('GENERATED', 'Generiert'), ('REVIEWED', 'Reviewed'), ('FAILED', 'Fehlgeschlagen')], default='DISABLED', max_length=16)), - ('technical_summary', models.TextField(blank=True)), - ('business_impact', models.TextField(blank=True)), - ('attack_path', models.TextField(blank=True)), - ('management_summary', models.TextField(blank=True)), - ('recommended_actions_json', models.JSONField(blank=True, default=list)), - ('evidence_needed_json', models.JSONField(blank=True, default=list)), - ('raw_llm_json', models.JSONField(blank=True, default=dict)), - ('confidence', models.CharField(blank=True, default='medium', max_length=16)), - ('prompt_hash', models.CharField(blank=True, max_length=64)), - ('reviewed_at', models.DateTimeField(blank=True, null=True)), - ('review_notes', models.TextField(blank=True)), - ('component', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cve_assessments', to='product_security.component')), - ('linked_vulnerability', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cve_assessments', to='product_security.vulnerability')), - ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cve_assessments', to='product_security.product')), - ('related_risk', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cve_assessments', to='risks.risk')), - ('release', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cve_assessments', to='product_security.productrelease')), - ('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_cve_assessments', to=settings.AUTH_USER_MODEL)), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)ss', to='organizations.tenant')), - ('cve', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assessments', to='vulnerability_intelligence.cverecord')), - ], - options={ - 'ordering': ['-created_at'], - }, - ), - ] diff --git a/apps/vulnerability_intelligence/migrations/0002_cveassessment_intelligence_context.py b/apps/vulnerability_intelligence/migrations/0002_cveassessment_intelligence_context.py deleted file mode 100644 index 0e6409b..0000000 --- a/apps/vulnerability_intelligence/migrations/0002_cveassessment_intelligence_context.py +++ /dev/null @@ -1,76 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('vulnerability_intelligence', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='cveassessment', - name='affects_critical_service', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='cveassessment', - name='deterministic_factors_json', - field=models.JSONField(blank=True, default=dict), - ), - migrations.AddField( - model_name='cveassessment', - name='epss_score', - field=models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True), - ), - migrations.AddField( - model_name='cveassessment', - name='exploit_maturity', - field=models.CharField(choices=[('UNKNOWN', 'Unbekannt'), ('UNPROVEN', 'Kein bekannter Exploit'), ('POC', 'Proof of Concept'), ('ACTIVE', 'Aktive Ausnutzung'), ('AUTOMATED', 'Automatisierbar / Massenangriff')], default='UNKNOWN', max_length=16), - ), - migrations.AddField( - model_name='cveassessment', - name='git_ref', - field=models.CharField(blank=True, max_length=128), - ), - migrations.AddField( - model_name='cveassessment', - name='in_kev_catalog', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='cveassessment', - name='nis2_impact_summary', - field=models.TextField(blank=True), - ), - migrations.AddField( - model_name='cveassessment', - name='nis2_relevant', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='cveassessment', - name='regulatory_tags_json', - field=models.JSONField(blank=True, default=list), - ), - migrations.AddField( - model_name='cveassessment', - name='repository_name', - field=models.CharField(blank=True, max_length=255), - ), - migrations.AddField( - model_name='cveassessment', - name='repository_url', - field=models.URLField(blank=True), - ), - migrations.AddField( - model_name='cveassessment', - name='source_package', - field=models.CharField(blank=True, max_length=255), - ), - migrations.AddField( - model_name='cveassessment', - name='source_package_version', - field=models.CharField(blank=True, max_length=128), - ), - ] diff --git a/apps/vulnerability_intelligence/migrations/0003_cverecord_intelligence_feed_fields.py b/apps/vulnerability_intelligence/migrations/0003_cverecord_intelligence_feed_fields.py deleted file mode 100644 index 1ec58a8..0000000 --- a/apps/vulnerability_intelligence/migrations/0003_cverecord_intelligence_feed_fields.py +++ /dev/null @@ -1,46 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('vulnerability_intelligence', '0002_cveassessment_intelligence_context'), - ] - - operations = [ - migrations.AddField( - model_name='cverecord', - name='epss_score', - field=models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True), - ), - migrations.AddField( - model_name='cverecord', - name='in_kev_catalog', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='cverecord', - name='kev_date_added', - field=models.DateField(blank=True, null=True), - ), - migrations.AddField( - model_name='cverecord', - name='kev_known_ransomware', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='cverecord', - name='kev_product', - field=models.CharField(blank=True, max_length=255), - ), - migrations.AddField( - model_name='cverecord', - name='kev_required_action', - field=models.TextField(blank=True), - ), - migrations.AddField( - model_name='cverecord', - name='kev_vendor_project', - field=models.CharField(blank=True, max_length=255), - ), - ] diff --git a/apps/vulnerability_intelligence/migrations/__init__.py b/apps/vulnerability_intelligence/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/vulnerability_intelligence/models.py b/apps/vulnerability_intelligence/models.py deleted file mode 100644 index a00411b..0000000 --- a/apps/vulnerability_intelligence/models.py +++ /dev/null @@ -1,123 +0,0 @@ -from django.conf import settings -from django.db import models - -from apps.core.models import TimeStampedModel, TenantModel - - -class CVERecord(TimeStampedModel): - class Severity(models.TextChoices): - CRITICAL = 'CRITICAL', 'Kritisch' - HIGH = 'HIGH', 'Hoch' - MEDIUM = 'MEDIUM', 'Mittel' - LOW = 'LOW', 'Niedrig' - UNKNOWN = 'UNKNOWN', 'Unbekannt' - - cve_id = models.CharField(max_length=32, unique=True) - source = models.CharField(max_length=32, default='NVD') - description = models.TextField(blank=True) - cvss_score = models.DecimalField(max_digits=4, decimal_places=1, null=True, blank=True) - cvss_vector = models.CharField(max_length=255, blank=True) - severity = models.CharField(max_length=16, choices=Severity.choices, default=Severity.UNKNOWN) - weakness_ids_json = models.JSONField(default=list, blank=True) - references_json = models.JSONField(default=list, blank=True) - configurations_json = models.JSONField(default=list, blank=True) - epss_score = models.DecimalField(max_digits=5, decimal_places=4, null=True, blank=True) - in_kev_catalog = models.BooleanField(default=False) - kev_date_added = models.DateField(null=True, blank=True) - kev_vendor_project = models.CharField(max_length=255, blank=True) - kev_product = models.CharField(max_length=255, blank=True) - kev_required_action = models.TextField(blank=True) - kev_known_ransomware = models.BooleanField(default=False) - raw_json = models.JSONField(default=dict, blank=True) - published_at = models.DateTimeField(null=True, blank=True) - modified_at = models.DateTimeField(null=True, blank=True) - - class Meta: - ordering = ['-published_at', 'cve_id'] - - def __str__(self): - return self.cve_id - - -class CVEAssessment(TenantModel): - tenant_relation_fields = { - 'product': 'tenant_id', - 'release': 'tenant_id', - 'component': 'tenant_id', - 'linked_vulnerability': 'tenant_id', - 'related_risk': 'tenant_id', - 'reviewed_by': 'tenant_id', - } - - class Exposure(models.TextChoices): - INTERNET = 'INTERNET', 'Internet-exponiert' - INTERNAL = 'INTERNAL', 'Nur intern' - CUSTOMER = 'CUSTOMER', 'Beim Kunden / ausgeliefert' - UNKNOWN = 'UNKNOWN', 'Unklar' - - class AssetCriticality(models.TextChoices): - CRITICAL = 'CRITICAL', 'Kritisch' - HIGH = 'HIGH', 'Hoch' - MEDIUM = 'MEDIUM', 'Mittel' - LOW = 'LOW', 'Niedrig' - - class LLMStatus(models.TextChoices): - DISABLED = 'DISABLED', 'Nicht aktiviert' - PENDING = 'PENDING', 'Ausstehend' - GENERATED = 'GENERATED', 'Generiert' - REVIEWED = 'REVIEWED', 'Reviewed' - FAILED = 'FAILED', 'Fehlgeschlagen' - - class ExploitMaturity(models.TextChoices): - UNKNOWN = 'UNKNOWN', 'Unbekannt' - UNPROVEN = 'UNPROVEN', 'Kein bekannter Exploit' - POC = 'POC', 'Proof of Concept' - ACTIVE = 'ACTIVE', 'Aktive Ausnutzung' - AUTOMATED = 'AUTOMATED', 'Automatisierbar / Massenangriff' - - cve = models.ForeignKey(CVERecord, on_delete=models.CASCADE, related_name='assessments') - product = models.ForeignKey('product_security.Product', on_delete=models.SET_NULL, null=True, blank=True, related_name='cve_assessments') - release = models.ForeignKey('product_security.ProductRelease', on_delete=models.SET_NULL, null=True, blank=True, related_name='cve_assessments') - component = models.ForeignKey('product_security.Component', on_delete=models.SET_NULL, null=True, blank=True, related_name='cve_assessments') - linked_vulnerability = models.ForeignKey('product_security.Vulnerability', on_delete=models.SET_NULL, null=True, blank=True, related_name='cve_assessments') - related_risk = models.ForeignKey('risks.Risk', on_delete=models.SET_NULL, null=True, blank=True, related_name='cve_assessments') - exposure = models.CharField(max_length=16, choices=Exposure.choices, default=Exposure.UNKNOWN) - asset_criticality = models.CharField(max_length=16, choices=AssetCriticality.choices, default=AssetCriticality.MEDIUM) - epss_score = models.DecimalField(max_digits=5, decimal_places=4, null=True, blank=True) - in_kev_catalog = models.BooleanField(default=False) - exploit_maturity = models.CharField(max_length=16, choices=ExploitMaturity.choices, default=ExploitMaturity.UNKNOWN) - affects_critical_service = models.BooleanField(default=False) - nis2_relevant = models.BooleanField(default=False) - nis2_impact_summary = models.TextField(blank=True) - repository_name = models.CharField(max_length=255, blank=True) - repository_url = models.URLField(blank=True) - git_ref = models.CharField(max_length=128, blank=True) - source_package = models.CharField(max_length=255, blank=True) - source_package_version = models.CharField(max_length=128, blank=True) - regulatory_tags_json = models.JSONField(default=list, blank=True) - deterministic_factors_json = models.JSONField(default=dict, blank=True) - business_context = models.TextField(blank=True) - existing_controls = models.TextField(blank=True) - deterministic_priority = models.CharField(max_length=16, default='MEDIUM') - deterministic_due_days = models.PositiveIntegerField(default=30) - llm_backend = models.CharField(max_length=32, default='LLAMA_CPP') - llm_model_name = models.CharField(max_length=128, blank=True) - llm_status = models.CharField(max_length=16, choices=LLMStatus.choices, default=LLMStatus.DISABLED) - technical_summary = models.TextField(blank=True) - business_impact = models.TextField(blank=True) - attack_path = models.TextField(blank=True) - management_summary = models.TextField(blank=True) - recommended_actions_json = models.JSONField(default=list, blank=True) - evidence_needed_json = models.JSONField(default=list, blank=True) - raw_llm_json = models.JSONField(default=dict, blank=True) - confidence = models.CharField(max_length=16, blank=True, default='medium') - prompt_hash = models.CharField(max_length=64, blank=True) - reviewed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='reviewed_cve_assessments') - reviewed_at = models.DateTimeField(null=True, blank=True) - review_notes = models.TextField(blank=True) - - class Meta: - ordering = ['-created_at'] - - def __str__(self): - return f'{self.cve.cve_id} ({self.tenant})' diff --git a/apps/vulnerability_intelligence/services.py b/apps/vulnerability_intelligence/services.py deleted file mode 100644 index 14e6ace..0000000 --- a/apps/vulnerability_intelligence/services.py +++ /dev/null @@ -1,947 +0,0 @@ -import hashlib -import json -import os -import csv -import time -from dataclasses import dataclass -from decimal import Decimal -from urllib.parse import urlencode -from urllib.error import HTTPError, URLError -from urllib.request import Request, urlopen - -from django.conf import settings -from django.db import transaction -from django.utils import timezone -from django.utils.dateparse import parse_datetime -from django.utils.dateparse import parse_date - -from apps.core.models import AuditLog -from apps.product_security.models import Vulnerability -from apps.risks.models import Risk - -from .models import CVEAssessment, CVERecord - - -@dataclass -class LLMEnrichmentResult: - technical_summary: str - business_impact: str - attack_path: str - management_summary: str - recommended_actions: list - evidence_needed: list - confidence: str - - -@dataclass -class LLMRuntimeInfo: - enabled: bool - backend: str - model_name: str - model_path: str - model_exists: bool - import_ok: bool - runtime_ok: bool - n_ctx: int - n_threads: int - n_gpu_layers: int - verbose_native: bool - note: str - error: str = '' - - -def _parse_nvd_datetime(value: str): - if not value: - return None - dt = parse_datetime(value) - if dt is None: - return None - if timezone.is_naive(dt): - return timezone.make_aware(dt, timezone.get_current_timezone()) - return dt - - -class NVDService: - @staticmethod - def normalize_cve_id_local(cve_id: str) -> str: - return str(cve_id or '').strip().upper() - - @staticmethod - def normalize_cve_id(cve_id: str) -> str: - normalized_local = NVDService.normalize_cve_id_local(cve_id) - if not normalized_local: - raise ValueError('Leere CVE-ID nach Normalisierung.') - - rust_only = bool(getattr(settings, 'VULN_INTEL_RUST_ONLY', True)) - if rust_only: - if not RustBackendClient.is_configured(): - raise RuntimeError('VULN_INTEL_RUST_ONLY ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return RustBackendClient.normalize_cve_id(normalized_local) - - if RustBackendClient.is_configured(): - return RustBackendClient.normalize_cve_id(normalized_local) - return normalized_local - - @staticmethod - def _request_payload(query_params: dict, *, max_retries=2, retry_sleep_seconds=1.5): - api_key = getattr(settings, 'NVD_API_KEY', '') - query = urlencode({k: v for k, v in query_params.items() if v not in (None, '')}) - request = Request(f'https://services.nvd.nist.gov/rest/json/cves/2.0?{query}') - request.add_header('Accept', 'application/json') - if api_key: - request.add_header('apiKey', api_key) - attempt = 0 - while True: - try: - with urlopen(request, timeout=30) as response: - return json.loads(response.read().decode('utf-8')) - except HTTPError as exc: - if exc.code in {429, 500, 502, 503, 504} and attempt < max_retries: - attempt += 1 - time.sleep(retry_sleep_seconds * attempt) - continue - raise - except URLError: - if attempt < max_retries: - attempt += 1 - time.sleep(retry_sleep_seconds * attempt) - continue - raise - - @staticmethod - def fetch_cve(cve_id: str): - payload = NVDService._request_payload({'cveId': NVDService.normalize_cve_id_local(cve_id)}) - vulnerabilities = payload.get('vulnerabilities') or [] - if not vulnerabilities: - raise ValueError(f'Keine CVE-Daten für {cve_id} gefunden') - return vulnerabilities[0].get('cve', {}), payload - - @staticmethod - def upsert_from_payload(cve: dict, payload: dict, fallback_cve_id: str = ''): - metrics = cve.get('metrics', {}) - score = None - vector = '' - severity = CVERecord.Severity.UNKNOWN - for key in ('cvssMetricV40', 'cvssMetricV31', 'cvssMetricV30'): - if metrics.get(key): - metric = metrics[key][0] - cvss = metric.get('cvssData', {}) - score = cvss.get('baseScore') - vector = cvss.get('vectorString', '') - sev = str(metric.get('baseSeverity') or cvss.get('baseSeverity') or '').upper() - if sev in dict(CVERecord.Severity.choices): - severity = sev - break - descriptions = cve.get('descriptions') or [] - description = '' - for item in descriptions: - if item.get('lang') == 'en': - description = item.get('value', '') - break - if not description and descriptions: - description = descriptions[0].get('value', '') - weaknesses = [ - desc.get('value', '') - for weakness in cve.get('weaknesses', []) - for desc in weakness.get('description', []) - if desc.get('value') - ] - references = [r.get('url') for r in cve.get('references', []) if r.get('url')] - configurations = cve.get('configurations', []) - record, _ = CVERecord.objects.update_or_create( - cve_id=cve.get('id', fallback_cve_id.upper()), - defaults={ - 'description': description, - 'cvss_score': Decimal(str(score)) if score is not None else None, - 'cvss_vector': vector, - 'severity': severity, - 'weakness_ids_json': weaknesses, - 'references_json': references, - 'configurations_json': configurations, - 'raw_json': payload, - 'published_at': _parse_nvd_datetime(cve.get('published', '')), - 'modified_at': _parse_nvd_datetime(cve.get('lastModified', '')), - }, - ) - return record - - @staticmethod - def upsert_cve(cve_id: str, *, assume_normalized: bool = False): - normalized = NVDService.normalize_cve_id_local(cve_id) if assume_normalized else NVDService.normalize_cve_id(cve_id) - rust_only = bool(getattr(settings, 'VULN_INTEL_RUST_ONLY', True)) - if rust_only: - if not RustBackendClient.is_configured(): - raise RuntimeError('VULN_INTEL_RUST_ONLY ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - persisted_cve_id = RustBackendClient.import_nvd_cve(normalized) - return CVERecord.objects.get(cve_id=persisted_cve_id) - - if RustBackendClient.is_configured(): - persisted_cve_id = RustBackendClient.import_nvd_cve(normalized) - return CVERecord.objects.get(cve_id=persisted_cve_id) - - cve, payload = NVDService.fetch_cve(normalized) - return NVDService.upsert_from_payload(cve, payload, fallback_cve_id=normalized) - - @staticmethod - def import_cve_collection(*, cve_tag='', cpe_name='', has_kev=False, last_mod_start='', last_mod_end='', results_per_page=2000, max_pages=1, request_delay_seconds=0.6): - if bool(getattr(settings, 'VULN_INTEL_RUST_ONLY', True)): - raise RuntimeError( - 'Python-NVD-Collection-Import ist im Rust-only-Modus deaktiviert. ' - 'Verwenden Sie die Management-Commands import_nvd_cves oder sync_nvd_recent; ' - 'sie orchestrieren den Rust-Primärpfad.' - ) - start_index = 0 - imported = 0 - seen = 0 - for _ in range(max_pages): - query = { - 'startIndex': start_index, - 'resultsPerPage': results_per_page, - 'cveTag': cve_tag or None, - 'cpeName': cpe_name or None, - 'lastModStartDate': last_mod_start or None, - 'lastModEndDate': last_mod_end or None, - } - if has_kev: - query['hasKev'] = '' - payload = NVDService._request_payload(query) - vulnerabilities = payload.get('vulnerabilities') or [] - if not vulnerabilities: - break - for entry in vulnerabilities: - cve = entry.get('cve', {}) - cve_id = cve.get('id', '') - if not cve_id: - continue - NVDService.upsert_from_payload(cve, payload, fallback_cve_id=cve_id) - imported += 1 - seen += len(vulnerabilities) - total_results = int(payload.get('totalResults') or 0) - start_index += int(payload.get('resultsPerPage') or len(vulnerabilities)) - if start_index >= total_results: - break - if request_delay_seconds > 0: - time.sleep(request_delay_seconds) - return {'imported_records': imported, 'seen_records': seen} - - -class RustBackendClient: - @staticmethod - def _base_url() -> str: - base = (getattr(settings, 'RUST_BACKEND_URL', '') or '').strip() - return base.rstrip('/') - - @staticmethod - def is_configured() -> bool: - return bool(RustBackendClient._base_url()) - - @staticmethod - def healthcheck(timeout: int = 5) -> bool: - base = RustBackendClient._base_url() - if not base: - return False - request = Request(f'{base}/health/live') - request.add_header('Accept', 'application/json') - try: - with urlopen(request, timeout=timeout) as response: - if response.status != 200: - return False - payload = json.loads(response.read().decode('utf-8')) - return payload.get('status') == 'ok' - except Exception: - return False - - @staticmethod - def normalize_cve_id(cve_id: str, timeout: int = 10) -> str: - base = RustBackendClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - data = json.dumps({'cve_id': cve_id}).encode('utf-8') - request = Request(f'{base}/api/v1/nvd/normalize', data=data, method='POST') - request.add_header('Content-Type', 'application/json') - request.add_header('Accept', 'application/json') - try: - with urlopen(request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - except HTTPError as exc: - detail = '' - try: - error_payload = json.loads(exc.read().decode('utf-8')) - detail = str(error_payload.get('error_code') or error_payload.get('message') or '').strip() - except Exception: - detail = str(exc.reason or '').strip() - raise RuntimeError(f'Rust-Backend CVE-Normalisierung fehlgeschlagen ({exc.code}): {detail}') from exc - normalized = str(payload.get('cve_id') or '').strip().upper() - if not normalized: - raise RuntimeError('Rust-Backend hat keine CVE-ID geliefert.') - return normalized - - @staticmethod - def upsert_nvd_cve(*, cve: dict, raw_payload: dict, fallback_cve_id: str = '', timeout: int = 30) -> str: - base = RustBackendClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - request_payload = { - 'cve': cve, - 'cve_id': fallback_cve_id, - 'raw_payload': raw_payload, - } - request = Request( - f'{base}/api/v1/nvd/upsert', - data=json.dumps(request_payload).encode('utf-8'), - method='POST', - ) - request.add_header('Content-Type', 'application/json') - request.add_header('Accept', 'application/json') - try: - with urlopen(request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - except HTTPError as exc: - detail = '' - try: - error_payload = json.loads(exc.read().decode('utf-8')) - detail = str(error_payload.get('error_code') or error_payload.get('message') or '').strip() - except Exception: - detail = str(exc.reason or '').strip() - raise RuntimeError(f'Rust-Backend CVE-Upsert fehlgeschlagen ({exc.code}): {detail}') from exc - - cve_id = str(payload.get('cve_id') or '').strip().upper() - if not cve_id: - raise RuntimeError('Rust-Backend hat keine persistierte CVE-ID geliefert.') - return cve_id - - @staticmethod - def import_nvd_cve(cve_id: str, timeout: int = 30) -> str: - base = RustBackendClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - request = Request( - f'{base}/api/v1/nvd/import', - data=json.dumps({'cve_id': cve_id}).encode('utf-8'), - method='POST', - ) - request.add_header('Content-Type', 'application/json') - request.add_header('Accept', 'application/json') - try: - with urlopen(request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - except HTTPError as exc: - detail = '' - try: - error_payload = json.loads(exc.read().decode('utf-8')) - detail = str(error_payload.get('error_code') or error_payload.get('message') or '').strip() - except Exception: - detail = str(exc.reason or '').strip() - raise RuntimeError(f'Rust-Backend NVD-Import fehlgeschlagen ({exc.code}): {detail}') from exc - - imported_cve_id = str(payload.get('cve_id') or '').strip().upper() - if not imported_cve_id: - raise RuntimeError('Rust-Backend hat keine importierte CVE-ID geliefert.') - return imported_cve_id - - @staticmethod - def calculate_priority(*, score: float, exposure: str, criticality: str, epss_score=None, - in_kev_catalog: bool = False, exploit_maturity: str = 'UNKNOWN', - affects_critical_service: bool = False, nis2_relevant: bool = False, timeout: int = 10): - base = RustBackendClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - request_payload = { - 'score': float(score or 0.0), - 'exposure': str(exposure or 'UNKNOWN'), - 'criticality': str(criticality or 'MEDIUM'), - 'epss_score': None if epss_score is None else float(epss_score), - 'in_kev_catalog': bool(in_kev_catalog), - 'exploit_maturity': str(exploit_maturity or 'UNKNOWN'), - 'affects_critical_service': bool(affects_critical_service), - 'nis2_relevant': bool(nis2_relevant), - } - data = json.dumps(request_payload).encode('utf-8') - request = Request(f'{base}/api/v1/risk/priority', data=data, method='POST') - request.add_header('Content-Type', 'application/json') - request.add_header('Accept', 'application/json') - with urlopen(request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - return ( - str(payload.get('priority') or 'LOW').upper(), - int(payload.get('due_days') or 60), - float(payload.get('effective_score') or 0.0), - ) - - @staticmethod - def summarize_cve_dashboard(*, total: int, critical: int, with_risk: int, llm_generated: int, nis2: int, kev: int, timeout: int = 10): - base = RustBackendClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - request_payload = { - 'total': int(total), - 'critical': int(critical), - 'with_risk': int(with_risk), - 'llm_generated': int(llm_generated), - 'nis2': int(nis2), - 'kev': int(kev), - } - request = Request( - f'{base}/api/v1/reports/cve-summary', - data=json.dumps(request_payload).encode('utf-8'), - method='POST', - ) - request.add_header('Content-Type', 'application/json') - request.add_header('Accept', 'application/json') - with urlopen(request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - return { - 'total': int(payload.get('total') or 0), - 'critical': int(payload.get('critical') or 0), - 'with_risk': int(payload.get('with_risk') or 0), - 'llm_generated': int(payload.get('llm_generated') or 0), - 'nis2': int(payload.get('nis2') or 0), - 'kev': int(payload.get('kev') or 0), - 'risk_hotspot_score': float(payload.get('risk_hotspot_score') or 0.0), - } - - -class LocalIntelligenceFeedService: - @staticmethod - def import_epss(file_obj): - if hasattr(file_obj, 'read'): - content = file_obj.read() - else: - with open(file_obj, 'rb') as handle: - content = handle.read() - if isinstance(content, bytes): - text = content.decode('utf-8-sig') - else: - text = str(content) - reader = csv.DictReader(text.splitlines()) - updated = 0 - touched_ids = set() - for row in reader: - cve_id = str(row.get('cve') or row.get('CVE') or '').strip().upper() - if not cve_id: - continue - epss_raw = row.get('epss') or row.get('EPSS') - if epss_raw in (None, ''): - continue - try: - epss_score = Decimal(str(epss_raw)) - except Exception: - continue - _, changed = CVERecord.objects.update_or_create( - cve_id=cve_id, - defaults={'epss_score': epss_score}, - ) - updated += 1 - touched_ids.add(cve_id) - LocalIntelligenceFeedService._propagate_to_assessments(touched_ids) - return {'updated_records': updated, 'touched_cves': len(touched_ids)} - - @staticmethod - def import_kev(file_obj, *, reset_missing=False): - if hasattr(file_obj, 'read'): - payload = json.load(file_obj) - else: - with open(file_obj, 'r', encoding='utf-8') as handle: - payload = json.load(handle) - vulnerabilities = payload.get('vulnerabilities') or [] - touched_ids = set() - for item in vulnerabilities: - cve_id = str(item.get('cveID') or '').strip().upper() - if not cve_id: - continue - defaults = { - 'in_kev_catalog': True, - 'kev_date_added': parse_date(str(item.get('dateAdded') or '')) if item.get('dateAdded') else None, - 'kev_vendor_project': str(item.get('vendorProject') or '').strip(), - 'kev_product': str(item.get('product') or '').strip(), - 'kev_required_action': str(item.get('requiredAction') or '').strip(), - 'kev_known_ransomware': str(item.get('knownRansomwareCampaignUse') or '').strip().lower() == 'known', - } - CVERecord.objects.update_or_create(cve_id=cve_id, defaults=defaults) - touched_ids.add(cve_id) - if reset_missing: - CVERecord.objects.exclude(cve_id__in=touched_ids).filter(in_kev_catalog=True).update( - in_kev_catalog=False, - kev_date_added=None, - kev_vendor_project='', - kev_product='', - kev_required_action='', - kev_known_ransomware=False, - ) - LocalIntelligenceFeedService._propagate_to_assessments(touched_ids or None) - return {'updated_records': len(touched_ids), 'touched_cves': len(touched_ids), 'reset_missing': reset_missing} - - @staticmethod - def _propagate_to_assessments(cve_ids=None): - assessments = CVEAssessment.objects.select_related('cve', 'tenant') - if cve_ids: - assessments = assessments.filter(cve__cve_id__in=cve_ids) - for assessment in assessments.iterator(): - updates = [] - if assessment.epss_score != assessment.cve.epss_score: - assessment.epss_score = assessment.cve.epss_score - updates.append('epss_score') - if assessment.in_kev_catalog != assessment.cve.in_kev_catalog: - assessment.in_kev_catalog = assessment.cve.in_kev_catalog - updates.append('in_kev_catalog') - if assessment.in_kev_catalog and assessment.exploit_maturity == CVEAssessment.ExploitMaturity.UNKNOWN: - assessment.exploit_maturity = CVEAssessment.ExploitMaturity.ACTIVE - updates.append('exploit_maturity') - priority, due_days, factors = CVERiskEnrichmentService._priority_from_context( - assessment.cve, - assessment.exposure, - assessment.asset_criticality, - epss_score=assessment.epss_score, - in_kev_catalog=assessment.in_kev_catalog, - exploit_maturity=assessment.exploit_maturity, - affects_critical_service=assessment.affects_critical_service, - nis2_relevant=assessment.nis2_relevant, - ) - if assessment.deterministic_priority != priority: - assessment.deterministic_priority = priority - updates.append('deterministic_priority') - if assessment.deterministic_due_days != due_days: - assessment.deterministic_due_days = due_days - updates.append('deterministic_due_days') - if assessment.deterministic_factors_json != factors: - assessment.deterministic_factors_json = factors - updates.append('deterministic_factors_json') - if updates: - updates.append('updated_at') - assessment.save(update_fields=updates) - - -class LocalLLMUnavailable(RuntimeError): - pass - - -class RustLLMProvider: - def __init__(self): - explicit = (getattr(settings, 'LOCAL_LLM_RUST_URL', '') or '').strip() - fallback = (getattr(settings, 'RUST_BACKEND_URL', '') or '').strip() - self.base_url = (explicit or fallback).rstrip('/') - if not self.base_url: - raise LocalLLMUnavailable('LOCAL_LLM_RUST_URL oder RUST_BACKEND_URL muss gesetzt sein.') - self.model_name = getattr(settings, 'LOCAL_LLM_MODEL_NAME', 'rust-llm') - - @staticmethod - def runtime_info() -> LLMRuntimeInfo: - enabled = bool(getattr(settings, 'LOCAL_LLM_ENABLED', False)) - backend = getattr(settings, 'LOCAL_LLM_BACKEND', 'rust_service') - model_name = getattr(settings, 'LOCAL_LLM_MODEL_NAME', 'rust-llm') - model_path = (getattr(settings, 'LOCAL_LLM_RUST_URL', '') or getattr(settings, 'RUST_BACKEND_URL', '') or '').strip() - configured = bool(model_path) - note = 'Rust-LLM-Service-Pfad für CVE-/Risiko-Enrichment.' - return LLMRuntimeInfo( - enabled=enabled, - backend=backend, - model_name=model_name, - model_path=model_path, - model_exists=configured, - import_ok=True, - runtime_ok=enabled and configured, - n_ctx=int(getattr(settings, 'LOCAL_LLM_N_CTX', 8192)), - n_threads=int(getattr(settings, 'LOCAL_LLM_N_THREADS', max(2, os.cpu_count() or 4))), - n_gpu_layers=int(getattr(settings, 'LOCAL_LLM_GPU_LAYERS', 0)), - verbose_native=bool(getattr(settings, 'LOCAL_LLM_VERBOSE_NATIVE', False)), - note=note, - error='', - ) - - def _post_json(self, path: str, payload: dict, timeout: int = 30) -> dict: - body = json.dumps(payload).encode('utf-8') - request = Request(f'{self.base_url}{path}', data=body, method='POST') - request.add_header('Content-Type', 'application/json') - request.add_header('Accept', 'application/json') - with urlopen(request, timeout=timeout) as response: - return json.loads(response.read().decode('utf-8')) - - def generate_json(self, prompt: str, max_tokens: int = 900) -> dict: - payload = self._post_json('/api/v1/llm/generate', {'prompt': prompt, 'max_tokens': max_tokens}) - result = payload.get('result') - if not isinstance(result, dict): - raise ValueError('Rust-LLM-Service lieferte kein result-Objekt.') - return result - - def runtime_test(self, prompt: str | None = None) -> dict: - prompt = prompt or 'Return exactly this JSON: {"status":"ok","message":"local-llm-ready"}' - payload = self._post_json( - '/api/v1/llm/generate', - {'prompt': prompt, 'max_tokens': int(getattr(settings, 'LOCAL_LLM_TEST_MAX_TOKENS', 96))}, - timeout=20, - ) - return { - 'raw_text': json.dumps(payload.get('result', {}), ensure_ascii=False), - 'model_name': payload.get('backend', self.model_name), - 'model_path': self.base_url, - } - - -def get_local_llm_runtime_info() -> LLMRuntimeInfo: - backend = str(getattr(settings, 'LOCAL_LLM_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - raise LocalLLMUnavailable(f'Nur LOCAL_LLM_BACKEND=rust_service wird unterstützt, gefunden: {backend}') - return RustLLMProvider.runtime_info() - - -def get_local_llm_provider(): - backend = str(getattr(settings, 'LOCAL_LLM_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - raise LocalLLMUnavailable(f'Nur LOCAL_LLM_BACKEND=rust_service wird unterstützt, gefunden: {backend}') - return RustLLMProvider() - - -class CVERiskEnrichmentService: - @staticmethod - def _strict_rust_mode() -> bool: - return bool(getattr(settings, 'RUST_STRICT_MODE', False)) - - @staticmethod - def dashboard_summary(tenant): - base = { - 'total': CVEAssessment.objects.for_tenant(tenant).count(), - 'critical': CVEAssessment.objects.for_tenant(tenant).filter(deterministic_priority='CRITICAL').count(), - 'with_risk': CVEAssessment.objects.for_tenant(tenant).exclude(related_risk__isnull=True).count(), - 'llm_generated': CVEAssessment.objects.for_tenant(tenant).filter(llm_status=CVEAssessment.LLMStatus.GENERATED).count(), - 'nis2': CVEAssessment.objects.for_tenant(tenant).filter(nis2_relevant=True).count(), - 'kev': CVEAssessment.objects.for_tenant(tenant).filter(in_kev_catalog=True).count(), - } - backend = str(getattr(settings, 'REPORT_SUMMARY_BACKEND', 'rust_service') or '').strip().lower() - if backend == 'rust_service' and RustBackendClient.is_configured(): - try: - return RustBackendClient.summarize_cve_dashboard(**base) - except Exception as exc: - if CVERiskEnrichmentService._strict_rust_mode(): - raise RuntimeError('Rust report summary backend ist aktiv, aber nicht erreichbar.') from exc - elif backend == 'rust_service' and CVERiskEnrichmentService._strict_rust_mode(): - raise RuntimeError('Rust report summary backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - base['risk_hotspot_score'] = 0.0 - return base - - @staticmethod - def _priority_from_context( - cve: CVERecord, - exposure: str, - criticality: str, - *, - epss_score=None, - in_kev_catalog: bool = False, - exploit_maturity: str = CVEAssessment.ExploitMaturity.UNKNOWN, - affects_critical_service: bool = False, - nis2_relevant: bool = False, - ): - backend = str(getattr(settings, 'RISK_SCORING_BACKEND', 'rust_service') or '').strip().lower() - score = float(cve.cvss_score or 0) - if backend == 'rust_service' and RustBackendClient.is_configured(): - try: - priority, due_days, effective_score = RustBackendClient.calculate_priority( - score=score, - exposure=exposure, - criticality=criticality, - epss_score=epss_score, - in_kev_catalog=in_kev_catalog, - exploit_maturity=exploit_maturity, - affects_critical_service=affects_critical_service, - nis2_relevant=nis2_relevant, - ) - factors = { - 'source': 'rust_service', - 'cvss_score': score, - 'exposure': exposure, - 'asset_criticality': criticality, - 'epss_score': float(epss_score) if epss_score is not None else None, - 'in_kev_catalog': bool(in_kev_catalog), - 'exploit_maturity': exploit_maturity, - 'affects_critical_service': bool(affects_critical_service), - 'nis2_relevant': bool(nis2_relevant), - 'effective_score': round(effective_score, 2), - } - return priority, due_days, factors - except Exception as exc: - if CVERiskEnrichmentService._strict_rust_mode(): - raise RuntimeError('Rust risk scoring backend ist aktiv, aber nicht erreichbar.') from exc - elif backend == 'rust_service' and CVERiskEnrichmentService._strict_rust_mode(): - raise RuntimeError('Rust risk scoring backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - - return CVERiskEnrichmentService._priority_from_context_local( - cve, - exposure, - criticality, - epss_score=epss_score, - in_kev_catalog=in_kev_catalog, - exploit_maturity=exploit_maturity, - affects_critical_service=affects_critical_service, - nis2_relevant=nis2_relevant, - ) - - @staticmethod - def _priority_from_context_local( - cve: CVERecord, - exposure: str, - criticality: str, - *, - epss_score=None, - in_kev_catalog: bool = False, - exploit_maturity: str = CVEAssessment.ExploitMaturity.UNKNOWN, - affects_critical_service: bool = False, - nis2_relevant: bool = False, - ): - score = float(cve.cvss_score or 0) - bump = 0.0 - factors = { - 'source': 'python_fallback', - 'cvss_score': score, - 'exposure': exposure, - 'asset_criticality': criticality, - 'epss_score': float(epss_score) if epss_score is not None else None, - 'in_kev_catalog': bool(in_kev_catalog), - 'exploit_maturity': exploit_maturity, - 'affects_critical_service': bool(affects_critical_service), - 'nis2_relevant': bool(nis2_relevant), - } - if exposure == CVEAssessment.Exposure.INTERNET: - bump += 1.5 - elif exposure == CVEAssessment.Exposure.CUSTOMER: - bump += 1.0 - if criticality == CVEAssessment.AssetCriticality.CRITICAL: - bump += 1.5 - elif criticality == CVEAssessment.AssetCriticality.HIGH: - bump += 1.0 - if epss_score is not None: - epss = float(epss_score) - if epss >= 0.90: - bump += 2.0 - elif epss >= 0.50: - bump += 1.0 - elif epss >= 0.20: - bump += 0.5 - if in_kev_catalog: - bump += 2.5 - if exploit_maturity == CVEAssessment.ExploitMaturity.AUTOMATED: - bump += 2.0 - elif exploit_maturity == CVEAssessment.ExploitMaturity.ACTIVE: - bump += 1.5 - elif exploit_maturity == CVEAssessment.ExploitMaturity.POC: - bump += 0.5 - if affects_critical_service: - bump += 1.5 - if nis2_relevant: - bump += 1.0 - effective = score + bump - factors['effective_score'] = round(effective, 2) - if effective >= 9.5: - return 'CRITICAL', 7, factors - if effective >= 8.0: - return 'HIGH', 14, factors - if effective >= 6.0: - return 'MEDIUM', 30, factors - return 'LOW', 60, factors - - @staticmethod - def _build_prompt(assessment: CVEAssessment): - cve = assessment.cve - return f'''You are a local cyber risk analyst for an ISMS and product-security planner. -Respond ONLY as valid JSON with these keys: -technical_summary, business_impact, attack_path, management_summary, recommended_actions, evidence_needed, confidence - -CVE: {cve.cve_id} -Severity: {cve.severity} -CVSS: {cve.cvss_score or 'unknown'} -Description: {cve.description} -Exposure: {assessment.exposure} -Asset criticality: {assessment.asset_criticality} -EPSS: {assessment.epss_score if assessment.epss_score is not None else 'unknown'} -Known exploited (KEV): {'yes' if assessment.in_kev_catalog else 'no'} -Exploit maturity: {assessment.exploit_maturity} -Critical service affected: {'yes' if assessment.affects_critical_service else 'no'} -NIS2 relevant: {'yes' if assessment.nis2_relevant else 'no'} -NIS2 impact: {assessment.nis2_impact_summary or 'n/a'} -Product: {assessment.product.name if assessment.product else 'n/a'} -Release: {assessment.release.version if assessment.release else 'n/a'} -Component: {assessment.component.name if assessment.component else 'n/a'} -Repository: {assessment.repository_name or 'n/a'} -Repository URL: {assessment.repository_url or 'n/a'} -Git ref: {assessment.git_ref or 'n/a'} -Source package: {assessment.source_package or 'n/a'} -Source package version: {assessment.source_package_version or 'n/a'} -Regulatory tags: {', '.join(assessment.regulatory_tags_json or []) or 'n/a'} -Business context: {assessment.business_context or 'n/a'} -Existing controls: {assessment.existing_controls or 'n/a'} - -Requirements: -- recommended_actions must be a JSON array of short strings -- evidence_needed must be a JSON array of short strings -- confidence must be low, medium, or high -- Include mitigation advice suitable for ISMS, product security, and NIS2 incident readiness when relevant -''' - - @staticmethod - def _normalize_result(payload: dict) -> LLMEnrichmentResult: - return LLMEnrichmentResult( - technical_summary=str(payload.get('technical_summary', '')).strip(), - business_impact=str(payload.get('business_impact', '')).strip(), - attack_path=str(payload.get('attack_path', '')).strip(), - management_summary=str(payload.get('management_summary', '')).strip(), - recommended_actions=[str(x).strip() for x in payload.get('recommended_actions', []) if str(x).strip()], - evidence_needed=[str(x).strip() for x in payload.get('evidence_needed', []) if str(x).strip()], - confidence=str(payload.get('confidence', 'medium')).strip().lower() or 'medium', - ) - - @staticmethod - def _auto_upsert_vulnerability(assessment: CVEAssessment): - if not assessment.product: - return None - vuln, _ = Vulnerability.objects.get_or_create( - tenant=assessment.tenant, - product=assessment.product, - release=assessment.release, - component=assessment.component, - cve=assessment.cve.cve_id, - defaults={ - 'title': assessment.cve.cve_id, - 'severity': assessment.cve.severity if assessment.cve.severity in dict(Vulnerability.Severity.choices) else Vulnerability.Severity.MEDIUM, - 'summary': assessment.cve.description[:2000], - 'status': Vulnerability.Status.OPEN, - }, - ) - if vuln.title == assessment.cve.cve_id: - vuln.summary = assessment.cve.description[:2000] - vuln.save(update_fields=['summary', 'updated_at']) - return vuln - - @staticmethod - def _auto_upsert_risk(assessment: CVEAssessment): - title = f'{assessment.cve.cve_id} – {assessment.product.name if assessment.product else "CVE-Risiko"}' - impact_map = {'CRITICAL': 5, 'HIGH': 4, 'MEDIUM': 3, 'LOW': 2} - likelihood_map = { - CVEAssessment.Exposure.INTERNET: 5, - CVEAssessment.Exposure.CUSTOMER: 4, - CVEAssessment.Exposure.INTERNAL: 3, - CVEAssessment.Exposure.UNKNOWN: 2, - } - risk, _ = Risk.objects.update_or_create( - tenant=assessment.tenant, - title=title, - defaults={ - 'process': None, - 'asset': None, - 'owner': None, - 'description': assessment.business_context or assessment.cve.description[:3000], - 'threat': assessment.attack_path or assessment.cve.description[:1000], - 'vulnerability': assessment.cve.cve_id, - 'impact': impact_map.get(assessment.deterministic_priority, 3), - 'likelihood': likelihood_map.get(assessment.exposure, 3), - 'status': Risk.Status.ANALYZING, - 'treatment_strategy': Risk.Treatment.MITIGATE, - 'treatment_plan': assessment.management_summary or assessment.business_impact, - }, - ) - return risk - - @staticmethod - @transaction.atomic - def create_or_update_assessment(*, tenant, user, cve_id: str, product=None, release=None, component=None, - exposure=CVEAssessment.Exposure.UNKNOWN, - asset_criticality=CVEAssessment.AssetCriticality.MEDIUM, - epss_score=None, in_kev_catalog=None, - exploit_maturity=CVEAssessment.ExploitMaturity.UNKNOWN, - affects_critical_service=False, nis2_relevant=None, - nis2_impact_summary='', - repository_name='', repository_url='', git_ref='', - source_package='', source_package_version='', - regulatory_tags=None, - business_context='', existing_controls='', - auto_create_risk=True, run_llm=True): - cve = NVDService.upsert_cve(cve_id) - if epss_score is None: - epss_score = cve.epss_score - if in_kev_catalog is None: - in_kev_catalog = cve.in_kev_catalog - if in_kev_catalog and exploit_maturity == CVEAssessment.ExploitMaturity.UNKNOWN: - exploit_maturity = CVEAssessment.ExploitMaturity.ACTIVE - if nis2_relevant is None: - nis2_relevant = bool(getattr(tenant, 'nis2_relevant', False)) - if regulatory_tags is None: - regulatory_tags = [] - if nis2_relevant and 'NIS2' not in regulatory_tags: - regulatory_tags = [*regulatory_tags, 'NIS2'] - priority, due_days, factors = CVERiskEnrichmentService._priority_from_context( - cve, - exposure, - asset_criticality, - epss_score=epss_score, - in_kev_catalog=in_kev_catalog, - exploit_maturity=exploit_maturity, - affects_critical_service=affects_critical_service, - nis2_relevant=nis2_relevant, - ) - assessment, created = CVEAssessment.objects.update_or_create( - tenant=tenant, - cve=cve, - product=product, - release=release, - component=component, - defaults={ - 'exposure': exposure, - 'asset_criticality': asset_criticality, - 'epss_score': epss_score, - 'in_kev_catalog': in_kev_catalog, - 'exploit_maturity': exploit_maturity, - 'affects_critical_service': affects_critical_service, - 'nis2_relevant': nis2_relevant, - 'nis2_impact_summary': nis2_impact_summary, - 'repository_name': repository_name, - 'repository_url': repository_url, - 'git_ref': git_ref, - 'source_package': source_package, - 'source_package_version': source_package_version, - 'regulatory_tags_json': regulatory_tags, - 'deterministic_factors_json': factors, - 'business_context': business_context, - 'existing_controls': existing_controls, - 'deterministic_priority': priority, - 'deterministic_due_days': due_days, - 'llm_backend': str(getattr(settings, 'LOCAL_LLM_BACKEND', 'rust_service')).upper(), - 'llm_model_name': getattr(settings, 'LOCAL_LLM_MODEL_NAME', 'Qwen3-8B-GGUF'), - 'llm_status': CVEAssessment.LLMStatus.PENDING if run_llm and getattr(settings, 'LOCAL_LLM_ENABLED', False) else CVEAssessment.LLMStatus.DISABLED, - }, - ) - - if run_llm and getattr(settings, 'LOCAL_LLM_ENABLED', False): - prompt = CVERiskEnrichmentService._build_prompt(assessment) - assessment.prompt_hash = hashlib.sha256(prompt.encode('utf-8')).hexdigest() - try: - payload = get_local_llm_provider().generate_json(prompt) - result = CVERiskEnrichmentService._normalize_result(payload) - assessment.technical_summary = result.technical_summary - assessment.business_impact = result.business_impact - assessment.attack_path = result.attack_path - assessment.management_summary = result.management_summary - assessment.recommended_actions_json = result.recommended_actions - assessment.evidence_needed_json = result.evidence_needed - assessment.confidence = result.confidence - assessment.raw_llm_json = payload - assessment.llm_status = CVEAssessment.LLMStatus.GENERATED - except Exception as exc: - assessment.llm_status = CVEAssessment.LLMStatus.FAILED - assessment.raw_llm_json = {'error': str(exc)} - assessment.save() - - if auto_create_risk: - assessment.linked_vulnerability = CVERiskEnrichmentService._auto_upsert_vulnerability(assessment) - assessment.related_risk = CVERiskEnrichmentService._auto_upsert_risk(assessment) - assessment.save(update_fields=['linked_vulnerability', 'related_risk', 'updated_at']) - - AuditLog.log( - user=user, - tenant=tenant, - action=AuditLog.Action.CREATE if created else AuditLog.Action.UPDATE, - entity=assessment, - changes={ - 'cve_id': assessment.cve.cve_id, - 'priority': assessment.deterministic_priority, - 'llm_status': assessment.llm_status, - 'run_llm': run_llm, - }, - ) - return assessment diff --git a/apps/vulnerability_intelligence/templates/vulnerability_intelligence/dashboard.html b/apps/vulnerability_intelligence/templates/vulnerability_intelligence/dashboard.html deleted file mode 100644 index 7f5b2c1..0000000 --- a/apps/vulnerability_intelligence/templates/vulnerability_intelligence/dashboard.html +++ /dev/null @@ -1,94 +0,0 @@ -{% extends "base.html" %} -{% block title %}CVE & Risikoanalyse{% endblock %} -{% block content %} -
-
-
-
-
-
-
-

Vulnerability Intelligence

-

CVE-Bewertung & lokale Risikoanalyse

-

Deterministische CVE-Fakten aus NVD plus lokales LLM-Enrichment über den Rust-Service, erweitert um EPSS/KEV, NIS2-Kontext und Git-/Repository-Vorbereitung.

-
-
- LLM-Runtime testen -
Assessments
{{ summary.total }}
-
kritisch
{{ summary.critical }}
-
mit Risiko
{{ summary.with_risk }}
-
LLM-Analysen
{{ summary.llm_generated }}
-
NIS2
{{ summary.nis2 }}
-
KEV
{{ summary.kev }}
-
Hotspot
{{ summary.risk_hotspot_score }}
-
-
-
-
-
- -
-
-
-

Neue CVE analysieren

- {% if not llm_enabled %} -
Lokales LLM ist aktuell deaktiviert. Deterministische CVE-/Risikoanalyse funktioniert trotzdem.
- {% else %} -
- Runtime: {% if runtime.runtime_ok %}bereit{% else %}noch nicht vollständig bereit{% endif %}
- Modell: {{ runtime.model_name }}
- Kontext: {{ runtime.n_ctx }} Tokens
- {{ runtime.note }} -
- {% endif %} -
- {% csrf_token %} - {% for field in form %} -
- - {{ field }} - {% if field.help_text %}
{{ field.help_text }}
{% endif %} - {% for error in field.errors %}
{{ error }}
{% endfor %} -
- {% endfor %} -
- -
-
-
-
-
- -
-
-
-

Bisherige CVE-Assessments

-
- - - - {% for item in assessments %} - - - - - - - - - {% empty %} - - {% endfor %} - -
CVEProduktPrioritätLLMRisiko
{{ item.cve.cve_id }}
{{ item.cve.severity }} · CVSS {{ item.cve.cvss_score|default:'–' }}
{{ item.product|default:'–' }} - {{ item.deterministic_priority }} - {% if item.in_kev_catalog %}KEV{% endif %} - {% if item.nis2_relevant %}NIS2{% endif %} - {{ item.get_llm_status_display }}{% if item.related_risk %}{{ item.related_risk.title|truncatechars:40 }}{% else %}–{% endif %}Öffnen
Noch keine CVE-Assessments vorhanden.
-
-
-
-
-
-
-{% endblock %} diff --git a/apps/vulnerability_intelligence/templates/vulnerability_intelligence/detail.html b/apps/vulnerability_intelligence/templates/vulnerability_intelligence/detail.html deleted file mode 100644 index 65a6f7c..0000000 --- a/apps/vulnerability_intelligence/templates/vulnerability_intelligence/detail.html +++ /dev/null @@ -1,109 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ assessment.cve.cve_id }}{% endblock %} -{% block content %} -
-
-
-
-
-
-
-

CVE Assessment

-

{{ assessment.cve.cve_id }}

-

{{ assessment.cve.description }}

-
-
- LLM-Runtime testen - {{ assessment.cve.severity }} - CVSS {{ assessment.cve.cvss_score|default:'–' }} - {% if assessment.in_kev_catalog %}KEV{% endif %} - {% if assessment.nis2_relevant %}NIS2{% endif %} - {{ assessment.get_llm_status_display }} -
-
-
-
-
- -
-
-
-

Kontext & Deterministik

-
-
Produkt
{{ assessment.product|default:'–' }}
-
Release
{{ assessment.release|default:'–' }}
-
Komponente
{{ assessment.component|default:'–' }}
-
Exponierung
{{ assessment.get_exposure_display }}
-
Kritikalität
{{ assessment.get_asset_criticality_display }}
-
EPSS
{{ assessment.epss_score|default:'–' }}
-
KEV
{% if assessment.in_kev_catalog %}Ja{% else %}Nein{% endif %}
-
Exploit-Reife
{{ assessment.get_exploit_maturity_display }}
-
Kritischer Dienst
{% if assessment.affects_critical_service %}Ja{% else %}Nein{% endif %}
-
NIS2
{% if assessment.nis2_relevant %}Ja{% else %}Nein{% endif %}
-
Priorität
{{ assessment.deterministic_priority }}
-
Frist
{{ assessment.deterministic_due_days }} Tage
-
Verknüpftes Risiko
{{ assessment.related_risk|default:'–' }}
-
Schwachstelle
{{ assessment.linked_vulnerability|default:'–' }}
-
Repository
{{ assessment.repository_name|default:'–' }}
-
Git-Ref
{{ assessment.git_ref|default:'–' }}
-
Paket
{{ assessment.source_package|default:'–' }}{% if assessment.source_package_version %} {{ assessment.source_package_version }}{% endif %}
-
-
- {% if assessment.cve.in_kev_catalog %} -

- KEV-Metadaten:
- {{ assessment.cve.kev_vendor_project|default:'–' }} / {{ assessment.cve.kev_product|default:'–' }}
- Added: {{ assessment.cve.kev_date_added|default:'–' }}
- Ransomware: {% if assessment.cve.kev_known_ransomware %}Ja{% else %}Nein{% endif %}
- Action: {{ assessment.cve.kev_required_action|default:'–' }} -

- {% endif %} - {% if assessment.nis2_impact_summary %} -

NIS2-Auswirkung:
{{ assessment.nis2_impact_summary }}

- {% endif %} - {% if assessment.deterministic_factors_json %} -
- Deterministische Faktoren -
{{ assessment.deterministic_factors_json }}
-
- {% endif %} -
- LLM-Runtime: {% if runtime.runtime_ok %}bereit{% else %}nicht bereit{% endif %}
- {{ runtime.note }} -
-
-
-
- -
-
-
-
-

Technische Zusammenfassung

-

{{ assessment.technical_summary|default:'Noch keine LLM-Zusammenfassung vorhanden.' }}

-

Business Impact

{{ assessment.business_impact|default:'–' }}

-

Angriffsweg

{{ assessment.attack_path|default:'–' }}

-

Management Summary

{{ assessment.management_summary|default:'–' }}

-
-
-
-
-

Empfohlene Maßnahmen

-
    - {% for action in assessment.recommended_actions_json %}
  • {{ action }}
  • {% empty %}
  • Keine LLM-Maßnahmen vorhanden.
  • {% endfor %} -
-
-
-
-
-

Benötigte Evidenzen

-
    - {% for evidence in assessment.evidence_needed_json %}
  • {{ evidence }}
  • {% empty %}
  • Keine spezifischen Evidenzen vorgeschlagen.
  • {% endfor %} -
-
-
-
-
-
-
-{% endblock %} diff --git a/apps/vulnerability_intelligence/templates/vulnerability_intelligence/llm_test.html b/apps/vulnerability_intelligence/templates/vulnerability_intelligence/llm_test.html deleted file mode 100644 index 5e2533c..0000000 --- a/apps/vulnerability_intelligence/templates/vulnerability_intelligence/llm_test.html +++ /dev/null @@ -1,56 +0,0 @@ -{% extends "base.html" %} -{% block title %}LLM-Runtime-Test{% endblock %} -{% block content %} -
-
-
-
-
-

Vulnerability Intelligence

-

LLM-Runtime-Test

-
- Status: {% if runtime.runtime_ok %}bereit{% else %}noch nicht vollständig bereit{% endif %}
- Backend: {{ runtime.backend }}
- Modell: {{ runtime.model_name }}
- Pfad: {{ runtime.model_path|default:'nicht gesetzt' }}
- Import: {% if runtime.import_ok %}OK{% else %}Fehler{% endif %}
- Kontext: {{ runtime.n_ctx }} · Threads {{ runtime.n_threads }} · GPU-Layers {{ runtime.n_gpu_layers }} -
-

{{ runtime.note }}

- {% if runtime.error %}
{{ runtime.error }}
{% endif %} -
-
-
-
-
-
-

Kleinen Prompt testen

-
- {% csrf_token %} - {% for field in form %} -
- - {{ field }} - {% if field.help_text %}
{{ field.help_text }}
{% endif %} -
- {% endfor %} -
- - Zurück zur CVE-Analyse -
-
- {% if test_output %} -
-

Testausgabe

-
{{ test_output.raw_text }}
- {% endif %} - {% if test_error %} -
-
{{ test_error }}
- {% endif %} -
-
-
-
-
-{% endblock %} diff --git a/apps/vulnerability_intelligence/tests.py b/apps/vulnerability_intelligence/tests.py deleted file mode 100644 index 9c18d31..0000000 --- a/apps/vulnerability_intelligence/tests.py +++ /dev/null @@ -1,502 +0,0 @@ -import io -import json -import os -import tempfile -from types import SimpleNamespace -from unittest.mock import Mock, patch -from urllib.error import HTTPError - -from django.core.management import call_command -from django.core.management.base import CommandError -from django.test import TestCase, override_settings - -from apps.vulnerability_intelligence.models import CVERecord -from apps.organizations.models import Tenant -from apps.vulnerability_intelligence.services import ( - CVERiskEnrichmentService, - NVDService, - RustBackendClient, - RustLLMProvider, - get_local_llm_provider, -) - - -class NVDServiceTests(TestCase): - @override_settings(VULN_INTEL_RUST_ONLY=False) - def test_import_cve_collection_upserts_records(self): - payload = { - 'totalResults': 1, - 'resultsPerPage': 1, - 'vulnerabilities': [ - { - 'cve': { - 'id': 'CVE-2026-0001', - 'descriptions': [{'lang': 'en', 'value': 'Test CVE'}], - 'metrics': { - 'cvssMetricV31': [ - { - 'baseSeverity': 'HIGH', - 'cvssData': {'baseScore': 8.1, 'vectorString': 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H'}, - } - ] - }, - 'weaknesses': [], - 'references': [], - 'configurations': [], - } - } - ], - } - with patch.object(NVDService, '_request_payload', return_value=payload): - result = NVDService.import_cve_collection(results_per_page=1, max_pages=1) - - self.assertEqual(result['imported_records'], 1) - self.assertEqual(CVERecord.objects.filter(cve_id='CVE-2026-0001').count(), 1) - - def test_import_cve_collection_rejects_python_path_when_rust_only_enabled(self): - with self.assertRaisesRegex(RuntimeError, 'Python-NVD-Collection-Import'): - NVDService.import_cve_collection(results_per_page=1, max_pages=1) - - def test_request_payload_retries_on_http_429(self): - query = {'cveId': 'CVE-2026-0002'} - response_mock = Mock() - response_mock.read.return_value = json.dumps({'vulnerabilities': []}).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - - too_many_requests = HTTPError( - url='https://services.nvd.nist.gov/rest/json/cves/2.0', - code=429, - msg='Too Many Requests', - hdrs=None, - fp=io.BytesIO(b''), - ) - with patch('apps.vulnerability_intelligence.services.urlopen', side_effect=[too_many_requests, response_ctx]): - payload = NVDService._request_payload(query, max_retries=2, retry_sleep_seconds=0) - - self.assertEqual(payload, {'vulnerabilities': []}) - - @override_settings(RUST_BACKEND_URL='') - def test_upsert_cve_requires_rust_when_rust_only_enabled(self): - with self.assertRaises(RuntimeError): - NVDService.upsert_cve('CVE-2026-4242') - - @override_settings(RUST_BACKEND_URL='http://rust-backend:9000') - def test_upsert_cve_uses_rust_persistence_when_rust_only_enabled(self): - def _rust_import(_cve_id): - return CVERecord.objects.create(cve_id='CVE-2026-4242', description='Rust CVE').cve_id - - with patch.object(RustBackendClient, 'import_nvd_cve', side_effect=_rust_import) as mocked_rust_import, \ - patch.object(NVDService, 'upsert_from_payload') as mocked_python_upsert: - record = NVDService.upsert_cve('CVE-2026-4242', assume_normalized=True) - - self.assertEqual(record.cve_id, 'CVE-2026-4242') - mocked_rust_import.assert_called_once_with('CVE-2026-4242') - mocked_python_upsert.assert_not_called() - - -class RustBackendClientTests(TestCase): - @override_settings(RUST_BACKEND_URL='http://rust-backend:9000') - def test_healthcheck_returns_true_for_ok_payload(self): - response_mock = Mock() - response_mock.status = 200 - response_mock.read.return_value = json.dumps({'status': 'ok'}).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - - with patch('apps.vulnerability_intelligence.services.urlopen', return_value=response_ctx): - self.assertTrue(RustBackendClient.healthcheck()) - - @override_settings(RUST_BACKEND_URL='http://rust-backend:9000') - def test_normalize_cve_id_uses_rust_response(self): - response_mock = Mock() - response_mock.read.return_value = json.dumps({ - 'accepted': True, - 'api_version': 'v1', - 'cve_id': 'CVE-2026-4242', - }).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - - with patch('apps.vulnerability_intelligence.services.urlopen', return_value=response_ctx) as mocked_urlopen: - normalized = RustBackendClient.normalize_cve_id(' cve-2026-4242 ') - - self.assertEqual(normalized, 'CVE-2026-4242') - request = mocked_urlopen.call_args.args[0] - self.assertEqual(request.full_url, 'http://rust-backend:9000/api/v1/nvd/normalize') - - @override_settings(RUST_BACKEND_URL='http://rust-backend:9000') - def test_normalize_cve_id_surfaces_rust_error_code(self): - error = HTTPError( - url='http://rust-backend:9000/api/v1/nvd/normalize', - code=422, - msg='Unprocessable Entity', - hdrs=None, - fp=io.BytesIO(json.dumps({ - 'accepted': False, - 'api_version': 'v1', - 'error_code': 'invalid_cve_id', - 'message': 'ungueltig', - }).encode('utf-8')), - ) - - with patch('apps.vulnerability_intelligence.services.urlopen', side_effect=error): - with self.assertRaisesRegex(RuntimeError, 'invalid_cve_id'): - RustBackendClient.normalize_cve_id('not-a-cve') - - @override_settings(RUST_BACKEND_URL='http://rust-backend:9000') - def test_upsert_nvd_cve_uses_rust_response(self): - response_mock = Mock() - response_mock.read.return_value = json.dumps({ - 'accepted': True, - 'api_version': 'v1', - 'cve_id': 'CVE-2026-4242', - 'persisted': True, - }).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - cve = {'id': 'CVE-2026-4242'} - raw_payload = {'vulnerabilities': [{'cve': cve}]} - - with patch('apps.vulnerability_intelligence.services.urlopen', return_value=response_ctx) as mocked_urlopen: - cve_id = RustBackendClient.upsert_nvd_cve( - cve=cve, - raw_payload=raw_payload, - fallback_cve_id='CVE-2026-4242', - ) - - self.assertEqual(cve_id, 'CVE-2026-4242') - request = mocked_urlopen.call_args.args[0] - self.assertEqual(request.full_url, 'http://rust-backend:9000/api/v1/nvd/upsert') - payload = json.loads(request.data.decode('utf-8')) - self.assertEqual(payload['cve']['id'], 'CVE-2026-4242') - self.assertEqual(payload['cve_id'], 'CVE-2026-4242') - self.assertEqual(payload['raw_payload'], raw_payload) - - @override_settings(RUST_BACKEND_URL='http://rust-backend:9000') - def test_upsert_nvd_cve_surfaces_rust_error_code(self): - error = HTTPError( - url='http://rust-backend:9000/api/v1/nvd/upsert', - code=503, - msg='Service Unavailable', - hdrs=None, - fp=io.BytesIO(json.dumps({ - 'accepted': False, - 'api_version': 'v1', - 'error_code': 'database_not_configured', - 'message': 'keine db', - }).encode('utf-8')), - ) - - with patch('apps.vulnerability_intelligence.services.urlopen', side_effect=error): - with self.assertRaisesRegex(RuntimeError, 'database_not_configured'): - RustBackendClient.upsert_nvd_cve(cve={'id': 'CVE-2026-4242'}, raw_payload={}) - - @override_settings(RUST_BACKEND_URL='http://rust-backend:9000') - def test_import_nvd_cve_uses_rust_response(self): - response_mock = Mock() - response_mock.read.return_value = json.dumps({ - 'accepted': True, - 'api_version': 'v1', - 'cve_id': 'CVE-2026-4242', - 'persisted': True, - }).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - - with patch('apps.vulnerability_intelligence.services.urlopen', return_value=response_ctx) as mocked_urlopen: - cve_id = RustBackendClient.import_nvd_cve(' cve-2026-4242 ') - - self.assertEqual(cve_id, 'CVE-2026-4242') - request = mocked_urlopen.call_args.args[0] - self.assertEqual(request.full_url, 'http://rust-backend:9000/api/v1/nvd/import') - payload = json.loads(request.data.decode('utf-8')) - self.assertEqual(payload['cve_id'], ' cve-2026-4242 ') - - @override_settings(RUST_BACKEND_URL='http://rust-backend:9000') - def test_import_nvd_cve_surfaces_rust_error_code(self): - error = HTTPError( - url='http://rust-backend:9000/api/v1/nvd/import', - code=404, - msg='Not Found', - hdrs=None, - fp=io.BytesIO(json.dumps({ - 'accepted': False, - 'api_version': 'v1', - 'error_code': 'cve_not_found', - 'message': 'nicht gefunden', - }).encode('utf-8')), - ) - - with patch('apps.vulnerability_intelligence.services.urlopen', side_effect=error): - with self.assertRaisesRegex(RuntimeError, 'cve_not_found'): - RustBackendClient.import_nvd_cve('CVE-2026-4242') - - @override_settings(RUST_BACKEND_URL='http://rust-backend:9000') - def test_calculate_priority_uses_rust_response(self): - response_mock = Mock() - response_mock.read.return_value = json.dumps({ - 'priority': 'HIGH', - 'due_days': 14, - 'effective_score': 8.7, - }).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - - with patch('apps.vulnerability_intelligence.services.urlopen', return_value=response_ctx): - priority, due_days, effective = RustBackendClient.calculate_priority( - score=7.5, - exposure='INTERNET', - criticality='HIGH', - epss_score=0.5, - in_kev_catalog=True, - exploit_maturity='ACTIVE', - affects_critical_service=True, - nis2_relevant=True, - ) - - self.assertEqual(priority, 'HIGH') - self.assertEqual(due_days, 14) - self.assertEqual(effective, 8.7) - - @override_settings(RUST_BACKEND_URL='http://rust-backend:9000') - def test_summarize_cve_dashboard_uses_rust_response(self): - response_mock = Mock() - response_mock.read.return_value = json.dumps({ - 'total': 10, - 'critical': 2, - 'with_risk': 4, - 'llm_generated': 5, - 'nis2': 3, - 'kev': 2, - 'risk_hotspot_score': 26.0, - }).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - - with patch('apps.vulnerability_intelligence.services.urlopen', return_value=response_ctx): - payload = RustBackendClient.summarize_cve_dashboard( - total=10, - critical=2, - with_risk=4, - llm_generated=5, - nis2=3, - kev=2, - ) - - self.assertEqual(payload['risk_hotspot_score'], 26.0) - - -class RustLLMProviderTests(TestCase): - @override_settings(LOCAL_LLM_RUST_URL='http://rust-backend:9000', LOCAL_LLM_BACKEND='rust_service') - def test_generate_json_uses_rust_service_response(self): - response_mock = Mock() - response_mock.read.return_value = json.dumps({ - 'backend': 'rust_service', - 'model': 'iscy-rust-llm-stub-v1', - 'result': {'technical_summary': 'ok'} - }).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - - with patch('apps.vulnerability_intelligence.services.urlopen', return_value=response_ctx): - payload = RustLLMProvider().generate_json('hello') - - self.assertEqual(payload['technical_summary'], 'ok') - - @override_settings(LOCAL_LLM_BACKEND='llama_cpp') - def test_only_rust_service_backend_is_supported(self): - with self.assertRaises(RuntimeError): - get_local_llm_provider() - - -class RiskScoringBridgeTests(TestCase): - @override_settings(RISK_SCORING_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000') - def test_priority_uses_rust_scoring_when_available(self): - cve = SimpleNamespace(cvss_score=8.0) - with patch('apps.vulnerability_intelligence.services.RustBackendClient.calculate_priority', return_value=('HIGH', 14, 8.9)): - priority, due_days, factors = CVERiskEnrichmentService._priority_from_context( - cve, - 'INTERNET', - 'HIGH', - epss_score=0.7, - in_kev_catalog=True, - exploit_maturity='ACTIVE', - affects_critical_service=True, - nis2_relevant=True, - ) - - self.assertEqual(priority, 'HIGH') - self.assertEqual(due_days, 14) - self.assertEqual(factors['source'], 'rust_service') - - @override_settings(REPORT_SUMMARY_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000') - def test_dashboard_summary_uses_rust_summary_when_available(self): - tenant = Mock() - with patch('apps.vulnerability_intelligence.services.CVEAssessment.objects.for_tenant') as for_tenant, \ - patch('apps.vulnerability_intelligence.services.RustBackendClient.summarize_cve_dashboard', return_value={ - 'total': 7, 'critical': 1, 'with_risk': 3, 'llm_generated': 2, 'nis2': 1, 'kev': 1, 'risk_hotspot_score': 17.14 - }): - qs = Mock() - qs.count.return_value = 7 - qs.filter.return_value.count.side_effect = [1, 2, 1, 1] - qs.exclude.return_value.count.return_value = 3 - for_tenant.return_value = qs - - summary = CVERiskEnrichmentService.dashboard_summary(tenant) - - self.assertEqual(summary['risk_hotspot_score'], 17.14) - - @override_settings(RISK_SCORING_BACKEND='rust_service', RUST_STRICT_MODE=True, RUST_BACKEND_URL='') - def test_priority_raises_in_strict_mode_without_rust_backend(self): - cve = SimpleNamespace(cvss_score=8.0) - with self.assertRaises(RuntimeError): - CVERiskEnrichmentService._priority_from_context( - cve, - 'INTERNET', - 'HIGH', - epss_score=0.7, - in_kev_catalog=True, - exploit_maturity='ACTIVE', - affects_critical_service=True, - nis2_relevant=True, - ) - - @override_settings(REPORT_SUMMARY_BACKEND='rust_service', RUST_STRICT_MODE=True, RUST_BACKEND_URL='') - def test_dashboard_summary_raises_in_strict_mode_without_rust_backend(self): - tenant = Tenant.objects.create(name='Strict Tenant', slug='strict-tenant', country='DE') - with self.assertRaises(RuntimeError): - CVERiskEnrichmentService.dashboard_summary(tenant) - - -class CanaryCommandTests(TestCase): - def test_local_normalizer(self): - self.assertEqual(NVDService.normalize_cve_id_local(' cve-2026-7777 '), 'CVE-2026-7777') - - @override_settings(RUST_BACKEND_URL='http://rust-backend:9000') - def test_canary_command_imports_and_reports_mismatch(self): - with patch( - 'apps.vulnerability_intelligence.management.commands.import_nvd_cves_canary.run_rust_canary', - return_value='Import abgeschlossen', - ) as mocked: - call_command( - 'import_nvd_cves_canary', - ' cve-2026-9999 ', - ) - mocked.assert_called_once() - - @override_settings(RUST_BACKEND_URL='http://rust-backend:9000') - def test_canary_command_requires_healthcheck(self): - with patch( - 'apps.vulnerability_intelligence.management.commands.import_nvd_cves_canary.run_rust_canary', - side_effect=CommandError('Rust-Backend Healthcheck fehlgeschlagen.'), - ): - with self.assertRaises(CommandError): - call_command( - 'import_nvd_cves_canary', - ' cve-2026-0001 ', - ) - - -class RustBridgeImportCommandTests(TestCase): - @override_settings(RUST_BACKEND_URL='http://rust-backend:9000') - def test_import_via_rust_delegates_to_rust_cli(self): - with patch( - 'apps.vulnerability_intelligence.management.commands.import_nvd_cves_via_rust.run_rust_canary', - return_value='Import abgeschlossen', - ) as mocked: - call_command( - 'import_nvd_cves_via_rust', - ' cve-2026-1234 ', - '--skip-healthcheck', - ) - mocked.assert_called_once() - - -class RustPrimaryCollectionCommandTests(TestCase): - def test_import_nvd_cves_delegates_collection_import_to_rust_cli(self): - with patch( - 'apps.vulnerability_intelligence.management.commands.import_nvd_cves.run_rust_canary', - return_value='{"seen_records":1,"imported_records":1}', - ) as mocked: - call_command( - 'import_nvd_cves', - '--results-per-page=10', - '--max-pages=2', - '--has-kev', - '--skip-healthcheck', - ) - - mocked.assert_called_once_with( - subcommand='import-collection', - args=[ - '--results-per-page', '10', - '--max-pages', '2', - '--has-kev', - '--skip-healthcheck', - ], - ) - - def test_sync_nvd_recent_delegates_recent_sync_to_rust_cli(self): - with patch( - 'apps.vulnerability_intelligence.management.commands.sync_nvd_recent.run_rust_canary', - return_value='{"seen_records":1,"imported_records":1}', - ) as mocked: - call_command( - 'sync_nvd_recent', - '--hours=6', - '--results-per-page=25', - '--max-pages=3', - '--cve-tag=disputed', - '--skip-healthcheck', - ) - - mocked.assert_called_once_with( - subcommand='sync-recent', - args=[ - '--hours', '6', - '--results-per-page', '25', - '--max-pages', '3', - '--cve-tag', 'disputed', - '--skip-healthcheck', - ], - ) - - -class CanaryParityReportTests(TestCase): - @override_settings(RUST_BACKEND_URL='http://rust-backend:9000') - def test_report_command_delegates_to_rust_cli(self): - with tempfile.TemporaryDirectory() as tmpdir, patch( - 'apps.vulnerability_intelligence.management.commands.report_nvd_canary_parity.run_rust_canary', - return_value='Canary-Parity-Report erstellt', - ) as mocked: - call_command( - 'report_nvd_canary_parity', - ' cve-2026-1111 ', - ' cve-2026-0002 ', - f'--out-dir={tmpdir}', - ) - mocked.assert_called_once() - - -class CanaryTrendReportTests(TestCase): - def test_trend_report_delegates_to_rust_cli(self): - with tempfile.TemporaryDirectory() as tmpdir, patch( - 'apps.vulnerability_intelligence.management.commands.report_nvd_canary_trend.run_rust_canary', - return_value='Canary-Trend geschrieben', - ) as mocked: - call_command( - 'report_nvd_canary_trend', - f'--reports-dir={tmpdir}', - '--window=3', - '--max-mismatch-rate=50', - ) - mocked.assert_called_once() diff --git a/apps/vulnerability_intelligence/urls.py b/apps/vulnerability_intelligence/urls.py deleted file mode 100644 index 6c7b311..0000000 --- a/apps/vulnerability_intelligence/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.urls import path - -from .views import CVEDashboardView, CVEDetailView, LLMRuntimeTestView - -app_name = 'vulnerability_intelligence' - -urlpatterns = [ - path('', CVEDashboardView.as_view(), name='dashboard'), - path('llm-test/', LLMRuntimeTestView.as_view(), name='llm_test'), - path('/', CVEDetailView.as_view(), name='detail'), -] diff --git a/apps/vulnerability_intelligence/views.py b/apps/vulnerability_intelligence/views.py deleted file mode 100644 index af9b67e..0000000 --- a/apps/vulnerability_intelligence/views.py +++ /dev/null @@ -1,115 +0,0 @@ -from django.conf import settings -from django.contrib import messages -from django.shortcuts import redirect -from django.views.generic import DetailView, FormView, TemplateView - -from apps.core.mixins import TenantAccessMixin - -from .forms import CVELookupForm, LLMRuntimeTestForm -from .models import CVEAssessment -from .services import CVERiskEnrichmentService, LocalLLMUnavailable, get_local_llm_provider, get_local_llm_runtime_info - - -class CVEDashboardView(TenantAccessMixin, FormView): - template_name = 'vulnerability_intelligence/dashboard.html' - form_class = CVELookupForm - - def get_tenant(self): - return getattr(self.request, 'tenant', None) - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs['tenant'] = getattr(self.request, 'tenant', None) - return kwargs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - tenant = getattr(self.request, 'tenant', None) - assessments = CVEAssessment.objects.for_tenant(tenant).select_related('cve', 'product', 'related_risk').order_by('-created_at')[:15] - context['assessments'] = assessments - context['summary'] = CVERiskEnrichmentService.dashboard_summary(tenant) - context['llm_enabled'] = getattr(settings, 'LOCAL_LLM_ENABLED', False) - context['runtime'] = get_local_llm_runtime_info() - return context - - def form_valid(self, form): - kwargs = dict( - tenant=self.request.tenant, - user=self.request.user, - cve_id=form.cleaned_data['cve_id'], - product=form.cleaned_data.get('product'), - release=form.cleaned_data.get('release'), - component=form.cleaned_data.get('component'), - exposure=form.cleaned_data['exposure'], - asset_criticality=form.cleaned_data['asset_criticality'], - epss_score=form.cleaned_data.get('epss_score'), - in_kev_catalog=form.cleaned_data.get('in_kev_catalog', False), - exploit_maturity=form.cleaned_data['exploit_maturity'], - affects_critical_service=form.cleaned_data.get('affects_critical_service', False), - nis2_relevant=form.cleaned_data.get('nis2_relevant', False), - nis2_impact_summary=form.cleaned_data.get('nis2_impact_summary', ''), - repository_name=form.cleaned_data.get('repository_name', ''), - repository_url=form.cleaned_data.get('repository_url', ''), - git_ref=form.cleaned_data.get('git_ref', ''), - source_package=form.cleaned_data.get('source_package', ''), - source_package_version=form.cleaned_data.get('source_package_version', ''), - regulatory_tags=form.cleaned_data.get('regulatory_tags', []), - business_context=form.cleaned_data.get('business_context', ''), - existing_controls=form.cleaned_data.get('existing_controls', ''), - auto_create_risk=form.cleaned_data.get('auto_create_risk', False), - ) - try: - assessment = CVERiskEnrichmentService.create_or_update_assessment( - **kwargs, - run_llm=form.cleaned_data.get('run_llm', False), - ) - except LocalLLMUnavailable as exc: - messages.warning(self.request, f'CVE geladen, aber lokale LLM-Analyse ist nicht verfügbar: {exc}') - assessment = CVERiskEnrichmentService.create_or_update_assessment(**kwargs, run_llm=False) - except Exception as exc: - messages.error(self.request, f'CVE konnte nicht analysiert werden: {exc}') - return self.form_invalid(form) - messages.success(self.request, f'{assessment.cve.cve_id} wurde verarbeitet.') - return redirect('vulnerability_intelligence:detail', pk=assessment.pk) - - -class CVEDetailView(TenantAccessMixin, DetailView): - model = CVEAssessment - template_name = 'vulnerability_intelligence/detail.html' - context_object_name = 'assessment' - - def get_tenant(self): - return getattr(self.request, 'tenant', None) - - def get_queryset(self): - return CVEAssessment.objects.for_tenant(self.request.tenant).select_related('cve', 'product', 'release', 'component', 'related_risk', 'linked_vulnerability') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['runtime'] = get_local_llm_runtime_info() - return context - - -class LLMRuntimeTestView(TenantAccessMixin, FormView): - template_name = 'vulnerability_intelligence/llm_test.html' - form_class = LLMRuntimeTestForm - - def get_tenant(self): - return getattr(self.request, 'tenant', None) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['runtime'] = get_local_llm_runtime_info() - context['test_output'] = getattr(self, 'test_output', None) - context['test_error'] = getattr(self, 'test_error', '') - return context - - def form_valid(self, form): - try: - provider = get_local_llm_provider() - self.test_output = provider.runtime_test(form.cleaned_data.get('prompt')) - messages.success(self.request, 'Lokaler LLM-Runtime-Test war erfolgreich.') - except Exception as exc: - self.test_error = str(exc) - messages.error(self.request, f'LLM-Runtime-Test fehlgeschlagen: {exc}') - return self.render_to_response(self.get_context_data(form=form)) diff --git a/apps/wizard/__init__.py b/apps/wizard/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/wizard/admin.py b/apps/wizard/admin.py deleted file mode 100644 index dc3524c..0000000 --- a/apps/wizard/admin.py +++ /dev/null @@ -1,31 +0,0 @@ -from django.contrib import admin -from .models import AssessmentSession, SessionAnswer, DomainScore, GeneratedGap, GeneratedMeasure - - -@admin.register(AssessmentSession) -class AssessmentSessionAdmin(admin.ModelAdmin): - list_display = ('id', 'tenant', 'assessment_type', 'status', 'current_step', 'progress_percent', 'updated_at') - list_filter = ('assessment_type', 'status', 'current_step') - - -@admin.register(SessionAnswer) -class SessionAnswerAdmin(admin.ModelAdmin): - list_display = ('session', 'question', 'score', 'selected_option') - list_filter = ('question__question_kind',) - - -@admin.register(DomainScore) -class DomainScoreAdmin(admin.ModelAdmin): - list_display = ('session', 'domain', 'score_percent', 'maturity_level', 'gap_level') - - -@admin.register(GeneratedGap) -class GeneratedGapAdmin(admin.ModelAdmin): - list_display = ('session', 'domain', 'severity', 'title') - list_filter = ('severity', 'domain') - - -@admin.register(GeneratedMeasure) -class GeneratedMeasureAdmin(admin.ModelAdmin): - list_display = ('session', 'domain', 'priority', 'title', 'target_phase', 'status') - list_filter = ('priority', 'measure_type', 'status') diff --git a/apps/wizard/apps.py b/apps/wizard/apps.py deleted file mode 100644 index da205b8..0000000 --- a/apps/wizard/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class WizardConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.wizard' diff --git a/apps/wizard/forms.py b/apps/wizard/forms.py deleted file mode 100644 index b46cade..0000000 --- a/apps/wizard/forms.py +++ /dev/null @@ -1,71 +0,0 @@ -from django import forms -from apps.organizations.models import Tenant -from apps.organizations.country_catalog import COUNTRY_CHOICES -from apps.organizations.sector_catalog import SECTOR_CHOICES -from .models import AssessmentSession - - -class AssessmentLaunchForm(forms.Form): - assessment_type = forms.ChoiceField( - choices=AssessmentSession.Type.choices, - label='Art des Assessments', - widget=forms.RadioSelect, - ) - sector = forms.ChoiceField( - choices=SECTOR_CHOICES, - label='Sektor', - required=True, - widget=forms.Select(attrs={'class': 'form-select'}), - help_text='Die Sektorauswahl wirkt auf Betroffenheitsprüfung, Schwerpunktdomänen, Maßnahmen und Roadmap.', - ) - countries = forms.MultipleChoiceField( - label='Operative Länder', - required=False, - choices=COUNTRY_CHOICES, - widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': 8}), - help_text='Mehrfachauswahl möglich. Länder wirken auf Governance, Incident-Koordination und Drittlandprüfung.', - ) - - -class CompanyProfileForm(forms.ModelForm): - annual_revenue_million = forms.DecimalField(label='Jahresumsatz in Mio. €', max_digits=10, decimal_places=2, required=False) - balance_sheet_million = forms.DecimalField(label='Bilanzsumme in Mio. €', max_digits=10, decimal_places=2, required=False) - countries = forms.MultipleChoiceField( - label='Länder / operative Präsenz', - required=False, - choices=COUNTRY_CHOICES, - widget=forms.SelectMultiple(attrs={'class': 'form-select', 'size': 10}), - help_text='Mehrfachauswahl möglich. Diese Auswahl beeinflusst Betroffenheitsindikation, grenzüberschreitende Governance, Incident-Koordination und Roadmap.', - ) - - class Meta: - model = Tenant - fields = [ - 'name', 'slug', 'sector', 'employee_count', 'annual_revenue_million', 'balance_sheet_million', - 'critical_services', 'supply_chain_role', 'description', - 'develops_digital_products', 'uses_ai_systems', 'ot_iacs_scope', 'automotive_scope', - 'psirt_defined', 'sbom_required', 'product_security_scope', - ] - widgets = { - 'description': forms.Textarea(attrs={'rows': 4}), - 'critical_services': forms.Textarea(attrs={'rows': 3}), - 'product_security_scope': forms.Textarea(attrs={'rows': 4}), - 'sector': forms.Select(attrs={'class': 'form-select'}), - } - help_texts = { - 'sector': 'Wähle den in Deutschland passenden NIS2-/KRITIS-nahen Sektor. Diese Auswahl beeinflusst Betroffenheitsindikation, Schwerpunktdomänen und Roadmap.', - 'develops_digital_products': 'Für Produkte mit digitalen Elementen / Softwareprodukte aktivieren.', - 'uses_ai_systems': 'Für AI-Systeme, AI-Komponenten oder AI-gestützte Produktfunktionen aktivieren.', - 'ot_iacs_scope': 'Für OT-/IACS-/Industrie- oder Anlagenkontexte aktivieren.', - 'automotive_scope': 'Für Automotive-/Fahrzeug-/E/E-Kontexte aktivieren.', - 'psirt_defined': 'Aktivieren, wenn bereits ein PSIRT-/Vulnerability-Handling-Prozess existiert.', - 'sbom_required': 'Aktivieren, wenn SBOM-/Komponenten-Transparenz benötigt oder gefordert wird.', - 'product_security_scope': 'Optionaler Scope-Text für Produkte, Releases, AI-Systeme, OT-/Automotive-Kontext oder Support-/Patch-Verantwortung.', - } - - -class ScopeCaptureForm(forms.Form): - scope_statement = forms.CharField(label='ISMS-Scope', widget=forms.Textarea(attrs={'rows': 4}), required=False) - business_units = forms.CharField(label='Geschäftsbereiche', required=False, widget=forms.Textarea(attrs={'rows': 4}), help_text='Je Zeile ein Geschäftsbereich') - processes = forms.CharField(label='Kritische Prozesse', required=False, widget=forms.Textarea(attrs={'rows': 6}), help_text='Je Zeile ein Prozess') - suppliers = forms.CharField(label='Kritische Dienstleister / Lieferanten', required=False, widget=forms.Textarea(attrs={'rows': 4}), help_text='Je Zeile ein Lieferant') diff --git a/apps/wizard/management/__init__.py b/apps/wizard/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/wizard/management/commands/__init__.py b/apps/wizard/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/wizard/migrations/0001_initial.py b/apps/wizard/migrations/0001_initial.py deleted file mode 100644 index 08b44d6..0000000 --- a/apps/wizard/migrations/0001_initial.py +++ /dev/null @@ -1,117 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-09 14:39 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('catalog', '0001_initial'), - ('organizations', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='AssessmentSession', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('assessment_type', models.CharField(choices=[('APPLICABILITY', 'NIS2-/KRITIS-Relevanz prüfen'), ('ISO_READINESS', 'ISO-27001-Readiness bewerten'), ('FULL', 'Vollständige ISMS-/NIS2-Gap-Analyse')], default='FULL', max_length=24)), - ('status', models.CharField(choices=[('DRAFT', 'Entwurf'), ('IN_PROGRESS', 'In Bearbeitung'), ('COMPLETED', 'Abgeschlossen')], default='DRAFT', max_length=20)), - ('current_step', models.CharField(choices=[('profile', 'Unternehmensprofil'), ('applicability', 'Betroffenheit'), ('scope', 'Scope & Struktur'), ('maturity', 'Reifegradanalyse'), ('results', 'Ergebnis')], default='profile', max_length=24)), - ('applicability_result', models.CharField(blank=True, max_length=64)), - ('applicability_reasoning', models.TextField(blank=True)), - ('executive_summary', models.TextField(blank=True)), - ('progress_percent', models.PositiveIntegerField(default=0)), - ('completed_at', models.DateTimeField(blank=True, null=True)), - ('started_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='started_sessions', to=settings.AUTH_USER_MODEL)), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assessment_sessions', to='organizations.tenant')), - ], - options={ - 'ordering': ['-updated_at'], - }, - ), - migrations.CreateModel( - name='GeneratedGap', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('severity', models.CharField(choices=[('CRITICAL', 'Kritisch'), ('HIGH', 'Hoch'), ('MEDIUM', 'Mittel'), ('LOW', 'Niedrig')], default='MEDIUM', max_length=16)), - ('title', models.CharField(max_length=255)), - ('description', models.TextField(blank=True)), - ('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='generated_gaps', to='catalog.assessmentdomain')), - ('question', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='catalog.assessmentquestion')), - ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='generated_gaps', to='wizard.assessmentsession')), - ], - options={ - 'ordering': ['severity', 'domain__sort_order', 'title'], - }, - ), - migrations.CreateModel( - name='GeneratedMeasure', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('title', models.CharField(max_length=255)), - ('description', models.TextField(blank=True)), - ('priority', models.CharField(choices=[('CRITICAL', 'Kritisch'), ('HIGH', 'Hoch'), ('MEDIUM', 'Mittel'), ('LOW', 'Niedrig')], default='MEDIUM', max_length=16)), - ('effort', models.CharField(choices=[('SMALL', 'Klein'), ('MEDIUM', 'Mittel'), ('LARGE', 'Groß')], default='MEDIUM', max_length=16)), - ('measure_type', models.CharField(choices=[('ORGANIZATIONAL', 'Organisatorisch'), ('TECHNICAL', 'Technisch'), ('DOCUMENTARY', 'Dokumentarisch')], default='ORGANIZATIONAL', max_length=20)), - ('target_phase', models.CharField(blank=True, max_length=64)), - ('owner_role', models.CharField(blank=True, max_length=64)), - ('reason', models.TextField(blank=True)), - ('status', models.CharField(choices=[('OPEN', 'Offen'), ('PLANNED', 'Geplant'), ('IN_PROGRESS', 'In Umsetzung'), ('DONE', 'Erledigt')], default='OPEN', max_length=16)), - ('domain', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='generated_measures', to='catalog.assessmentdomain')), - ('question', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='catalog.assessmentquestion')), - ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='generated_measures', to='wizard.assessmentsession')), - ], - options={ - 'ordering': ['priority', 'domain__sort_order', 'title'], - }, - ), - migrations.CreateModel( - name='DomainScore', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('score_raw', models.IntegerField(default=0)), - ('score_percent', models.PositiveIntegerField(default=0)), - ('maturity_level', models.CharField(blank=True, max_length=64)), - ('gap_level', models.CharField(blank=True, max_length=32)), - ('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='domain_scores', to='catalog.assessmentdomain')), - ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='domain_scores', to='wizard.assessmentsession')), - ], - options={ - 'ordering': ['domain__sort_order'], - 'unique_together': {('session', 'domain')}, - }, - ), - migrations.CreateModel( - name='SessionAnswer', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('free_text', models.TextField(blank=True)), - ('score', models.IntegerField(default=0)), - ('is_na', models.BooleanField(default=False)), - ('comment', models.TextField(blank=True)), - ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='session_answers', to='catalog.assessmentquestion')), - ('selected_option', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='session_answers', to='catalog.answeroption')), - ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='wizard.assessmentsession')), - ], - options={ - 'ordering': ['question__sort_order'], - 'unique_together': {('session', 'question')}, - }, - ), - ] diff --git a/apps/wizard/migrations/__init__.py b/apps/wizard/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/wizard/models.py b/apps/wizard/models.py deleted file mode 100644 index 3ffc963..0000000 --- a/apps/wizard/models.py +++ /dev/null @@ -1,137 +0,0 @@ -from django.conf import settings -from django.db import models -from apps.core.models import TimeStampedModel - - -class AssessmentSession(TimeStampedModel): - class Type(models.TextChoices): - APPLICABILITY = 'APPLICABILITY', 'NIS2-/KRITIS-Relevanz prüfen' - ISO_READINESS = 'ISO_READINESS', 'ISO-27001-Readiness bewerten' - FULL = 'FULL', 'Vollständige ISMS-/NIS2-Gap-Analyse' - - class Status(models.TextChoices): - DRAFT = 'DRAFT', 'Entwurf' - IN_PROGRESS = 'IN_PROGRESS', 'In Bearbeitung' - COMPLETED = 'COMPLETED', 'Abgeschlossen' - - class Step(models.TextChoices): - PROFILE = 'profile', 'Unternehmensprofil' - APPLICABILITY = 'applicability', 'Betroffenheit' - SCOPE = 'scope', 'Scope & Struktur' - MATURITY = 'maturity', 'Reifegradanalyse' - RESULTS = 'results', 'Ergebnis' - - tenant = models.ForeignKey('organizations.Tenant', on_delete=models.CASCADE, related_name='assessment_sessions') - assessment_type = models.CharField(max_length=24, choices=Type.choices, default=Type.FULL) - status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT) - current_step = models.CharField(max_length=24, choices=Step.choices, default=Step.PROFILE) - started_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='started_sessions') - applicability_result = models.CharField(max_length=64, blank=True) - applicability_reasoning = models.TextField(blank=True) - executive_summary = models.TextField(blank=True) - progress_percent = models.PositiveIntegerField(default=0) - completed_at = models.DateTimeField(null=True, blank=True) - - class Meta: - ordering = ['-updated_at'] - - def __str__(self): - return f'{self.tenant} - {self.get_assessment_type_display()}' - - -class SessionAnswer(TimeStampedModel): - session = models.ForeignKey(AssessmentSession, on_delete=models.CASCADE, related_name='answers') - question = models.ForeignKey('catalog.AssessmentQuestion', on_delete=models.CASCADE, related_name='session_answers') - selected_option = models.ForeignKey('catalog.AnswerOption', on_delete=models.SET_NULL, null=True, blank=True, related_name='session_answers') - free_text = models.TextField(blank=True) - score = models.IntegerField(default=0) - # F08: N/A-Flag, damit N/A-Antworten aus Score- und Gap-Berechnung ausgeschlossen werden - is_na = models.BooleanField(default=False) - comment = models.TextField(blank=True) - - class Meta: - unique_together = ('session', 'question') - ordering = ['question__sort_order'] - - def __str__(self): - return f'{self.session_id} - {self.question.code}' - - -class DomainScore(TimeStampedModel): - session = models.ForeignKey(AssessmentSession, on_delete=models.CASCADE, related_name='domain_scores') - domain = models.ForeignKey('catalog.AssessmentDomain', on_delete=models.CASCADE, related_name='domain_scores') - score_raw = models.IntegerField(default=0) - score_percent = models.PositiveIntegerField(default=0) - maturity_level = models.CharField(max_length=64, blank=True) - gap_level = models.CharField(max_length=32, blank=True) - - class Meta: - unique_together = ('session', 'domain') - ordering = ['domain__sort_order'] - - def __str__(self): - return f'{self.session_id} - {self.domain.code}' - - -class GeneratedGap(TimeStampedModel): - class Severity(models.TextChoices): - CRITICAL = 'CRITICAL', 'Kritisch' - HIGH = 'HIGH', 'Hoch' - MEDIUM = 'MEDIUM', 'Mittel' - LOW = 'LOW', 'Niedrig' - - session = models.ForeignKey(AssessmentSession, on_delete=models.CASCADE, related_name='generated_gaps') - domain = models.ForeignKey('catalog.AssessmentDomain', on_delete=models.CASCADE, related_name='generated_gaps') - question = models.ForeignKey('catalog.AssessmentQuestion', on_delete=models.SET_NULL, null=True, blank=True) - severity = models.CharField(max_length=16, choices=Severity.choices, default=Severity.MEDIUM) - title = models.CharField(max_length=255) - description = models.TextField(blank=True) - - class Meta: - ordering = ['severity', 'domain__sort_order', 'title'] - - def __str__(self): - return self.title - - -class GeneratedMeasure(TimeStampedModel): - class Priority(models.TextChoices): - CRITICAL = 'CRITICAL', 'Kritisch' - HIGH = 'HIGH', 'Hoch' - MEDIUM = 'MEDIUM', 'Mittel' - LOW = 'LOW', 'Niedrig' - - class Effort(models.TextChoices): - SMALL = 'SMALL', 'Klein' - MEDIUM = 'MEDIUM', 'Mittel' - LARGE = 'LARGE', 'Groß' - - class Type(models.TextChoices): - ORGANIZATIONAL = 'ORGANIZATIONAL', 'Organisatorisch' - TECHNICAL = 'TECHNICAL', 'Technisch' - DOCUMENTARY = 'DOCUMENTARY', 'Dokumentarisch' - - class Status(models.TextChoices): - OPEN = 'OPEN', 'Offen' - PLANNED = 'PLANNED', 'Geplant' - IN_PROGRESS = 'IN_PROGRESS', 'In Umsetzung' - DONE = 'DONE', 'Erledigt' - - session = models.ForeignKey(AssessmentSession, on_delete=models.CASCADE, related_name='generated_measures') - domain = models.ForeignKey('catalog.AssessmentDomain', on_delete=models.SET_NULL, null=True, blank=True, related_name='generated_measures') - question = models.ForeignKey('catalog.AssessmentQuestion', on_delete=models.SET_NULL, null=True, blank=True) - title = models.CharField(max_length=255) - description = models.TextField(blank=True) - priority = models.CharField(max_length=16, choices=Priority.choices, default=Priority.MEDIUM) - effort = models.CharField(max_length=16, choices=Effort.choices, default=Effort.MEDIUM) - measure_type = models.CharField(max_length=20, choices=Type.choices, default=Type.ORGANIZATIONAL) - target_phase = models.CharField(max_length=64, blank=True) - owner_role = models.CharField(max_length=64, blank=True) - reason = models.TextField(blank=True) - status = models.CharField(max_length=16, choices=Status.choices, default=Status.OPEN) - - class Meta: - ordering = ['priority', 'domain__sort_order', 'title'] - - def __str__(self): - return self.title diff --git a/apps/wizard/services.py b/apps/wizard/services.py deleted file mode 100644 index 2aa562e..0000000 --- a/apps/wizard/services.py +++ /dev/null @@ -1,1403 +0,0 @@ -"""Wizard-Service V19 – Findings F01, F02, F03, F05, F08, F16, F17 umgesetzt.""" - -import json -from dataclasses import dataclass -from datetime import timedelta -from typing import Dict, List, Optional -from urllib.error import HTTPError -from urllib.request import Request, urlopen - -from django.conf import settings -from django.db import transaction -from django.utils import timezone - -from apps.catalog.models import AssessmentDomain, AssessmentQuestion -from apps.core.models import AuditLog -from apps.organizations.models import BusinessUnit, Supplier, Tenant -from apps.organizations.sector_catalog import get_sector_definition -from apps.organizations.country_catalog import EU_EEA_CODES, get_country_labels -from apps.requirements_app.models import Requirement -from apps.requirements_app.services import RegulatoryMappingService -from apps.processes.models import Process -from apps.reports.models import ReportSnapshot -from apps.reports.services import ReportSessionRef, ReportSnapshotDetailItem -from apps.evidence.services import EvidenceNeedService -from apps.roadmap.models import RoadmapPlan, RoadmapPhase, RoadmapTask, RoadmapTaskDependency -from apps.roadmap.services import RoadmapPlanBridgeDetail, RoadmapRustClient -from apps.product_security.services import ProductSecurityService -from .models import AssessmentSession, DomainScore, GeneratedGap, GeneratedMeasure, SessionAnswer - - -@dataclass -class ApplicabilityResult: - label: str - nis2_category: str # F01: 'besonders_wichtig', 'wichtig', 'nicht_betroffen' - reasoning: str - score: int - kritis_indication: str # F03: Klartext-Indikation - - -@dataclass(frozen=True) -class WizardDomainRef: - id: int - code: str - name: str - sort_order: int = 0 - - def __str__(self) -> str: - return self.name - - -@dataclass(frozen=True) -class WizardSessionBridgeItem: - id: int - tenant: object - tenant_id: int - tenant_name: str - assessment_type: str - assessment_type_label: str - status: str - status_label: str - current_step: str - current_step_label: str - started_by_id: int | None - started_by_display: str | None - applicability_result: str - applicability_reasoning: str - executive_summary: str - progress_percent: int - completed_at: str | None - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - def get_assessment_type_display(self) -> str: - return self.assessment_type_label - - def get_status_display(self) -> str: - return self.status_label - - def get_current_step_display(self) -> str: - return self.current_step_label - - -@dataclass(frozen=True) -class WizardDomainScoreBridgeItem: - id: int - session_id: int - domain: WizardDomainRef - domain_id: int - score_raw: int - score_percent: int - maturity_level: str - gap_level: str - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - -@dataclass(frozen=True) -class WizardGapBridgeItem: - id: int - session_id: int - domain: WizardDomainRef - domain_id: int - question_id: int | None - severity: str - severity_label: str - title: str - description: str - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - def get_severity_display(self) -> str: - return self.severity_label - - -@dataclass(frozen=True) -class WizardMeasureBridgeItem: - id: int - session_id: int - domain: WizardDomainRef | None - domain_id: int | None - question_id: int | None - title: str - description: str - priority: str - priority_label: str - effort: str - effort_label: str - measure_type: str - measure_type_label: str - target_phase: str - owner_role: str - reason: str - status: str - status_label: str - created_at: str - updated_at: str - - @property - def pk(self) -> int: - return self.id - - def get_priority_display(self) -> str: - return self.priority_label - - def get_effort_display(self) -> str: - return self.effort_label - - def get_measure_type_display(self) -> str: - return self.measure_type_label - - def get_status_display(self) -> str: - return self.status_label - - -@dataclass(frozen=True) -class WizardResultsBridgeResult: - session: WizardSessionBridgeItem - report: ReportSnapshotDetailItem | None - plan_detail: RoadmapPlanBridgeDetail | None - domain_scores: list[WizardDomainScoreBridgeItem] - gaps: list[WizardGapBridgeItem] - measures: list[WizardMeasureBridgeItem] - evidence_count: int - - @property - def plan(self): - return self.plan_detail.plan if self.plan_detail else None - - -class WizardRustClient: - @staticmethod - def _base_url() -> str: - base = (getattr(settings, 'RUST_BACKEND_URL', '') or '').strip() - return base.rstrip('/') - - @staticmethod - def fetch_sessions(request, tenant, timeout: int = 8) -> list[WizardSessionBridgeItem]: - base = WizardRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = WizardRustClient._authenticated_request( - request, - tenant, - f'{base}/api/v1/wizard/sessions', - ) - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - - tenant_id = WizardRustClient._int_field(payload, 'tenant_id') - if tenant_id != int(tenant.id): - raise RuntimeError('Rust-Wizard-Sessions gehoeren nicht zum angefragten Tenant.') - - return [ - WizardRustClient._session_from_payload(item, tenant) - for item in payload.get('sessions', []) - if isinstance(item, dict) - ] - - @staticmethod - def fetch_results(request, tenant, session_id: int, timeout: int = 8) -> WizardResultsBridgeResult | None: - base = WizardRustClient._base_url() - if not base: - raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') - - rust_request = WizardRustClient._authenticated_request( - request, - tenant, - f'{base}/api/v1/wizard/sessions/{int(session_id)}/results', - ) - try: - with urlopen(rust_request, timeout=timeout) as response: - payload = json.loads(response.read().decode('utf-8')) - except HTTPError as exc: - if exc.code == 404: - return None - raise - - session_payload = payload.get('session') or {} - if not isinstance(session_payload, dict): - raise RuntimeError('Rust-Wizard-Ergebnis hat kein session-Objekt geliefert.') - session = WizardRustClient._session_from_payload(session_payload, tenant) - if session.tenant_id != int(tenant.id): - raise RuntimeError('Rust-Wizard-Ergebnis gehoert nicht zum angefragten Tenant.') - - report_payload = payload.get('report') - report = WizardRustClient._report_from_payload(report_payload, tenant) if isinstance(report_payload, dict) else None - - roadmap_payload = payload.get('roadmap') - plan_detail = WizardRustClient._roadmap_from_payload(roadmap_payload, tenant) if isinstance(roadmap_payload, dict) else None - - return WizardResultsBridgeResult( - session=session, - report=report, - plan_detail=plan_detail, - domain_scores=[ - WizardRustClient._domain_score_from_payload(item) - for item in payload.get('domain_scores', []) - if isinstance(item, dict) - ], - gaps=[ - WizardRustClient._gap_from_payload(item) - for item in payload.get('gaps', []) - if isinstance(item, dict) - ], - measures=[ - WizardRustClient._measure_from_payload(item) - for item in payload.get('measures', []) - if isinstance(item, dict) - ], - evidence_count=WizardRustClient._int_field(payload, 'evidence_count'), - ) - - @staticmethod - def _session_from_payload(item: dict, tenant) -> WizardSessionBridgeItem: - return WizardSessionBridgeItem( - id=WizardRustClient._int_field(item, 'id'), - tenant=tenant, - tenant_id=WizardRustClient._int_field(item, 'tenant_id'), - tenant_name=str(item.get('tenant_name') or getattr(tenant, 'name', '')), - assessment_type=str(item.get('assessment_type') or AssessmentSession.Type.FULL), - assessment_type_label=str(item.get('assessment_type_label') or ''), - status=str(item.get('status') or AssessmentSession.Status.DRAFT), - status_label=str(item.get('status_label') or ''), - current_step=str(item.get('current_step') or AssessmentSession.Step.PROFILE), - current_step_label=str(item.get('current_step_label') or ''), - started_by_id=WizardRustClient._optional_int_field(item, 'started_by_id'), - started_by_display=WizardRustClient._optional_str_field(item, 'started_by_display'), - applicability_result=str(item.get('applicability_result') or ''), - applicability_reasoning=str(item.get('applicability_reasoning') or ''), - executive_summary=str(item.get('executive_summary') or ''), - progress_percent=WizardRustClient._int_field(item, 'progress_percent'), - completed_at=WizardRustClient._optional_str_field(item, 'completed_at'), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - - @staticmethod - def _domain_ref_from_payload(item: dict) -> WizardDomainRef: - return WizardDomainRef( - id=WizardRustClient._int_field(item, 'domain_id'), - code=str(item.get('domain_code') or ''), - name=str(item.get('domain_name') or ''), - sort_order=WizardRustClient._int_field(item, 'domain_sort_order'), - ) - - @staticmethod - def _domain_score_from_payload(item: dict) -> WizardDomainScoreBridgeItem: - domain = WizardRustClient._domain_ref_from_payload(item) - return WizardDomainScoreBridgeItem( - id=WizardRustClient._int_field(item, 'id'), - session_id=WizardRustClient._int_field(item, 'session_id'), - domain=domain, - domain_id=domain.id, - score_raw=WizardRustClient._int_field(item, 'score_raw'), - score_percent=WizardRustClient._int_field(item, 'score_percent'), - maturity_level=str(item.get('maturity_level') or ''), - gap_level=str(item.get('gap_level') or ''), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - - @staticmethod - def _gap_from_payload(item: dict) -> WizardGapBridgeItem: - domain = WizardRustClient._domain_ref_from_payload(item) - severity = str(item.get('severity') or GeneratedGap.Severity.MEDIUM) - return WizardGapBridgeItem( - id=WizardRustClient._int_field(item, 'id'), - session_id=WizardRustClient._int_field(item, 'session_id'), - domain=domain, - domain_id=domain.id, - question_id=WizardRustClient._optional_int_field(item, 'question_id'), - severity=severity, - severity_label=str(item.get('severity_label') or dict(GeneratedGap.Severity.choices).get(severity, severity)), - title=str(item.get('title') or ''), - description=str(item.get('description') or ''), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - - @staticmethod - def _measure_from_payload(item: dict) -> WizardMeasureBridgeItem: - domain_id = WizardRustClient._optional_int_field(item, 'domain_id') - domain = None - if domain_id is not None: - domain = WizardDomainRef( - id=domain_id, - code=str(item.get('domain_code') or ''), - name=str(item.get('domain_name') or ''), - ) - priority = str(item.get('priority') or GeneratedMeasure.Priority.MEDIUM) - effort = str(item.get('effort') or GeneratedMeasure.Effort.MEDIUM) - measure_type = str(item.get('measure_type') or GeneratedMeasure.Type.ORGANIZATIONAL) - status = str(item.get('status') or GeneratedMeasure.Status.OPEN) - return WizardMeasureBridgeItem( - id=WizardRustClient._int_field(item, 'id'), - session_id=WizardRustClient._int_field(item, 'session_id'), - domain=domain, - domain_id=domain_id, - question_id=WizardRustClient._optional_int_field(item, 'question_id'), - title=str(item.get('title') or ''), - description=str(item.get('description') or ''), - priority=priority, - priority_label=str(item.get('priority_label') or dict(GeneratedMeasure.Priority.choices).get(priority, priority)), - effort=effort, - effort_label=str(item.get('effort_label') or dict(GeneratedMeasure.Effort.choices).get(effort, effort)), - measure_type=measure_type, - measure_type_label=str(item.get('measure_type_label') or dict(GeneratedMeasure.Type.choices).get(measure_type, measure_type)), - target_phase=str(item.get('target_phase') or ''), - owner_role=str(item.get('owner_role') or ''), - reason=str(item.get('reason') or ''), - status=status, - status_label=str(item.get('status_label') or dict(GeneratedMeasure.Status.choices).get(status, status)), - created_at=str(item.get('created_at') or ''), - updated_at=str(item.get('updated_at') or ''), - ) - - @staticmethod - def _report_from_payload(report: dict, tenant) -> ReportSnapshotDetailItem: - session_id = WizardRustClient._int_field(report, 'session_id') - return ReportSnapshotDetailItem( - id=WizardRustClient._int_field(report, 'id'), - tenant=tenant, - tenant_id=WizardRustClient._int_field(report, 'tenant_id'), - session=ReportSessionRef(id=session_id), - session_id=session_id, - title=str(report.get('title') or ''), - executive_summary=str(report.get('executive_summary') or ''), - applicability_result=str(report.get('applicability_result') or ''), - iso_readiness_percent=WizardRustClient._int_field(report, 'iso_readiness_percent'), - nis2_readiness_percent=WizardRustClient._int_field(report, 'nis2_readiness_percent'), - kritis_readiness_percent=WizardRustClient._int_field(report, 'kritis_readiness_percent'), - cra_readiness_percent=WizardRustClient._int_field(report, 'cra_readiness_percent'), - ai_act_readiness_percent=WizardRustClient._int_field(report, 'ai_act_readiness_percent'), - iec62443_readiness_percent=WizardRustClient._int_field(report, 'iec62443_readiness_percent'), - iso_sae_21434_readiness_percent=WizardRustClient._int_field(report, 'iso_sae_21434_readiness_percent'), - regulatory_matrix_json=WizardRustClient._dict_field(report, 'regulatory_matrix_json'), - compliance_versions_json=WizardRustClient._dict_field(report, 'compliance_versions_json'), - product_security_json=WizardRustClient._dict_field(report, 'product_security_json'), - top_gaps_json=WizardRustClient._list_field(report, 'top_gaps_json'), - top_measures_json=WizardRustClient._list_field(report, 'top_measures_json'), - roadmap_summary=WizardRustClient._list_field(report, 'roadmap_summary'), - domain_scores_json=WizardRustClient._list_field(report, 'domain_scores_json'), - next_steps_json=WizardRustClient._dict_field(report, 'next_steps_json'), - created_at=str(report.get('created_at') or ''), - updated_at=str(report.get('updated_at') or ''), - ) - - @staticmethod - def _roadmap_from_payload(payload: dict, tenant) -> RoadmapPlanBridgeDetail | None: - plan_payload = payload.get('plan') or {} - if not isinstance(plan_payload, dict): - return None - plan = RoadmapRustClient._plan_from_payload(plan_payload, tenant) - - phase_by_id = {} - for item in payload.get('phases', []): - if not isinstance(item, dict): - continue - phase = RoadmapRustClient._phase_from_payload(item, plan) - phase_by_id[phase.id] = phase - plan.phases.add(phase) - - tasks = [] - task_by_id = {} - for item in payload.get('tasks', []): - if not isinstance(item, dict): - continue - phase = phase_by_id.get(WizardRustClient._int_field(item, 'phase_id')) - if phase is None: - continue - task = RoadmapRustClient._task_from_payload(item, phase) - tasks.append(task) - task_by_id[task.id] = task - phase.tasks.add(task) - - dependencies = [] - for item in payload.get('dependencies', []): - if not isinstance(item, dict): - continue - dependency = RoadmapRustClient._dependency_from_payload(item, task_by_id) - dependencies.append(dependency) - if dependency.successor is not None: - dependency.successor.incoming_dependencies.add(dependency) - - return RoadmapPlanBridgeDetail(plan=plan, tasks=tasks, dependencies=dependencies) - - @staticmethod - def _authenticated_request(request, tenant, url: str) -> Request: - user = getattr(request, 'user', None) - user_id = getattr(user, 'id', None) - if not user_id: - raise RuntimeError('Wizard-Rust-Bridge braucht einen authentifizierten User.') - - rust_request = Request(url) - rust_request.add_header('Accept', 'application/json') - rust_request.add_header('X-ISCY-Tenant-ID', str(tenant.id)) - rust_request.add_header('X-ISCY-User-ID', str(user_id)) - user_email = (getattr(user, 'email', '') or '').strip() - if user_email: - rust_request.add_header('X-ISCY-User-Email', user_email) - return rust_request - - @staticmethod - def _int_field(payload: dict, key: str) -> int: - return int(payload.get(key) or 0) - - @staticmethod - def _optional_int_field(payload: dict, key: str) -> int | None: - value = payload.get(key) - if value in (None, ''): - return None - return int(value) - - @staticmethod - def _optional_str_field(payload: dict, key: str) -> str | None: - value = str(payload.get(key) or '').strip() - return value or None - - @staticmethod - def _dict_field(payload: dict, key: str) -> dict: - value = payload.get(key) - return value if isinstance(value, dict) else {} - - @staticmethod - def _list_field(payload: dict, key: str) -> list: - value = payload.get(key) - return value if isinstance(value, list) else [] - - -class WizardResultsBridge: - @staticmethod - def fetch_sessions(request, tenant): - if tenant is None: - return None - - backend = str(getattr(settings, 'WIZARD_RESULTS_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not WizardRustClient._base_url(): - if WizardResultsBridge._strict_rust_mode(): - raise RuntimeError('Rust wizard backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return WizardRustClient.fetch_sessions(request, tenant) - except Exception as exc: - if WizardResultsBridge._strict_rust_mode(): - raise RuntimeError('Rust wizard backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def fetch_results(request, tenant, session_id): - if tenant is None or not session_id: - return None - - backend = str(getattr(settings, 'WIZARD_RESULTS_BACKEND', 'rust_service') or '').strip().lower() - if backend != 'rust_service': - return None - - if not WizardRustClient._base_url(): - if WizardResultsBridge._strict_rust_mode(): - raise RuntimeError('Rust wizard backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') - return None - - try: - return WizardRustClient.fetch_results(request, tenant, int(session_id)) - except Exception as exc: - if WizardResultsBridge._strict_rust_mode(): - raise RuntimeError('Rust wizard backend ist aktiv, aber nicht erreichbar.') from exc - return None - - @staticmethod - def _strict_rust_mode() -> bool: - return bool(getattr(settings, 'RUST_STRICT_MODE', False)) - - -# ────────────────────────────────────────────────────────────────────── -# F01: NIS2-Klassifikation – 'besonders wichtig' vs 'wichtig' sauber trennen -# F02: Groessenschwellen nur aus strukturierten Feldern, keine Doppelzaehlung -# ────────────────────────────────────────────────────────────────────── - -def _classify_company_size(tenant: Tenant) -> dict: - """F02: Einmalige, zentrale Groessenklassifizierung aus den strukturierten Feldern.""" - employees = tenant.employee_count or 0 - revenue = float(tenant.annual_revenue_million or 0) - balance = float(tenant.balance_sheet_million or 0) - - is_large = employees >= 250 or revenue >= 50 or balance >= 43 - is_medium = (not is_large) and (employees >= 50 or revenue >= 10 or balance >= 10) - - return { - 'is_large': is_large, - 'is_medium': is_medium, - 'is_small': not is_large and not is_medium, - 'size_label': 'Grossunternehmen' if is_large else 'Mittleres Unternehmen' if is_medium else 'Kleinunternehmen', - 'employees': employees, - 'revenue': revenue, - 'balance': balance, - } - - -def _determine_nis2_category(sector, size_info: dict, answer_score: int, country_context: dict) -> tuple: - """F01: Bestimmt die NIS2-Einrichtungskategorie nach Art. 3 NIS2. - - Besonders wichtige Einrichtung (Art. 3 Abs. 1): - - Anhang-I-Sektor UND Grossunternehmen - - ODER qualifizierte Vertrauensdiensteanbieter, TLD-Registrierungen, DNS etc. - - Wichtige Einrichtung (Art. 3 Abs. 2): - - Anhang-I oder Anhang-II-Sektor UND mindestens mittleres Unternehmen - - Und nicht bereits besonders wichtig - """ - is_annex_i = sector.nis2_annex == 'I' - is_annex_ii = sector.nis2_annex == 'II' - is_regulated_sector = is_annex_i or is_annex_ii - - if is_annex_i and size_info['is_large']: - return ( - 'Voraussichtlich besonders wichtige Einrichtung (Art. 3 Abs. 1 NIS2)', - 'besonders_wichtig', - ) - if is_annex_i and size_info['is_medium']: - return ( - 'Voraussichtlich wichtige Einrichtung (Art. 3 Abs. 2 NIS2)', - 'wichtig', - ) - if is_annex_ii and (size_info['is_large'] or size_info['is_medium']): - return ( - 'Voraussichtlich wichtige Einrichtung (Art. 3 Abs. 2 NIS2)', - 'wichtig', - ) - # Indirekte Relevanz ueber Lieferkette / hohen Answer-Score - if is_regulated_sector and answer_score >= 12: - return ( - 'Moeglicherweise relevant / erhoehte regulatorische Naehe', - 'moeglich', - ) - if answer_score >= 10 or (is_regulated_sector and size_info['is_small']): - return ( - 'Moeglicherweise relevant – Einzelfallpruefung empfohlen', - 'moeglich', - ) - return ( - 'Aktuell nicht direkt betroffen – ISO-27001-first empfohlen', - 'nicht_betroffen', - ) - - -def _determine_kritis_indication(sector, size_info: dict) -> str: - """F03: KRITIS-Indikation mit Hinweis auf sektorspezifische Schwellenwerte.""" - if not sector.kritis_related: - return 'Sektor ist nicht KRITIS-nah – keine KRITIS-Indikation.' - note = sector.kritis_note or 'Sektorspezifische Schwellenwerte der BSI-KritisV pruefen.' - if size_info['is_large']: - return f'Erhoehte KRITIS-Indikation (Grossunternehmen in KRITIS-nahem Sektor). HINWEIS: Dies ist KEINE KRITIS-Einstufung. {note}' - if size_info['is_medium']: - return f'Moderate KRITIS-Indikation (mittleres Unternehmen in KRITIS-nahem Sektor). {note}' - return f'Geringere KRITIS-Indikation (Kleinunternehmen), aber sektorale Pruefung empfohlen. {note}' - - -class WizardService: - PHASE_SEQUENCE = [ - ('Phase 1 – Governance', 'Scope, Verantwortlichkeiten und Management-Commitment festlegen.', 2), - ('Phase 2 – Transparenz', 'Geschaeftsbereiche, Prozesse, Assets und Lieferanten erfassen.', 3), - ('Phase 3 – Gap-Analyse', 'Domaenen bewerten, Gaps priorisieren und Quick Wins identifizieren.', 2), - ('Phase 4 – Quick Wins', 'MFA, Logging, Incident-Meldewege und Awareness kurzfristig anheben.', 4), - ('Phase 5 – Strukturmassnahmen', 'Lieferkette, SDLC, Backup/BCM und Reviews nachhaltig staerken.', 6), - ('Phase 6 – Audit Readiness', 'Management Review, Nachweise und Zertifizierungsfaehigkeit vorbereiten.', 2), - ] - - @staticmethod - def get_default_tenant(user): - """F09: Kein .first()-Fallback mehr. Nur den Tenant des Users zurueckgeben.""" - if getattr(user, 'tenant', None): - return user.tenant - return None - - @staticmethod - def get_sector_context(tenant: Tenant | None): - sector = get_sector_definition(getattr(tenant, 'sector', None)) - return { - 'code': sector.code, - 'label': sector.label, - 'nis2_group': sector.nis2_group, - 'nis2_annex': sector.nis2_annex, - 'indicative_classification': sector.indicative_classification, - 'reasoning': sector.reasoning, - 'downstream_impact': sector.downstream_impact, - 'roadmap_focus': sector.roadmap_focus, - 'key_domains': sector.key_domains, - 'special_regime': sector.special_regime, - 'kritis_related': sector.kritis_related, - 'kritis_note': sector.kritis_note, - 'score_bonus': sector.score_bonus, - } - - @staticmethod - def get_country_context(tenant: Tenant | None): - if not tenant: - return { - 'selected_codes': [], 'selected_labels': [], 'display': '-', - 'count': 0, 'is_multi_country': False, 'includes_germany': False, - 'has_non_eu_eea': False, 'geo_impact': 'Noch keine Laender ausgewaehlt.', - } - selected_codes = tenant.operation_countries or ([tenant.country] if tenant.country else []) - selected_labels = get_country_labels(selected_codes) - includes_germany = 'DE' in selected_codes - has_non_eu_eea = any(code not in EU_EEA_CODES for code in selected_codes) - is_multi_country = len(selected_codes) > 1 - - impact_parts = [] - if includes_germany: - impact_parts.append('Deutschland bleibt Referenzmarkt fuer die regulatorische Einordnung und die Basis-Roadmap.') - if is_multi_country: - impact_parts.append('Mehrere Laender erhoehen den Bedarf an grenzueberschreitender Governance, Incident-Koordination und harmonisierten Policies.') - if has_non_eu_eea: - impact_parts.append('Nicht-EU-/EWR-Laender erfordern zusaetzliche Pruefung von Drittlandbezuegen, Lieferkette und Datenfluessen.') - if not impact_parts: - impact_parts.append('Einzelstaatlicher Fokus erlaubt einen kompakteren Scope und eine schlankere Umsetzungsroadmap.') - - return { - 'selected_codes': selected_codes, 'selected_labels': selected_labels, - 'display': ', '.join(selected_labels) if selected_labels else '-', - 'count': len(selected_codes), 'is_multi_country': is_multi_country, - 'includes_germany': includes_germany, 'has_non_eu_eea': has_non_eu_eea, - 'geo_impact': ' '.join(impact_parts), - } - - @staticmethod - def get_mode_context(session_or_type): - assessment_type = session_or_type.assessment_type if hasattr(session_or_type, 'assessment_type') else session_or_type - if assessment_type == AssessmentSession.Type.APPLICABILITY: - return { - 'code': AssessmentSession.Type.APPLICABILITY, - 'title': 'NIS2-/KRITIS-Relevanz pruefen', - 'summary': 'Fokussiert auf Sektor, Schwellenwerte, Laenderkontext und eine unverbindliche Betroffenheitsindikation. Keine vollstaendige Gap-Analyse.', - 'steps': ['Unternehmensprofil', 'Betroffenheitspruefung', 'Ergebnis'], - } - if assessment_type == AssessmentSession.Type.ISO_READINESS: - return { - 'code': AssessmentSession.Type.ISO_READINESS, - 'title': 'ISO-27001-Readiness bewerten', - 'summary': 'Fokussiert auf Scope, Reifegrad und ISO-orientierte Massnahmen. Keine NIS2-/KRITIS-Einstufung als Ergebnis.', - 'steps': ['Unternehmensprofil', 'Scope & Struktur', 'Reifegradanalyse', 'Ergebnis'], - } - return { - 'code': AssessmentSession.Type.FULL, - 'title': 'Vollstaendige ISMS-/NIS2-Gap-Analyse', - 'summary': 'Kombiniert Betroffenheitspruefung, Scope, Reifegradanalyse, Massnahmen und Roadmap.', - 'steps': ['Unternehmensprofil', 'Betroffenheitspruefung', 'Scope & Struktur', 'Reifegradanalyse', 'Ergebnis'], - } - - @staticmethod - def next_step_after_profile(session: AssessmentSession): - if session.assessment_type == AssessmentSession.Type.APPLICABILITY: - return AssessmentSession.Step.APPLICABILITY - return AssessmentSession.Step.SCOPE if session.assessment_type == AssessmentSession.Type.ISO_READINESS else AssessmentSession.Step.APPLICABILITY - - @staticmethod - def update_progress(session: AssessmentSession): - mapping = { - AssessmentSession.Step.PROFILE: 10, - AssessmentSession.Step.APPLICABILITY: 30, - AssessmentSession.Step.SCOPE: 50, - AssessmentSession.Step.MATURITY: 75, - AssessmentSession.Step.RESULTS: 100, - } - session.progress_percent = mapping.get(session.current_step, 0) - session.save(update_fields=['progress_percent', 'updated_at']) - - @staticmethod - def save_answers(session: AssessmentSession, answers: Dict[str, str], comments: Dict[str, str] | None = None): - comments = comments or {} - questions = AssessmentQuestion.objects.filter(code__in=answers.keys()).prefetch_related('options') - for question in questions: - option = question.options.filter(id=answers[question.code]).first() - SessionAnswer.objects.update_or_create( - session=session, - question=question, - defaults={ - 'selected_option': option, - 'score': option.score if option else 0, - 'comment': comments.get(question.code, ''), - # F08: N/A-Flag direkt speichern - 'is_na': option.is_na if option else False, - }, - ) - - @staticmethod - def evaluate_applicability(session: AssessmentSession) -> ApplicabilityResult: - """F01+F02: Saubere NIS2-Klassifikation ohne Doppelzaehlung.""" - answers = session.answers.select_related('question', 'selected_option').filter( - question__question_kind=AssessmentQuestion.Kind.APPLICABILITY - ) - # F02: Nur Answer-Scores verwenden, keine separaten size/finance-Boni mehr - answer_score = sum(answer.score for answer in answers) - sector = get_sector_definition(session.tenant.sector) - country_context = WizardService.get_country_context(session.tenant) - size_info = _classify_company_size(session.tenant) - - # F01: Saubere Klassifikation - label, nis2_category = _determine_nis2_category(sector, size_info, answer_score, country_context) - - # F03: KRITIS-Indikation - kritis_indication = _determine_kritis_indication(sector, size_info) - - total_score = answer_score + sector.score_bonus - - reasoning_parts = [ - f'Ausgewaehlter Sektor: {sector.label} ({sector.nis2_group}).', - f'Unternehmensgroesse: {size_info["size_label"]} ({size_info["employees"]} MA, {size_info["revenue"]} Mio. EUR Umsatz, {size_info["balance"]} Mio. EUR Bilanzsumme).', - sector.reasoning, - f'Die Auswahl beeinflusst den weiteren Prozess wie folgt: {sector.downstream_impact}', - f'Ausgewaehlte Laender/Prasenzen: {country_context["display"]}. {country_context["geo_impact"]}', - ] - if sector.special_regime: - reasoning_parts.append(f'Zusaetzliche sektorale Pruefung: {sector.special_regime}.') - if kritis_indication: - reasoning_parts.append(f'KRITIS-Indikation: {kritis_indication}') - if session.tenant.critical_services: - reasoning_parts.append('Kritische Dienstleistungen wurden angegeben und erhoehen die fachliche Relevanz der Folgeanalyse.') - if getattr(session.tenant, 'supply_chain_role', ''): - reasoning_parts.append('Die Lieferkettenrolle wird in der Priorisierung von Massnahmen mitberuecksichtigt.') - reasoning_parts.append(ProductSecurityService.build_summary(session.tenant, ProductSecurityService.get_regime_matrix(session.tenant))) - - session.tenant.nis2_relevant = nis2_category in ('besonders_wichtig', 'wichtig', 'moeglich') - session.tenant.kritis_relevant = sector.kritis_related and (size_info['is_large'] or size_info['is_medium']) - session.tenant.save(update_fields=['nis2_relevant', 'kritis_relevant', 'updated_at']) - session.applicability_result = label - session.applicability_reasoning = ' '.join(reasoning_parts) - session.save(update_fields=['applicability_result', 'applicability_reasoning', 'updated_at']) - return ApplicabilityResult( - label=label, - nis2_category=nis2_category, - reasoning=session.applicability_reasoning, - score=total_score, - kritis_indication=kritis_indication, - ) - - @staticmethod - @transaction.atomic - def generate_results(session: AssessmentSession): - session.domain_scores.all().delete() - session.generated_gaps.all().delete() - session.generated_measures.all().delete() - session.roadmap_plans.all().delete() - session.report_snapshots.all().delete() - - if session.assessment_type == AssessmentSession.Type.APPLICABILITY: - return WizardService._generate_applicability_only_results(session) - - domain_scores = [] - answers = {a.question_id: a for a in session.answers.select_related('question', 'selected_option', 'question__domain')} - domains = AssessmentDomain.objects.prefetch_related('questions__recommendation_rules').all() - - for domain in domains: - questions = domain.questions.filter(question_kind=AssessmentQuestion.Kind.MATURITY) - if session.assessment_type == AssessmentSession.Type.ISO_READINESS: - questions = questions.filter(applies_to_iso27001=True) - if not questions.exists(): - continue - - # F08: N/A-Antworten aus Berechnung ausschliessen - answered_questions = [] - for question in questions: - answer = answers.get(question.id) - is_na = getattr(answer, 'is_na', False) if answer else False - if answer and answer.selected_option and answer.selected_option.is_na: - is_na = True - if not is_na: - answered_questions.append((question, answer)) - - if not answered_questions: - continue - - max_score = len(answered_questions) * 5 - raw_score = 0 - for question, answer in answered_questions: - score = answer.score if answer else 0 - raw_score += score - if answer is None or score <= 2: - severity = 'CRITICAL' if answer is None or score == 0 else 'HIGH' if score == 1 else 'MEDIUM' - session.generated_gaps.create( - domain=domain, - question=question, - severity=severity, - title=f'Gap in {domain.name}: {question.text}', - description=question.why_it_matters or question.help_text, - ) - for rule in question.recommendation_rules.filter(max_score_threshold__gte=score).order_by('sort_order')[:1]: - session.generated_measures.create( - domain=domain, - question=question, - title=rule.title, - description=rule.description, - priority=rule.priority, - effort=rule.effort, - measure_type=rule.measure_type, - target_phase=rule.target_phase, - owner_role=rule.owner_role, - reason=f'Abgeleitet aus Antwortscore {score} fuer: {question.text}', - ) - percent = int((raw_score / max_score) * 100) if max_score else 0 - if percent <= 20: - maturity, gap = 'Kritisch', 'CRITICAL' - elif percent <= 40: - maturity, gap = 'Sehr niedriger Reifegrad', 'HIGH' - elif percent <= 60: - maturity, gap = 'Grundlagen vorhanden', 'MEDIUM' - elif percent <= 80: - maturity, gap = 'Brauchbare Readiness', 'LOW' - else: - maturity, gap = 'Fortgeschritten / auditnah', 'LOW' - domain_scores.append( - DomainScore.objects.create( - session=session, domain=domain, - score_raw=raw_score, score_percent=percent, - maturity_level=maturity, gap_level=gap, - ) - ) - - if session.assessment_type == AssessmentSession.Type.FULL: - WizardService.evaluate_applicability(session) - WizardService._generate_sector_specific_measures(session) - WizardService._generate_country_specific_measures(session) - else: - session.applicability_result = 'ISO-27001-Fokusmodus – keine NIS2-/KRITIS-Einstufung durchgefuehrt' - session.applicability_reasoning = 'Dieser Modus bewertet die organisatorische und technische Readiness gegen einen ISO-27001-orientierten Fragenkatalog. Eine regulatorische NIS2-/KRITIS-Einordnung ist nicht Teil dieses Assessments.' - session.save(update_fields=['applicability_result', 'applicability_reasoning', 'updated_at']) - WizardService._generate_iso_context_measures(session) - WizardService._generate_country_specific_measures(session) - - ProductSecurityService.generate_measures(session) - WizardService._generate_roadmap(session) - session.executive_summary = WizardService._build_executive_summary(session, domain_scores) - WizardService._generate_report(session, domain_scores) - EvidenceNeedService.sync_for_session(session) - ProductSecurityService.sync_snapshot_for_tenant(session.tenant, session) - session.status = AssessmentSession.Status.COMPLETED - session.current_step = AssessmentSession.Step.RESULTS - session.progress_percent = 100 - session.completed_at = timezone.now() - session.save() - return domain_scores - - @staticmethod - def _generate_applicability_only_results(session: AssessmentSession): - result = WizardService.evaluate_applicability(session) - sector = get_sector_definition(session.tenant.sector) - country_context = WizardService.get_country_context(session.tenant) - - session.generated_measures.get_or_create( - title='Betroffenheitspruefung rechtlich und organisatorisch validieren', - defaults={ - 'description': 'Die automatisierte Vorpruefung ist unverbindlich. Pruefen Sie Schwellenwerte, Einrichtungsart, Konzernkontext und moegliche Ausnahmen mit Compliance-/Rechtsfunktion.', - 'priority': 'CRITICAL' if result.nis2_category in ('besonders_wichtig', 'wichtig') else 'HIGH', - 'effort': 'SMALL', - 'measure_type': 'DOCUMENTARY', - 'target_phase': 'Phase 1 – Governance', - 'owner_role': 'Compliance Manager', - 'reason': 'Aus dem Modus NIS2-/KRITIS-Relevanz pruefen abgeleitet.', - }, - ) - if result.nis2_category in ('besonders_wichtig', 'wichtig'): - session.generated_measures.get_or_create( - title='NIS2-nahe Pflichten vorbereiten: Registrierung, Meldewege, Risikomanagement', - defaults={ - 'description': 'Bereiten Sie Rollen, Meldewege, erste Risikoanalyse und Registrierungsinformationen fuer eine moegliche NIS2-Betroffenheit vor.', - 'priority': 'HIGH', - 'effort': 'MEDIUM', - 'measure_type': 'ORGANIZATIONAL', - 'target_phase': 'Phase 2 – Transparenz', - 'owner_role': 'ISMS Manager', - 'reason': f'Betroffenheitsindikation: {result.nis2_category}.', - }, - ) - elif result.nis2_category == 'moeglich': - session.generated_measures.get_or_create( - title='Vertiefte NIS2-Einzelfallpruefung durchfuehren', - defaults={ - 'description': 'Die Vorpruefung zeigt moegliche Relevanz. Eine vertiefte Pruefung mit juristischer Unterstuetzung wird empfohlen.', - 'priority': 'HIGH', - 'effort': 'SMALL', - 'measure_type': 'DOCUMENTARY', - 'target_phase': 'Phase 1 – Governance', - 'owner_role': 'Compliance Manager', - 'reason': 'Indikation: moeglicherweise relevant.', - }, - ) - else: - session.generated_measures.get_or_create( - title='ISO-27001-Readiness als strukturierte Anschlussmassnahme starten', - defaults={ - 'description': 'Auch ohne direkte NIS2-Betroffenheit ist ein ISO-27001-orientierter Aufbau sinnvoll.', - 'priority': 'MEDIUM', - 'effort': 'MEDIUM', - 'measure_type': 'ORGANIZATIONAL', - 'target_phase': 'Phase 2 – Transparenz', - 'owner_role': 'ISMS Manager', - 'reason': 'Keine direkte Betroffenheit erkannt; ISO-first empfohlen.', - }, - ) - if country_context['is_multi_country']: - WizardService._generate_country_specific_measures(session) - ProductSecurityService.generate_measures(session) - - session.executive_summary = ( - f'Die Vorpruefung fuer {session.tenant.name} im Sektor {sector.label} ergibt: {result.label}. ' - f'NIS2-Kategorie: {result.nis2_category}. ' - f'{result.kritis_indication} ' - f'Laenderkontext: {country_context["display"]}. {country_context["geo_impact"]}' - ) - WizardService._generate_roadmap(session) - WizardService._generate_report(session, []) - ProductSecurityService.sync_snapshot_for_tenant(session.tenant, session) - session.status = AssessmentSession.Status.COMPLETED - session.current_step = AssessmentSession.Step.RESULTS - session.progress_percent = 100 - session.completed_at = timezone.now() - session.save() - return [] - - @staticmethod - def _generate_iso_context_measures(session: AssessmentSession): - sector = get_sector_definition(session.tenant.sector) - session.generated_measures.get_or_create( - title=f'Sektoralen Schwerpunkt {sector.label} in der ISO-Roadmap priorisieren', - defaults={ - 'description': f'Der gewaehlte Sektor {sector.label} beeinflusst die Reihenfolge der Kontroll- und Massnahmenpakete. Priorisieren Sie insbesondere: {", ".join(sector.roadmap_focus[:3])}.', - 'priority': 'HIGH' if sector.score_bonus >= 4 else 'MEDIUM', - 'effort': 'SMALL', - 'measure_type': 'DOCUMENTARY', - 'target_phase': 'Phase 1 – Governance', - 'owner_role': 'ISMS Manager', - 'reason': 'Sektor wirkt in diesem Modus auf Schwerpunktsetzung, nicht auf regulatorische Einstufung.', - }, - ) - - @staticmethod - def _generate_sector_specific_measures(session: AssessmentSession): - sector = get_sector_definition(session.tenant.sector) - sector_domain_map = {domain.code: domain for domain in AssessmentDomain.objects.filter(code__in=sector.key_domains)} - - common_title = f'Sektor-spezifische Pflichten und Schwellenwerte fuer {sector.label} fachlich verifizieren' - session.generated_measures.get_or_create( - title=common_title, - defaults={ - 'domain': sector_domain_map.get(sector.key_domains[0]) if sector.key_domains else None, - 'description': f'Pruefen, welche Einrichtungsarten, Schwellenwerte und Melde-/Registrierungspflichten fuer den gewaehlten Sektor {sector.label} einschlaegig sind.', - 'priority': 'CRITICAL' if sector.score_bonus >= 6 else 'HIGH' if sector.score_bonus >= 4 else 'MEDIUM', - 'effort': 'MEDIUM', - 'measure_type': 'DOCUMENTARY', - 'target_phase': 'Phase 1 – Governance', - 'owner_role': 'Compliance Manager', - 'reason': 'Automatisch aus der Sektorauswahl abgeleitet.', - }, - ) - - if sector.special_regime: - session.generated_measures.get_or_create( - title=f'{sector.special_regime} in der Zielarchitektur beruecksichtigen', - defaults={ - 'domain': sector_domain_map.get('DOC') or sector_domain_map.get(sector.key_domains[0]) if sector.key_domains else None, - 'description': f'Der gewaehlte Sektor weist auf eine moegliche sektorale Spezialregulierung hin: {sector.special_regime}.', - 'priority': 'HIGH', - 'effort': 'SMALL', - 'measure_type': 'DOCUMENTARY', - 'target_phase': 'Phase 1 – Governance', - 'owner_role': 'Compliance Manager', - 'reason': 'Automatisch aus sektoraler Ueberschneidung abgeleitet.', - }, - ) - - sector_packages = { - 'DIGITAL': [ - ('Sektorpaket Digital – Cloud- und Shared-Responsibility-Nachweise etablieren', 'CLOUD', 'HIGH', 'MEDIUM', 'DOCUMENTARY', 'Phase 2 – Transparenz', 'Architekturbeschreibungen, Provider-Controls und Verantwortungsabgrenzungen je Cloud-Dienst dokumentieren.'), - ('Sektorpaket Digital – Erkennung, Logging und Incident Response operationalisieren', 'DETECT', 'CRITICAL', 'LARGE', 'TECHNICAL', 'Phase 4 – Quick Wins', 'Fuer digitale Dienste sind Erkennung, Alarmierung und Incident-Handhabung besonders zentral.'), - ], - 'FINANCE': [ - ('Sektorpaket Finance – Governance, Nachweisfuehrung und Pruefpfade verdichten', 'DOC', 'HIGH', 'MEDIUM', 'DOCUMENTARY', 'Phase 1 – Governance', 'Kontrollnachweise, Freigaben und Review-Pfade sektorbezogen strukturiert aufbauen.'), - ], - 'CRITICAL_INFRA': [ - ('Sektorpaket Kritische Infrastruktur – Resilienz, BCM und Wiederanlauf testen', 'BCM', 'HIGH', 'LARGE', 'TECHNICAL', 'Phase 5 – Strukturmassnahmen', 'Fuer kritische Versorgung und KRITIS-nahe Sektoren sind Resilienz und Wiederanlauf evidenzpflichtig.'), - ], - } - for package in EvidenceNeedService.get_sector_packages(session.tenant): - for title, domain_code, priority, effort, measure_type, target_phase, desc in sector_packages.get(package, []): - session.generated_measures.get_or_create( - title=title, - defaults={ - 'domain': sector_domain_map.get(domain_code), - 'description': desc, - 'priority': priority, - 'effort': effort, - 'measure_type': measure_type, - 'target_phase': target_phase, - 'owner_role': 'ISMS Manager', - 'reason': f'Sektorpaket {package} automatisch aus Sektorauswahl abgeleitet.', - }, - ) - - # Sektorspezifische Zusatzmassnahmen - if sector.code in {'DIGITAL_INFRASTRUCTURE', 'ICT_SERVICE_MANAGEMENT', 'DIGITAL_PROVIDERS', 'MSSP'}: - session.generated_measures.get_or_create( - title='Logging, Angriffserkennung und sichere Betriebsprozesse fuer digitale Dienste vertiefen', - defaults={ - 'domain': sector_domain_map.get('DETECT'), - 'description': 'Fuer digitale und IKT-nahe Sektoren sollte die Readiness von Monitoring, Erkennung, Incident Response und sicheren Betriebsprozessen priorisiert werden.', - 'priority': 'CRITICAL', - 'effort': 'LARGE', - 'measure_type': 'TECHNICAL', - 'target_phase': 'Phase 4 – Quick Wins', - 'owner_role': 'Security Officer', - 'reason': 'Sektoraler Schwerpunkt auf digitale Kernservices.', - }, - ) - elif sector.code in {'ENERGY', 'HYDROGEN', 'TRANSPORT', 'HEALTH', 'DRINKING_WATER', 'WASTEWATER', 'CHEMICALS', 'FOOD'}: - session.generated_measures.get_or_create( - title='Betriebsresilienz, BCM und physische Sicherheit sektorbezogen konkretisieren', - defaults={ - 'domain': sector_domain_map.get('BCM') or sector_domain_map.get('PHYS'), - 'description': 'Fuer den gewaehlten Sektor sollten Wiederanlauf, physische Schutzmassnahmen, kritische Lieferketten und Eskalationswege besonders frueh detailliert werden.', - 'priority': 'HIGH', - 'effort': 'LARGE', - 'measure_type': 'ORGANIZATIONAL', - 'target_phase': 'Phase 5 – Strukturmassnahmen', - 'owner_role': 'ISMS Manager', - 'reason': 'Sektoraler Schwerpunkt auf Resilienz und Versorgungssicherheit.', - }, - ) - elif sector.code in {'BANKING', 'FINANCIAL_MARKET_INFRASTRUCTURE'}: - session.generated_measures.get_or_create( - title='Governance-, Nachweis- und Meldeprozesse mit Finanzsektor-Fokus schaerfen', - defaults={ - 'domain': sector_domain_map.get('DOC') or sector_domain_map.get('INC'), - 'description': 'Fuer Finanzsektoren sollten Nachweisfaehigkeit, Governance und Vorfallprozesse frueh mit sektoralen Anforderungen abgeglichen werden.', - 'priority': 'HIGH', - 'effort': 'MEDIUM', - 'measure_type': 'DOCUMENTARY', - 'target_phase': 'Phase 1 – Governance', - 'owner_role': 'Compliance Manager', - 'reason': 'Sektoraler Schwerpunkt auf Nachweis- und Meldepflichten.', - }, - ) - - @staticmethod - def _generate_country_specific_measures(session: AssessmentSession): - country_context = WizardService.get_country_context(session.tenant) - if country_context['is_multi_country']: - session.generated_measures.get_or_create( - title='Laenderuebergreifende Governance, Rollen und Eskalationswege harmonisieren', - defaults={ - 'description': f'Das Unternehmen ist in mehreren Laendern aktiv ({country_context["display"]}). Policies, Eskalationswege, Incident-Kommunikation und Verantwortlichkeiten sollten harmonisiert werden.', - 'priority': 'HIGH', - 'effort': 'MEDIUM', - 'measure_type': 'ORGANIZATIONAL', - 'target_phase': 'Phase 1 – Governance', - 'owner_role': 'ISMS Manager', - 'reason': 'Automatisch aus Multi-Country-Auswahl abgeleitet.', - }, - ) - if country_context['has_non_eu_eea']: - session.generated_measures.get_or_create( - title='Drittlandbezuege, Lieferkette und Datenfluesse strukturiert pruefen', - defaults={ - 'description': 'Nicht-EU-/EWR-Laender wurden ausgewaehlt. Pruefen Sie Datenfluesse, externe Dienstleister, Support-Modelle und zusaetzliche vertragliche bzw. organisatorische Schutzmassnahmen.', - 'priority': 'HIGH', - 'effort': 'MEDIUM', - 'measure_type': 'DOCUMENTARY', - 'target_phase': 'Phase 5 – Strukturmassnahmen', - 'owner_role': 'Compliance Manager', - 'reason': 'Automatisch aus Laender-/Drittlandkontext abgeleitet.', - }, - ) - - @staticmethod - def _build_executive_summary(session: AssessmentSession, domain_scores: List[DomainScore]) -> str: - average = int(sum(item.score_percent for item in domain_scores) / len(domain_scores)) if domain_scores else 0 - gaps = session.generated_gaps.count() - measures = session.generated_measures.count() - sector = get_sector_definition(session.tenant.sector) - country_context = WizardService.get_country_context(session.tenant) - size_info = _classify_company_size(session.tenant) - product_summary = ProductSecurityService.build_summary(session.tenant) - - if session.assessment_type == AssessmentSession.Type.APPLICABILITY: - return ( - f'Die Vorpruefung fuer {session.tenant.name} im Sektor {sector.label} liefert die Indikation: {session.applicability_result}. ' - f'Unternehmensgroesse: {size_info["size_label"]}. ' - f'Operative Laender/Prasenzen: {country_context["display"]}. {country_context["geo_impact"]} ' - f'{product_summary}' - ) - if session.assessment_type == AssessmentSession.Type.ISO_READINESS: - return ( - f'Das ISO-27001-Readiness-Assessment fuer {session.tenant.name} im Sektor {sector.label} ergibt aktuell eine durchschnittliche Readiness von {average}%. ' - f'Dabei wurden {gaps} relevante Gaps und {measures} priorisierte Massnahmen identifiziert. ' - f'Operative Laender/Prasenzen: {country_context["display"]}. {country_context["geo_impact"]} ' - f'{product_summary}' - ) - return ( - f'Das vollstaendige Assessment fuer {session.tenant.name} im Sektor {sector.label} ergibt aktuell eine durchschnittliche Readiness von {average}%. ' - f'Dabei wurden {gaps} relevante Gaps und {measures} priorisierte Massnahmen identifiziert. ' - f'Die Einordnung zur regulatorischen Naehe lautet: {session.applicability_result or "noch nicht bewertet"}. ' - f'Sektoraler Schwerpunkt: {", ".join(sector.roadmap_focus[:3])}. ' - f'Operative Laender/Prasenzen: {country_context["display"]}. {country_context["geo_impact"]} ' - f'{sector.downstream_impact} {product_summary}' - ) - - # ── F17: Dynamische Roadmap-Phasen basierend auf Reifegrad und Groesse ── - - @staticmethod - def _calculate_phase_duration(base_weeks: int, average_readiness: int, size_info: dict) -> int: - """F17: Phasendauer an Reifegrad und Unternehmensgroesse koppeln.""" - factor = 1.0 - if average_readiness < 30: - factor *= 1.5 - elif average_readiness > 70: - factor *= 0.7 - if size_info.get('is_large'): - factor *= 1.3 - elif size_info.get('is_small'): - factor *= 0.8 - return max(1, round(base_weeks * factor)) - - @staticmethod - def _generate_roadmap(session: AssessmentSession): - start_date = timezone.localdate() - sector = get_sector_definition(session.tenant.sector) - country_context = WizardService.get_country_context(session.tenant) - mode_context = WizardService.get_mode_context(session) - size_info = _classify_company_size(session.tenant) - - # F17: Durchschnittliche Readiness fuer Phasendauer-Berechnung - ds = list(session.domain_scores.all()) - avg_readiness = int(sum(d.score_percent for d in ds) / len(ds)) if ds else 30 - - plan = RoadmapPlan.objects.create( - tenant=session.tenant, session=session, - title=f'Roadmap fuer {session.tenant.name}', - summary=( - f'Automatisch abgeleitete Umsetzungsroadmap fuer den Modus {mode_context["title"]} basierend auf Assessment, Gaps, Massnahmen, der Sektorauswahl {sector.label} ' - f'und den operativen Laendern {country_context["display"]}.' - ), - overall_priority='HIGH' if session.generated_measures.filter(priority__in=['CRITICAL', 'HIGH']).exists() else 'MEDIUM', - planned_start=start_date, - ) - phase_map = {} - phase_start = start_date - phase_sequence = WizardService.PHASE_SEQUENCE - - if session.assessment_type == AssessmentSession.Type.APPLICABILITY: - phase_sequence = [ - ('Phase 1 – Vorpruefung', 'Unternehmensprofil, Sektor, Schwellenwerte und Laender kontextualisieren.', 1), - ('Phase 2 – Validierung', 'Betroffenheitsindikation mit Compliance/Legal und Konzernsicht pruefen.', 2), - ('Phase 3 – Anschlussentscheidung', 'Entscheiden, ob NIS2-Folgeprojekt oder ISO-27001-first gestartet wird.', 1), - ] - elif session.assessment_type == AssessmentSession.Type.ISO_READINESS: - phase_sequence = [ - ('Phase 1 – Governance', 'Scope, Verantwortlichkeiten und Management-Commitment festlegen.', 2), - ('Phase 2 – Transparenz', 'Geschaeftsbereiche, Prozesse, Assets und Lieferanten erfassen.', 3), - ('Phase 3 – Gap-Analyse', 'ISO-orientierte Domaenen bewerten und Massnahmen priorisieren.', 2), - ('Phase 4 – Strukturmassnahmen', 'Policies, Controls, SDLC und Resilienz nachhaltig staerken.', 6), - ('Phase 5 – Audit Readiness', 'Management Review, Nachweise und Zertifizierungsfaehigkeit vorbereiten.', 2), - ] - - for idx, (name, objective, base_weeks) in enumerate(phase_sequence, start=1): - # F17: Dynamische Wochen - weeks = WizardService._calculate_phase_duration(base_weeks, avg_readiness, size_info) - sector_objective = objective - if name == 'Phase 3 – Gap-Analyse': - sector_objective = f'{objective} Zusaetzlicher Sektor-Fokus: {", ".join(sector.roadmap_focus[:2])}.' - if country_context['is_multi_country'] and 'Governance' in name: - sector_objective += ' Zusaetzlich muessen laenderuebergreifende Rollen, Eskalationswege und harmonisierte Policies festgelegt werden.' - if country_context['has_non_eu_eea'] and 'Strukturmassnahmen' in name: - sector_objective += ' Drittlandbezuege, Lieferkette und Datenfluesse sind gesondert zu pruefen.' - if session.tenant.develops_digital_products and ('Governance' in name or 'Strukturmassnahmen' in name or 'Gap-Analyse' in name): - sector_objective += ' Product Security, Secure Development, Vulnerability Handling und Release-/Support-Verantwortung muessen mitberuecksichtigt werden.' - phase_end = phase_start + timedelta(weeks=weeks) - timedelta(days=1) - phase_map[name] = RoadmapPhase.objects.create( - plan=plan, name=name, sort_order=idx * 10, - objective=sector_objective, duration_weeks=weeks, - planned_start=phase_start, planned_end=phase_end, - ) - phase_start = phase_end + timedelta(days=1) - - ordered_phases = list(phase_map.values()) - phase_task_map = {phase.id: [] for phase in ordered_phases} - for index, measure in enumerate(session.generated_measures.all(), start=1): - phase = phase_map.get(measure.target_phase) or ordered_phases[0] - planned_start = phase.planned_start or start_date - due_days = 30 if measure.priority == 'CRITICAL' else 60 if measure.priority == 'HIGH' else 90 if measure.priority == 'MEDIUM' else 120 - task = RoadmapTask.objects.create( - phase=phase, measure=measure, title=measure.title, - description=measure.description, priority=measure.priority, - owner_role=measure.owner_role, due_in_days=due_days, - dependency_text='Vor Umsetzung sollten Verantwortlichkeiten und Scope geklaert sein.' if 'Governance' not in phase.name else '', - status=RoadmapTask.Status.PLANNED, - planned_start=planned_start + timedelta(days=(index - 1) % 14), - due_date=planned_start + timedelta(days=due_days), - notes='Automatisch generierte Aufgabe aus dem Assessment. Bitte konkretisieren und mit Evidenzen unterlegen.', - ) - phase_task_map[phase.id].append(task) - - sector_phase = phase_map.get('Phase 1 – Governance') or ordered_phases[0] - sector_task, _ = RoadmapTask.objects.get_or_create( - phase=sector_phase, - title=f'Sektorprofil {sector.label} validieren und dokumentieren', - defaults={ - 'description': f'Dokumentieren, warum der ausgewaehlte Sektor {sector.label} gewaehlt wurde und welche Auswirkungen dies auf Betroffenheit, Scope und Massnahmen hat.', - 'priority': 'HIGH' if sector.score_bonus else 'MEDIUM', - 'owner_role': 'Compliance Manager', - 'due_in_days': 21, - 'status': RoadmapTask.Status.PLANNED, - 'planned_start': sector_phase.planned_start, - 'due_date': (sector_phase.planned_start or start_date) + timedelta(days=21), - 'notes': sector.downstream_impact, - }, - ) - phase_task_map[sector_phase.id].append(sector_task) - - # Dependency graph - governance_anchor = sector_task - for idx, phase in enumerate(ordered_phases): - current_tasks = phase_task_map.get(phase.id, []) - if not current_tasks: - continue - if idx > 0: - previous_phase = ordered_phases[idx - 1] - previous_tasks = phase_task_map.get(previous_phase.id, []) - predecessors = previous_tasks[:2] or ([governance_anchor] if governance_anchor else []) - for task in current_tasks[:4]: - for predecessor in predecessors[:2]: - if predecessor.id == task.id: - continue - RoadmapTaskDependency.objects.get_or_create( - predecessor=predecessor, successor=task, - defaults={ - 'dependency_type': RoadmapTaskDependency.DependencyType.FINISH_TO_START, - 'rationale': f'Phase {task.phase.name} baut auf Ergebnissen aus {predecessor.phase.name} auf.', - }, - ) - task.dependency_text = '; '.join(pre.title for pre in predecessors[:2]) - task.save(update_fields=['dependency_text']) - anchor = next((t for t in current_tasks if t.priority in {'CRITICAL', 'HIGH'}), current_tasks[0]) - for task in current_tasks[1:4]: - if anchor.id == task.id: - continue - RoadmapTaskDependency.objects.get_or_create( - predecessor=anchor, successor=task, - defaults={ - 'dependency_type': RoadmapTaskDependency.DependencyType.START_TO_START, - 'rationale': 'Innerhalb der Phase sollten priorisierte Arbeitspakete vor nachgelagerten Aufgaben beginnen.', - }, - ) - return plan - - @staticmethod - def _build_next_steps(session: AssessmentSession): - measures = list(session.generated_measures.all().order_by('priority', 'created_at')) - return { - 'next_30_days': [{'title': m.title, 'priority': m.priority} for m in measures if m.priority == 'CRITICAL'][:5], - 'next_60_days': [{'title': m.title, 'priority': m.priority} for m in measures if m.priority in {'CRITICAL', 'HIGH'}][:5], - 'next_90_days': [{'title': m.title, 'priority': m.priority} for m in measures if m.priority in {'MEDIUM', 'LOW'}][:5], - } - - @staticmethod - def _generate_report(session: AssessmentSession, domain_scores: List[DomainScore]): - average = int(sum(item.score_percent for item in domain_scores) / len(domain_scores)) if domain_scores else 0 - measures = list(session.generated_measures.values('title', 'priority', 'target_phase')[:10]) - gaps = list(session.generated_gaps.values('title', 'severity')[:10]) - plan = session.roadmap_plans.first() - sector = get_sector_definition(session.tenant.sector) - country_context = WizardService.get_country_context(session.tenant) - roadmap_summary = list(plan.phases.values('name', 'duration_weeks', 'objective')) if plan else [] - domain_scores_json = [ - {'domain': item.domain.name, 'score_percent': item.score_percent, 'maturity_level': item.maturity_level} - for item in domain_scores - ] - top_measures = [ - {'title': 'Sektor-Fokus: ' + sector.label, 'priority': sector.indicative_classification, 'target_phase': 'Governance'} - ] + measures - dependency_summary = [] - if plan: - for dep in RoadmapTaskDependency.objects.filter(successor__phase__plan=plan).select_related('predecessor', 'successor')[:12]: - dependency_summary.append({ - 'predecessor': dep.predecessor.title, 'successor': dep.successor.title, - 'type': dep.dependency_type, 'rationale': dep.rationale, - }) - - # Regulatorische Scores aus versionierten Requirement-Mappings ableiten - nis2_percent = WizardService._calculate_nis2_readiness(session, domain_scores) - kritis_percent = RegulatoryMappingService.calculate_framework_readiness(session, Requirement.Framework.KRITIS) - cra_percent = RegulatoryMappingService.calculate_framework_readiness(session, Requirement.Framework.CRA) - product_matrix = ProductSecurityService.get_regime_matrix(session.tenant) - framework_readiness = ProductSecurityService.calculate_framework_readiness(session) - compliance_versions = RegulatoryMappingService.build_version_snapshot() - - return ReportSnapshot.objects.create( - tenant=session.tenant, session=session, - title=f'Report {session.tenant.name}', - executive_summary=(session.executive_summary or '') + f' Laenderkontext: {country_context["display"]}.', - applicability_result=session.applicability_result, - iso_readiness_percent=average, - nis2_readiness_percent=nis2_percent, - kritis_readiness_percent=kritis_percent, - cra_readiness_percent=cra_percent or framework_readiness['cra_readiness_percent'], - ai_act_readiness_percent=framework_readiness['ai_act_readiness_percent'], - iec62443_readiness_percent=framework_readiness['iec62443_readiness_percent'], - iso_sae_21434_readiness_percent=framework_readiness['iso_sae_21434_readiness_percent'], - regulatory_matrix_json=product_matrix, - compliance_versions_json=compliance_versions, - product_security_json={ - 'summary': product_matrix['summary'], - 'product_security_scope': session.tenant.product_security_scope, - 'flags': { - 'develops_digital_products': session.tenant.develops_digital_products, - 'uses_ai_systems': session.tenant.uses_ai_systems, - 'ot_iacs_scope': session.tenant.ot_iacs_scope, - 'automotive_scope': session.tenant.automotive_scope, - 'psirt_defined': session.tenant.psirt_defined, - 'sbom_required': session.tenant.sbom_required, - }, - }, - top_gaps_json=gaps, - top_measures_json=top_measures[:10], - roadmap_summary=roadmap_summary, - domain_scores_json=domain_scores_json, - next_steps_json={**WizardService._build_next_steps(session), 'dependencies': dependency_summary}, - ) - - @staticmethod - def _calculate_nis2_readiness(session: AssessmentSession, domain_scores: List[DomainScore]) -> int: - """F16: NIS2-Readiness basierend auf tatsaechlich NIS2-relevanten Fragen berechnen. - - Anstatt den ISO-Durchschnitt mit einem Sektor-Bonus zu addieren (was fachlich - keinen Sinn ergibt), berechnen wir den Erfuellungsgrad der NIS2-markierten Fragen. - """ - if session.assessment_type == AssessmentSession.Type.ISO_READINESS: - return 0 # Kein NIS2-Score im reinen ISO-Modus - if session.assessment_type == AssessmentSession.Type.APPLICABILITY: - # Fuer den Applicability-Modus: Score aus der Betroffenheitspruefung normalisieren - total = session.answers.filter(question__question_kind=AssessmentQuestion.Kind.APPLICABILITY).count() - if total == 0: - return 0 - score = sum(a.score for a in session.answers.filter(question__question_kind=AssessmentQuestion.Kind.APPLICABILITY)) - max_score = total * 5 - return min(100, int((score / max_score) * 100)) if max_score else 0 - - return RegulatoryMappingService.calculate_framework_readiness(session, Requirement.Framework.NIS2) diff --git a/apps/wizard/tests.py b/apps/wizard/tests.py deleted file mode 100644 index 7a26011..0000000 --- a/apps/wizard/tests.py +++ /dev/null @@ -1,355 +0,0 @@ -import json -from unittest.mock import Mock, patch - -from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings -from django.urls import reverse - -from apps.catalog.models import AssessmentDomain -from apps.organizations.models import Tenant -from apps.reports.models import ReportSnapshot -from apps.roadmap.models import RoadmapPhase, RoadmapPlan -from apps.wizard.models import AssessmentSession, DomainScore, GeneratedGap, GeneratedMeasure - - -User = get_user_model() - - -@override_settings(WIZARD_RESULTS_BACKEND='local', RUST_BACKEND_URL='') -class WizardViewTests(TestCase): - def setUp(self): - self.tenant_a = Tenant.objects.create( - name='Tenant A', - slug='tenant-a', - country='DE', - operation_countries=['DE'], - sector='MSSP', - ) - self.tenant_b = Tenant.objects.create(name='Tenant B', slug='tenant-b', country='DE') - self.user_a = User.objects.create_user( - username='tenant-a-wizard', - email='wizard-user@example.test', - password='testpass123', - tenant=self.tenant_a, - first_name='Ada', - last_name='Lovelace', - ) - self.user_b = User.objects.create_user( - username='tenant-b-wizard', - password='testpass123', - tenant=self.tenant_b, - ) - self.session_a = AssessmentSession.objects.create( - tenant=self.tenant_a, - started_by=self.user_a, - assessment_type=AssessmentSession.Type.FULL, - status=AssessmentSession.Status.COMPLETED, - current_step=AssessmentSession.Step.RESULTS, - applicability_result='NIS2 relevant', - applicability_reasoning='Critical managed service provider', - executive_summary='Executive summary from wizard', - progress_percent=100, - ) - self.session_b = AssessmentSession.objects.create( - tenant=self.tenant_b, - started_by=self.user_b, - assessment_type=AssessmentSession.Type.FULL, - ) - self.domain = AssessmentDomain.objects.create( - code='GOV', - name='Governance', - sort_order=1, - ) - DomainScore.objects.create( - session=self.session_a, - domain=self.domain, - score_raw=8, - score_percent=80, - maturity_level='Managed', - gap_level='LOW', - ) - GeneratedGap.objects.create( - session=self.session_a, - domain=self.domain, - severity=GeneratedGap.Severity.HIGH, - title='MFA-Abdeckung fehlt', - description='MFA is not consistently enforced.', - ) - GeneratedMeasure.objects.create( - session=self.session_a, - domain=self.domain, - title='MFA ausrollen', - description='Roll out MFA.', - priority=GeneratedMeasure.Priority.CRITICAL, - effort=GeneratedMeasure.Effort.MEDIUM, - measure_type=GeneratedMeasure.Type.TECHNICAL, - target_phase='30 Tage', - owner_role='IAM Lead', - ) - self.report = ReportSnapshot.objects.create( - tenant=self.tenant_a, - session=self.session_a, - title='April Readiness', - executive_summary='Report summary', - applicability_result='NIS2 relevant', - iso_readiness_percent=80, - nis2_readiness_percent=75, - domain_scores_json=[{'domain': 'Governance', 'score_percent': 80, 'maturity_level': 'Managed'}], - regulatory_matrix_json={'summary': 'applicable'}, - compliance_versions_json={}, - product_security_json={}, - next_steps_json={'dependencies': [], 'next_30_days': [], 'next_60_days': [], 'next_90_days': []}, - ) - self.plan = RoadmapPlan.objects.create( - tenant=self.tenant_a, - session=self.session_a, - title='Security Roadmap', - summary='Roadmap summary', - overall_priority='HIGH', - ) - RoadmapPhase.objects.create( - plan=self.plan, - name='Governance Phase', - sort_order=1, - objective='Create governance.', - duration_weeks=2, - ) - - def test_wizard_start_uses_local_sessions_by_default(self): - self.client.force_login(self.user_a) - - response = self.client.get(reverse('wizard:start')) - - self.assertEqual(response.status_code, 200) - self.assertEqual(list(response.context['sessions']), [self.session_a]) - self.assertEqual(response.context['wizard_results_source'], 'django') - - @patch('apps.wizard.services.urlopen') - def test_wizard_start_can_use_rust_session_bridge(self, mock_urlopen): - self._mock_rust_response(mock_urlopen, { - 'api_version': 'v1', - 'tenant_id': self.tenant_a.id, - 'sessions': [self._rust_session_payload(assessment_type='ISO_READINESS')], - }) - self.client.force_login(self.user_a) - - with self.settings(WIZARD_RESULTS_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.get(reverse('wizard:start')) - - self.assertEqual(response.status_code, 200) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual(rust_request.full_url, 'http://rust-backend:9000/api/v1/wizard/sessions') - self.assertEqual(rust_request.get_header('X-iscy-tenant-id'), str(self.tenant_a.id)) - self.assertEqual(response.context['wizard_results_source'], 'rust_service') - self.assertEqual(list(response.context['sessions'])[0].assessment_type, 'ISO_READINESS') - - @patch('apps.wizard.services.urlopen') - def test_wizard_results_can_use_rust_result_bridge(self, mock_urlopen): - self._mock_rust_response(mock_urlopen, self._rust_results_payload()) - self.client.force_login(self.user_a) - - with self.settings(WIZARD_RESULTS_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'): - response = self.client.get(reverse('wizard:results', kwargs={'pk': self.session_a.id})) - - self.assertEqual(response.status_code, 200) - rust_request = mock_urlopen.call_args.args[0] - self.assertEqual( - rust_request.full_url, - f'http://rust-backend:9000/api/v1/wizard/sessions/{self.session_a.id}/results', - ) - self.assertEqual(response.context['wizard_results_source'], 'rust_service') - self.assertEqual(response.context['session'].executive_summary, 'Rust Executive Summary') - self.assertEqual(response.context['report'].title, 'Rust Report') - self.assertEqual(response.context['plan'].title, 'Rust Roadmap') - self.assertEqual(response.context['domain_scores'][0].domain.name, 'Governance') - self.assertEqual(response.context['measures'][0].get_priority_display(), 'Kritisch') - self.assertContains(response, 'Rust Executive Summary') - self.assertContains(response, 'Governance Phase') - - @patch('apps.wizard.services.urlopen', side_effect=OSError('backend down')) - def test_wizard_results_falls_back_to_django_when_rust_unavailable_in_non_strict_mode(self, _mock_urlopen): - self.client.force_login(self.user_a) - - with self.settings( - WIZARD_RESULTS_BACKEND='rust_service', - RUST_BACKEND_URL='http://rust-backend:9000', - RUST_STRICT_MODE=False, - ): - response = self.client.get(reverse('wizard:results', kwargs={'pk': self.session_a.id})) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['wizard_results_source'], 'django') - self.assertEqual(response.context['session'], self.session_a) - - def test_wizard_results_raises_in_strict_mode_without_rust_backend_url(self): - self.client.force_login(self.user_a) - - with self.settings(WIZARD_RESULTS_BACKEND='rust_service', RUST_BACKEND_URL='', RUST_STRICT_MODE=True): - with self.assertRaises(RuntimeError): - self.client.get(reverse('wizard:results', kwargs={'pk': self.session_a.id})) - - def _mock_rust_response(self, mock_urlopen, payload): - response_mock = Mock() - response_mock.read.return_value = json.dumps(payload).encode('utf-8') - response_ctx = Mock() - response_ctx.__enter__ = Mock(return_value=response_mock) - response_ctx.__exit__ = Mock(return_value=False) - mock_urlopen.return_value = response_ctx - - def _rust_session_payload(self, **overrides): - payload = { - 'id': self.session_a.id, - 'tenant_id': self.tenant_a.id, - 'tenant_name': self.tenant_a.name, - 'assessment_type': 'FULL', - 'assessment_type_label': 'Vollstaendige ISMS-/NIS2-Gap-Analyse', - 'status': 'COMPLETED', - 'status_label': 'Abgeschlossen', - 'current_step': 'results', - 'current_step_label': 'Ergebnis', - 'started_by_id': self.user_a.id, - 'started_by_display': 'Ada Lovelace', - 'applicability_result': 'NIS2 relevant', - 'applicability_reasoning': 'Critical managed service provider', - 'executive_summary': 'Rust Executive Summary', - 'progress_percent': 100, - 'completed_at': '2026-04-18T12:00:00Z', - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - } - payload.update(overrides) - return payload - - def _rust_results_payload(self): - return { - 'api_version': 'v1', - 'session': self._rust_session_payload(), - 'report': { - 'id': self.report.id, - 'tenant_id': self.tenant_a.id, - 'session_id': self.session_a.id, - 'title': 'Rust Report', - 'executive_summary': 'Report summary', - 'applicability_result': 'NIS2 relevant', - 'iso_readiness_percent': 80, - 'nis2_readiness_percent': 75, - 'kritis_readiness_percent': 30, - 'cra_readiness_percent': 20, - 'ai_act_readiness_percent': 10, - 'iec62443_readiness_percent': 15, - 'iso_sae_21434_readiness_percent': 25, - 'regulatory_matrix_json': {'summary': 'applicable'}, - 'compliance_versions_json': {}, - 'product_security_json': {}, - 'top_gaps_json': [{'title': 'MFA-Abdeckung fehlt'}], - 'top_measures_json': [{'title': 'MFA ausrollen', 'priority': 'CRITICAL'}], - 'roadmap_summary': [{'name': 'Governance'}], - 'domain_scores_json': [{'domain': 'Governance', 'score_percent': 80, 'maturity_level': 'Managed'}], - 'next_steps_json': {'dependencies': [], 'next_30_days': [], 'next_60_days': [], 'next_90_days': []}, - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - }, - 'roadmap': { - 'plan': { - 'id': self.plan.id, - 'tenant_id': self.tenant_a.id, - 'tenant_name': self.tenant_a.name, - 'session_id': self.session_a.id, - 'title': 'Rust Roadmap', - 'summary': 'Roadmap summary', - 'overall_priority': 'HIGH', - 'planned_start': '2026-05-01', - 'phase_count': 1, - 'task_count': 1, - 'open_task_count': 1, - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - }, - 'phases': [{ - 'id': 50, - 'plan_id': self.plan.id, - 'name': 'Governance Phase', - 'sort_order': 1, - 'objective': 'Create governance.', - 'duration_weeks': 2, - 'planned_start': '2026-05-01', - 'planned_end': '2026-05-14', - 'task_count': 1, - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - }], - 'tasks': [{ - 'id': 51, - 'phase_id': 50, - 'phase_name': 'Governance Phase', - 'measure_id': None, - 'title': 'Policy aktualisieren', - 'description': 'Update security policy', - 'priority': 'HIGH', - 'owner_role': 'CISO', - 'due_in_days': 14, - 'dependency_text': '', - 'status': 'OPEN', - 'status_label': 'Offen', - 'planned_start': '2026-05-01', - 'due_date': '2026-05-07', - 'notes': '', - 'incoming_dependency_count': 0, - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - }], - 'dependencies': [], - }, - 'domain_scores': [{ - 'id': 10, - 'session_id': self.session_a.id, - 'domain_id': self.domain.id, - 'domain_code': 'GOV', - 'domain_name': 'Governance', - 'domain_sort_order': 1, - 'score_raw': 8, - 'score_percent': 80, - 'maturity_level': 'Managed', - 'gap_level': 'LOW', - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - }], - 'gaps': [{ - 'id': 20, - 'session_id': self.session_a.id, - 'domain_id': self.domain.id, - 'domain_code': 'GOV', - 'domain_name': 'Governance', - 'question_id': None, - 'severity': 'HIGH', - 'severity_label': 'Hoch', - 'title': 'MFA-Abdeckung fehlt', - 'description': 'MFA is not consistently enforced.', - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - }], - 'measures': [{ - 'id': 30, - 'session_id': self.session_a.id, - 'domain_id': self.domain.id, - 'domain_code': 'GOV', - 'domain_name': 'Governance', - 'question_id': None, - 'title': 'MFA ausrollen', - 'description': 'Roll out MFA.', - 'priority': 'CRITICAL', - 'priority_label': 'Kritisch', - 'effort': 'MEDIUM', - 'effort_label': 'Mittel', - 'measure_type': 'TECHNICAL', - 'measure_type_label': 'Technisch', - 'target_phase': '30 Tage', - 'owner_role': 'IAM Lead', - 'reason': 'Identity gap', - 'status': 'OPEN', - 'status_label': 'Offen', - 'created_at': '2026-04-18T10:00:00Z', - 'updated_at': '2026-04-18T11:00:00Z', - }], - 'evidence_count': 2, - } diff --git a/apps/wizard/urls.py b/apps/wizard/urls.py deleted file mode 100644 index f13ba81..0000000 --- a/apps/wizard/urls.py +++ /dev/null @@ -1,20 +0,0 @@ -from django.urls import path -from .views import ( - WizardApplicabilityView, - WizardMaturityView, - WizardProfileView, - WizardResultsView, - WizardScopeView, - WizardStartView, -) - -app_name = 'wizard' - -urlpatterns = [ - path('', WizardStartView.as_view(), name='start'), - path('wizard//profile/', WizardProfileView.as_view(), name='profile'), - path('wizard//applicability/', WizardApplicabilityView.as_view(), name='applicability'), - path('wizard//scope/', WizardScopeView.as_view(), name='scope'), - path('wizard//maturity/', WizardMaturityView.as_view(), name='maturity'), - path('wizard//results/', WizardResultsView.as_view(), name='results'), -] diff --git a/apps/wizard/views.py b/apps/wizard/views.py deleted file mode 100644 index a0af982..0000000 --- a/apps/wizard/views.py +++ /dev/null @@ -1,360 +0,0 @@ -"""Wizard Views V19 – F09 (Tenant-Pruefung), F10 (Audit-Trail), F11 (nicht-destruktives Scope-Update), F14 (Objektautorisierung).""" - -from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin -from django.core.exceptions import PermissionDenied -from django.shortcuts import get_object_or_404, redirect, render -from django.views import View -from django.views.generic import FormView, TemplateView - -from apps.catalog.models import AssessmentDomain, AssessmentQuestion -from apps.core.models import AuditLog -from apps.organizations.models import BusinessUnit, Supplier -from apps.processes.models import Process -from apps.reports.models import ReportSnapshot -from apps.roadmap.models import RoadmapPlan -from apps.evidence.models import EvidenceItem -from apps.product_security.services import ProductSecurityService - -from .forms import AssessmentLaunchForm, CompanyProfileForm, ScopeCaptureForm -from .models import AssessmentSession -from .services import WizardResultsBridge, WizardService - - -def _require_tenant(request): - """F09+F14: Stellt sicher dass der User einen Tenant hat.""" - if request.tenant is None: - raise PermissionDenied('Kein Mandant zugeordnet. Bitte Admin kontaktieren.') - return request.tenant - - -def _require_session_access(request, pk): - """F14: Stellt sicher dass der User Zugriff auf die Session hat.""" - tenant = _require_tenant(request) - session = get_object_or_404(AssessmentSession, pk=pk) - if session.tenant_id != tenant.pk: - raise PermissionDenied('Kein Zugriff auf diese Session.') - return session - - -class WizardStartView(LoginRequiredMixin, FormView): - template_name = 'wizard/start.html' - form_class = AssessmentLaunchForm - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - tenant = WizardService.get_default_tenant(self.request.user) - rust_sessions = WizardResultsBridge.fetch_sessions(self.request, tenant) - context['tenant'] = tenant - if rust_sessions is not None: - context['sessions'] = rust_sessions - context['wizard_results_source'] = 'rust_service' - else: - context['sessions'] = AssessmentSession.objects.filter(tenant=tenant) if tenant else [] - context['wizard_results_source'] = 'django' - context['latest_report'] = ReportSnapshot.objects.filter(tenant=tenant).first() if tenant else None - context['sector_context'] = WizardService.get_sector_context(tenant) if tenant else None - context['country_context'] = WizardService.get_country_context(tenant) if tenant else None - context['mode_options'] = [WizardService.get_mode_context(choice[0]) for choice in AssessmentSession.Type.choices] - context['product_matrix'] = ProductSecurityService.get_regime_matrix(tenant) if tenant else None - return context - - def form_valid(self, form): - tenant = WizardService.get_default_tenant(self.request.user) - if not tenant: - messages.error(self.request, 'Es gibt noch keinen Tenant. Bitte zuerst Demo-Daten seeden oder eine Organisation anlegen.') - return redirect('organizations:create') - - selected_countries = form.cleaned_data.get('countries') or [] - tenant.sector = form.cleaned_data['sector'] - tenant.operation_countries = selected_countries - tenant.country = selected_countries[0] if selected_countries else tenant.country - tenant.save(update_fields=['sector', 'operation_countries', 'country', 'updated_at']) - - session = AssessmentSession.objects.create( - tenant=tenant, - assessment_type=form.cleaned_data['assessment_type'], - started_by=self.request.user, - status=AssessmentSession.Status.IN_PROGRESS, - current_step=AssessmentSession.Step.PROFILE, - progress_percent=10, - ) - # F10: Audit-Trail - AuditLog.log( - request=self.request, action=AuditLog.Action.CREATE, - entity=session, changes={'assessment_type': session.assessment_type}, - ) - return redirect('wizard:profile', pk=session.pk) - - -class WizardProfileView(LoginRequiredMixin, View): - template_name = 'wizard/profile.html' - - def get(self, request, pk): - session = _require_session_access(request, pk) - initial_countries = session.tenant.operation_countries or ([session.tenant.country] if session.tenant.country else []) - form = CompanyProfileForm(instance=session.tenant, initial={'countries': initial_countries}) - return render(request, self.template_name, { - 'session': session, - 'form': form, - 'sector_context': WizardService.get_sector_context(session.tenant), - 'country_context': WizardService.get_country_context(session.tenant), - 'product_matrix': ProductSecurityService.get_regime_matrix(session.tenant), - }) - - def post(self, request, pk): - session = _require_session_access(request, pk) - form = CompanyProfileForm(request.POST, instance=session.tenant) - if form.is_valid(): - tenant = form.save(commit=False) - selected_countries = form.cleaned_data.get('countries') or [] - tenant.operation_countries = selected_countries - tenant.country = selected_countries[0] if selected_countries else '' - tenant.annual_revenue_million = form.cleaned_data.get('annual_revenue_million') or 0 - tenant.balance_sheet_million = form.cleaned_data.get('balance_sheet_million') or 0 - tenant.save() - # F10: Audit-Trail - AuditLog.log( - request=request, action=AuditLog.Action.UPDATE, - entity=tenant, changes={'sector': tenant.sector, 'countries': selected_countries}, - ) - next_step = WizardService.next_step_after_profile(session) - session.current_step = next_step - session.status = AssessmentSession.Status.IN_PROGRESS - session.save(update_fields=['current_step', 'status', 'updated_at']) - WizardService.update_progress(session) - messages.info(request, 'Der ausgewaehlte Sektor beeinflusst nun Betroffenheitsindikation, Schwerpunktdomaenen und Roadmap.') - if next_step == AssessmentSession.Step.APPLICABILITY: - return redirect('wizard:applicability', pk=session.pk) - return redirect('wizard:scope', pk=session.pk) - return render(request, self.template_name, { - 'session': session, - 'form': form, - 'sector_context': WizardService.get_sector_context(session.tenant), - 'country_context': WizardService.get_country_context(session.tenant), - 'product_matrix': ProductSecurityService.get_regime_matrix(session.tenant), - }) - - -class WizardApplicabilityView(LoginRequiredMixin, View): - template_name = 'wizard/applicability.html' - - def get(self, request, pk): - session = _require_session_access(request, pk) - questions = AssessmentQuestion.objects.filter( - question_kind=AssessmentQuestion.Kind.APPLICABILITY - ).exclude(code='APP_SECTOR').prefetch_related('options') - existing = { - answer.question_id: answer.selected_option_id - for answer in session.answers.filter(question__question_kind=AssessmentQuestion.Kind.APPLICABILITY) - } - return render(request, self.template_name, { - 'session': session, - 'questions': questions, - 'existing': existing, - 'sector_context': WizardService.get_sector_context(session.tenant), - 'country_context': WizardService.get_country_context(session.tenant), - }) - - def post(self, request, pk): - session = _require_session_access(request, pk) - questions = AssessmentQuestion.objects.filter( - question_kind=AssessmentQuestion.Kind.APPLICABILITY - ).exclude(code='APP_SECTOR').prefetch_related('options') - answers = {} - for question in questions: - selected = request.POST.get(f'question_{question.id}') - if selected: - answers[question.code] = selected - WizardService.save_answers(session, answers) - WizardService.evaluate_applicability(session) - if session.assessment_type == AssessmentSession.Type.APPLICABILITY: - WizardService.generate_results(session) - messages.success(request, 'Betroffenheitspruefung ausgewertet. Ergebnis und empfohlene naechste Schritte wurden erzeugt.') - return redirect('wizard:results', pk=session.pk) - session.current_step = AssessmentSession.Step.SCOPE - session.save(update_fields=['current_step', 'updated_at']) - WizardService.update_progress(session) - return redirect('wizard:scope', pk=session.pk) - - -class WizardScopeView(LoginRequiredMixin, View): - template_name = 'wizard/scope.html' - - def get(self, request, pk): - session = _require_session_access(request, pk) - business_units = '\n'.join(session.tenant.business_units.values_list('name', flat=True)) - processes = '\n'.join(session.tenant.processes.values_list('name', flat=True)) - suppliers = '\n'.join(session.tenant.suppliers.values_list('name', flat=True)) - form = ScopeCaptureForm( - initial={ - 'scope_statement': session.tenant.description, - 'business_units': business_units, - 'processes': processes, - 'suppliers': suppliers, - } - ) - return render(request, self.template_name, { - 'session': session, - 'form': form, - 'sector_context': WizardService.get_sector_context(session.tenant), - 'country_context': WizardService.get_country_context(session.tenant), - }) - - def post(self, request, pk): - session = _require_session_access(request, pk) - form = ScopeCaptureForm(request.POST) - if form.is_valid(): - tenant = session.tenant - tenant.description = form.cleaned_data['scope_statement'] - tenant.save(update_fields=['description', 'updated_at']) - - # F11: Nicht-destruktives Upsert statt Delete-Recreate - _upsert_scope_entities( - tenant, BusinessUnit, - form.cleaned_data['business_units'], - lambda name: BusinessUnit(tenant=tenant, name=name), - ) - _upsert_scope_entities( - tenant, Process, - form.cleaned_data['processes'], - lambda name: Process( - tenant=tenant, name=name, - description='Aus dem Wizard angelegter Kernprozess', - status=Process.Status.MISSING, - ), - ) - _upsert_scope_entities( - tenant, Supplier, - form.cleaned_data['suppliers'], - lambda name: Supplier( - tenant=tenant, name=name, - criticality='HIGH', - service_description='Aus dem Wizard angelegter Lieferant', - ), - ) - # F10: Audit-Trail - AuditLog.log( - request=request, action=AuditLog.Action.UPDATE, - entity=tenant, - changes={'scope': 'Scope-Daten aktualisiert (Geschaeftsbereiche, Prozesse, Lieferanten)'}, - ) - session.current_step = AssessmentSession.Step.MATURITY - session.save(update_fields=['current_step', 'updated_at']) - WizardService.update_progress(session) - return redirect('wizard:maturity', pk=session.pk) - return render(request, self.template_name, { - 'session': session, - 'form': form, - 'sector_context': WizardService.get_sector_context(session.tenant), - 'country_context': WizardService.get_country_context(session.tenant), - }) - - -def _upsert_scope_entities(tenant, model_class, text_block, create_fn): - """F11: Merge/Upsert statt Delete-Recreate. - - - Neue Namen werden angelegt. - - Bestehende Namen bleiben erhalten (inkl. aller Relationen). - - Namen die nicht mehr in der Liste sind, werden NICHT geloescht, - um abhaengige Assessments/Evidenzen zu schuetzen. - """ - new_names = {line.strip() for line in text_block.splitlines() if line.strip()} - existing_names = set(model_class.objects.filter(tenant=tenant).values_list('name', flat=True)) - to_create = new_names - existing_names - for name in to_create: - obj = create_fn(name) - obj.save() - - -class WizardMaturityView(LoginRequiredMixin, View): - template_name = 'wizard/maturity.html' - - def get(self, request, pk): - session = _require_session_access(request, pk) - sector_context = WizardService.get_sector_context(session.tenant) - domains = AssessmentDomain.objects.prefetch_related('questions__options').all().order_by('sort_order') - if session.assessment_type == AssessmentSession.Type.ISO_READINESS: - domains = domains.filter(questions__applies_to_iso27001=True).distinct() - existing = { - answer.question_id: answer.selected_option_id - for answer in session.answers.filter(question__question_kind=AssessmentQuestion.Kind.MATURITY) - } - return render(request, self.template_name, { - 'session': session, - 'domains': domains, - 'existing': existing, - 'sector_context': sector_context, - 'country_context': WizardService.get_country_context(session.tenant), - }) - - def post(self, request, pk): - session = _require_session_access(request, pk) - questions = AssessmentQuestion.objects.filter(question_kind=AssessmentQuestion.Kind.MATURITY) - if session.assessment_type == AssessmentSession.Type.ISO_READINESS: - questions = questions.filter(applies_to_iso27001=True) - answers = {} - comments = {} - for question in questions: - selected = request.POST.get(f'question_{question.id}') - if selected: - answers[question.code] = selected - comments[question.code] = request.POST.get(f'comment_{question.id}', '') - WizardService.save_answers(session, answers, comments) - WizardService.generate_results(session) - # F10: Audit-Trail - AuditLog.log( - request=request, action=AuditLog.Action.STATUS_CHANGE, - entity=session, changes={'status': 'COMPLETED', 'step': 'maturity -> results'}, - ) - messages.success(request, 'Assessment ausgewertet. Massnahmen und Roadmap wurden erzeugt.') - return redirect('wizard:results', pk=session.pk) - - -class WizardResultsView(LoginRequiredMixin, TemplateView): - template_name = 'wizard/results.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - tenant = _require_tenant(self.request) - rust_results = WizardResultsBridge.fetch_results(self.request, tenant, self.kwargs['pk']) - if rust_results is not None: - session = rust_results.session - context.update( - { - 'session': session, - 'report': rust_results.report, - 'plan': rust_results.plan, - 'domain_scores': rust_results.domain_scores, - 'gaps': rust_results.gaps, - 'measures': rust_results.measures, - 'evidence_count': rust_results.evidence_count, - 'sector_context': WizardService.get_sector_context(session.tenant), - 'country_context': WizardService.get_country_context(session.tenant), - 'mode_context': WizardService.get_mode_context(session), - 'product_matrix': ProductSecurityService.get_regime_matrix(session.tenant), - 'wizard_results_source': 'rust_service', - } - ) - return context - - session = _require_session_access(self.request, self.kwargs['pk']) - report = ReportSnapshot.objects.filter(session=session).first() - plan = RoadmapPlan.objects.filter(session=session).first() - context.update( - { - 'session': session, - 'report': report, - 'plan': plan, - 'domain_scores': session.domain_scores.select_related('domain'), - 'gaps': session.generated_gaps.select_related('domain')[:20], - 'measures': session.generated_measures.select_related('domain')[:20], - 'evidence_count': EvidenceItem.objects.filter(session=session).count(), - 'sector_context': WizardService.get_sector_context(session.tenant), - 'country_context': WizardService.get_country_context(session.tenant), - 'mode_context': WizardService.get_mode_context(session), - 'product_matrix': ProductSecurityService.get_regime_matrix(session.tenant), - 'wizard_results_source': 'django', - } - ) - return context diff --git a/config/__init__.py b/config/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/config/asgi.py b/config/asgi.py deleted file mode 100644 index dac9666..0000000 --- a/config/asgi.py +++ /dev/null @@ -1,5 +0,0 @@ -import os -from django.core.asgi import get_asgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') -application = get_asgi_application() diff --git a/config/settings.py b/config/settings.py deleted file mode 100644 index 7ce9162..0000000 --- a/config/settings.py +++ /dev/null @@ -1,185 +0,0 @@ -from pathlib import Path -import os -from dotenv import load_dotenv -import dj_database_url - -BASE_DIR = Path(__file__).resolve().parent.parent -load_dotenv(BASE_DIR / '.env') -APP_DISPLAY_NAME = 'ISCY' - -# --- F13: Kein unsicherer Default-Key. In Production MUSS SECRET_KEY gesetzt sein. --- -_secret = os.getenv('SECRET_KEY', '') -DEBUG = os.getenv('DEBUG', 'True').lower() == 'true' -if not _secret and not DEBUG: - raise RuntimeError( - 'SECRET_KEY environment variable is not set. ' - 'Refusing to start in non-DEBUG mode with an empty secret.' - ) -SECRET_KEY = _secret or 'dev-only-insecure-key-do-not-use-in-production' - -ALLOWED_HOSTS = [host.strip() for host in os.getenv('ALLOWED_HOSTS', '127.0.0.1,localhost').split(',') if host.strip()] - -INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'apps.core', - 'apps.accounts', - 'apps.organizations', - 'apps.requirements_app', - 'apps.processes', - 'apps.risks', - 'apps.assessments', - 'apps.dashboard', - 'apps.guidance', - 'apps.catalog', - 'apps.wizard', - 'apps.roadmap', - 'apps.reports', - 'apps.evidence', - 'apps.assets_app', - 'apps.import_center', - 'apps.product_security', - 'apps.vulnerability_intelligence', -] - -MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'whitenoise.middleware.WhiteNoiseMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - # F09: Tenant-Isolation-Middleware - 'apps.core.middleware.TenantMiddleware', -] - -ROOT_URLCONF = 'config.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR / 'templates'], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - 'apps.core.context_processors.app_metadata', - ], - }, - }, -] - -WSGI_APPLICATION = 'config.wsgi.application' -ASGI_APPLICATION = 'config.asgi.application' - -DATABASES = { - 'default': dj_database_url.parse( - os.getenv('DATABASE_URL', f'sqlite:///{BASE_DIR / "db.sqlite3"}'), - conn_max_age=600, - ) -} - -AUTH_PASSWORD_VALIDATORS = [ - {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, - {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, -] - -LANGUAGE_CODE = 'de-de' -TIME_ZONE = 'Europe/Berlin' -USE_I18N = True -USE_TZ = True - -STATIC_URL = '/static/' -STATICFILES_DIRS = [BASE_DIR / 'static'] -STATIC_ROOT = BASE_DIR / 'staticfiles' -STORAGES = { - 'default': { - 'BACKEND': 'django.core.files.storage.FileSystemStorage', - }, - 'staticfiles': { - 'BACKEND': 'whitenoise.storage.CompressedManifestStaticFilesStorage', - }, -} - -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -AUTH_USER_MODEL = 'accounts.User' - -LOGIN_URL = '/login/' -LOGIN_REDIRECT_URL = 'wizard:start' -LOGOUT_REDIRECT_URL = 'login' - -MEDIA_URL = '/media/' -MEDIA_ROOT = BASE_DIR / 'media' - -# --- F15: Datei-Upload-Beschraenkungen --- -EVIDENCE_ALLOWED_EXTENSIONS = ['.pdf', '.docx', '.xlsx', '.png', '.jpg', '.jpeg', '.csv', '.txt', '.msg'] -EVIDENCE_MAX_FILE_SIZE_MB = 25 -DATA_UPLOAD_MAX_MEMORY_SIZE = EVIDENCE_MAX_FILE_SIZE_MB * 1024 * 1024 -FILE_UPLOAD_MAX_MEMORY_SIZE = EVIDENCE_MAX_FILE_SIZE_MB * 1024 * 1024 - -# --- F12: Konfigurierbare Evidence-Coverage-Schwellen --- -EVIDENCE_COVERAGE_THRESHOLDS = { - 'covered': 2, - 'partial': 1, -} - -def _env_bool(name: str, default: bool = False) -> bool: - return os.getenv(name, str(default)).lower() == 'true' - - -# Reverse-Proxy / Production-Haertung -USE_X_FORWARDED_HOST = _env_bool('USE_X_FORWARDED_HOST', not DEBUG) -SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') if _env_bool('TRUST_X_FORWARDED_PROTO', not DEBUG) else None -CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in os.getenv('CSRF_TRUSTED_ORIGINS', '').split(',') if origin.strip()] - -if not DEBUG: - SECURE_SSL_REDIRECT = _env_bool('SECURE_SSL_REDIRECT', False) - SESSION_COOKIE_SECURE = _env_bool('SESSION_COOKIE_SECURE', False) - CSRF_COOKIE_SECURE = _env_bool('CSRF_COOKIE_SECURE', False) - SECURE_HSTS_SECONDS = int(os.getenv('SECURE_HSTS_SECONDS', '0')) - SECURE_HSTS_INCLUDE_SUBDOMAINS = _env_bool('SECURE_HSTS_INCLUDE_SUBDOMAINS', False) - SECURE_HSTS_PRELOAD = _env_bool('SECURE_HSTS_PRELOAD', False) - SECURE_CONTENT_TYPE_NOSNIFF = True - SECURE_BROWSER_XSS_FILTER = True - X_FRAME_OPTIONS = 'DENY' - -# Lokales LLM / CVE-Intelligence -LOCAL_LLM_ENABLED = os.getenv('LOCAL_LLM_ENABLED', 'False').lower() == 'true' -LOCAL_LLM_BACKEND = os.getenv('LOCAL_LLM_BACKEND', 'rust_service') -LOCAL_LLM_MODEL_NAME = os.getenv('LOCAL_LLM_MODEL_NAME', 'Qwen3-8B-GGUF') -LOCAL_LLM_MODEL_PATH = os.getenv('LOCAL_LLM_MODEL_PATH', '') -LOCAL_LLM_RUST_URL = os.getenv('LOCAL_LLM_RUST_URL', '').strip() -LOCAL_LLM_N_CTX = int(os.getenv('LOCAL_LLM_N_CTX', '8192')) -LOCAL_LLM_N_THREADS = int(os.getenv('LOCAL_LLM_N_THREADS', str(max(2, os.cpu_count() or 4)))) -LOCAL_LLM_GPU_LAYERS = int(os.getenv('LOCAL_LLM_GPU_LAYERS', '0')) -LOCAL_LLM_VERBOSE_NATIVE = os.getenv('LOCAL_LLM_VERBOSE_NATIVE', 'False').lower() == 'true' -LOCAL_LLM_TEST_MAX_TOKENS = int(os.getenv('LOCAL_LLM_TEST_MAX_TOKENS', '96')) -NVD_API_KEY = os.getenv('NVD_API_KEY', '') -RUST_BACKEND_URL = os.getenv('RUST_BACKEND_URL', '').strip() -RUST_ONLY_MODE = os.getenv('RUST_ONLY_MODE', 'True').lower() == 'true' -VULN_INTEL_RUST_ONLY = os.getenv('VULN_INTEL_RUST_ONLY', 'True').lower() == 'true' -RISK_SCORING_BACKEND = os.getenv('RISK_SCORING_BACKEND', 'rust_service') -GUIDANCE_SCORING_BACKEND = os.getenv('GUIDANCE_SCORING_BACKEND', 'rust_service') -REPORT_SUMMARY_BACKEND = os.getenv('REPORT_SUMMARY_BACKEND', 'rust_service') -REPORT_SNAPSHOT_BACKEND = os.getenv('REPORT_SNAPSHOT_BACKEND', 'rust_service') -DASHBOARD_SUMMARY_BACKEND = os.getenv('DASHBOARD_SUMMARY_BACKEND', 'rust_service') -CATALOG_BACKEND = os.getenv('CATALOG_BACKEND', 'rust_service') -REQUIREMENTS_BACKEND = os.getenv('REQUIREMENTS_BACKEND', 'rust_service') -ASSET_INVENTORY_BACKEND = os.getenv('ASSET_INVENTORY_BACKEND', 'rust_service') -PROCESS_REGISTER_BACKEND = os.getenv('PROCESS_REGISTER_BACKEND', 'rust_service') -RISK_REGISTER_BACKEND = os.getenv('RISK_REGISTER_BACKEND', 'rust_service') -EVIDENCE_REGISTER_BACKEND = os.getenv('EVIDENCE_REGISTER_BACKEND', 'rust_service') -ASSESSMENT_REGISTER_BACKEND = os.getenv('ASSESSMENT_REGISTER_BACKEND', 'rust_service') -ROADMAP_REGISTER_BACKEND = os.getenv('ROADMAP_REGISTER_BACKEND', 'rust_service') -WIZARD_RESULTS_BACKEND = os.getenv('WIZARD_RESULTS_BACKEND', 'rust_service') -IMPORT_CENTER_BACKEND = os.getenv('IMPORT_CENTER_BACKEND', 'rust_service') -PRODUCT_SECURITY_BACKEND = os.getenv('PRODUCT_SECURITY_BACKEND', 'rust_service') -RUST_STRICT_MODE = os.getenv('RUST_STRICT_MODE', 'True').lower() == 'true' diff --git a/config/urls.py b/config/urls.py deleted file mode 100644 index daf4913..0000000 --- a/config/urls.py +++ /dev/null @@ -1,33 +0,0 @@ -from django.conf import settings -from django.conf.urls.static import static -from django.contrib import admin -from django.urls import include, path -from django.contrib.auth import views as auth_views -from apps.core import views as core_views - -urlpatterns = [ - path('health/live/', core_views.live_health, name='health_live'), - path('health/ready/', core_views.ready_health, name='health_ready'), - path('admin/', admin.site.urls), - path('login/', auth_views.LoginView.as_view(template_name='registration/login.html'), name='login'), - path('logout/', auth_views.LogoutView.as_view(), name='logout'), - path('', include('apps.wizard.urls')), - path('navigator/', include('apps.guidance.urls')), - path('dashboard/', include('apps.dashboard.urls')), - path('catalog/', include('apps.catalog.urls')), - path('reports/', include('apps.reports.urls')), - path('roadmap/', include('apps.roadmap.urls')), - path('evidence/', include('apps.evidence.urls')), - path('assets/', include('apps.assets_app.urls')), - path('imports/', include('apps.import_center.urls')), - path('processes/', include('apps.processes.urls')), - path('requirements/', include('apps.requirements_app.urls')), - path('risks/', include('apps.risks.urls')), - path('assessments/', include('apps.assessments.urls')), - path('organizations/', include('apps.organizations.urls')), - path('product-security/', include('apps.product_security.urls')), - path('cves/', include('apps.vulnerability_intelligence.urls')), -] - -if settings.DEBUG: - urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/config/wsgi.py b/config/wsgi.py deleted file mode 100644 index 885c6e5..0000000 --- a/config/wsgi.py +++ /dev/null @@ -1,5 +0,0 @@ -import os -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') -application = get_wsgi_application() diff --git a/docker-compose.llm.yml b/docker-compose.llm.yml index 92975c6..2a19f05 100644 --- a/docker-compose.llm.yml +++ b/docker-compose.llm.yml @@ -1,14 +1,9 @@ services: app: environment: - LOCAL_LLM_ENABLED: "True" - LOCAL_LLM_BACKEND: rust_service LOCAL_LLM_MODEL_NAME: ${LOCAL_LLM_MODEL_NAME:-iscy-rust-llm} - LOCAL_LLM_RUST_URL: ${LOCAL_LLM_RUST_URL:-http://rust-backend:9000} LOCAL_LLM_N_CTX: ${LOCAL_LLM_N_CTX:-8192} LOCAL_LLM_N_THREADS: ${LOCAL_LLM_N_THREADS:-8} LOCAL_LLM_GPU_LAYERS: ${LOCAL_LLM_GPU_LAYERS:-0} - LOCAL_LLM_VERBOSE_NATIVE: ${LOCAL_LLM_VERBOSE_NATIVE:-False} volumes: - media_data:/app/media - - static_data:/app/staticfiles diff --git a/docker-compose.override.yml b/docker-compose.override.yml index b8c8b63..2917e1c 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -5,12 +5,8 @@ services: app: environment: - DEBUG: "True" - RUN_SEEDS: "1" - APP_SERVER: runserver + RUST_BACKEND_BIND: "0.0.0.0:9000" ports: - - "8000:8000" + - "${ISCY_HTTP_PORT:-9000}:9000" volumes: - - .:/app - media_data:/app/media - - static_data:/app/staticfiles diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5be6186..dfcc19e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,15 +1,9 @@ services: app: environment: - DEBUG: "False" - RUN_SEEDS: "0" - RUN_COLLECTSTATIC: "1" - APP_SERVER: gunicorn - ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1} + RUST_BACKEND_BIND: "0.0.0.0:9000" volumes: - media_data:/app/media - - static_data:/app/staticfiles - - model_data:/app/models reverse-proxy: image: nginx:1.27-alpine @@ -26,13 +20,9 @@ services: volumes: - ./docker/nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro - media_data:/var/www/media:ro - - static_data:/var/www/static:ro healthcheck: - test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/health/live/ || exit 1"] + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/health/live || exit 1"] interval: 20s timeout: 5s retries: 5 start_period: 10s - -volumes: - model_data: diff --git a/docker-compose.stage.yml b/docker-compose.stage.yml index 4feeb4a..170f26b 100644 --- a/docker-compose.stage.yml +++ b/docker-compose.stage.yml @@ -1,15 +1,9 @@ services: app: environment: - DEBUG: "False" - RUN_SEEDS: "0" - RUN_COLLECTSTATIC: "1" - APP_SERVER: gunicorn - ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1} + RUST_BACKEND_BIND: "0.0.0.0:9000" volumes: - media_data:/app/media - - static_data:/app/staticfiles - - model_data:/app/models reverse-proxy: image: nginx:1.27-alpine @@ -26,13 +20,9 @@ services: volumes: - ./docker/nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro - media_data:/var/www/media:ro - - static_data:/var/www/static:ro healthcheck: - test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/health/live/ || exit 1"] + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/health/live || exit 1"] interval: 20s timeout: 5s retries: 5 start_period: 10s - -volumes: - model_data: diff --git a/docker-compose.yml b/docker-compose.yml index 1a7d9b7..abc9c86 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,72 +16,21 @@ services: app: build: - context: . - args: - INSTALL_LOCAL_LLM: ${INSTALL_LOCAL_LLM:-false} + context: ./rust/iscy-backend restart: unless-stopped depends_on: db: condition: service_healthy - rust-backend: - condition: service_healthy - env_file: - - .env environment: - DEBUG: ${DEBUG:-False} - SECRET_KEY: ${SECRET_KEY:-change-me-in-production} - ALLOWED_HOSTS: ${ALLOWED_HOSTS:-127.0.0.1,localhost} + RUST_BACKEND_BIND: "0.0.0.0:9000" DATABASE_URL: ${DATABASE_URL:-postgresql://isms:isms@db:5432/isms} - WAIT_FOR_DB: ${WAIT_FOR_DB:-1} - RUN_MIGRATIONS: ${RUN_MIGRATIONS:-1} - RUN_COLLECTSTATIC: ${RUN_COLLECTSTATIC:-0} - RUN_SEEDS: ${RUN_SEEDS:-0} - DJANGO_CHECK: ${DJANGO_CHECK:-1} - APP_SERVER: ${APP_SERVER:-gunicorn} - PORT: "8000" - GUNICORN_WORKERS: ${GUNICORN_WORKERS:-3} - GUNICORN_TIMEOUT: ${GUNICORN_TIMEOUT:-120} - RUST_BACKEND_URL: ${RUST_BACKEND_URL:-http://rust-backend:9000} - LOCAL_LLM_RUST_URL: ${LOCAL_LLM_RUST_URL:-http://rust-backend:9000} - RISK_SCORING_BACKEND: ${RISK_SCORING_BACKEND:-rust_service} - GUIDANCE_SCORING_BACKEND: ${GUIDANCE_SCORING_BACKEND:-rust_service} - REPORT_SUMMARY_BACKEND: ${REPORT_SUMMARY_BACKEND:-rust_service} - REPORT_SNAPSHOT_BACKEND: ${REPORT_SNAPSHOT_BACKEND:-rust_service} - DASHBOARD_SUMMARY_BACKEND: ${DASHBOARD_SUMMARY_BACKEND:-rust_service} - CATALOG_BACKEND: ${CATALOG_BACKEND:-rust_service} - REQUIREMENTS_BACKEND: ${REQUIREMENTS_BACKEND:-rust_service} - ASSET_INVENTORY_BACKEND: ${ASSET_INVENTORY_BACKEND:-rust_service} - PROCESS_REGISTER_BACKEND: ${PROCESS_REGISTER_BACKEND:-rust_service} - RISK_REGISTER_BACKEND: ${RISK_REGISTER_BACKEND:-rust_service} - EVIDENCE_REGISTER_BACKEND: ${EVIDENCE_REGISTER_BACKEND:-rust_service} - ASSESSMENT_REGISTER_BACKEND: ${ASSESSMENT_REGISTER_BACKEND:-rust_service} - ROADMAP_REGISTER_BACKEND: ${ROADMAP_REGISTER_BACKEND:-rust_service} - WIZARD_RESULTS_BACKEND: ${WIZARD_RESULTS_BACKEND:-rust_service} - IMPORT_CENTER_BACKEND: ${IMPORT_CENTER_BACKEND:-rust_service} - PRODUCT_SECURITY_BACKEND: ${PRODUCT_SECURITY_BACKEND:-rust_service} - RUST_ONLY_MODE: ${RUST_ONLY_MODE:-true} - RUST_STRICT_MODE: ${RUST_STRICT_MODE:-true} - expose: - - "8000" + ISCY_MEDIA_ROOT: ${ISCY_MEDIA_ROOT:-/app/media} + NVD_API_BASE_URL: ${NVD_API_BASE_URL:-https://services.nvd.nist.gov} + NVD_API_KEY: ${NVD_API_KEY:-} + ports: + - "${ISCY_HTTP_PORT:-9000}:9000" volumes: - media_data:/app/media - - static_data:/app/staticfiles - healthcheck: - test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready/', timeout=5)\""] - interval: 20s - timeout: 10s - retries: 5 - start_period: 30s - - rust-backend: - build: - context: ./rust/iscy-backend - restart: unless-stopped - environment: - RUST_BACKEND_BIND: "0.0.0.0:9000" - DATABASE_URL: ${DATABASE_URL:-postgresql://isms:isms@db:5432/isms} - expose: - - "9000" healthcheck: test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:9000/health/live || exit 1"] interval: 20s @@ -92,4 +41,3 @@ services: volumes: postgres_data: media_data: - static_data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh deleted file mode 100755 index 5ad1d25..0000000 --- a/docker/entrypoint.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -cd /app - -log() { printf '[entrypoint] %s\n' "$1"; } - -wait_for_db() { - python - <<'PY' -import os -import time -import sys -import dj_database_url -import psycopg - -url = os.getenv('DATABASE_URL', '') -if not url or url.startswith('sqlite'): - sys.exit(0) - -cfg = dj_database_url.parse(url) -kwargs = { - 'dbname': cfg.get('NAME') or cfg.get('ENGINE', ''), - 'user': cfg.get('USER') or '', - 'password': cfg.get('PASSWORD') or '', - 'host': cfg.get('HOST') or 'db', - 'port': cfg.get('PORT') or 5432, -} -for attempt in range(1, 31): - try: - conn = psycopg.connect(**kwargs) - conn.close() - print('database-ready') - sys.exit(0) - except Exception as exc: - print(f'database-wait attempt={attempt}: {exc}') - time.sleep(2) -print('database-not-ready') -sys.exit(1) -PY -} - -if [[ "${WAIT_FOR_DB:-1}" == "1" ]]; then - log "waiting for database" - wait_for_db -fi - -if [[ "${RUN_MIGRATIONS:-1}" == "1" ]]; then - log "running migrations" - python manage.py migrate --noinput -fi - -if [[ "${RUN_COLLECTSTATIC:-0}" == "1" ]]; then - log "collecting static files" - python manage.py collectstatic --noinput -fi - -if [[ "${RUN_SEEDS:-0}" == "1" ]]; then - log "running seed commands" - python manage.py seed_demo || true - python manage.py seed_catalog || true - python manage.py seed_requirements || true - python manage.py seed_product_security || true -fi - -if [[ "${DJANGO_CHECK:-1}" == "1" ]]; then - log "running django check" - python manage.py check -fi - -if [[ "${VERIFY_LOCAL_LLM:-0}" == "1" && "${LOCAL_LLM_ENABLED:-False}" == "True" ]]; then - log "verifying local llm runtime" - python manage.py check_local_llm || true -fi - -if [[ $# -gt 0 ]]; then - exec "$@" -fi - -if [[ "${APP_SERVER:-gunicorn}" == "runserver" ]]; then - exec python manage.py runserver 0.0.0.0:${PORT:-8000} -fi - -exec gunicorn config.wsgi:application \ - --bind 0.0.0.0:${PORT:-8000} \ - --workers ${GUNICORN_WORKERS:-3} \ - --timeout ${GUNICORN_TIMEOUT:-120} \ - --access-logfile - \ - --error-logfile - diff --git a/docker/nginx/default.conf.template b/docker/nginx/default.conf.template index 62d23f2..0035ec9 100644 --- a/docker/nginx/default.conf.template +++ b/docker/nginx/default.conf.template @@ -3,13 +3,6 @@ server { server_name ${NGINX_HOST}; client_max_body_size ${NGINX_CLIENT_MAX_BODY_SIZE}; - location /static/ { - alias /var/www/static/; - access_log off; - expires 7d; - add_header Cache-Control "public, max-age=604800"; - } - location /media/ { alias /var/www/media/; access_log off; @@ -18,21 +11,21 @@ server { } location /health/live/ { - proxy_pass http://app:8000/health/live/; + proxy_pass http://app:9000/health/live; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location /health/ready/ { - proxy_pass http://app:8000/health/ready/; + proxy_pass http://app:9000/health/ready; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location / { - proxy_pass http://app:8000; + proxy_pass http://app:9000; proxy_http_version 1.1; proxy_read_timeout ${NGINX_PROXY_READ_TIMEOUT}; proxy_set_header Host $host; diff --git a/docs/ISCY_Handbuch.md b/docs/ISCY_Handbuch.md index 3bca0ce..a6985c8 100644 --- a/docs/ISCY_Handbuch.md +++ b/docs/ISCY_Handbuch.md @@ -530,7 +530,7 @@ Wenn du ISCY schnell und reproduzierbar starten willst, nutze Docker. 1. `make docker-check` Prueft zuerst alle Compose-Dateien auf Syntax und Zusammenbau. 2. `make docker-smoke` - Startet eine kurze Funktionsprobe (DB, Migration, Django-Check) und faehrt danach wieder herunter. + Startet eine kurze Rust-Funktionsprobe (DB, Healthcheck und zentrale API-Probes) und faehrt danach wieder herunter. 3. Danach den gewuenschten Modus starten - lokal: `make dev-up` - stage: `make stage-up` @@ -641,13 +641,10 @@ ISCY strukturiert, dokumentiert, priorisiert und verbindet. Entscheidungen muess - Risiken nicht technisch, sondern geschaeftlich formulieren - Produkt- und Schwachstellenlogik nur dort aktivieren, wo sie wirklich gebraucht wird -## 10. Git- und PDF-Bezug dieses Handbuchs +## 10. Git-Bezug dieses Handbuchs Dieses Handbuch ist bewusst als Markdown-Datei im Repository abgelegt, damit es: - versioniert werden kann - mit dem Produkt mitwaechst - in Pull Requests geprueft werden kann -- als PDF exportierbar bleibt - -Der PDF-Export erfolgt ueber das Skript `scripts/export_iscy_handbook_pdf.py`. diff --git a/docs/PROXMOX_PRODUCTION_RUNBOOK.md b/docs/PROXMOX_PRODUCTION_RUNBOOK.md index 91a3ff6..bea055d 100644 --- a/docs/PROXMOX_PRODUCTION_RUNBOOK.md +++ b/docs/PROXMOX_PRODUCTION_RUNBOOK.md @@ -49,8 +49,8 @@ make prod-up-llm ## 5. Nach dem Start pruefen - `docker compose -f docker-compose.yml -f docker-compose.prod.yml ps` -- `curl -f http://127.0.0.1/health/live/` -- `curl -f http://127.0.0.1/health/ready/` +- `curl -f http://127.0.0.1/health/live` +- `curl -f http://127.0.0.1/health/ready` ## 6. Backup / Restore diff --git a/docs/RUST_ABLOESE_CHECKLISTE.md b/docs/RUST_ABLOESE_CHECKLISTE.md index f784de6..24fc390 100644 --- a/docs/RUST_ABLOESE_CHECKLISTE.md +++ b/docs/RUST_ABLOESE_CHECKLISTE.md @@ -1,30 +1,31 @@ -# Rust-Ablöse-Checkliste (Python -> Rust) +# Rust-Abloese-Checkliste -Stand: 2026-04-22 +Stand: 2026-06-07 ## Ziel -Komplette Ablösung der produktiven Python/Django-Pfade durch Rust mit harten Gates gegen Mischbetrieb. Die CVE-Normalisierung war der erste harte Gate-Bereich; CI und lokaler Start laufen inzwischen Rust-first. - -## Umgesetzte Punkte -- [x] **Rust-only Gate in den Django-Settings aktiviert** (`VULN_INTEL_RUST_ONLY=True` per Default). -- [x] **Zentrale CVE-Upserts auf Rust-Normalisierung umgestellt** (`NVDService.upsert_cve` normalisiert standardmäßig via Rust). -- [x] **Harter Fehler bei fehlender Rust-Konfiguration** in Rust-only-Modus (`RUST_BACKEND_URL` erforderlich). -- [x] **Legacy-Canary-Import auf Rust-only vereinfacht** (kein `--apply-source` Mischmodus mehr). -- [x] **README auf Rust-only Betrieb aktualisiert**. -- [x] **Regression-Tests ergänzt/angepasst** (Rust-only Verhalten und Legacy-Command). -- [x] **Versionierter Rust-Normalisierungsvertrag eingeführt** (`/api/v1/nvd/normalize` mit `api_version` und stabilen Fehlercodes wie `invalid_cve_id`). -- [x] **Python-Collection-Import im Rust-only-Modus gesperrt**; `import_nvd_cves` und `sync_nvd_recent` orchestrieren den Rust-Primärpfad über die Rust-CLI. -- [x] **CI-Policy auf Rust-first umgestellt**: Rust-Tests, Rust-DB-/HTTP-Smoke und Nix-Rust-App-Smoke sind verbindlich. -- [x] **Rust-Session-Schicht eingefuehrt**: DB-validierte Tenant-/User-Sessions, Cookie/Bearer-Aufloesung und Web-Kontext ohne Query-Parameter. - -## Verbleibende Ablösearbeiten -- [ ] CVE-Normalisierung vollständig als produktiven Rust-Primärpfad konsolidieren (inkl. Monitoring, Betriebsdoku und finaler Entfernung des Python-Kompatibilitätsendpunkts). -- [ ] NVD-Collection-Import in Rust um Persistenz-/DB-Schreibpfad erweitern, damit Django nicht mehr für CVE-Upserts benötigt wird. -- [ ] Parity-Reports als optionales Audit-Feature markieren (nicht als Betriebsnotwendigkeit). -- [ ] Betriebsdoku für Stage/Prod um verpflichtende Rust-Health-SLOs und Alerting ergänzen. - -## Abnahmekriterien („komplett zu Rust“) -1. **Kein produktiver Python-Fallback** für CVE-Normalisierung mehr aktiv. -2. **Alle produktiven Upserts verwenden Rust-normalisierte CVE-IDs**. -3. **Rust-Healthchecks und Integrationstests sind verbindlich in CI/CD**. -4. **Runbook und Betriebshandbuch enthalten Rust-only Incident- und Rollback-Pfade**. + +Komplette Abloesung der produktiven Python/Django-Runtime durch Rust. + +## Abschlussstatus + +- [x] Rust-Axum-Service ist die einzige produktive App-Runtime. +- [x] Docker Compose baut und startet den Rust-Service als `app`. +- [x] Stage/Production-Nginx proxyt auf den Rust-Service. +- [x] Lokaler Start laeuft ueber `./start.sh` und `nix run .#iscy-backend`. +- [x] CI prueft Rust-Formatierung, Clippy, Rust-Tests, Rust-Smoke, Nix-Smoke und Compose-Konfiguration. +- [x] Django-App-Code wurde entfernt. +- [x] Django-Templates wurden entfernt. +- [x] `manage.py`, Django-Settings und ASGI/WSGI wurden entfernt. +- [x] Python-Requirements wurden entfernt. +- [x] Python-Dockerfile und Python-Entrypoint wurden entfernt. +- [x] Makefile-Ziele verweisen nicht mehr auf Django-Checks oder Django-Tests. +- [x] Env-Beispiele enthalten nur noch Rust-/Compose-relevante Runtime-Schluessel. + +## Abnahmekriterien + +1. Kein produktiver Request-Pfad ueber Django. +2. Kein Python-Dependency-Install fuer Runtime oder CI. +3. Rust-Healthchecks und Rust-Integrationstests sind verbindlich. +4. Neue Features werden in Rust-Web/API/CLI/Stores umgesetzt. + +Status: **erfuellt**. diff --git a/docs/RUST_CUTOVER_PLAN.md b/docs/RUST_CUTOVER_PLAN.md index a55376b..986e579 100644 --- a/docs/RUST_CUTOVER_PLAN.md +++ b/docs/RUST_CUTOVER_PLAN.md @@ -2,44 +2,29 @@ ## Ziel -Kontrollierter Wechsel von Python-Pfaden auf Rust-Pfade ohne Big-Bang-Risiko. Der fachlich freigegebene Runtime-Cutover ist jetzt als Rust-only-Konfiguration abgesichert. +Kontrollierter Wechsel der produktiven Runtime auf Rust. -## Aktueller Cutover-Stand +## Ergebnis -- `RUST_ONLY_MODE=True` ist der Default. -- `RUST_STRICT_MODE=True` ist der Default. -- `RUST_BACKEND_URL` ist in Rust-only-Runtime Pflicht. -- Alle migrierten Backend-Schalter muessen auf `rust_service` stehen. -- `VULN_INTEL_RUST_ONLY=True` ist Pflicht, damit CVE-Normalisierung und Imports nicht ueber Python-Fallbacks laufen. -- Docker Compose startet die Django-App nur noch mit `rust-backend` als Health-Abhaengigkeit und mit Rust-only-Guards. -- Legacy-Fallbacks sind nur noch fuer explizit markierte Tests/Debug-Laeufe mit `RUST_ONLY_MODE=False` erlaubt. +Der Cutover ist abgeschlossen. Der fruehere Parallelbetrieb wurde beendet und die Django/Python-Anwendung aus dem Repository entfernt. -## Stufe 0 – Vorbereitung +## Finaler Betriebsmodus -- Rust-Service stabil deployen (`/health/live` erreichbar) -- `RUST_BACKEND_URL` in Stage setzen -- Team-Test + Canary-Report als Pflicht vor Rollout +- `app` in Docker Compose ist der Rust-Axum-Service. +- `rust/iscy-backend` enthaelt Runtime, Weboberflaeche, API, Stores, CLI und Tests. +- Nginx proxyt Stage/Production auf `app:9000`. +- Lokaler Start erfolgt ueber `./start.sh`, `make dev-up` oder `nix run .#iscy-backend`. +- Datenbank-Bootstrap erfolgt ueber `iscy-backend init-demo`. +- Canary-/NVD-Jobs laufen ueber `iscy-canary`. -## Stufe 1 – Canary (Read-Only Vergleich) +## Historische Stufen -- `report_nvd_canary_parity` regelmaessig laufen lassen -- Match-Rate beobachten, Mismatches analysieren -- Keine fachliche Umschaltung, nur Transparenz +1. Vorbereitung: Rust-Service mit Health, CI und Compose integriert. +2. Canary: Python/Rust-Paritaet ueber Reports abgesichert. +3. Bridge-Write: Django-Kommandos auf Rust-Backend/CLI umgestellt. +4. Teil-Cutover: zentrale Web-/API-Slices in Rust umgesetzt. +5. Voll-Cutover: Python/Django-Code und Startpfade entfernt. -## Stufe 2 – Bridge-Write (kontrolliert) +## Nach dem Cutover -- `import_nvd_cves_via_rust` fuer selektierte Jobs/CVEs aktivieren -- Python-Upsert bleibt Source-of-Truth -- Fehlerbudget + Rollback-Regel definieren - -## Stufe 3 – Teil-Cutover - -- Rust-CLI/Wrapper (`import_nvd_cves`, `sync_nvd_recent`, `import_nvd_cves_canary`) fuer Stage mit verpflichtendem Rust-Healthcheck erzwingen -- In Production schrittweise per Job-Kohorten aktivieren -- Mismatch-Grenzwerte definieren (z. B. <0.5%) - -## Stufe 4 – Voll-Cutover - -- Rust-Normalisierung als Default -- Python-Normalisierung nur noch in expliziten Test-/Debug-Laeufen mit `RUST_ONLY_MODE=False` -- Runbook/Alerting auf Rust-Pfade final anpassen +Neue Arbeit erfolgt direkt in Rust. Falls alte Datenbanken weitergenutzt werden, bleiben historische Tabellennamen als Schema-Vertrag erhalten. diff --git a/docs/RUST_CUTOVER_STATUS.md b/docs/RUST_CUTOVER_STATUS.md index 6fc6421..42356a7 100644 --- a/docs/RUST_CUTOVER_STATUS.md +++ b/docs/RUST_CUTOVER_STATUS.md @@ -1,90 +1,54 @@ # ISCY Rust-Cutover-Status -Stand: 2026-04-25 +Stand: 2026-06-07 ## Kurzfassung -ISCY ist fachlich weit in Richtung Rust migriert, aber noch nicht sicher Python-frei. Der Rust-Axum-Service ist als Backend direkt startbar, lokale Starts laufen Rust-only, Rust kann eigene Session-Cookies aus Django-kompatiblen Passwort-Hashes oder DB-validierten Tenant-/User-Kontexten ausstellen, liest Rollen, Gruppen und Django-kompatible Permissions aus der Rust-DB-Schicht, und erzwingt schreibende Rollen fuer migrierte Write-Flows. Die Rust-Web-Shell ist aktiv und ersetzt die frueheren Platzhalter fuer Dashboard, Navigator, Catalog, Requirements, Risks, Assessments, Organizations, Evidence, Reports, Roadmap, Assets, Processes, Imports, Product Security, CVE-Feed und User-Administration durch serverseitig gerenderte, datengetriebene Seiten. Die Django-Schicht ist aber weiterhin fuer einzelne komplexe Browser-/Mapping-Flows relevant. +Der Runtime-Cutover nach Rust ist abgeschlossen. -Deshalb ist der finale Loeschschritt fuer Python noch nicht fachlich freigegeben. Python jetzt zu entfernen wuerde die Anwendung nicht abschliessen, sondern zentrale UI- und Betriebsfunktionen abschalten. +ISCY startet produktiv und lokal ueber den Rust-Axum-Service in `rust/iscy-backend`. Die fruehere Django/Python-Anwendung wurde aus dem Repository entfernt: `apps/`, `config/`, `templates/`, `manage.py`, `requirements*.txt`, der Python-Dockerfile und der Python-Entrypoint sind nicht mehr Teil des Runtime-Pfads. -## Direkt startbarer Rust-Pfad +## Runtime -Rust-Backend auf NixOS aus dem Repository-Root starten: +- Lokaler Start: `./start.sh` +- Nix-App: `nix run .#iscy-backend` +- Compose-App-Service: Rust-Backend auf Port `9000` +- Stage/Production-Reverse-Proxy: Nginx -> `app:9000` +- Healthchecks: `/health/live`, `/health/ready` -```bash -nix run .#iscy-backend -``` +## Abgedeckte Rust-Web-/API-Bereiche -Mit expliziter lokaler Konfiguration: +- Dashboard +- Navigator/Guidance +- Catalog und Requirements +- Assessments +- Organizations +- Risks +- Evidence inklusive Upload +- Reports +- Roadmap +- Assets +- Imports inklusive CSV/XLSX/XLSM-Preview +- Processes +- Product Security +- CVE Feed, CVE Assessments und NVD-Import +- User-Administration, Rollen, Gruppen und direkte Permissions -```bash -RUST_BACKEND_BIND=127.0.0.1:9000 DATABASE_URL=sqlite:///db.sqlite3 nix run .#iscy-backend -``` +## Entfernte Legacy-Pfade -Rust-Demo-Datenbank ohne Django-Migration initialisieren: +- Django-App-Code und Django-Templates +- Django-Settings, ASGI/WSGI und URL-Konfiguration +- `manage.py` +- Python-Requirements und lokale Python-Bootstrap-Ziele +- Python-Docker-Image und Entrypoint +- Django-Teamtests und Django-Smoke-Checks -```bash -DATABASE_URL=sqlite:///db.sqlite3 nix run .#iscy-backend -- init-demo -``` +## Verbleibende Kompatibilitaet -Healthcheck: - -```bash -curl -fsS http://127.0.0.1:9000/health -``` - -Der lokale Wrapper initialisiert die Rust-Datenbank und startet den Rust-Axum-Service: - -```bash -./start.sh -``` - -Rust-Demo-Login im Browser: `http://127.0.0.1:9000/login/` mit `admin / Admin123!`. Die Rust-Userverwaltung liegt danach unter `http://127.0.0.1:9000/admin/users/` und erlaubt User-Anlage, User-Bearbeitung, Rollen-/Gruppen-/Direktrechtewechsel und Passwortreset. - -Der alte Django-Runserver ist damit nicht mehr der lokale Standardpfad. - -## Bereits nach Rust verschoben - -- CVE-/NVD-Normalisierung, Einzelimport-, Upsert- und Canary-Pfade. -- Lokaler LLM-Endpunkt, Risiko-Priorisierung, Guidance-Auswertung und CVE-Report-Summary. -- Dashboard-, Report-, Catalog-, Requirements-, Asset-, Process- und Assessment-Read-Flows. -- Risk-Register Read-, Detail-, Create- und Update-Flows. -- Evidence Read-/Detail-Flows, Evidence-Need-Sync und Evidence-Dateiuploads ueber Rust-Web/API. -- Rust-Web-Shell mit Kontext-Formular sowie datengetriebenem Dashboard, Guidance Navigator, Catalog, Requirements, Assessments, Organizations, Risk-Register, Evidence-Ueberblick, Reports, Roadmap, Assets, Imports, Processes und Product Security. -- Rust-CVE-Slice mit `GET /api/v1/cves`, `GET /api/v1/cves/{cve_id}`, `GET /api/v1/cve-assessments`, `POST /api/v1/cve-assessments`, `GET /api/v1/cve-assessments/{assessment_id}`, `POST /api/v1/nvd/import` sowie Webrouten `/cves/`, `/cves/{assessment_id}/`, `/cves/assessments/{assessment_id}` und `/cves/llm-test/` fuer Feed, tenantgebundene Assessment-Reads, Assessment-Create-/Update-Upserts, echten Rust-NVD-Einzelimport, automatische Risk-/Vulnerability-Verknuepfung und LLM-Runtime-Checks. -- Rust-DB-Admin-CLI mit `migrate`, `seed-demo` und `init-demo` fuer Rust-eigenen SQLite/PostgreSQL-Bootstrap der operativen Kern-Tabellen inklusive Product-Security sowie vollstaendigem Katalog-/Requirement-Seed. -- Rust-Session-Schicht mit `iscy_auth_session`, `/api/v1/auth/sessions`, `/api/v1/auth/session`, Logout, Cookie/Bearer-Aufloesung, Django-kompatibler `pbkdf2_sha256`-Passwortpruefung und Web-Kontext ohne Query-Parameter. -- Rust-RBAC-Grundlage mit `accounts_role`, `accounts_userrole`, Session-Rollencodes, Header-Rollen-Fallback und Schreibschutz fuer migrierte Write-Endpunkte. -- Rust-Account-Administration mit `/api/v1/accounts/users`, `/api/v1/accounts/users/{user_id}`, `/api/v1/accounts/roles`, `/api/v1/accounts/groups`, `/api/v1/accounts/permissions` sowie Webroute `/admin/users/` fuer User-Liste, User-Anlage, User-Bearbeitung, Passwort-Hashing/-Reset, tenant-scoped Rollenzuweisung, Django-kompatible Gruppenzuweisung und direkte User-Permissions. -- Rust-first CI mit Rust-Tests, Rust-DB-/HTTP-Smoke und Nix-Rust-App-Smoke. -- `start.sh` startet lokal Rust-only statt Django-runserver. -- Roadmap Liste, Detail, Kanban, Task-Updates und Exportdaten. -- Wizard Start-/Result-Flows. -- Import-Center bestaetigte Importjobs plus Rust-Preview-/CSV-APIs `/api/v1/import-center/preview` und `/api/v1/import-center/csv` sowie Rust-Webflow `/imports/` + `/imports/preview/` fuer CSV-/XLSX-/XLSM-Importe, Mapping-Vorschau und Bestaetigung von Business-Units, Prozessen, Lieferanten und Assets. -- Evidence-Upload-API `/api/v1/evidence/uploads` und Rust-Webupload unter `/evidence/` mit Django-kompatibler Dateiablage `evidence/YYYY/MM/...`. -- Product-Security Liste, Produktdetail, Roadmap, Task-Updates und Vulnerability-Updates. -- Rust-only-/Strict-Guards fuer migrierte Backend-Schalter. - -## Blocker vor Python-Loeschung - -1. **Weboberflaeche:** Rust liefert fuer `/dashboard/`, `/navigator/`, `/catalog/`, `/requirements/`, `/assessments/`, `/organizations/`, `/risks/`, `/evidence/`, `/reports/`, `/roadmap/`, `/assets/`, `/imports/`, `/processes/`, `/product-security/` und `/cves/` bereits echte serverseitige Seiten. Die restlichen Detail-/Form-Flows, Zwischenwizard-Schritte und spezialisierten Exporte liegen noch in Django-Templates und Django-Views. -2. **Auth, Sessions und Admin:** Rust-Sessions, Passwort-Login, Rollen-/Schreibrechte sowie Account-Administration fuer User/Rollen-/Gruppen-/Direktrechtewechsel sind vorhanden. Die Django-kompatiblen Tabellen fuer Gruppen, Permissions und User-Zuordnung sind gebootstrapped; vollstaendige Django-Admin-Paritaet ist noch nicht komplett ersetzt. -3. **Migrations und Seeds:** Ein Rust-eigener Bootstrap fuer operative Kern-Tabellen inklusive Product-Security, Catalog und Requirements ist vorhanden. Einzelne historische Django-Schema-Details ausserhalb dieser Cutover-Slices sind noch nicht vollstaendig abgeloest. -4. **Formulare und Uploads:** Evidence-Dateiuploads sowie Import-Center Datei-Upload, CSV/XLSX/XLSM-Parsing und Mapping-Vorschau laufen direkt ueber Rust-Web/API. Weitere Django-Formreste ausserhalb dieser Cutover-Slices muessen noch ersetzt werden; im CVE-Bereich sind tenantgebundene Assessment-Upserts inklusive automatischer Risikoanreicherung und der NVD-Einzelimport jetzt in Rust, offen bleiben vor allem Bulk-/Sync-Orchestrierung und letzte Edge-Paritaet. -5. **Python-Dateien im Repo:** CI und lokaler Start sind Rust-first. Python/Django-Dateien bleiben noch als Legacy-Kompatibilitaet und muessen nach Abschluss von Auth/Web/Form-Flows gezielt entfernt werden. - -## Naechster fachlich sinnvoller Cutover-Schritt - -Der naechste Abschlussblock ist nicht mehr ein weiterer einzelner API-Endpunkt, sondern die Web- und Betriebsablösung: - -1. Letzte `vulnerability_intelligence`- und Admin-Reste fachlich auf Rust umhaengen. -2. Django-Templates durch Rust-Web oder ein separates Frontend auf Rust-API ersetzen. -3. Verbleibende Django-Template-/Form-Reste systematisch durch Rust-Web oder ein separates Frontend auf Rust-API ersetzen. -4. Python-Dateien, Requirements, Django-Settings und Django-Startpfade entfernen. +Die Rust-DB-Schicht nutzt weiterhin bestehende historische Tabellennamen wie `requirements_app_*` oder `vulnerability_intelligence_*`. Das ist Schema-Kompatibilitaet, kein aktiver Django-Runtime-Pfad. ## Cutover-Entscheidung -Aktueller Status: **Rust-Backend startbar, produktive Kern-APIs weit migriert, aber kein fachlich sicherer Python-Loeschzeitpunkt.** +Status: **abgeschlossen**. -Freigabe fuer `apps/`, `config/`, `manage.py`, `requirements*.txt` und Python-CI-Loeschung erst, wenn die oben genannten Blocker ersetzt und die verbliebenen Django-Form-/Detailpfade fachlich durch Rust abgeloest sind. +Neue Produktarbeit soll direkt im Rust-Service, den Rust-Stores, Rust-Webrouten, Rust-CLI-Binaries und Rust-Tests erfolgen. diff --git a/docs/RUST_REWRITE_ROADMAP.md b/docs/RUST_REWRITE_ROADMAP.md index 78650ac..e610766 100644 --- a/docs/RUST_REWRITE_ROADMAP.md +++ b/docs/RUST_REWRITE_ROADMAP.md @@ -1,152 +1,20 @@ -# ISCY Full-Rewrite nach Rust – Team-Roadmap +# ISCY Rust-Rewrite Roadmap -## Ziel +## Status -ISCY schrittweise von Django/Python auf Rust ueberfuehren, ohne Fachfunktionalitaet zu verlieren. +Die Roadmap ist abgeschlossen. ISCY wurde fuer die produktive Runtime auf Rust umgestellt. -## Vorgehen (Strangler Pattern) +## Erreichte Meilensteine -### Phase 1 – Foundation (jetzt) +- Rust-Axum-Service als Runtime. +- Rust-Weboberflaechen fuer zentrale Produktbereiche. +- Rust-APIs fuer Dashboard, Catalog, Requirements, Assets, Processes, Risks, Evidence, Assessments, Roadmap, Wizard, Imports, Product Security und CVEs. +- Rust-Session-, Auth- und Account-Administration. +- Rust-NVD-/CVE-Import und Canary-CLI. +- Rust-DB-Bootstrap fuer lokale Demo- und operative Daten. +- Rust-only Compose-, Nix- und CI-Pfade. +- Entfernung der Django/Python-Runtime aus dem Repository. -- Rust-Service `rust/iscy-backend` mit Health und erstem API-Endpunkt -- Build/Test in Team-Workflow integrieren -- API-Vertraege fuer Migration definieren +## Weiterentwicklung -### Phase 2 – Parallelbetrieb - -- NVD-Import/Sync als Rust-Worker migrieren -- Django ruft Rust-Service ueber HTTP/gRPC auf -- Ergebnisvergleich Python vs Rust (Canary) -- Bridge-Command vorhanden: `import_nvd_cves_via_rust` (Rust-Normalisierung + Python-NVD-Upsert) -- Canary-Command vorhanden: `import_nvd_cves_canary` (Paritaetsvergleich Python vs Rust, optional strict) -- Parity-Report vorhanden: `report_nvd_canary_parity` (JSON/CSV-Ausgabe) - -### Phase 3 – Domänenmigration - -- nacheinander: Vulnerability-Intelligence -> Guidance -> Reports -- jede Migration mit Feature-Parity-Check, Data-Mapping und Rollback-Pfad - -### Phase 4 – Cutover - -- Python-Endpunkte stilllegen -- Rust-Service als System of Record -- verbleibende Django-Teile entfernen - -## Engineering-Gates pro Phase - -1. Unit + Integration Tests gruen -2. API-Kontrakte stabil -3. Performance-Benchmark dokumentiert -4. Security-Review abgeschlossen -5. Rollback getestet - -## Stand 2026-04-18 - -- Rust-Backend ist in CI mit Format, Clippy und Tests verdrahtet. -- NVD-CVE-Normalisierung, Collection/Recent-Import und Einzel-CVE-Upsert laufen ueber Rust. -- Rust schreibt CVERecord-Daten direkt in die bestehende Django-Tabelle `vulnerability_intelligence_cverecord`. -- Django bleibt aktuell noch Kompatibilitaets-/Web-Schicht und liest nach Rust-Upserts bestehende Models zurueck. -- Rust hat einen ersten Request-Kontext-Vertrag fuer Tenant/User-Header: - - `GET /api/v1/context/whoami` fuer Diagnose/Bridge-Kontext - - `GET /api/v1/context/tenant` als Muster fuer geschuetzte tenantgebundene Rust-Routen -- `organizations` hat den ersten echten App-Read-Slice in Rust: - - `GET /api/v1/organizations/tenant-profile` liest das Tenant-Profil ueber den Rust-Tenant-Store - - die Route ist durch `X-ISCY-User-ID` und `X-ISCY-Tenant-ID` geschuetzt - - `/organizations/` rendert das tenantgebundene Organisationsprofil direkt aus dem Rust-Store -- `dashboard` bekommt den naechsten Read-Slice in Rust: - - `GET /api/v1/dashboard/summary` liefert tenantgebundene Zaehler fuer Prozesse, Assets, offene Risiken, Evidenzen, offene Roadmap-Tasks und den neuesten Report - - die Route nutzt denselben geschuetzten Tenant-Kontext wie die Organizations-API - - Django kann die Dashboard-KPI-Zeile ueber `DASHBOARD_SUMMARY_BACKEND=rust_service` aus Rust lesen und faellt im Nicht-Strict-Modus auf lokale ORM-Zaehler zurueck -- `reports` hat erste tenantgeschuetzte Read-APIs in Rust: - - `GET /api/v1/reports/snapshots` liefert die ReportSnapshot-Liste fuer den aktuellen Tenant - - `GET /api/v1/reports/snapshots/{report_id}` liefert ReportSnapshot-Details inklusive Readiness-Prozenten und JSON-Auswertungen - - Django kann die Report-Liste und die Report-Detailseite ueber `REPORT_SNAPSHOT_BACKEND=rust_service` aus Rust lesen und im Nicht-Strict-Modus auf lokale ORM-Reports zurueckfallen -- `assets_app` hat den naechsten tenantgeschuetzten Read-Slice in Rust: - - `GET /api/v1/assets/information-assets` liefert das Asset-Register inklusive Business-Unit- und Owner-Anzeige fuer den aktuellen Tenant - - Django kann die Asset-Liste ueber `ASSET_INVENTORY_BACKEND=rust_service` aus Rust lesen und im Nicht-Strict-Modus auf lokale ORM-Assets zurueckfallen -- `processes` hat tenantgeschuetzte Read-APIs in Rust: - - `GET /api/v1/processes` liefert das Prozessregister inklusive Business-Unit-, Owner- und Status-Anzeige fuer den aktuellen Tenant - - `GET /api/v1/processes/{process_id}` liefert die Prozessdetaildaten fuer die 10-Dimensionen-Ansicht - - Django kann Prozessliste und Prozessdetail ueber `PROCESS_REGISTER_BACKEND=rust_service` aus Rust lesen und im Nicht-Strict-Modus auf lokale ORM-Prozesse zurueckfallen -- `risks` hat tenantgeschuetzte Read-APIs in Rust: - - `GET /api/v1/risks` liefert das Risikoregister inklusive Kategorie, Prozess, Asset, Owner, Score und Risikolevel fuer den aktuellen Tenant - - `GET /api/v1/risks/{risk_id}` liefert die Risikodetaildaten fuer Bewertung und Behandlung - - Django kann Risikoliste und Risikodetail ueber `RISK_REGISTER_BACKEND=rust_service` aus Rust lesen und im Nicht-Strict-Modus auf lokale ORM-Risiken zurueckfallen -- `evidence` hat die erste tenantgeschuetzte Read-API in Rust: - - `GET /api/v1/evidence` liefert Evidenzliste, Nachweispflichten und Coverage-Summary fuer den aktuellen Tenant - - optional filtert `session_id` die Evidenzen und Nachweispflichten auf eine Assessment-Session - - `POST /api/v1/evidence/uploads` nimmt Evidence-Dateien als Multipart-Upload entgegen, speichert sie Django-kompatibel unter `evidence/YYYY/MM/...`, erstellt den Evidence-Datensatz und synchronisiert sessionbezogene Needs - - `/evidence/` stellt den Upload als Rust-Webformular bereit - - Django kann die Evidence-Liste ueber `EVIDENCE_REGISTER_BACKEND=rust_service` aus Rust lesen und im Nicht-Strict-Modus auf lokale ORM-Evidenzen zurueckfallen -- `assessments` hat erste tenantgeschuetzte Listen-APIs in Rust: - - `GET /api/v1/assessments/applicability` liefert Betroffenheitsanalysen fuer den aktuellen Tenant - - `GET /api/v1/assessments` liefert Prozess-/Requirement-Assessments inklusive Prozess-, Requirement- und Owner-Anzeige - - `GET /api/v1/assessments/measures` liefert Massnahmen inklusive Assessment- und Owner-Anzeige - - `/assessments/` rendert Applicability, Assessments und Measures als kombinierte Rust-Webseite - - Django kann die drei Listen ueber `ASSESSMENT_REGISTER_BACKEND=rust_service` aus Rust lesen und im Nicht-Strict-Modus auf lokale ORM-Daten zurueckfallen -- `catalog` und `requirements_app` haben tenantkontext-geschuetzte Read-APIs in Rust: - - `GET /api/v1/catalog/domains` liefert Domaenen inklusive eingebetteter Fragen und Gesamtzaehler - - `GET /api/v1/requirements` liefert Requirements inklusive Mapping-Versionen und Primaerquellen - - `/catalog/` und `/requirements/` rendern die Bibliotheken direkt aus den Rust-Stores - - Django kann Fragenkatalog und Requirement Library ueber `CATALOG_BACKEND=rust_service` und `REQUIREMENTS_BACKEND=rust_service` aus Rust lesen und im Nicht-Strict-Modus auf lokale ORM-Daten zurueckfallen -- `guidance` hat einen ersten echten Rust-Webslice: - - `POST /api/v1/guidance/evaluate` bleibt der gemeinsame Rust-Scoring-Pfad fuer Guided Journey und Tenant-Todos - - `/navigator/` rendert Fortschritt, naechsten Schritt, Journey-Status und Todo-Liste direkt aus Tenant-, Assessment-, Process-, Risk- und Requirement-Stores -- `roadmap` hat tenantgeschuetzte Read-/Update-APIs in Rust: - - `GET /api/v1/roadmap/plans` liefert Roadmap-Planlisten inklusive Phasen-/Task-Zaehlern fuer den aktuellen Tenant - - `GET /api/v1/roadmap/plans/{plan_id}` liefert den Plan-Detailbaum mit Phasen, Tasks und Abhaengigkeiten - - `PATCH /api/v1/roadmap/tasks/{task_id}` aktualisiert Status, Termine, Owner-Rolle und Notizen tenantgeschuetzt - - Django kann Liste, Detail, Kanban, Task-Updates sowie PDF/PNG-Exportdaten ueber `ROADMAP_REGISTER_BACKEND=rust_service` aus Rust bedienen und im Nicht-Strict-Modus auf lokale ORM-Daten zurueckfallen -- `wizard` hat tenantgeschuetzte Read-/Result-APIs in Rust: - - `GET /api/v1/wizard/sessions` liefert die Assessment-Session-Historie fuer den aktuellen Tenant - - `GET /api/v1/wizard/sessions/{session_id}/results` liefert Ergebnisdaten inklusive Domain-Scores, Gaps, Massnahmen, Evidenzzaehler, Report und Roadmap-Detailbaum - - Django kann Wizard-Start und Ergebnisansicht ueber `WIZARD_RESULTS_BACKEND=rust_service` aus Rust lesen und im Nicht-Strict-Modus auf lokale ORM-Daten zurueckfallen -- `import_center` hat tenantgeschuetzte Importjob-Writes in Rust: - - `POST /api/v1/import-center/jobs` legt gemappte Business-Units, Prozesse, Lieferanten und Assets an oder aktualisiert sie; optional ersetzt es vorhandene Eintraege dieses Typs - - `POST /api/v1/import-center/csv` parst CSV direkt in Rust und wendet Business-Unit-, Prozess-, Lieferanten- und Asset-Importe tenantgeschuetzt an - - `POST /api/v1/import-center/preview` parst CSV/XLSX/XLSM-Dateien direkt in Rust, leitet Default-Mappings aus Headern ab und liefert die Mapping-Vorschau als Rust-API - - `/imports/` und `/imports/preview/` stellen Datei-Upload, Mapping-Vorschau und Import-Bestaetigung als Rust-Webflow bereit - - Django behaelt fuer den Import-Center-Cutover keinen fachlich notwendigen Datei-/Mapping-Pfad mehr -- `product_security` hat tenantgeschuetzte Read-APIs in Rust: - - `GET /api/v1/product-security/overview` liefert Product-Security-Matrix, Posture-Kennzahlen, Produktliste und letzte Snapshots fuer den aktuellen Tenant - - `GET /api/v1/product-security/products/{product_id}` liefert den Detailbaum mit Releases, Komponenten, Threat Models, TARAs, Schwachstellen, AI-Systemen, PSIRT, Advisories, Snapshot und Roadmap-Aufgaben - - `GET /api/v1/product-security/products/{product_id}/roadmap` liefert Roadmap, Snapshot und Aufgaben fuer die Produkt-Roadmap - - `PATCH /api/v1/product-security/roadmap-tasks/{task_id}` aktualisiert Status, Prioritaet, Owner-Rolle, Ziel-Tage und Abhaengigkeitstext tenantgeschuetzt - - `PATCH /api/v1/product-security/vulnerabilities/{vulnerability_id}` aktualisiert Schweregrad, Status, Remediation-Datum und Zusammenfassung tenantgeschuetzt - - `/product-security/` rendert Matrix, Kennzahlen, Produktliste und Snapshots direkt aus dem Rust-Store - - Django kann Product-Security-Liste, Produktdetail, Roadmap, Roadmap-Task-Updates und Schwachstellen-Updates ueber `PRODUCT_SECURITY_BACKEND=rust_service` aus Rust bedienen und im Nicht-Strict-Modus auf lokale ORM-Daten zurueckfallen -- `vulnerability_intelligence` hat nun einen Rust-Read-Slice fuer globalen CVE-Feed, tenantgebundene Assessments und LLM-Runtime-Checks: - - `GET /api/v1/cves` liefert Kennzahlen und die aktuelle CVE-Liste aus `vulnerability_intelligence_cverecord` - - `GET /api/v1/cves/{cve_id}` liefert Schwachstellen-Detaildaten inklusive Weaknesses, Referenzen, KEV-Flags und Raw-JSON - - `POST /api/v1/nvd/import` importiert eine einzelne CVE direkt aus der NVD in Rust, persistiert sie im Rust-Store und ersetzt damit den Python-fetch-plus-upsert-Pfad fuer Einzelimporte - - `GET /api/v1/cve-assessments` liefert tenantgebundene CVE-Assessments inklusive Summary, Prioritaet, Produkt- und Risiko-Bezug - - `POST /api/v1/cve-assessments` legt tenantgebundene CVE-Assessments in Rust an oder aktualisiert sie per Natural-Key-Upsert, zieht Defaultwerte aus CVE/Tenant, berechnet Prioritaet/Faelligkeit, haengt den lokalen LLM-Stub an und verknuepft bei Bedarf automatisch Produkt-Security-Vulnerability und Risiko - - `GET /api/v1/cve-assessments/{assessment_id}` liefert den tenantgebundenen Assessment-Detailbaum inklusive LLM-Ergebnis, empfohlenen Massnahmen und benoetigten Evidenzen - - `/cves/` rendert CVE-Feed plus letzte tenantgebundene Assessments direkt aus dem Rust-Store und bietet den neuen Rust-Webflow fuer Assessment-Anlage/-Update - - `/cves/{assessment_id}/` und `/cves/assessments/{assessment_id}` rendert die Assessment-Detailansicht direkt aus dem Rust-Store und haengt die alte Django-URL-Form an den Rust-Webslice - - `/cves/llm-test/` prueft die lokale Rust-LLM-Runtime direkt ueber die Web-Shell - - offen bleiben im CVE-Bereich vor allem Bulk-/Sync-Orchestrierung, letzte Edge-Paritaet und das anschliessende Django-Abraumen - -## App-Migrationsreihenfolge - -Die Apps werden nicht alle gleichzeitig ersetzt. Jede Zeile bekommt eigene Rust-Routen, Tests, Datenzugriff und danach erst Django-Cutover. - -| Reihenfolge | Apps | Ziel in Rust | Warum diese Reihenfolge | -| --- | --- | --- | --- | -| 1 | `core`, `accounts`, `organizations` | Auth-/Tenant-Kontext, Berechtigungen, Tenant-Read-APIs | Ohne diese Basis waeren alle weiteren App-Endpunkte unsicher oder doppelt implementiert. | -| 2 | `vulnerability_intelligence`, `reports`, `dashboard` | Read-/Summary-APIs, CVE-Importe, Reporting | Bereits angefangen; hoher Nutzen, gut testbar, wenig Form-UI-Abhaengigkeit. | -| 3 | `guidance`, `requirements_app`, `catalog` | Regel-/Katalogdaten, Guided Journey Evaluation | Viel Fachlogik, aber vergleichsweise klare Datenmodelle. | -| 4 | `product_security`, `assets_app`, `processes`, `risks` | Tenant-gebundene CRUD-APIs und Bewertungslogik | Stark miteinander verknuepft; profitiert von fertiger Tenant-Schicht. | -| 5 | `assessments`, `evidence`, `roadmap`, `import_center`, `wizard` | Workflows, Writes, Importjobs, Evidence/Assessment-Flows | Hohe UI-/Form- und Workflow-Dichte; braucht stabile Rust-Basis. | -| 6 | Django-Web/Admin-Reste | Abschalten oder durch Rust-Web/Frontend ersetzen | Erst nach Funktionsparitaet sinnvoll. | - -## Dauer grob - -Diese Schaetzung gilt fuer einen kontrollierten Strangler-Umbau mit Tests und laufender CI: - -- Rust-Core + Auth/Tenant/App-API-Basis: ca. 2-4 Arbeitstage -- Read-only APIs fuer Dashboard, Reports, CVE, Guidance: ca. 1-2 Wochen -- Schreibende APIs fuer Assets, Prozesse, Risiken, Assessments, Evidence: ca. 2-4 Wochen -- Weboberflaeche/Admin-Ersatz und finaler Django-Cutover: ca. 2-4 Wochen - -Realistisch: Ein belastbarer Rust-Core kann schnell wachsen; die komplette Django-Abloesung aller Apps ist eher ein mehrwoechiges Projekt. Der sichere Weg ist: pro App ein Rust-API-Slice, Tests gruen, Django-Route umschalten, alten Python-Pfad entfernen. +Die alte Migrationsreihenfolge ist nicht mehr aktiv. Neue Produktarbeit wird als normale Rust-Backend-Weiterentwicklung geplant. diff --git a/docs/RUST_WEBSTACK_REWRITE_PLAN.md b/docs/RUST_WEBSTACK_REWRITE_PLAN.md index 3cecd9f..e0b12ec 100644 --- a/docs/RUST_WEBSTACK_REWRITE_PLAN.md +++ b/docs/RUST_WEBSTACK_REWRITE_PLAN.md @@ -1,29 +1,25 @@ -# Rust-Webstack Umbauplan (Python-Ablösung) - -Stand: 2026-04-18 - -## Zielbild -Vollständige Ablösung der Django-Anwendungslogik durch Rust-Services (API + Background Jobs + LLM + NVD), Python nur noch bis zum finalen Cutover als temporäre Kompatibilitätsschicht. - -## Jetzt umgesetzt (dieser Schritt) -- Lokaler LLM-Pfad in der App ist auf **Rust-Service only** gestellt. -- Legacy `llama_cpp`-Codepfad wurde aus der CVE-Service-Logik entfernt. -- Runtime-Checks/Views verwenden nur noch den Rust-Provider. - -## Nächste Umbaupakete -1. **Auth & Tenant API in Rust** - - Login/Session/JWT - - Tenant-Scoping und Rollenmodell -2. **Wizard/Guidance/Reporting in Rust** - - bestehende Django-Views durch Rust-API + Frontend adapter ersetzen -3. **Datenzugriff konsolidieren** - - Rust ORM/SQL Layer, Migrationen/Seeds in Rust-Pipeline -4. **Finaler Cutover** - - Django-Server deaktivieren - - Python-Dependencies + `manage.py` entfernen - - CI auf Rust-only umstellen - -## Abnahmekriterien -- Kein produktiver Request-Pfad mehr über Django -- Keine Python-Runtime in Build/Runtime-Images -- Rust-only CI (Tests, Lints, Migrations, Smoke) +# ISCY Rust-Webstack + +## Status + +Der Rust-Webstack ist der produktive Webstack. + +## Architektur + +- Axum liefert Weboberflaechen und JSON-APIs. +- Stores in `rust/iscy-backend/src/*_store.rs` kapseln Datenzugriffe. +- `db_admin` initialisiert operative Tabellen und Demo-/Katalogdaten. +- `iscy-canary` uebernimmt NVD-/CVE-Canary- und Importjobs. +- Nginx ist nur Reverse Proxy fuer Stage/Production. + +## Abgeloeste Komponenten + +- Django-Views und Templates +- Django-Admin als Runtime-Abhaengigkeit +- Django-Settings, URL-Konfiguration, ASGI/WSGI +- Python-Management-Commands +- Python-Docker-Runtime + +## Entwicklungsregel + +Neue UI-, API-, Job- und Datenzugriffsarbeit wird im Rust-Backend umgesetzt und mit Rust-Tests oder Rust-Smoke-Probes abgesichert. diff --git a/flake.nix b/flake.nix index 65a4621..b97f8dc 100644 --- a/flake.nix +++ b/flake.nix @@ -16,7 +16,6 @@ system: let pkgs = import nixpkgs { inherit system; }; - python = pkgs.python311; runtimeLibs = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib pkgs.zlib @@ -78,7 +77,6 @@ devShells.default = pkgs.mkShell { packages = [ - python pkgs.git pkgs.pkg-config pkgs.cmake diff --git a/manage.py b/manage.py deleted file mode 100644 index 022c381..0000000 --- a/manage.py +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env python -import os -import sys - - -def main(): - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') - from django.core.management import execute_from_command_line - execute_from_command_line(sys.argv) - - -if __name__ == '__main__': - main() diff --git a/requirements-llm.txt b/requirements-llm.txt deleted file mode 100644 index 4c3a73e..0000000 --- a/requirements-llm.txt +++ /dev/null @@ -1,2 +0,0 @@ -# LLM runtime moved to Rust service (`LOCAL_LLM_BACKEND=rust_service`). -# Keep this file for compatibility with existing bootstrap scripts. diff --git a/requirements-prod.txt b/requirements-prod.txt deleted file mode 100644 index 579dadb..0000000 --- a/requirements-prod.txt +++ /dev/null @@ -1,2 +0,0 @@ --r requirements.txt -gunicorn>=22,<24 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index f17f522..0000000 --- a/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -Django>=5.0,<6.0 -psycopg[binary]>=3.1 -dj-database-url>=2.1 -python-dotenv>=1.0 -reportlab>=4.0 -Pillow>=10.0 -whitenoise>=6.8,<7 - -openpyxl>=3.1 diff --git a/rust/iscy-backend/Dockerfile b/rust/iscy-backend/Dockerfile index 8d36387..f5bdf41 100644 --- a/rust/iscy-backend/Dockerfile +++ b/rust/iscy-backend/Dockerfile @@ -3,6 +3,7 @@ WORKDIR /build COPY Cargo.toml ./ COPY src ./src +COPY seeds ./seeds RUN cargo build --release diff --git a/scripts/backup_compose.sh b/scripts/backup_compose.sh index c56fee9..b8a270e 100755 --- a/scripts/backup_compose.sh +++ b/scripts/backup_compose.sh @@ -31,7 +31,7 @@ docker compose "${COMPOSE_ARGS[@]}" exec -T db \ pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" | gzip -c > "$TARGET_DIR/postgres.sql.gz" docker compose "${COMPOSE_ARGS[@]}" run --rm --entrypoint sh app \ - -lc 'mkdir -p /app/media /app/staticfiles /app/models && tar -C /app -czf - media staticfiles models 2>/dev/null || true' \ + -lc 'mkdir -p /app/media && tar -C /app -czf - media 2>/dev/null || true' \ > "$TARGET_DIR/storage.tar.gz" if [[ -f "$ENV_FILE" ]]; then diff --git a/scripts/download_local_llm.py b/scripts/download_local_llm.py deleted file mode 100755 index 562ccee..0000000 --- a/scripts/download_local_llm.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import os -from pathlib import Path - -DEFAULT_REPO = "MaziyarPanahi/Qwen3-8B-GGUF" -DEFAULT_FILENAME = "Qwen3-8B.Q4_K_M.gguf" - - -def main(): - parser = argparse.ArgumentParser(description="Download a local GGUF model for llama-cpp-python") - parser.add_argument("--repo-id", default=os.getenv("LOCAL_LLM_HF_REPO_ID", DEFAULT_REPO)) - parser.add_argument("--filename", default=os.getenv("LOCAL_LLM_HF_FILENAME", DEFAULT_FILENAME)) - parser.add_argument("--target-dir", default=os.getenv("LOCAL_LLM_TARGET_DIR", "models")) - parser.add_argument("--token", default=os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_HUB_TOKEN")) - parser.add_argument("--dry-run", action="store_true") - parser.add_argument("--print-path", action="store_true") - parser.add_argument("--force", action="store_true") - args = parser.parse_args() - - target_dir = Path(args.target_dir).resolve() - target_dir.mkdir(parents=True, exist_ok=True) - expected_path = target_dir / args.filename - - if args.print_path: - print(str(expected_path)) - return - - if expected_path.exists() and not args.force: - print(f"Model already present: {expected_path}") - return - - if args.dry_run: - print(f"Would download {args.repo_id}:{args.filename} -> {expected_path}") - return - - from huggingface_hub import hf_hub_download - - downloaded = hf_hub_download( - repo_id=args.repo_id, - filename=args.filename, - token=args.token, - local_dir=str(target_dir), - local_dir_use_symlinks=False, - resume_download=True, - ) - print(downloaded) - - -if __name__ == "__main__": - main() diff --git a/scripts/export_iscy_handbook_pdf.py b/scripts/export_iscy_handbook_pdf.py deleted file mode 100644 index 2240b29..0000000 --- a/scripts/export_iscy_handbook_pdf.py +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env python3 -"""Export the repository handbook markdown as a simple PDF.""" - -from pathlib import Path -import sys - - -ROOT = Path(__file__).resolve().parent.parent -SOURCE = ROOT / "docs" / "ISCY_Handbuch.md" -TARGET = ROOT / "docs" / "ISCY_Handbuch.pdf" - - -def _styles(): - from reportlab.lib import colors - from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet - - styles = getSampleStyleSheet() - styles.add(ParagraphStyle("DocTitle", parent=styles["Heading1"], fontSize=20, leading=24, textColor=colors.HexColor("#0f172a"), spaceAfter=12)) - styles.add(ParagraphStyle("Section", parent=styles["Heading2"], fontSize=14, leading=18, textColor=colors.HexColor("#1d4ed8"), spaceBefore=12, spaceAfter=6)) - styles.add(ParagraphStyle("SubSection", parent=styles["Heading3"], fontSize=11, leading=14, textColor=colors.HexColor("#334155"), spaceBefore=8, spaceAfter=4)) - styles.add(ParagraphStyle("Body", parent=styles["BodyText"], fontSize=9, leading=13, spaceAfter=6)) - styles.add(ParagraphStyle("Bullet2", parent=styles["BodyText"], fontSize=9, leading=12, leftIndent=14, bulletIndent=4, spaceAfter=3)) - return styles - - -def build_pdf(source: Path = SOURCE, target: Path = TARGET) -> Path: - try: - from reportlab.lib.pagesizes import A4 - from reportlab.lib.units import cm - from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer - except Exception as exc: # pragma: no cover - raise RuntimeError("reportlab ist nicht installiert. Bitte zuerst die Projekt-Dependencies installieren.") from exc - - if not source.exists(): - raise FileNotFoundError(f"Quelldatei fehlt: {source}") - - styles = _styles() - story = [] - for raw_line in source.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line: - story.append(Spacer(1, 0.15 * cm)) - continue - if line.startswith("# "): - story.append(Paragraph(line[2:].strip(), styles["DocTitle"])) - continue - if line.startswith("## "): - story.append(Paragraph(line[3:].strip(), styles["Section"])) - continue - if line.startswith("### "): - story.append(Paragraph(line[4:].strip(), styles["SubSection"])) - continue - if line.startswith("- "): - story.append(Paragraph(line[2:].strip(), styles["Bullet2"], bulletText="•")) - continue - if line.startswith(("1. ", "2. ", "3. ", "4. ", "5. ", "6. ", "7. ", "8. ", "9. ")): - marker, text = line.split(" ", 1) - story.append(Paragraph(text.strip(), styles["Bullet2"], bulletText=marker)) - continue - story.append(Paragraph(line, styles["Body"])) - - target.parent.mkdir(parents=True, exist_ok=True) - doc = SimpleDocTemplate( - str(target), - pagesize=A4, - leftMargin=2 * cm, - rightMargin=2 * cm, - topMargin=2 * cm, - bottomMargin=2 * cm, - title="ISCY Handbuch", - ) - doc.build(story) - return target - - -def main() -> int: - try: - target = build_pdf() - except Exception as exc: - print(f"Fehler beim PDF-Export: {exc}", file=sys.stderr) - return 1 - print(target) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/production_readiness_check.sh b/scripts/production_readiness_check.sh index bd23ded..2543849 100755 --- a/scripts/production_readiness_check.sh +++ b/scripts/production_readiness_check.sh @@ -14,18 +14,12 @@ if [ ! -f ".env" ]; then fi info "Pruefe kritische .env-Parameter ..." -if grep -Eq '^SECRET_KEY=change-me-in-production$' .env; then - err "SECRET_KEY ist noch auf Platzhalterwert." - exit 1 -fi -if grep -Eq '^DEBUG=True$' .env; then - err "DEBUG=True ist fuer Produktion nicht zulaessig." - exit 1 -fi -if ! grep -Eq '^ALLOWED_HOSTS=.+$' .env; then - err "ALLOWED_HOSTS fehlt oder ist leer." +if grep -Eq '^POSTGRES_PASSWORD=(change-me|isms)?$' .env; then + err "POSTGRES_PASSWORD ist noch auf einem Platzhalterwert." exit 1 fi +grep -Eq '^DATABASE_URL=.+$' .env || { err "DATABASE_URL fehlt."; exit 1; } +grep -Eq '^RUST_BACKEND_URL=.+$' .env || { err "RUST_BACKEND_URL fehlt."; exit 1; } if command -v docker >/dev/null 2>&1; then info "Validiere Compose-Konfiguration (prod/prod+llm) ..." @@ -35,7 +29,7 @@ else warn "Docker nicht vorhanden, Compose-Validierung wird uebersprungen." fi -info "Django-Basispruefung ..." -python manage.py check +info "Rust-Backend-Basispruefung ..." +cargo test --manifest-path rust/iscy-backend/Cargo.toml info "Readiness-Check abgeschlossen." diff --git a/scripts/restore_compose.sh b/scripts/restore_compose.sh index 69a6578..c4960c7 100755 --- a/scripts/restore_compose.sh +++ b/scripts/restore_compose.sh @@ -41,8 +41,8 @@ docker compose "${COMPOSE_ARGS[@]}" exec -T db \ gunzip -c "$RESTORE_DIR/postgres.sql.gz" | docker compose "${COMPOSE_ARGS[@]}" exec -T db \ psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -echo "[restore] restoring media/static/models" +echo "[restore] restoring media" cat "$RESTORE_DIR/storage.tar.gz" | docker compose "${COMPOSE_ARGS[@]}" run --rm --entrypoint sh app \ - -lc 'mkdir -p /app/media /app/staticfiles /app/models && tar -C /app -xzf -' + -lc 'mkdir -p /app/media && tar -C /app -xzf -' echo "[restore] done" diff --git a/templates/assessments/applicability_list.html b/templates/assessments/applicability_list.html deleted file mode 100644 index 0ba24ba..0000000 --- a/templates/assessments/applicability_list.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} -{% block content %} -

Betroffenheitsanalyse

Neue Analyse
-
-{% for item in items %} - -{% empty %} - -{% endfor %} -
TenantSektorStatusErstellt
{{ item.tenant }}{{ item.sector }}{{ item.get_status_display }}{{ item.created_at }}
Noch keine Analysen.
-{% endblock %} diff --git a/templates/assessments/assessment_list.html b/templates/assessments/assessment_list.html deleted file mode 100644 index b859e37..0000000 --- a/templates/assessments/assessment_list.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} -{% block content %} -

Assessments

Neues Assessment
-
-{% for item in items %} - -{% empty %} - -{% endfor %} -
ProzessRequirementStatusScore
{{ item.process }}{{ item.requirement }}{{ item.get_status_display }}{{ item.score }}
Noch keine Assessments.
-{% endblock %} diff --git a/templates/assessments/audit_detail.html b/templates/assessments/audit_detail.html deleted file mode 100644 index b00b1fe..0000000 --- a/templates/assessments/audit_detail.html +++ /dev/null @@ -1,46 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ audit.title }}{% endblock %} -{% block content %} -
-

{{ audit.title }}

{{ audit.get_audit_type_display }} · {{ audit.get_status_display }}

- -
-
-
Findings
{{ findings.count }}
-
Major NC
{{ major_count }}
-
Minor NC
{{ minor_count }}
-
Offen
{{ open_count }}
-
-
-
-
-
Audit-Details
- {% if audit.lead_auditor %}

Lead Auditor: {{ audit.lead_auditor }}

{% endif %} - {% if audit.scope %}

Scope: {{ audit.scope }}

{% endif %} - {% if audit.planned_start %}

Zeitraum: {{ audit.planned_start|date:"d.m.Y" }} – {{ audit.planned_end|date:"d.m.Y"|default:"offen" }}

{% endif %} - {% if audit.conclusion %}

Fazit: {{ audit.conclusion }}

{% endif %} -
-
-
-
-
-
Findings
- + Finding -
- {% for finding in findings %} -
-
-
- {{ finding.finding_number }}: {{ finding.title }} -
{{ finding.get_severity_display }} {{ finding.get_status_display }}{% if finding.due_date %} Faellig: {{ finding.due_date|date:"d.m.Y" }}{% endif %}
-
- Edit -
-
- {% empty %} -

Noch keine Findings erfasst.

- {% endfor %} -
-
-
-{% endblock %} diff --git a/templates/assessments/audit_form.html b/templates/assessments/audit_form.html deleted file mode 100644 index 901f5ef..0000000 --- a/templates/assessments/audit_form.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} -{% block title %}{% if form.instance.pk %}Audit bearbeiten{% else %}Neues Audit{% endif %}{% endblock %} -{% block content %} -

{% if form.instance.pk %}Audit bearbeiten{% else %}Neues Audit planen{% endif %}

-
-
{% csrf_token %} - {% for field in form %}
{{ field }}{% if field.help_text %}
{{ field.help_text }}
{% endif %}{% for error in field.errors %}
{{ error }}
{% endfor %}
{% endfor %} - -
-
-{% endblock %} diff --git a/templates/assessments/audit_list.html b/templates/assessments/audit_list.html deleted file mode 100644 index 2094dcc..0000000 --- a/templates/assessments/audit_list.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "base.html" %} -{% block title %}Audits{% endblock %} -{% block content %} -
-

Audit-Management

Interne und externe Audits mit Findings und Korrekturmassnahmen.

- Neues Audit -
-{% for audit in audits %} -
-
-
-

{{ audit.title }}

-
- {{ audit.get_audit_type_display }} - {{ audit.get_status_display }} - {% if audit.planned_start %}{{ audit.planned_start|date:"d.m.Y" }}{% if audit.planned_end %} – {{ audit.planned_end|date:"d.m.Y" }}{% endif %}{% endif %} - {{ audit.findings_count }} Findings ({{ audit.open_findings_count }} offen) -
- {% if audit.lead_auditor %}

Lead: {{ audit.lead_auditor }}

{% endif %} -
- Oeffnen -
-
-{% empty %} -

Noch keine Audits geplant.

-{% endfor %} -{% endblock %} diff --git a/templates/assessments/finding_form.html b/templates/assessments/finding_form.html deleted file mode 100644 index d7b7df9..0000000 --- a/templates/assessments/finding_form.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "base.html" %} -{% block title %}{% if form.instance.pk %}Finding bearbeiten{% else %}Neues Finding{% endif %}{% endblock %} -{% block content %} -

{% if form.instance.pk %}Finding bearbeiten{% else %}Neues Finding{% endif %}

-{% if audit %}

Audit: {{ audit.title }}

{% endif %} -
-
{% csrf_token %} - {% for field in form %}
{{ field }}{% if field.help_text %}
{{ field.help_text }}
{% endif %}
{% endfor %} - -
-
-{% endblock %} diff --git a/templates/assessments/measure_list.html b/templates/assessments/measure_list.html deleted file mode 100644 index 3442781..0000000 --- a/templates/assessments/measure_list.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} -{% block content %} -

Maßnahmen

Neue Maßnahme
-
-{% for item in items %} - -{% empty %} - -{% endfor %} -
TitelPriorityStatusDue Date
{{ item.title }}{{ item.get_priority_display }}{{ item.get_status_display }}{{ item.due_date|default:'-' }}
Noch keine Maßnahmen.
-{% endblock %} diff --git a/templates/assessments/review_action_form.html b/templates/assessments/review_action_form.html deleted file mode 100644 index df00eeb..0000000 --- a/templates/assessments/review_action_form.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} -{% block title %}Massnahme erfassen{% endblock %} -{% block content %} -

Review-Massnahme erfassen

-
-
{% csrf_token %} - {% for field in form %}
{{ field }}
{% endfor %} - -
-
-{% endblock %} diff --git a/templates/assessments/review_detail.html b/templates/assessments/review_detail.html deleted file mode 100644 index ad8dffd..0000000 --- a/templates/assessments/review_detail.html +++ /dev/null @@ -1,50 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ review.title }}{% endblock %} -{% block content %} -
-

{{ review.title }}

{{ review.get_status_display }}{% if review.review_date %} · {{ review.review_date|date:"d.m.Y" }}{% endif %}

- -
- -
- {# ── Inputs (ISO 9.3.2) ── #} -
-
-
Review-Inputs (ISO 27001 Kap. 9.3.2)
- {% if review.input_isms_status %}
ISMS-Status

{{ review.input_isms_status }}

{% endif %} - {% if review.input_audit_results %}
Audit-Ergebnisse

{{ review.input_audit_results }}

{% endif %} - {% if review.input_risk_status %}
Risikostatus

{{ review.input_risk_status }}

{% endif %} - {% if review.input_incidents %}
Sicherheitsvorfaelle

{{ review.input_incidents }}

{% endif %} - {% if review.input_metrics %}
Kennzahlen

{{ review.input_metrics }}

{% endif %} - {% if review.input_feedback %}
Feedback

{{ review.input_feedback }}

{% endif %} - {% if review.input_improvement %}
Verbesserungsmoeglichkeiten

{{ review.input_improvement }}

{% endif %} -
-
- {# ── Outputs (ISO 9.3.3) ── #} -
-
-
Review-Outputs / Entscheidungen (ISO 27001 Kap. 9.3.3)
- {% if review.output_decisions %}
Entscheidungen

{{ review.output_decisions }}

{% endif %} - {% if review.output_actions %}
Beschlossene Massnahmen

{{ review.output_actions }}

{% endif %} - {% if review.output_resource_needs %}
Ressourcenbedarf

{{ review.output_resource_needs }}

{% endif %} - {% if review.output_improvement %}
Verbesserungsmassnahmen

{{ review.output_improvement }}

{% endif %} - {% if review.protocol_summary %}
Protokoll-Zusammenfassung

{{ review.protocol_summary }}

{% endif %} -
-
-
- -{# ── Massnahmen aus Review ── #} -
-
-
Massnahmen aus dieser Review
- + Massnahme -
- {% for action in actions %} -
-
{{ action.title }}{{ action.get_status_display }}
- {% if action.description %}

{{ action.description }}

{% endif %} -
{% if action.responsible %}{{ action.responsible }}{% endif %}{% if action.due_date %}Faellig: {{ action.due_date|date:"d.m.Y" }}{% endif %}
-
- {% empty %}

Noch keine Massnahmen erfasst.

{% endfor %} -
-{% endblock %} diff --git a/templates/assessments/review_form.html b/templates/assessments/review_form.html deleted file mode 100644 index f2307cd..0000000 --- a/templates/assessments/review_form.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "base.html" %} -{% block title %}{% if form.instance.pk %}Review bearbeiten{% else %}Neue Management Review{% endif %}{% endblock %} -{% block content %} -

{% if form.instance.pk %}Management Review bearbeiten{% else %}Neue Management Review{% endif %}

-

Felder nach ISO 27001 Kap. 9.3 strukturiert: Inputs, Session-Daten, Outputs.

-
-
{% csrf_token %} -

Allgemein

- {% for field in form %} - {% if field.name == 'input_isms_status' %}

Inputs (ISO 9.3.2)

{% endif %} - {% if field.name == 'output_decisions' %}

Outputs / Entscheidungen (ISO 9.3.3)

{% endif %} - {% if field.name == 'protocol_summary' %}

Protokoll

{% endif %} -
{{ field }}{% if field.help_text %}
{{ field.help_text }}
{% endif %}
- {% endfor %} - -
-
-{% endblock %} diff --git a/templates/assessments/review_list.html b/templates/assessments/review_list.html deleted file mode 100644 index 1697d1e..0000000 --- a/templates/assessments/review_list.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "base.html" %} -{% block title %}Management Reviews{% endblock %} -{% block content %} -
-

Management Reviews

ISO 27001 Kap. 9.3 – Bewertung durch die oberste Leitung.

- Neue Review -
-{% for review in reviews %} -
-
-
-

{{ review.title }}

-
- {{ review.get_status_display }} - {% if review.review_date %}{{ review.review_date|date:"d.m.Y" }}{% endif %} - {{ review.actions.count }} Massnahmen -
-
- Oeffnen -
-
-{% empty %} -

Noch keine Management Reviews durchgefuehrt.

-{% endfor %} -{% endblock %} diff --git a/templates/assessments/soa_detail.html b/templates/assessments/soa_detail.html deleted file mode 100644 index a3ea4d1..0000000 --- a/templates/assessments/soa_detail.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ soa.title }}{% endblock %} -{% block content %} -
-

{{ soa.title }}

Version {{ soa.version }} · {{ soa.get_status_display }} · {{ soa.tenant.name }}

- Zurueck -
-
-
Anwendbar
{{ applicable_count }}
-
Umgesetzt
{{ implemented_count }}
-
Ausgeschlossen
{{ excluded_count }}
-
-
-
Annex-A Controls
-
- - - {% for entry in entries %} - - - - - - - - - - {% endfor %} - -
CodeControlAnwendbarStatusOwnerBegruendung
{{ entry.requirement.code }}{{ entry.requirement.title }}{% if entry.is_applicable %}Ja{% else %}Nein{% endif %}{{ entry.get_implementation_status_display }}{{ entry.control_owner|default:"–" }}{{ entry.justification|truncatechars:80|default:"–" }}Edit
-
-{% endblock %} diff --git a/templates/assessments/soa_entry_form.html b/templates/assessments/soa_entry_form.html deleted file mode 100644 index ac7156e..0000000 --- a/templates/assessments/soa_entry_form.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} -{% block title %}SoA-Eintrag bearbeiten{% endblock %} -{% block content %} -

SoA-Eintrag bearbeiten

-

{{ object.requirement.code }} – {{ object.requirement.title }}

-
-
{% csrf_token %} - {% for field in form %}
{{ field }}{% if field.help_text %}
{{ field.help_text }}
{% endif %}
{% endfor %} - - Abbrechen -
-
-{% endblock %} diff --git a/templates/assessments/soa_list.html b/templates/assessments/soa_list.html deleted file mode 100644 index 40d8afc..0000000 --- a/templates/assessments/soa_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends "base.html" %} -{% block title %}Statement of Applicability{% endblock %} -{% block content %} -
-

Statement of Applicability (SoA)

ISO 27001 Pflichtdokument – Anwendbarkeit aller Annex-A-Controls.

-
{% csrf_token %}
-
-{% for doc in documents %} -
-
-
-

{{ doc.title }} v{{ doc.version }} {{ doc.get_status_display }}

-

{{ doc.applicable_count }} anwendbar · {{ doc.not_applicable_count }} ausgeschlossen · {{ doc.implemented_count }} umgesetzt · erstellt {{ doc.created_at|date:"d.m.Y" }}

-
- Oeffnen -
-
-{% empty %} -

Noch kein SoA vorhanden. Generieren Sie eins aus den ISO-27001-Requirements.

-{% endfor %} -{% endblock %} diff --git a/templates/assets/asset_list.html b/templates/assets/asset_list.html deleted file mode 100644 index 14b0245..0000000 --- a/templates/assets/asset_list.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "base.html" %} -{% block title %}Informationswerte{% endblock %} -{% block content %} -
-
-

Informationswerte & Assets

-

Register fuer Anwendungen, Datenbestaende, Services und Infrastruktur.

-
- Neuer Informationswert -
-
-
- - - - {% for asset in assets %} - - - - - - - - - {% empty %} - - {% endfor %} - -
NameTypKritikalitaetBusiness UnitOwnerIm Scope
{{ asset.name }}{{ asset.get_asset_type_display }}{{ asset.get_criticality_display }}{{ asset.business_unit|default:"–" }}{{ asset.owner|default:"–" }}{% if asset.is_in_scope %}✓{% else %}–{% endif %}
Noch keine Informationswerte erfasst.
-
-
-{% endblock %} diff --git a/templates/base.html b/templates/base.html deleted file mode 100644 index ece3563..0000000 --- a/templates/base.html +++ /dev/null @@ -1,829 +0,0 @@ - - - - - - {% block title %}{{ APP_DISPLAY_NAME }}{% endblock %} - - - - - -
- {% if messages %} - {% for message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} - {% block content %}{% endblock %} -
- - diff --git a/templates/catalog/domain_list.html b/templates/catalog/domain_list.html deleted file mode 100644 index cc61f41..0000000 --- a/templates/catalog/domain_list.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "base.html" %} -{% block title %}Fragenkatalog{% endblock %} -{% block content %} -
-
-

Fragenkatalog

-

Vordefinierte Domänen und Fragen für Betroffenheit, Reifegrad und Maßnahmenableitung.

-
- {{ question_count }} Fragen -
-{% for domain in domains %} -
-
-

{{ domain.name }}

-

{{ domain.description }}

-
    - {% for question in domain.questions.all %} -
  • {{ question.text }}
  • - {% empty %} -
  • Keine Fragen gepflegt.
  • - {% endfor %} -
-
-
-{% endfor %} -{% endblock %} diff --git a/templates/dashboard/home.html b/templates/dashboard/home.html deleted file mode 100644 index 18da6ad..0000000 --- a/templates/dashboard/home.html +++ /dev/null @@ -1,372 +0,0 @@ -{% extends "base.html" %} -{% load core_extras %} -{% block content %} - - -
-
-
-

Management-Dashboard

-

Executive-Portfolioansicht für Sektoren, Länder und Mandanten mit klarer KPI-Zeile, Heatmaps, Vergleichsansicht und fokussierten Management-Insights.

-
- -
- -
-
-
Portfolio Ø ISO
-
{{ portfolio_avg_iso }}%
-
Durchschnittliche ISO-Readiness über die gefilterten Mandanten.
-
-
-
Portfolio Ø NIS2
-
{{ portfolio_avg_nis2 }}%
-
Durchschnittliche regulatorische Readiness über das aktuelle Portfolio.
-
-
-
Mandanten im Scope
-
{{ total_tenants }}
-
Anzahl der aktuell gefilterten Gesellschaften / Business Units im Portfolio.
-
-
-
Offene Roadmap-Aufgaben
-
{{ open_task_count }}
-
Noch nicht geschlossene Arbeitspakete im aktuellen Default-Mandanten.
-
-
-
Kritische / hohe Risiken
-
{{ critical_risk_count }}/{{ high_risk_count }}
-
Kritische sowie erhöhte Risiken im fokussierten Mandantenkontext.
-
-
-
Evidenzen
-
{{ evidence_count }}
-
Vorhandene Nachweise für Assessments, Maßnahmen und Requirements.
-
-
- -
-
-
- - -
-
- - -
-
- - -
-
- -
-
-
- -
-
-
-
-
-

Sektor- und Länder-Heatmaps

-
Visuelle Schwerpunktanalyse für Portfolio-Steuerung. Die Karten zeigen Readiness, Konzentrationen und operative Last in einer managementtauglichen Hauptfläche statt in einer schmalen Seitenleiste.
-
-
-
- {% for item in sector_heatmap %} -
-
- {{ item.label }} -
{{ item.score }}%
-
NIS2 Ø {{ item.score }}% · ISO Ø {{ item.subscore }}%
-
-
{{ item.meta }}
-
- {% empty %} -
Noch keine Sektordaten vorhanden.
- {% endfor %} - {% for item in country_heatmap %} -
-
- {{ item.label }} -
{{ item.score }}%
-
ISO Ø {{ item.score }}% · NIS2 Ø {{ item.subscore }}%
-
-
{{ item.meta }}
-
- {% endfor %} -
-
- -
-
-
-

Portfolio-Vergleich

-
Mandantenvergleich mit direkter Gegenüberstellung von Sektor, Ländern, Readiness, offenen Tasks und Evidenzen.
-
-
-
-
- - - - - - - - - - - - - - - {% for row in comparison_rows %} - - - - - - - - - - - {% empty %} - - {% endfor %} - -
MandantSektorLänderISONIS2Offene TasksEvidenzen
{{ row.tenant.name }}{{ row.sector }}{{ row.countries }}{{ row.iso }}%{{ row.nis2 }}%{{ row.open_tasks }}{{ row.evidence }} - Drilldown - {% if row.report_id %}Bericht{% endif %} -
Noch keine Mandanten vorhanden.
-
-
-
-

Sektorvergleich

-
- {% for item in sector_summary %} -
-
{{ item.sector|truncatechars:24 }}
-
-
{{ item.avg_nis2 }}%
-
- {% empty %}
Noch keine Sektordaten vorhanden.
{% endfor %} -
-
-
-

Ländervergleich

-
- {% for item in country_summary %} -
-
{{ item.country|truncatechars:20 }}
-
-
{{ item.avg_iso }}%
-
- {% empty %}
Noch keine Länderdaten vorhanden.
{% endfor %} -
-
-
-
-
- - {% if drilldown_tenant %} -
-
-
-

Drilldown: {{ drilldown_tenant.name }}

-
Detaillierte Sicht auf einen einzelnen Mandanten mit Bericht, Aufgaben und Evidenzpflichten.
-
-
-
-
-
Mandantenprofil
-
Sektor: {{ drilldown_tenant.sector_label }}
-
Länder: {{ drilldown_tenant.countries_display }}
- {% if drilldown_report %} -
ISO: {{ drilldown_report.iso_readiness_percent }}% · NIS2: {{ drilldown_report.nis2_readiness_percent }}%
-

{{ drilldown_report.executive_summary|truncatechars:240 }}

- Bericht öffnen - {% endif %} -
-
-
Offene Aufgaben
-
- {% for item in drilldown_open_tasks %} -
- {{ item.title }} - Status: {{ item.get_status_display }}{% if item.due_date %} · Fällig {{ item.due_date }}{% endif %} -
- {% empty %} -
Keine offenen Aufgaben.
- {% endfor %} -
-
-
-
Evidenzpflichten
-
Offen: {{ drilldown_need_summary.open }}
-
Teilweise: {{ drilldown_need_summary.partial }}
-
Abgedeckt: {{ drilldown_need_summary.covered }}
-
-
-
- {% endif %} -
- -
-
-
-
-

Management-Insights

-
Priorisierte Sicht auf Risiken, Gaps und Maßnahmen für die Steuerung auf C-Level- und Programm-Ebene.
-
-
-
-
-

Top Risiken

-
- {% for risk in top_risks %} -
- {{ risk.title }} - {{ risk.risk_level_label }} · Score {{ risk.score }}{% if risk.process %} · Prozess {{ risk.process.name }}{% endif %} -
- {% empty %} -
Keine offenen Risiken vorhanden.
- {% endfor %} -
-
-
-

Top Gaps

-
- {% for gap in top_gaps %} -
- {{ gap.title|default:gap.domain }} - {{ gap.description|default:gap.reason|default:'Gap aus der letzten Auswertung'|truncatechars:120 }} -
- {% empty %} -
Keine Gaps aus dem letzten Report vorhanden.
- {% endfor %} -
-
-
-

Top Maßnahmen

-
- {% for item in top_measures %} -
- {{ item.title }} - {{ item.priority|default:'PRIO' }}{% if item.owner_role %} · {{ item.owner_role }}{% endif %}{% if item.target_phase %} · {{ item.target_phase }}{% endif %} -
- {% empty %} -
Keine priorisierten Maßnahmen aus dem letzten Report vorhanden.
- {% endfor %} -
-
-
-
- -
-
-
-

Portfolio-Zusammenfassung

-
Verdichtete Sicht auf Verteilung, Schwerpunktländer und Sektorcluster.
-
-
-
- {% for item in sector_summary|slice:":4" %} -
-
{{ item.sector }}
-
{{ item.tenant_count }} Mandant(en) · Ø ISO {{ item.avg_iso }}% · Ø NIS2 {{ item.avg_nis2 }}%
-
- {% empty %} -
Noch keine sektorale Portfolio-Sicht vorhanden.
- {% endfor %} - {% for item in country_summary|slice:":4" %} -
-
{{ item.country }}
-
{{ item.tenant_count }} Mandant(en) · Ø ISO {{ item.avg_iso }}% · Ø NIS2 {{ item.avg_nis2 }}%
-
- {% endfor %} -
-
-
-
-
-{% endblock %} diff --git a/templates/evidence/evidence_form.html b/templates/evidence/evidence_form.html deleted file mode 100644 index 28330ce..0000000 --- a/templates/evidence/evidence_form.html +++ /dev/null @@ -1,31 +0,0 @@ -{% extends "base.html" %} -{% block title %}Evidenz erfassen{% endblock %} -{% block content %} -
-
-
-

Evidenz erfassen

-

Dokumentiere Nachweise fuer Kontrollen, Massnahmen und Reviews.

-
- {% csrf_token %} - {% for field in form %} -
- - {{ field }} - {% if field.help_text %}
{{ field.help_text }}
{% endif %} - {% for error in field.errors %}
{{ error }}
{% endfor %} -
- {% endfor %} - - Abbrechen -
-
-
-
-
-
Warum ist das wichtig?
-

Nachweise belegen Umsetzungsstand und Wirksamkeit. Audits brauchen belastbare Evidenzen, nicht nur Aussagen. Jede Evidenz kann direkt einer Massnahme und einer Anforderung zugeordnet werden.

-
-
-
-{% endblock %} diff --git a/templates/evidence/evidence_list.html b/templates/evidence/evidence_list.html deleted file mode 100644 index 2fea416..0000000 --- a/templates/evidence/evidence_list.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends "base.html" %} -{% block title %}Evidenzen{% endblock %} -{% block content %} -
-
-

Evidenzen & Nachweise

-

Nachweisablage mit Status-Tracking und Requirement-Zuordnung.

-
- Neue Evidenz -
-{% if evidence_needs %} -
-
Offene Nachweispflichten
-
- - - - {% for need in evidence_needs %} - - - - - - - - {% endfor %} - -
RequirementMappingQuelleStatusAbgedeckt
{{ need.requirement.framework }} {{ need.requirement.code }}
{{ need.requirement.title }}
{{ need.requirement_mapping_version|default:"–" }}{{ need.requirement_source_citation|default:"–" }}{{ need.get_status_display }}{{ need.covered_count }}
-
-
-{% endif %} -
-
Hochgeladene Evidenzen
-
- - - - {% for item in evidence_items %} - - - - - - - - - - {% empty %} - - {% endfor %} - -
TitelRequirementMappingQuelleStatusOwnerAktualisiert
{{ item.title }}{{ item.requirement_display|default:"–" }}{% if item.requirement and item.requirement.mapping_version %}{{ item.requirement.mapping_version.program_name }} {{ item.requirement.framework }} v{{ item.requirement.mapping_version.version }}{% else %}–{% endif %}{% if item.requirement and item.requirement.primary_source %}{{ item.requirement.primary_source.authority }} - {{ item.requirement.primary_source.citation|default:item.requirement.primary_source.title }}{% else %}–{% endif %}{{ item.get_status_display }}{{ item.owner|default:"–" }}{{ item.updated_at|date:"d.m.Y" }}
Noch keine Evidenzen hochgeladen.
-
-
-{% endblock %} diff --git a/templates/guidance/dashboard.html b/templates/guidance/dashboard.html deleted file mode 100644 index adfae70..0000000 --- a/templates/guidance/dashboard.html +++ /dev/null @@ -1,119 +0,0 @@ -{% extends "base.html" %} -{% block title %}ISMS-Navigator{% endblock %} -{% block content %} -
-
-

ISMS-Navigator

- {% if tenant %}

Geführte Umsetzung für {{ tenant.name }}

{% endif %} -
- Kennzahlen-Dashboard -
- -{% if error %} -
{{ error }}
-{% endif %} - -{% if state %} -
-
-
-
-

Was ist jetzt zu tun?

-
-
{{ state.progress_percent }}%
-
-

Zusammenfassung: {{ state.summary }}

-

Nächste empfohlene Aktion: {{ state.next_action_text }}

-
- {% if state.current_step %} - Schritt öffnen - {% endif %} - {% if next_step_url %} - {{ next_step_label|default:'Direkt umsetzen' }} - {% endif %} -
-
-
-
-
-
-
-

Warum das wichtig ist

-

Die App führt nicht nur durch Formulare, sondern erklärt den fachlichen Zweck jedes Schritts.

-
    -
  • Readiness statt bloßer Dokumentenablage
  • -
  • klare Reihenfolge von Betroffenheit bis Maßnahmen
  • -
  • sichtbare Lücken und konkrete nächste Schritte
  • -
-
-
-
-
- -
-
-
-
-

Offene To-dos

-
    - {% for item in todo_items %} -
  • {{ item }}
  • - {% empty %} -
  • Aktuell keine offenen To-dos erkannt.
  • - {% endfor %} -
-
-
- -
-
-

Geführte Schritte

-
- - - - - - - - - - - {% for step in steps %} - - - - - - - {% empty %} - - {% endfor %} - -
#PhaseSchrittAktion
{{ step.sort_order }}{{ step.get_phase_display }} -
{{ step.title }}
-
{{ step.description|truncatechars:110 }}
-
Öffnen
Keine Guidance Steps vorhanden.
-
-
-
-
- -
-
-
-

Phasenüberblick

-
    - {% for phase in phase_progress %} -
  • - {{ phase.label }} - {{ phase.total_steps }} -
  • - {% endfor %} -
-
-
-
-
-{% endif %} -{% endblock %} diff --git a/templates/guidance/step_detail.html b/templates/guidance/step_detail.html deleted file mode 100644 index 23307ca..0000000 --- a/templates/guidance/step_detail.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ step.title }}{% endblock %} -{% block content %} -
-
-

{{ step.title }}

-

{{ step.get_phase_display }}

-
- Zurück zum Navigator -
- -
-
-

Was ist zu tun?

{{ step.description|linebreaks }}

-

Warum ist das wichtig?

{{ step.why_it_matters|linebreaks }}

-

Welche Informationen werden benötigt?

{{ step.required_inputs|linebreaks }}

-

Welches Ergebnis entsteht?

{{ step.expected_outputs|linebreaks }}

-

Definition of Done

{{ step.definition_of_done|linebreaks }}

-
-
- {% if state %} -
-

Mandantenstatus

-

Fortschritt: {{ state.progress_percent }}%

-

Nächste Aktion: {{ state.next_action_text }}

- {% if next_step_url %}{{ next_step_label|default:'Direkt umsetzen' }}{% endif %} -
- {% endif %} -
-

Hinweis

-

Dieser Schritt übersetzt den Prompt in konkrete Arbeit: Was ist zu tun, warum ist es relevant und welches Ergebnis wird erwartet.

-
-
-
-{% endblock %} diff --git a/templates/imports/guide.html b/templates/imports/guide.html deleted file mode 100644 index 94e10fc..0000000 --- a/templates/imports/guide.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "base.html" %} -{% load core_extras %} -{% block title %}Import-Guide{% endblock %} -{% block content %} -
-
-
-
-

Import-Guide

-

Hier siehst du die erwarteten Spalten je Registertyp und welche alternativen Spaltennamen ebenfalls erkannt werden.

-
- Zurück -
- {% for key, cols in examples.items %} -
-

{{ key }}

- - - - {% for col in cols %} - - - - - {% endfor %} - -
Erwartete SpalteErkannte Alternativen
{{ col }}{% if synonyms|get_item:col %}{{ synonyms|get_item:col|join:', ' }}{% else %}{% endif %}
-
- {% endfor %} -
-
-{% endblock %} diff --git a/templates/imports/import_center.html b/templates/imports/import_center.html deleted file mode 100644 index 6f2745f..0000000 --- a/templates/imports/import_center.html +++ /dev/null @@ -1,59 +0,0 @@ -{% extends "base.html" %} -{% block title %}Import Center{% endblock %} -{% block content %} -
-
-
-
-
-
-

Import Center

-

Importiere Geschäftsbereiche, Prozesse, Lieferanten oder Informationswerte per CSV/XLSX. In V11 wird vor dem Schreiben zuerst eine Import-Vorschau erzeugt.

-
- -
-
- {% csrf_token %} - {{ form.as_p }} - -
-
-
-
-
-
-
-

Excel-/CSV-Templates

-

Lade fertige Vorlagen herunter und befülle sie in Excel oder per CSV-Export aus deinen Bestandslisten.

-
- {% for key, cols in examples.items %} -
-
{{ key }}
-
{{ cols|join:', ' }}
-
- XLSX - CSV - Mapping -
-
- {% endfor %} -
-
-
-
-
-

Fachliche Wirkung

-
    -
  • Import-Vorschau zeigt erkannte Spalten, Synonyme und Beispielzeilen vor dem Schreiben.
  • -
  • Prozess- und Asset-Import verbessert Scope, Transparenz und Gap-Analyse.
  • -
  • Lieferanten-Import stärkt Third-Party- und NIS2-Lieferkettenbetrachtung.
  • -
-

Mandant: {{ tenant.name }}

-
-
-
-
-{% endblock %} diff --git a/templates/imports/mapping_assistant.html b/templates/imports/mapping_assistant.html deleted file mode 100644 index a539a15..0000000 --- a/templates/imports/mapping_assistant.html +++ /dev/null @@ -1,45 +0,0 @@ -{% extends "base.html" %} -{% block title %}Mapping-Assistent{% endblock %} -{% block content %} -
-
-

Import-Mapping-Assistent

-

Wähle den Registertyp und prüfe, welche Quellspalten von der App erkannt werden. So reduzierst du Nacharbeit bei Excel-/CSV-Importen.

-
- Zurück zum Import Center -
-
-
-
-
- - -
-
- -
-
-
-
-
-
-

Erwartete Felder

- - - - {% for row in mapping_rows %} - - - - - - {% endfor %} - -
Erwartete SpalteAlternativenPflicht
{{ row.expected }}{% if row.synonyms %}{{ row.synonyms|join:', ' }}{% else %}{% endif %}{% if row.required %}Pflicht{% else %}Optional{% endif %}
-
-
-{% endblock %} diff --git a/templates/imports/preview.html b/templates/imports/preview.html deleted file mode 100644 index 4991209..0000000 --- a/templates/imports/preview.html +++ /dev/null @@ -1,71 +0,0 @@ -{% extends "base.html" %} -{% load core_extras %} -{% block title %}Import-Vorschau{% endblock %} -{% block content %} -
-
-

Import-Vorschau

-

Prüfe die Zuordnung Feld für Feld, bevor Daten übernommen werden.

-
- Zurück -
-{% if preview %} -
-
-

Zuordnung für {{ preview.import_type }}

-

Erkannt: {{ preview.matched }}/{{ preview.mapping_rows|length }} erwartete Felder

-
- {% csrf_token %} -
- - - - {% for row in preview.mapping_rows %} - - - - - - - {% endfor %} - -
Erwartetes FeldQuelldatei-SpalteStatusSynonyme
{{ row.expected }}{% if row.required %} Pflicht{% endif %} - - {% if row.status == 'ok' %}zugeordnet{% else %}fehlt{% endif %}{{ row.synonyms|join:", "|default:"–" }}
-
- {% if preview.extra_headers %} -
Nicht verwendete Spalten: {{ preview.extra_headers|join:", " }}
- {% endif %} -
- - -
-
-
-
-
-
-

Beispieldaten

-
- - - {% for header in headers %}{% endfor %} - - - {% for row in sample_rows %} - {% for header in headers %}{% endfor %} - {% empty %}{% endfor %} - -
{{ header }}
{{ row|get_item:header }}
Keine Zeilen vorhanden.
-
-
-
-{% else %} -
Keine Vorschau vorhanden.
-{% endif %} -{% endblock %} diff --git a/templates/organizations/tenant_list.html b/templates/organizations/tenant_list.html deleted file mode 100644 index 178ca16..0000000 --- a/templates/organizations/tenant_list.html +++ /dev/null @@ -1,31 +0,0 @@ -{% extends "base.html" %} -{% block title %}Organisationen{% endblock %} -{% block content %} -
-
-

Organisationen

-

Mandanten, Gesellschaften und Standorte.

-
- Neue Organisation -
-
-
- - - - {% for tenant in tenants %} - - - - - - - - {% empty %} - - {% endfor %} - -
NameLaenderSektorNIS2KRITIS
{{ tenant.name }}{{ tenant.countries_display }}{{ tenant.sector_label|default:"–" }}{% if tenant.nis2_relevant %}Ja{% else %}Nein{% endif %}{% if tenant.kritis_relevant %}Ja{% else %}Nein{% endif %}
Noch keine Organisationen.
-
-
-{% endblock %} diff --git a/templates/processes/process_detail.html b/templates/processes/process_detail.html deleted file mode 100644 index acc6316..0000000 --- a/templates/processes/process_detail.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ process.name }}{% endblock %} -{% block content %} -
-
-

{{ process.name }}

-

{{ process.tenant.name }} · {{ process.get_status_display }}

-
- Zurueck -
-
-
-
-
Details
- {% if process.scope %}

Scope: {{ process.scope }}

{% endif %} - {% if process.description %}

Beschreibung: {{ process.description }}

{% endif %} - {% if process.owner %}

Owner: {{ process.owner }}

{% endif %} - {% if process.reviewed_at %}

Letzter Review: {{ process.reviewed_at|date:"d.m.Y" }}

{% endif %} -
-
-
-
-
10-Dimensionen-Bewertung
-
- {% for label, value in dimensions %} -
-
-
{% if value %}✅{% else %}⬜{% endif %}
-
{{ label }}
-
-
- {% endfor %} -
-
-
-
-{% endblock %} diff --git a/templates/processes/process_list.html b/templates/processes/process_list.html deleted file mode 100644 index 565326a..0000000 --- a/templates/processes/process_list.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "base.html" %} -{% block title %}Prozesse{% endblock %} -{% block content %} -
-
-

Prozessregister

-

Erfasste Geschaeftsprozesse mit Status und Verantwortlichkeiten.

-
- Neuer Prozess -
-
-
- - - - {% for process in processes %} - - - - - - - - - - {% empty %} - - {% endfor %} - -
NameStatusOwnerDokumentiertUmgesetztEvidenz
{{ process.name }}{{ process.get_status_display }}{{ process.owner|default:"–" }}{% if process.documented %}✓{% else %}–{% endif %}{% if process.implemented %}✓{% else %}–{% endif %}{% if process.evidenced %}✓{% else %}–{% endif %}Details
Noch keine Prozesse erfasst.
-
-
-{% endblock %} diff --git a/templates/product_security/product_detail.html b/templates/product_security/product_detail.html deleted file mode 100644 index bc59ec6..0000000 --- a/templates/product_security/product_detail.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ product.name }}{% endblock %} -{% block content %} -
-
-

{{ product.name }}

-

{{ product.family.name|default:'Ohne Produktfamilie' }}

-

{{ product.description|default:'-' }}

-
- -
-
-

Produktkontext

  • Digitale Elemente: {% if product.has_digital_elements %}Ja{% else %}Nein{% endif %}
  • AI im Scope: {% if product.includes_ai %}Ja{% else %}Nein{% endif %}
  • OT/IACS-Kontext: {% if product.ot_iacs_context %}Ja{% else %}Nein{% endif %}
  • Automotive-Kontext: {% if product.automotive_context %}Ja{% else %}Nein{% endif %}
  • Support-Fenster: {{ product.support_window_months }} Monate
-

Readiness Snapshot

{% if snapshot %}
  • CRA: {{ snapshot.cra_readiness_percent }}%
  • AI Act: {{ snapshot.ai_act_readiness_percent }}%
  • IEC 62443: {{ snapshot.iec62443_readiness_percent }}%
  • ISO/SAE 21434: {{ snapshot.iso_sae_21434_readiness_percent }}%
  • Threat-Model-Coverage: {{ snapshot.threat_model_coverage_percent }}%
  • PSIRT-Readiness: {{ snapshot.psirt_readiness_percent }}%
  • Offene Schwachstellen: {{ snapshot.open_vulnerability_count }} (kritisch: {{ snapshot.critical_vulnerability_count }})

{{ snapshot.summary }}

{% else %}

Noch kein Snapshot vorhanden.

{% endif %}
-
-
-

Releases & Komponenten

    {% for release in releases %}
  • {{ release.version }} – {{ release.get_status_display }}{% if release.release_date %} ({{ release.release_date }}){% endif %}
  • {% empty %}
  • Keine Releases.
  • {% endfor %}

    {% for component in components %}
  • {{ component.name }}{% if component.version %} ({{ component.version }}){% endif %} – {{ component.get_component_type_display }}{% if component.has_sbom %} · SBOM{% endif %}
  • {% empty %}
  • Keine Komponenten.
  • {% endfor %}
-

Threat Models, TARA und AI

    {% for tm in threat_models %}
  • {{ tm.name }} – {{ tm.methodology }} · {{ tm.get_status_display }}
    {{ tm.scenarios.count }} Threat Scenarios
  • {% empty %}
  • Keine Threat Models.
  • {% endfor %}

    {% for tara in taras|slice:":5" %}
  • {{ tara.name }} – Score {{ tara.risk_score }} · {{ tara.get_status_display }}
  • {% empty %}
  • Keine TARAs.
  • {% endfor %}

    {% for ai in ai_systems %}
  • {{ ai.name }} – {{ ai.get_risk_classification_display }}
  • {% empty %}
  • Keine AI-Systeme.
  • {% endfor %}
-
-
-

Schwachstellen & PSIRT

    {% for vuln in vulnerabilities|slice:":8" %}
  • {{ vuln.title }} – {{ vuln.get_severity_display }} / {{ vuln.get_status_display }}{% if vuln.component %} · {{ vuln.component.name }}{% endif %} Bearbeiten
  • {% empty %}
  • Keine Schwachstellen erfasst.
  • {% endfor %}

    {% for case in psirt_cases|slice:":6" %}
  • {{ case.case_id }} – {{ case.title }} · {{ case.get_status_display }}
  • {% empty %}
  • Keine PSIRT-Cases.
  • {% endfor %}

    {% for adv in advisories|slice:":4" %}
  • {{ adv.advisory_id }} – {{ adv.get_status_display }}
  • {% empty %}
  • Keine Advisories.
  • {% endfor %}
-

Roadmap-Nächste Schritte

{% if roadmap_tasks %}
    {% for task in roadmap_tasks|slice:":8" %}
  • {{ task.get_phase_display }}: {{ task.title }}
    {{ task.priority|default:'-' }} · {{ task.owner_role|default:'-' }} · {{ task.due_in_days }} Tage
  • {% endfor %}
{% else %}

Noch keine Product-Security-Roadmap vorhanden.

{% endif %}
-
-{% endblock %} diff --git a/templates/product_security/product_list.html b/templates/product_security/product_list.html deleted file mode 100644 index 04371b5..0000000 --- a/templates/product_security/product_list.html +++ /dev/null @@ -1,80 +0,0 @@ -{% extends "base.html" %} -{% block title %}Product Security{% endblock %} -{% block content %} -
-
-

Product Security

-

Produkte, Releases, Threat Models, TARA, PSIRT und Product-Security-Readiness.

-
-
-{% if matrix %} -
-

Applicability Matrix

-
- {% for key, item in matrix.items %} - {% if key != 'summary' %} -
-
{{ item.label }}{% if item.applicable %}aktiv{% else %}derzeit nein{% endif %}
-

{{ item.reason }}

-
- {% endif %} - {% endfor %} -
-

Zusammenfassung: {{ matrix.summary }}

-
-{% endif %} -{% if posture %} -
-
Produkte
{{ posture.products }}
-
Aktive Releases
{{ posture.active_releases }}
-
Threat Models
{{ posture.threat_models }}
-
TARAs
{{ posture.taras }}
-
Offene Vulns
{{ posture.open_vulnerabilities }}
-
PSIRT offen
{{ posture.psirt_cases_open }}
-
-{% endif %} -
-
-
-

Produkte

-
- - {% for product in products %} - - - - - - - - {% empty %} - - {% endfor %} -
ProduktFamilieRelease/TM/TARAVulnsAktion
{{ product.name }}
{{ product.description|default:'-' }}
{{ product.family.name|default:'-' }} - {{ product.releases.count }} Releases
- {{ product.threat_models.count }} Threat Models
- {{ product.taras.count }} TARAs -
- {{ product.vulnerabilities.count }} gesamt
- {{ product.psirt_cases.count }} PSIRT Cases -
- Detail - Roadmap -
Noch keine Produktdaten vorhanden.
-
-
-
-
-
-

Letzte Snapshots

-
    - {% for snap in snapshots %} -
  • {{ snap.product.name }}
    CRA {{ snap.cra_readiness_percent }}% · AI {{ snap.ai_act_readiness_percent }}% · IEC 62443 {{ snap.iec62443_readiness_percent }}% · ISO/SAE 21434 {{ snap.iso_sae_21434_readiness_percent }}%
    Threat Coverage {{ snap.threat_model_coverage_percent }}% · PSIRT {{ snap.psirt_readiness_percent }}%
  • - {% empty %} -
  • Noch keine Product-Security-Snapshots vorhanden.
  • - {% endfor %} -
-
-
-
-{% endblock %} diff --git a/templates/product_security/roadmap_detail.html b/templates/product_security/roadmap_detail.html deleted file mode 100644 index c501eb4..0000000 --- a/templates/product_security/roadmap_detail.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends "base.html" %} -{% block title %}Product-Security-Roadmap{% endblock %} -{% block content %} -
-
-

{{ roadmap.title }}

-

{{ product.name }}

-

{{ roadmap.summary }}

-
- -
-{% if snapshot %} -
-
CRA
{{ snapshot.cra_readiness_percent }}%
-
AI Act
{{ snapshot.ai_act_readiness_percent }}%
-
IEC 62443
{{ snapshot.iec62443_readiness_percent }}%
-
21434
{{ snapshot.iso_sae_21434_readiness_percent }}%
-
Threat
{{ snapshot.threat_model_coverage_percent }}%
-
PSIRT
{{ snapshot.psirt_readiness_percent }}%
-
-{% endif %} -
- {% for phase, tasks in grouped_tasks.items %} -
-
-
-

{{ phase.1 }}

- {{ tasks|length }} Aufgaben -
- {% if tasks %} -
- {% for task in tasks %} -
-
- {{ task.title }} -
- {{ task.priority|default:'-' }} - Bearbeiten -
-
-

{{ task.description }}

-
Owner: {{ task.owner_role|default:'-' }} · Ziel: {{ task.due_in_days }} Tage
- {% if task.dependency_text %}
Abhängigkeit: {{ task.dependency_text }}
{% endif %} - {% if task.related_vulnerability %}
Bezug: {{ task.related_vulnerability.title }}
{% endif %} -
- {% endfor %} -
- {% else %} -

Keine Aufgaben in dieser Phase.

- {% endif %} -
-
- {% endfor %} -
-{% endblock %} diff --git a/templates/product_security/roadmap_task_form.html b/templates/product_security/roadmap_task_form.html deleted file mode 100644 index b6a6490..0000000 --- a/templates/product_security/roadmap_task_form.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends "base.html" %} -{% block title %}Product-Security-Aufgabe pflegen{% endblock %} -{% block content %} -
-
-
-
-

Product-Security-Aufgabe pflegen

-

{{ object.roadmap.product.name }} · {{ object.get_phase_display }}

-
- {{ object.title }}
- {{ object.description }} -
-
- {% csrf_token %} - {{ form.as_p }} - - Zurück -
-
-
-
-
-
-
-

Operative Steuerung

-
    -
  • Status hält PSIRT und Delivery synchron
  • -
  • Owner-Rollen machen Verantwortung sichtbar
  • -
  • Ziel-Tage und Abhängigkeiten bleiben auditierbar
  • -
-
-
-
-
-{% endblock %} diff --git a/templates/product_security/vulnerability_form.html b/templates/product_security/vulnerability_form.html deleted file mode 100644 index 2c0abde..0000000 --- a/templates/product_security/vulnerability_form.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends "base.html" %} -{% block title %}Schwachstelle pflegen{% endblock %} -{% block content %} -
-
-
-
-

Schwachstelle pflegen

-

{{ object.product.name }}{% if object.component %} · {{ object.component.name }}{% endif %}

-
- {{ object.title }}
- {{ object.summary|default:"-" }} -
-
- {% csrf_token %} - {{ form.as_p }} - - Zurück -
-
-
-
-
-
-
-

PSIRT-Steuerung

-
    -
  • Status und Schweregrad halten Roadmap und Response synchron
  • -
  • Remediation-Datum bleibt direkt am Finding nachvollziehbar
  • -
  • Zusammenfassung dokumentiert Triage- und Mitigation-Stand
  • -
-
-
-
-
-{% endblock %} diff --git a/templates/registration/login.html b/templates/registration/login.html deleted file mode 100644 index 1006851..0000000 --- a/templates/registration/login.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "base.html" %} -{% block title %}Login{% endblock %} -{% block content %} -
-
-
-
-

Login

-

Demo-Login: admin / Admin123!

-
{% csrf_token %}{{ form.as_p }}
-
-
-
-
-{% endblock %} diff --git a/templates/reports/report_detail.html b/templates/reports/report_detail.html deleted file mode 100644 index 2f51776..0000000 --- a/templates/reports/report_detail.html +++ /dev/null @@ -1,72 +0,0 @@ -{% extends "base.html" %} -{% load core_extras %} -{% block title %}{{ report.title }}{% endblock %} -{% block content %} -
-
-

{{ report.title }}

-

{{ report.tenant.name }}

-

Länder / Präsenz: {{ report.tenant.countries_display }}

-

Betroffenheit: {{ report.applicability_result }}

-
-
- PDF (einfach) - PDF Audit-Report - {% if report.session.roadmap_plans.first %}Roadmap-PDF{% endif %} -
-
-
-
ISO

{{ report.iso_readiness_percent }}%

-
NIS2

{{ report.nis2_readiness_percent }}%

-
KRITIS

{{ report.kritis_readiness_percent }}%

-
CRA

{{ report.cra_readiness_percent }}%

-
AI Act

{{ report.ai_act_readiness_percent }}%

-
IEC 62443

{{ report.iec62443_readiness_percent }}%

-
21434

{{ report.iso_sae_21434_readiness_percent }}%

-
-{% if report.compliance_versions_json %} -
-

{{ APP_DISPLAY_NAME }} Regulatory Mapping Versions

-
- {% for key, item in report.compliance_versions_json.items %} -
{{ item.framework }} v{{ item.version }}
{{ item.title }}
{{ item.requirement_count }} Requirements · {{ item.source_count }} Quellen
- {% endfor %} -
-
-{% endif %} -
-

Regulatory / Product Security Matrix

-
- {% for key, item in report.regulatory_matrix_json.items %} - {% if key != 'summary' %} -
{{ item.label }}{% if item.applicable %}aktiv{% else %}nein{% endif %}

{{ item.reason }}

- {% endif %} - {% endfor %} -
-

Zusammenfassung: {{ report.regulatory_matrix_json.summary }}

-
-
-

Domänen-Radar

-

Heatmap / Gap-Matrix

{% for item in report.domain_scores_json %}
{{ item.domain }}{{ item.score_percent }}%{{ item.maturity_level }}
{% endfor %}
-
-

Executive Summary

{{ report.executive_summary }}

-
-

Top Gaps

    {% for item in report.top_gaps_json %}
  • {{ item.title }}{% if item.severity %} – {{ item.severity }}{% endif %}
  • {% empty %}
  • Keine Einträge.
  • {% endfor %}
-

Top Maßnahmen / Pakete

    {% for item in report.top_measures_json %}
  • {{ item.title }}{% if item.priority %} – {{ item.priority }}{% endif %}
  • {% empty %}
  • Keine Einträge.
  • {% endfor %}
-
-

Maßnahmen mit Evidenzpflichten

{% for row in measure_evidence_rows %}{% empty %}{% endfor %}
MaßnahmeOffenTeilweiseAbgedecktBeispielhafte Nachweise
{{ row.measure.title }}
{{ row.measure.domain.name|default:'Allgemein' }}
{{ row.summary.open }}{{ row.summary.partial }}{{ row.summary.covered }}{% for need in row.summary.items %}
{{ need.requirement.framework }} {{ need.requirement.code }} – {{ need.get_status_display }}
{% empty %}Keine verknüpften Evidenzpflichten.{% endfor %}
Noch keine Maßnahmen vorhanden.
-

Evidenzpflichten je Requirement

{% for need in evidence_needs %}{% empty %}{% endfor %}
RequirementStatusLeitfadenMapping / Quelle
{{ need.requirement.framework }} {{ need.requirement.code }}
{{ need.requirement.title }}
{{ need.get_status_display }}
{{ need.covered_count }} Evidenz(en)
{{ need.description|default:need.rationale }}{% if need.requirement_mapping_version %}
{{ need.requirement_mapping_version }}
{% endif %}{% if need.requirement_source_citation %}
{{ need.requirement_source_citation }}
{% endif %}
Nachweis anlegen
Noch keine Evidenzpflichten generiert.
-

Roadmap

    {% for item in report.roadmap_summary %}
  • {{ item.name }} – {{ item.duration_weeks }} Wochen – {{ item.objective }}
  • {% empty %}
  • Keine Roadmap vorhanden.
  • {% endfor %}
- - -{% endblock %} diff --git a/templates/reports/report_list.html b/templates/reports/report_list.html deleted file mode 100644 index 55bbaa4..0000000 --- a/templates/reports/report_list.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends "base.html" %} -{% block title %}Berichte{% endblock %} -{% block content %} -
-
-

Berichte

-

Management-Berichte aus abgeschlossenen Assessments mit Exportmöglichkeit.

-
-
-
-
-
- - - - {% for report in reports %} - - - - - - - - {% empty %} - - {% endfor %} - -
TitelMandantISONIS2
{{ report.title }}{{ report.tenant.name }}{{ report.iso_readiness_percent }}%{{ report.nis2_readiness_percent }}% - Öffnen - PDF -
Noch keine Berichte vorhanden.
-
-
-
-{% endblock %} diff --git a/templates/requirements/requirement_list.html b/templates/requirements/requirement_list.html deleted file mode 100644 index 44e0e65..0000000 --- a/templates/requirements/requirement_list.html +++ /dev/null @@ -1,48 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ APP_DISPLAY_NAME }} Requirement Library{% endblock %} -{% block content %} -
-
-

{{ APP_DISPLAY_NAME }} Requirement Library

-

Versionierte Anforderungen und Mapping-Basis für ISO 27001, NIS2, KRITIS und CRA.

-
-
-{% if mapping_versions %} -
- {% for key, item in mapping_versions.items %} -
-
-
-
{{ item.program_name }}
-
{{ item.framework }} v{{ item.version }}
-
{{ item.title }}
-
{{ item.requirement_count }} Requirements · {{ item.source_count }} Quellen
-
-
-
- {% endfor %} -
-{% endif %} -
-
- - - - {% for req in requirements %} - - - - - - - - - - {% empty %} - - {% endfor %} - -
FrameworkCodeVersionReferenzTitelDomaeneEvidenz
{{ req.framework }}{{ req.code }}{% if req.mapping_version %}{{ req.mapping_version.version }}{% else %}–{% endif %}{{ req.legal_reference|default:"–" }}{{ req.title }}{{ req.domain }}{% if req.evidence_required %}✓{% else %}–{% endif %}
Noch keine Requirements. Bitte seed_requirements ausfuehren.
-
-
-{% endblock %} diff --git a/templates/risks/risk_detail.html b/templates/risks/risk_detail.html deleted file mode 100644 index 2e764ed..0000000 --- a/templates/risks/risk_detail.html +++ /dev/null @@ -1,40 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ risk.title }}{% endblock %} -{% block content %} -
-

{{ risk.title }}

{{ risk.tenant.name }} · {{ risk.get_status_display }}

- -
-
-
-
-
Risikobewertung
-
-
{{ risk.score }}
BRUTTO
- {% if risk.residual_score %}
-
{{ risk.residual_score }}
NETTO
{% endif %} -
-

Auswirkung: {{ risk.get_impact_display }}

-

Wahrscheinlichkeit: {{ risk.get_likelihood_display }}

-

Risikolevel: {{ risk.risk_level_label }}

- {% if risk.treatment_strategy %}

Behandlung: {{ risk.get_treatment_strategy_display }}

{% endif %} -
-
-
-
-
Details
-

Beschreibung: {{ risk.description }}

- {% if risk.threat %}

Bedrohung: {{ risk.threat }}

{% endif %} - {% if risk.vulnerability %}

Schwachstelle: {{ risk.vulnerability }}

{% endif %} - {% if risk.treatment_plan %}

Behandlungsplan: {{ risk.treatment_plan }}

{% endif %} -
- {% if risk.owner %}Owner: {{ risk.owner }}{% endif %} - {% if risk.process %}Prozess: {{ risk.process }}{% endif %} - {% if risk.asset %}Asset: {{ risk.asset }}{% endif %} - {% if risk.review_date %}Review: {{ risk.review_date|date:"d.m.Y" }}{% endif %} - {% if risk.treatment_due_date %}Fällig: {{ risk.treatment_due_date|date:"d.m.Y" }}{% endif %} -
-
-
-
-{% endblock %} diff --git a/templates/risks/risk_form.html b/templates/risks/risk_form.html deleted file mode 100644 index 93affc2..0000000 --- a/templates/risks/risk_form.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "base.html" %} -{% block title %}{% if form.instance.pk %}Risiko bearbeiten{% else %}Neues Risiko{% endif %}{% endblock %} -{% block content %} -

{% if form.instance.pk %}Risiko bearbeiten{% else %}Neues Risiko erfassen{% endif %}

-
-
{% csrf_token %} - {% for field in form %} -
- - {{ field }} - {% if field.help_text %}
{{ field.help_text }}
{% endif %} - {% for error in field.errors %}
{{ error }}
{% endfor %} -
- {% endfor %} - - Abbrechen -
-
-{% endblock %} diff --git a/templates/risks/risk_list.html b/templates/risks/risk_list.html deleted file mode 100644 index 72c7046..0000000 --- a/templates/risks/risk_list.html +++ /dev/null @@ -1,90 +0,0 @@ -{% extends "base.html" %} -{% block title %}Risikoregister{% endblock %} -{% block content %} -
-
-

Risikoregister

-

Identifizierte Risiken mit 5×5-Bewertungsmatrix und Behandlungsstatus.

-
- Neues Risiko -
- -{# ── Summary Cards ── #} -
-
Gesamt
{{ summary.total }}
-
Kritisch
{{ summary.critical }}
-
Hoch
{{ summary.high }}
-
Mittel / Niedrig
{{ summary.medium|add:summary.low }}
-
- -{# ── 5×5 Risk Heatmap ── #} -
-
5×5 Risikomatrix – Auswirkung × Eintrittswahrscheinlichkeit
- - - - - {% for label in impact_labels %} - - {% endfor %} - - - - {% for row in matrix %} - - - {% for cell in row.cells %} - - {% endfor %} - - {% endfor %} - -
Wahrscheinlichkeit ↓ / Auswirkung →{{ label }}
{{ likelihood_labels|cut:" "|truncatechars:20 }}{% with idx=row.likelihood %}{% if idx == 5 %}Sehr wahrscheinlich{% elif idx == 4 %}Wahrscheinlich{% elif idx == 3 %}Moeglich{% elif idx == 2 %}Selten{% else %}Unwahrscheinlich{% endif %}{% endwith %} -
{% if cell.count %}{{ cell.count }}{% else %} {% endif %}
-
{{ cell.score }}
-
-
- -{# ── Risk Table ── #} -
-
Risikoliste
-
- - - - - - {% for risk in risks %} - - - - - - - - - - - - {% empty %} - - {% endfor %} - -
RisikoKategorieAuswirkungWahrscheinl.ScoreLevelStatusOwner
{{ risk.title|truncatechars:45 }}{{ risk.category|default:"–" }}{{ risk.get_impact_display }}{{ risk.get_likelihood_display }}{{ risk.score }} - {{ risk.risk_level_label }} - {{ risk.get_status_display }}{{ risk.owner|default:"–" }}Edit
Noch keine Risiken erfasst.
-
-
-{% endblock %} diff --git a/templates/roadmap/kanban.html b/templates/roadmap/kanban.html deleted file mode 100644 index 873d5d8..0000000 --- a/templates/roadmap/kanban.html +++ /dev/null @@ -1,39 +0,0 @@ -{% extends "base.html" %} -{% block title %}Kanban – {{ plan.title }}{% endblock %} -{% block content %} -
-
-
Delivery Board
-

Kanban-Board

-

{{ plan.title }}

-
- Zur Roadmap -
-
- {% for choice, tasks in kanban_columns.items %} -
-
-

{{ choice.1 }}

- {{ tasks|length }} -
- {% for task in tasks %} -
-
- {{ task.title }} - {{ task.priority }} -
-
{{ task.phase.name }}
-
{{ task.owner_role|default:'Owner offen' }}
-
Deadline: {{ task.due_date|date:"d.m.Y" }}
- {% if task.incoming_dependencies.count %} -
{{ task.incoming_dependencies.count }} Abhängigkeit(en)
- {% endif %} - Bearbeiten -
- {% empty %} -

Keine Aufgaben.

- {% endfor %} -
- {% endfor %} -
-{% endblock %} diff --git a/templates/roadmap/plan_detail.html b/templates/roadmap/plan_detail.html deleted file mode 100644 index 4b49338..0000000 --- a/templates/roadmap/plan_detail.html +++ /dev/null @@ -1,302 +0,0 @@ -{% extends "base.html" %} -{% load core_extras %} -{% block title %}{{ plan.title }}{% endblock %} -{% block content %} -
-
-
Programm-Roadmap
-

{{ plan.title }}

-

{{ plan.tenant.name }} · Gesamtpriorität {{ plan.overall_priority|default:"–" }}

-

{{ plan.summary }}

-
- -
- -
-
Zeitraum
{{ timeline_total_weeks }}W
{{ timeline_start|date:"d.m.Y" }} – {{ timeline_end|date:"d.m.Y" }}
-
Phasen
{{ plan.phases.count }}
Governance bis Audit-Readiness
-
Aufgaben
{{ total_tasks }}
Priorisierte Arbeitspakete
-
Abhängigkeiten
{{ total_dependencies }}
Sequenzen und Vorbedingungen
-
- -
-
-
-
Sektorbezug
-

{{ plan.tenant.sector_label }}

-

{{ plan.tenant.sector_profile.downstream_impact }}

-

{{ plan.tenant.sector_profile.indicative_classification }}

-
-
-
-
-
Roadmap-Fokus
-

Schwerpunkte dieser Umsetzungsreise

-
- {% for item in plan.tenant.sector_profile.roadmap_focus %} - {{ item }} - {% endfor %} -
-
-
-
- -
-
-
-

Programmsteuerung

-

Gantt-Darstellung mit Meilensteinen, Phasenbalken und operativen Arbeitspaketen pro Phase.

-
-
-
- Ansicht - -
-
- Zoom -
- Monat - Quartal -
- Theme -
- Ocean - Forest - Slate -
-
-
-
- - {% if current_view == 'boardroom' %} -
-
-
-
Vorstandssicht
-

Programm in {{ plan.phases.count }} Phasen

-

{{ timeline_total_weeks }} Wochen von {{ timeline_start|date:"d.m.Y" }} bis {{ timeline_end|date:"d.m.Y" }}.

-
-
-
Top-Phasen
- {% for row in boardroom_top_phases %} -
{{ row.phase.name }}{{ row.task_count }} Aufgaben
- {% endfor %} -
-
-
Kritische Arbeitspakete
- {% for task in critical_tasks|slice:":4" %} -
{{ task.title|truncatechars:42 }}{% if task.due_date %}{{ task.due_date|date:"d.m.Y" }}{% else %}–{% endif %}
- {% empty %} -
Keine kritischen Pakete identifiziert.
- {% endfor %} -
-
- - {# ── Milestone Flags über der Timeline ── #} -
- {% for item in milestone_feed %} -
-
{{ item.title|truncatechars:30 }}
-
{{ item.date|date:"d.m" }}
-
🚩
-
-
- {% endfor %} -
- - {# ── Month Axis ── #} -
-
- Phase -
-
- {% for month in timeline_month_segments %} - {{ month.label }} - {% endfor %} -
-
- - {# ── Gantt Lanes (like VamiSec) ── #} -
- {% for row in timeline_rows %} -
-
-
-
-
{{ row.phase.name }}
- {{ row.phase.duration_weeks }}W · {{ row.task_count }} Aufgaben -
-
-
-
- {% for pos in timeline_grid %}{% endfor %} -
- {{ row.phase.name }} -
-
- {# ── Inline Tasks (like VamiSec sub-items) ── #} -
- {% for task in row.tasks|slice:":3" %} -
- {% if task.planned_start %}{{ task.planned_start|date:"d.m" }}{% endif %}{% if task.due_date %} - {{ task.due_date|date:"d.m" }}{% endif %} - - {{ task.title|truncatechars:55 }} -
- {% endfor %} -
-
-
- {% endfor %} -
- -
-
-
Nächste Meilensteine
-
- {% for item in milestone_feed %} -
-
- {{ item.title|truncatechars:48 }} - {{ item.phase }} -
- {{ item.date|date:"d.m.Y" }} -
- {% empty %} -
Keine Meilensteine verfügbar.
- {% endfor %} -
-
-
-
Executive Narrative
-

Die Roadmap priorisiert zuerst Governance und Transparenz, anschließend Gap-Schließung, Strukturmaßnahmen und Audit-Readiness.

-

Die Darstellung zeigt je Phase den Zeitbalken und die operativen Arbeitspakete.

-
-
-
- {% endif %} - - {% if current_view == 'executive' or current_view == 'combined' %} -
- {# ── Milestone Flags ── #} -
- {% for item in milestone_feed %} -
-
{{ item.title|truncatechars:28 }}
-
{{ item.date|date:"d.m" }}
-
🏁
-
-
- {% endfor %} -
- -
-
Programmphase
-
- {% for month in timeline_month_segments %} - {{ month.label }} - {% endfor %} -
-
- - {% for row in timeline_rows %} -
-
-
Phase {{ forloop.counter }}
-
{{ row.phase.name }}
-

{{ row.phase.objective|truncatechars:140 }}

-
- {{ row.phase.duration_weeks }} Wochen - {{ row.task_count }} Aufgaben -
-
-
-
- {% for pos in timeline_grid %}{% endfor %} -
- {{ row.phase.name }} -
-
-
-
-
Meilensteine
-
- {% for milestone in row.milestones %} -
{{ milestone.date|date:"d.m.Y" }}{{ milestone.short_title }}
- {% empty %} -
Keine Meilensteine.
- {% endfor %} -
-
-
-
Arbeitspakete
-
- {% for task in row.key_tasks %} -
-
- {{ task.title|truncatechars:52 }} - {{ task.priority|default:'–' }} · {{ task.owner_role|default:'Owner offen' }} -
- {% if task.due_date %}{{ task.due_date|date:"d.m.Y" }}{% else %}–{% endif %} -
- {% empty %} -
Keine priorisierten Aufgaben.
- {% endfor %} -
-
-
-
-
- {% endfor %} -
- {% endif %} - - {% if current_view == 'delivery' or current_view == 'combined' %} -
-
-
-
Delivery View
-

Operative Umsetzung nach Phase

-

Detailsicht für Arbeitspakete, Owner, Fälligkeiten und Abhängigkeiten.

-
-
- {{ total_tasks }} Tasks - {{ total_dependencies }} Abhängigkeiten -
-
-
- {% for row in timeline_rows %} -
-
Phase {{ forloop.counter }}
-
{{ row.phase.name }}
-

{{ row.phase.objective }}

-
- {{ row.phase.duration_weeks }} Wochen - {{ row.task_count }} Aufgaben -
-
- {% for task in row.tasks|slice:":5" %} -
- {{ task.title|truncatechars:56 }} - {{ task.priority|default:'–' }} · {{ task.owner_role|default:'Owner offen' }}{% if task.due_date %} · fällig {{ task.due_date|date:"d.m.Y" }}{% endif %} -
- {% empty %} -
Keine Aufgaben.
- {% endfor %} -
-
- {% endfor %} -
-
- {% endif %} -
-{% endblock %} diff --git a/templates/roadmap/plan_list.html b/templates/roadmap/plan_list.html deleted file mode 100644 index 81027c8..0000000 --- a/templates/roadmap/plan_list.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "base.html" %} -{% block title %}Roadmaps{% endblock %} -{% block content %} -
-
-

Roadmaps

-

Grafische Projektpläne mit Phasen, Meilensteinen und Kanban-Status.

-
- Neues Assessment -
-
- {% for plan in plans %} -
-
-
{{ plan.overall_priority|default:'–' }} Priorität
-

{{ plan.title }}

-

{{ plan.tenant.name }} · Start {{ plan.planned_start|date:"d.m.Y" }}

-

{{ plan.summary|truncatechars:180 }}

-
- {{ plan.phases.count }} Phasen -
- Öffnen -
-
- {% empty %} -
-
-

Noch keine Roadmaps vorhanden. Starten Sie ein neues Assessment.

-
-
- {% endfor %} -
-{% endblock %} diff --git a/templates/roadmap/task_form.html b/templates/roadmap/task_form.html deleted file mode 100644 index 831ff8f..0000000 --- a/templates/roadmap/task_form.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends "base.html" %} -{% block title %}Roadmap-Aufgabe pflegen{% endblock %} -{% block content %} -
-
-
-
-

Roadmap-Aufgabe pflegen

-

Status, Deadlines und Hinweise können hier manuell verfeinert werden.

-
- {{ object.title }}
- {{ object.description }} -
-
- {% csrf_token %} - {{ form.as_p }} - - Zurück -
-
-
-
-
-
-
-

Warum das hilft

-
    -
  • Projektplan wird operativ nutzbar
  • -
  • Deadlines und Status machen Umsetzung messbar
  • -
  • Abhängigkeiten und Blocker werden sichtbar
  • -
-
-
-
-
-{% endblock %} diff --git a/templates/shared/form.html b/templates/shared/form.html deleted file mode 100644 index 9c6b09a..0000000 --- a/templates/shared/form.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ view.model|default:"Formular" }}{% endblock %} -{% block content %} -
-

{{ view.model|default:"Eintrag" }} erfassen

-
- {% csrf_token %} - {% for field in form %} -
- - {{ field }} - {% if field.help_text %}
{{ field.help_text }}
{% endif %} - {% for error in field.errors %}
{{ error }}
{% endfor %} -
- {% endfor %} - -
-
-{% endblock %} diff --git a/templates/wizard/applicability.html b/templates/wizard/applicability.html deleted file mode 100644 index 041374d..0000000 --- a/templates/wizard/applicability.html +++ /dev/null @@ -1,58 +0,0 @@ -{% extends "base.html" %} -{% load core_extras %} -{% block title %}Schritt 2 – Betroffenheitscheck{% endblock %} -{% block content %} -
-
-
-
-

Schritt 2 – NIS2-/KRITIS-Betroffenheit prüfen

-

Beantworte die vordefinierten Leitfragen. Daraus wird eine strukturierte Relevanz-Indikation erzeugt – keine Rechtsberatung, sondern eine belastbare fachliche Vorprüfung.

-
- {% csrf_token %} - {% for question in questions %} -
-
{{ forloop.counter }}. {{ question.text }}
-

Warum wichtig? {{ question.why_it_matters }}

- {% for option in question.options.all %} -
- - -
- {% endfor %} -
- {% endfor %} - -
-
-
-
-
-
-
-

Was entsteht daraus?

-
    -
  • Indikation: wichtig / besonders wichtig / indirekt relevant / ISO-first
  • -
  • Begründung für Management und Projektstart
  • -
  • Risikobasierte Fokussierung des Folge-Assessments
  • -
-
-
- {% if sector_context %} -
-
-

Gewählter Sektor

-

{{ sector_context.label }}

-

{{ sector_context.nis2_group }} · {{ sector_context.indicative_classification }}

-

{{ sector_context.reasoning }}

-

Auswirkung: {{ sector_context.downstream_impact }}

- {% if country_context %}

Länder-Kontext: {{ country_context.geo_impact }}

{% endif %} - {% if sector_context.special_regime %} -

Zusatzprüfung: {{ sector_context.special_regime }}

- {% endif %} -
-
- {% endif %} -
-
-{% endblock %} diff --git a/templates/wizard/maturity.html b/templates/wizard/maturity.html deleted file mode 100644 index de7bbb5..0000000 --- a/templates/wizard/maturity.html +++ /dev/null @@ -1,54 +0,0 @@ -{% extends "base.html" %} -{% load core_extras %} -{% block title %}Schritt 4 – Reifegradanalyse{% endblock %} -{% block content %} -
-
-

Schritt 4 – Reifegrad- und Gap-Analyse

-

Wähle für jede Aussage die passende Reifestufe. Aus den Antworten werden Scores, Gaps, priorisierte Maßnahmen und eine Roadmap erzeugt.

-
-
-
-
-

Sektoraler Fokus in dieser Bewertung

-

{{ sector_context.label }} · {{ sector_context.nis2_group }}

-

{{ sector_context.downstream_impact }}

- {% if country_context %}

{{ country_context.geo_impact }}

{% endif %} -

Besonders relevante Domänen: {{ sector_context.key_domains|join:", " }}

-
-
-
- {% csrf_token %} - {% for domain in domains %} -
-
-

{{ domain.name }} {% if domain.code in sector_context.key_domains %}Sektor-Fokus{% endif %}

-

{{ domain.description }}

- {% for question in domain.questions.all %} - {% if question.question_kind == 'MATURITY' %} -
-
{{ question.text }}
-

Warum wichtig? {{ question.why_it_matters }}

-
- {% for option in question.options.all %} -
-
- - -
-
- {% endfor %} -
-
- - -
-
- {% endif %} - {% endfor %} -
-
- {% endfor %} - -
-{% endblock %} diff --git a/templates/wizard/profile.html b/templates/wizard/profile.html deleted file mode 100644 index ee29720..0000000 --- a/templates/wizard/profile.html +++ /dev/null @@ -1,62 +0,0 @@ -{% extends "base.html" %} -{% block title %}Schritt 1 – Unternehmensprofil{% endblock %} -{% block content %} -
-
-
-
-

Schritt 1 – Unternehmensprofil

-

Was ist zu tun? Erfasse Grunddaten, Sektor, Länder und – für Softwareentwicklungsunternehmen – den Product-Security-/Secure-Development-Kontext.

-
- {% csrf_token %} - {{ form.as_p }} - -
-
-
-
-
-
-
-

Warum ist das wichtig?

-

Branche, Größe, kritische Leistungen und Konzernkontext bestimmen, ob ein Unternehmen regulatorisch näher an NIS2/KRITIS liegt. Für Softwareentwicklungsunternehmen beeinflussen zusätzlich Product Security, Secure Development, CRA, AI Act, IEC 62443 und ISO/SAE 21434 den weiteren Prozess.

-
Ergebnis dieses Schritts
-
    -
  • saubere Ausgangsdaten
  • -
  • Grundlage für Betroffenheitscheck
  • -
  • Grundlage für Product-Security-Applicability
  • -
  • Scope- und Priorisierungskontext
  • -
-
-
- {% if sector_context %} -
-
-

Sektor-Auswirkung

-

{{ sector_context.label }}

-

{{ sector_context.nis2_group }} · {{ sector_context.indicative_classification }}

-

{{ sector_context.reasoning }}

-

Einfluss auf den weiteren Prozess: {{ sector_context.downstream_impact }}

-

Roadmap-Fokus: {{ sector_context.roadmap_focus|join:", " }}

- {% if country_context %}

Länder-Kontext: {{ country_context.display }}

{{ country_context.geo_impact }}

{% endif %} -
-
- {% endif %} - {% if product_matrix %} -
-
-

Product Security / Cyber Security Management System

-

Zusätzliche Applicability-Logik für Software- und Produktentwicklung.

-
    -
  • CRA: {% if product_matrix.cra.applicable %}relevant{% else %}derzeit nicht im Fokus{% endif %}
  • -
  • AI Act: {% if product_matrix.ai_act.applicable %}relevant{% else %}derzeit nicht im Fokus{% endif %}
  • -
  • IEC 62443: {% if product_matrix.iec62443.applicable %}relevant{% else %}derzeit nicht im Fokus{% endif %}
  • -
  • ISO/SAE 21434: {% if product_matrix.iso_sae_21434.applicable %}relevant{% else %}derzeit nicht im Fokus{% endif %}
  • -
-

Zusammenfassung: {{ product_matrix.summary }}

-
-
- {% endif %} -
-
-{% endblock %} diff --git a/templates/wizard/results.html b/templates/wizard/results.html deleted file mode 100644 index 13e86b0..0000000 --- a/templates/wizard/results.html +++ /dev/null @@ -1,146 +0,0 @@ -{% extends "base.html" %} -{% load core_extras %} -{% block title %}Assessment-Ergebnis{% endblock %} -{% block content %} -
-
-
-

Ergebnisübersicht

-

{{ mode_context.title }} · {{ session.applicability_result }}

-

{{ session.executive_summary }}

-
- {% if report %} -
- Bericht öffnen - PDF - {% if plan %}Roadmap-PDF{% endif %} -
- {% endif %} -
-
-
-
ISO

{{ report.iso_readiness_percent }}%

-
NIS2

{{ report.nis2_readiness_percent }}%

-
KRITIS

{{ report.kritis_readiness_percent }}%

-
CRA

{{ report.cra_readiness_percent }}%

-
AI Act

{{ report.ai_act_readiness_percent }}%

-
IEC 62443

{{ report.iec62443_readiness_percent }}%

-
21434

{{ report.iso_sae_21434_readiness_percent }}%

-
-{% if report.compliance_versions_json %} -
-

{{ APP_DISPLAY_NAME }} Regulatory Mapping Versions

-
- {% for key, item in report.compliance_versions_json.items %} -
{{ item.framework }} v{{ item.version }}
{{ item.title }}
{{ item.requirement_count }} Requirements · {{ item.source_count }} Quellen
- {% endfor %} -
-
-{% endif %} -
-
-

Domänen-Radar

-
-
-
-

Management Summary

-

Sektor: {{ sector_context.label }}

-

Operative Länder: {{ country_context.display }}

-

Modus: {{ mode_context.title }}

-

Einordnung: {{ session.applicability_result }}

-

Begründung: {{ session.applicability_reasoning }}

-

Nächster Fokus: {{ sector_context.roadmap_focus|join:", " }}

- -
-
-
-
-
-

Heatmap / Gap-Matrix

-
- {% for item in report.domain_scores_json %} -
{{ item.domain }}{{ item.score_percent }}%{{ item.maturity_level }}
- {% endfor %} -
-
-
-

Größte Lücken

-
    {% for gap in gaps %}
  • {{ gap.domain.name }}: {{ gap.title }}
  • {% empty %}
  • Keine Gaps generiert.
  • {% endfor %}
-
-
-
-

Sektor-Auswirkung auf den weiteren Prozess

-

{{ sector_context.label }} · {{ sector_context.nis2_group }} · {{ sector_context.indicative_classification }}

-

{{ sector_context.downstream_impact }}

-

Fokusdomänen: {{ sector_context.key_domains|join:", " }}

-

Länder-Kontext: {{ country_context.geo_impact }}

- {% if session.assessment_type == 'ISO_READINESS' %}

Bewertungshinweis: In diesem Modus beeinflusst der Sektor Prioritäten und Roadmap, aber nicht die regulatorische Einstufung.

{% endif %} - {% if sector_context.special_regime and session.assessment_type != 'ISO_READINESS' %}

Zusatzprüfung: {{ sector_context.special_regime }}

{% endif %} -
-{% if product_matrix %} -
-

Cyber Security Management System für das Softwareentwicklungsunternehmen

-

Zusätzliche Product-Security-/Secure-Development-Schicht auf Basis der Angaben im Unternehmensprofil.

-
- {% for key, item in report.regulatory_matrix_json.items %} - {% if key != 'summary' %} -
-
{{ item.label }}{% if item.applicable %}aktiv{% else %}nicht im Fokus{% endif %}
-

{{ item.reason }}

-
- {% endif %} - {% endfor %} -
-

Zusammenfassung: {{ report.regulatory_matrix_json.summary }}

- {% if report.product_security_json.product_security_scope %}

Product-Security-Scope: {{ report.product_security_json.product_security_scope }}

{% endif %} -
-{% endif %} -
-
-

Priorisierte Maßnahmen

-
    {% for measure in measures %}
  • {{ measure.title }}{{ measure.get_priority_display }} / {{ measure.target_phase }}
  • {% empty %}
  • Keine Maßnahmen generiert.
  • {% endfor %}
-
-
-

Maßnahmen-Abhängigkeiten

-
    {% for dep in report.next_steps_json.dependencies %}
  • {{ dep.predecessor }} → {{ dep.successor }} ({{ dep.type }})
    {{ dep.rationale }}
  • {% empty %}
  • Keine Abhängigkeiten dokumentiert.
  • {% endfor %}
-
-
-
-

Nächste 30 Tage

    {% for item in report.next_steps_json.next_30_days %}
  • {{ item.title }}
  • {% empty %}
  • Keine kritischen Aufgaben.
  • {% endfor %}
-

Nächste 60 Tage

    {% for item in report.next_steps_json.next_60_days %}
  • {{ item.title }}
  • {% empty %}
  • Keine priorisierten Aufgaben.
  • {% endfor %}
-

Nächste 90 Tage

    {% for item in report.next_steps_json.next_90_days %}
  • {{ item.title }}
  • {% empty %}
  • Keine zusätzlichen Aufgaben.
  • {% endfor %}
-
-{% if plan %} -
-
-

Automatischer Projektplan / Roadmap

Die Phasen wurden direkt aus Betroffenheit, Reifegrad, Product Security, Maßnahmen und der Sektorauswahl abgeleitet.

- -
-

Grafische Roadmap-Vorschau

ScopeGap-AnalyseProduct SecurityAudit
{% for phase in plan.phases.all %}
{{ phase.name }}{{ phase.duration_weeks }} Wochen
{% endfor %}
-
{% for phase in plan.phases.all %}
{{ phase.name }}

{{ phase.duration_weeks }} Wochen{% if phase.planned_start %} · {{ phase.planned_start|date:"d.m.Y" }} bis {{ phase.planned_end|date:"d.m.Y" }}{% endif %}

{{ phase.objective }}

    {% for task in phase.tasks.all|slice:":3" %}
  • {{ task.title }}
  • {% empty %}
  • Derzeit keine Aufgaben
  • {% endfor %}
{% endfor %}
-
-{% endif %} - - - -{% endblock %} diff --git a/templates/wizard/scope.html b/templates/wizard/scope.html deleted file mode 100644 index 302a2d2..0000000 --- a/templates/wizard/scope.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends "base.html" %} -{% block title %}Schritt 3 – Scope & Struktur{% endblock %} -{% block content %} -
-
-
-
-

Schritt 3 – Scope, Prozesse und Lieferanten

-

Erfasse die zentralen Geschäftsbereiche, kritischen Prozesse und wichtigsten Dienstleister. Die App nutzt diese Angaben, um Maßnahmen nicht abstrakt, sondern organisatorisch anschlussfähig abzuleiten.

-
- {% csrf_token %} - {{ form.as_p }} - -
-
-
-
-
-
-
-

Definition of Done

-
    -
  • Scope-Statement gepflegt
  • -
  • mindestens 3 Kernprozesse erfasst
  • -
  • Geschäftsbereiche benannt
  • -
  • kritische Lieferanten aufgenommen
  • -
-
Nächster Schritt
-

Danach bewertet die App je Domäne den Reifegrad und leitet Maßnahmen sowie Projektphasen ab.

-

Register per CSV/XLSX importieren

- {% if country_context %}

Länder-Kontext: {{ country_context.geo_impact }}

{% endif %} -
-
-
-
-{% endblock %} diff --git a/templates/wizard/start.html b/templates/wizard/start.html deleted file mode 100644 index a4b099d..0000000 --- a/templates/wizard/start.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends "base.html" %} -{% block title %}Assessment starten{% endblock %} -{% block content %} -
-
-
-
-

Assessment starten

-

Wähle den fachlich richtigen Modus. Sektor und Länder steuern Betroffenheit, Schwerpunktdomänen, Maßnahmen, Roadmap und regulatorische Hinweise.

-
- {% csrf_token %} - {{ form.as_p }} - -
-
-
-
-

Assessment-Modi

-
- {% for mode in mode_options %} -
-
{{ mode.title }}
-

{{ mode.summary }}

-
    {% for step in mode.steps %}
  • {{ step }}
  • {% endfor %}
-
- {% endfor %} -
-
-
-
- {% if sector_context %} -
-

Aktueller Unternehmenskontext

-

{{ tenant.name }}

-

Sektor: {{ sector_context.label }}

-

Länder: {{ country_context.display }}

-

{{ sector_context.downstream_impact }}

-
- {% endif %} - {% if product_matrix %} -
-

Cyber Security Management System

-

Zusätzliche Produkt-/Secure-Development-Schicht für Softwareentwicklungsunternehmen.

-
    -
  • CRA für Produkte mit digitalen Elementen
  • -
  • AI Act für AI-Systeme/Funktionen
  • -
  • IEC 62443 für OT-/IACS-/Industriekontexte
  • -
  • ISO/SAE 21434 für Automotive-/Fahrzeugkontexte
  • -
-

Status: {{ product_matrix.summary }}

-
- {% endif %} -
-
-{% endblock %} From 0727ea348495b05d2ed284dbd4a5fcc3cec372fa Mon Sep 17 00:00:00 2001 From: Enrico Wilhelm Date: Sun, 7 Jun 2026 09:06:54 +0200 Subject: [PATCH 2/2] Fix clippy large error result --- rust/iscy-backend/src/lib.rs | 40 +++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/rust/iscy-backend/src/lib.rs b/rust/iscy-backend/src/lib.rs index 039312c..b71be5c 100644 --- a/rust/iscy-backend/src/lib.rs +++ b/rust/iscy-backend/src/lib.rs @@ -952,24 +952,36 @@ fn api_error_response( .into_response() } -fn validated_cve_id(raw_cve_id: &str) -> Result { +struct ApiError { + status: StatusCode, + error_code: &'static str, + message: String, +} + +impl ApiError { + fn into_response(self) -> Response { + api_error_response(self.status, self.error_code, self.message) + } +} + +fn validated_cve_id(raw_cve_id: &str) -> Result { let normalized = normalize_cve_id(raw_cve_id); if normalized.is_empty() { - return Err(api_error_response( - StatusCode::BAD_REQUEST, - "empty_cve_id", - "CVE-ID darf nicht leer sein.", - )); + return Err(ApiError { + status: StatusCode::BAD_REQUEST, + error_code: "empty_cve_id", + message: "CVE-ID darf nicht leer sein.".to_string(), + }); } if !is_valid_cve_id(&normalized) { - return Err(api_error_response( - StatusCode::UNPROCESSABLE_ENTITY, - "invalid_cve_id", - format!( + return Err(ApiError { + status: StatusCode::UNPROCESSABLE_ENTITY, + error_code: "invalid_cve_id", + message: format!( "CVE-ID '{}' entspricht nicht dem erwarteten Format CVE-YYYY-NNNN.", normalized ), - )); + }); } Ok(normalized) } @@ -1107,7 +1119,7 @@ fn first_nvd_cve(payload: &Value) -> Option { fn nvd_normalize_response(payload: NvdImportRequest) -> Response { let normalized = match validated_cve_id(&payload.cve_id) { Ok(normalized) => normalized, - Err(response) => return response, + Err(err) => return err.into_response(), }; ( StatusCode::OK, @@ -8306,7 +8318,7 @@ async fn nvd_import( ) -> Response { let normalized = match validated_cve_id(&payload.cve_id) { Ok(normalized) => normalized, - Err(response) => return response, + Err(err) => return err.into_response(), }; let Some(store) = state.cve_store.as_ref() else { return api_error_response( @@ -8357,7 +8369,7 @@ async fn nvd_upsert( let record = NvdCveRecord::from_nvd_value(&payload.cve, &raw_payload, fallback_cve_id); let normalized = match validated_cve_id(&record.cve_id) { Ok(normalized) => normalized, - Err(response) => return response, + Err(err) => return err.into_response(), }; let Some(store) = state.cve_store.as_ref() else {