Skip to content

Commit db83c90

Browse files
Add Rust evidence register bridge
1 parent b9f110b commit db83c90

13 files changed

Lines changed: 1840 additions & 24 deletions

File tree

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ COMPOSE_STAGE=docker compose -f docker-compose.yml -f docker-compose.stage.yml
33
COMPOSE_PROD=docker compose -f docker-compose.yml -f docker-compose.prod.yml
44
COMPOSE_PROD_LLM=docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.llm.yml
55
PYTHON_BIN=$(shell if [ -x .venv/bin/python ]; then echo .venv/bin/python; else echo python; fi)
6-
TEAM_TEST_ENV=GUIDANCE_SCORING_BACKEND=local RISK_SCORING_BACKEND=local REPORT_SUMMARY_BACKEND=local REPORT_SNAPSHOT_BACKEND=local DASHBOARD_SUMMARY_BACKEND=local ASSET_INVENTORY_BACKEND=local PROCESS_REGISTER_BACKEND=local RISK_REGISTER_BACKEND=local RUST_BACKEND_URL=
6+
TEAM_TEST_ENV=GUIDANCE_SCORING_BACKEND=local RISK_SCORING_BACKEND=local REPORT_SUMMARY_BACKEND=local REPORT_SNAPSHOT_BACKEND=local DASHBOARD_SUMMARY_BACKEND=local ASSET_INVENTORY_BACKEND=local PROCESS_REGISTER_BACKEND=local RISK_REGISTER_BACKEND=local EVIDENCE_REGISTER_BACKEND=local RUST_BACKEND_URL=
77

88
.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 canary-daily rust-import-collection rust-sync-recent rust-canary-parity rust-canary-trend rust-canary-import
99

@@ -20,7 +20,7 @@ local-test:
2020

2121
team-test:
2222
$(TEAM_TEST_ENV) $(PYTHON_BIN) manage.py check
23-
$(TEAM_TEST_ENV) $(PYTHON_BIN) manage.py test apps.core apps.reports apps.product_security apps.guidance apps.dashboard apps.assets_app apps.processes apps.risks apps.vulnerability_intelligence
23+
$(TEAM_TEST_ENV) $(PYTHON_BIN) manage.py test apps.core apps.reports apps.product_security apps.guidance apps.dashboard apps.assets_app apps.processes apps.risks apps.evidence apps.vulnerability_intelligence
2424

2525
docker-check:
2626
$(COMPOSE_DEV) config >/dev/null

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ Der funktionierende Referenzpfad auf Ubuntu 24.04 ist:
161161
- `ASSET_INVENTORY_BACKEND=rust_service`
162162
- `PROCESS_REGISTER_BACKEND=rust_service`
163163
- `RISK_REGISTER_BACKEND=rust_service`
164+
- `EVIDENCE_REGISTER_BACKEND=rust_service`
164165
- `RUST_STRICT_MODE=True` (erzwingt Rust-Backends ohne Fallback)
165166

166167
Danach kann weiter wie gewohnt gestartet werden:

apps/evidence/services.py

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,357 @@
1+
import json
2+
from dataclasses import dataclass
3+
from datetime import datetime
4+
from urllib.parse import urlencode
5+
from urllib.request import Request, urlopen
6+
17
from django.conf import settings
28

39
from apps.evidence.models import EvidenceItem, RequirementEvidenceNeed
410
from apps.organizations.sector_catalog import get_sector_definition
511
from apps.requirements_app.models import Requirement
612

713

14+
@dataclass(frozen=True)
15+
class EvidenceRelatedRef:
16+
id: int | None
17+
name: str
18+
19+
def __str__(self) -> str:
20+
return self.name
21+
22+
23+
@dataclass(frozen=True)
24+
class EvidenceMappingVersionRef:
25+
program_name: str
26+
framework: str
27+
version: str
28+
29+
def __str__(self) -> str:
30+
return f'{self.program_name} {self.framework} {self.version}'
31+
32+
33+
@dataclass(frozen=True)
34+
class EvidenceRegulatorySourceRef:
35+
authority: str
36+
citation: str
37+
title: str
38+
39+
def __str__(self) -> str:
40+
return ' - '.join(part for part in [self.authority, self.citation or self.title] if part)
41+
42+
43+
@dataclass(frozen=True)
44+
class EvidenceRequirementRef:
45+
id: int
46+
framework: str
47+
code: str
48+
title: str
49+
mapping_version: EvidenceMappingVersionRef | None
50+
primary_source: EvidenceRegulatorySourceRef | None
51+
52+
def __str__(self) -> str:
53+
return f'{self.framework} - {self.code}'
54+
55+
56+
@dataclass(frozen=True)
57+
class EvidenceItemBridgeItem:
58+
id: int
59+
tenant: object
60+
tenant_id: int
61+
session_id: int | None
62+
domain_id: int | None
63+
measure: EvidenceRelatedRef | None
64+
measure_id: int | None
65+
requirement: EvidenceRequirementRef | None
66+
requirement_id: int | None
67+
title: str
68+
description: str
69+
linked_requirement: str
70+
file: str | None
71+
status: str
72+
status_label: str
73+
owner: EvidenceRelatedRef | None
74+
owner_id: int | None
75+
review_notes: str
76+
reviewed_by: EvidenceRelatedRef | None
77+
reviewed_by_id: int | None
78+
reviewed_at: datetime | None
79+
created_at: datetime | None
80+
updated_at: datetime | None
81+
82+
@property
83+
def pk(self) -> int:
84+
return self.id
85+
86+
@property
87+
def requirement_display(self) -> str:
88+
if self.requirement:
89+
return f'{self.requirement.framework} {self.requirement.code}{self.requirement.title}'
90+
return self.linked_requirement
91+
92+
def get_status_display(self) -> str:
93+
return self.status_label
94+
95+
96+
@dataclass(frozen=True)
97+
class RequirementEvidenceNeedBridgeItem:
98+
id: int
99+
tenant: object
100+
tenant_id: int
101+
session_id: int | None
102+
requirement: EvidenceRequirementRef
103+
requirement_id: int
104+
title: str
105+
description: str
106+
is_mandatory: bool
107+
status: str
108+
status_label: str
109+
rationale: str
110+
covered_count: int
111+
created_at: datetime | None
112+
updated_at: datetime | None
113+
114+
@property
115+
def pk(self) -> int:
116+
return self.id
117+
118+
@property
119+
def requirement_mapping_version(self) -> str:
120+
requirement = self.requirement
121+
if not requirement or not requirement.mapping_version:
122+
return ''
123+
return f'{requirement.mapping_version.program_name} {requirement.framework} v{requirement.mapping_version.version}'
124+
125+
@property
126+
def requirement_source_citation(self) -> str:
127+
requirement = self.requirement
128+
if not requirement or not requirement.primary_source:
129+
return ''
130+
source = requirement.primary_source
131+
parts = [source.authority, source.citation or source.title]
132+
return ' - '.join(part for part in parts if part)
133+
134+
def get_status_display(self) -> str:
135+
return self.status_label
136+
137+
138+
@dataclass(frozen=True)
139+
class EvidenceOverviewBridgeResult:
140+
evidence_items: list[EvidenceItemBridgeItem]
141+
evidence_needs: list[RequirementEvidenceNeedBridgeItem]
142+
need_summary: dict[str, int]
143+
144+
145+
class EvidenceRustClient:
146+
@staticmethod
147+
def _base_url() -> str:
148+
base = (getattr(settings, 'RUST_BACKEND_URL', '') or '').strip()
149+
return base.rstrip('/')
150+
151+
@staticmethod
152+
def fetch_overview(request, tenant, session_id: str | int | None = None, timeout: int = 8) -> EvidenceOverviewBridgeResult:
153+
base = EvidenceRustClient._base_url()
154+
if not base:
155+
raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.')
156+
157+
url = f'{base}/api/v1/evidence'
158+
if session_id:
159+
url = f'{url}?{urlencode({"session_id": int(session_id)})}'
160+
rust_request = EvidenceRustClient._authenticated_request(request, tenant, url)
161+
with urlopen(rust_request, timeout=timeout) as response:
162+
payload = json.loads(response.read().decode('utf-8'))
163+
164+
tenant_id = EvidenceRustClient._int_field(payload, 'tenant_id')
165+
if tenant_id != int(tenant.id):
166+
raise RuntimeError('Rust-Evidenzliste gehoert nicht zum angefragten Tenant.')
167+
168+
return EvidenceOverviewBridgeResult(
169+
evidence_items=[
170+
EvidenceRustClient._evidence_item_from_payload(item, tenant)
171+
for item in payload.get('evidence_items', [])
172+
if isinstance(item, dict)
173+
],
174+
evidence_needs=[
175+
EvidenceRustClient._evidence_need_from_payload(item, tenant)
176+
for item in payload.get('evidence_needs', [])
177+
if isinstance(item, dict)
178+
],
179+
need_summary=EvidenceRustClient._need_summary_from_payload(payload.get('need_summary') or {}),
180+
)
181+
182+
@staticmethod
183+
def _evidence_item_from_payload(item: dict, tenant) -> EvidenceItemBridgeItem:
184+
measure_id = EvidenceRustClient._optional_int_field(item, 'measure_id')
185+
measure_title = str(item.get('measure_title') or '').strip()
186+
owner_id = EvidenceRustClient._optional_int_field(item, 'owner_id')
187+
owner_display = str(item.get('owner_display') or '').strip()
188+
reviewed_by_id = EvidenceRustClient._optional_int_field(item, 'reviewed_by_id')
189+
reviewed_by_display = str(item.get('reviewed_by_display') or '').strip()
190+
requirement = EvidenceRustClient._requirement_from_payload(item)
191+
return EvidenceItemBridgeItem(
192+
id=EvidenceRustClient._int_field(item, 'id'),
193+
tenant=tenant,
194+
tenant_id=EvidenceRustClient._int_field(item, 'tenant_id'),
195+
session_id=EvidenceRustClient._optional_int_field(item, 'session_id'),
196+
domain_id=EvidenceRustClient._optional_int_field(item, 'domain_id'),
197+
measure=EvidenceRelatedRef(measure_id, measure_title) if measure_title else None,
198+
measure_id=measure_id,
199+
requirement=requirement,
200+
requirement_id=EvidenceRustClient._optional_int_field(item, 'requirement_id'),
201+
title=str(item.get('title') or ''),
202+
description=str(item.get('description') or ''),
203+
linked_requirement=str(item.get('linked_requirement') or ''),
204+
file=EvidenceRustClient._optional_str_field(item, 'file_name'),
205+
status=str(item.get('status') or EvidenceItem.Status.DRAFT),
206+
status_label=str(item.get('status_label') or dict(EvidenceItem.Status.choices).get(item.get('status'), 'Entwurf')),
207+
owner=EvidenceRelatedRef(owner_id, owner_display) if owner_display else None,
208+
owner_id=owner_id,
209+
review_notes=str(item.get('review_notes') or ''),
210+
reviewed_by=EvidenceRelatedRef(reviewed_by_id, reviewed_by_display) if reviewed_by_display else None,
211+
reviewed_by_id=reviewed_by_id,
212+
reviewed_at=EvidenceRustClient._datetime_field(item, 'reviewed_at'),
213+
created_at=EvidenceRustClient._datetime_field(item, 'created_at'),
214+
updated_at=EvidenceRustClient._datetime_field(item, 'updated_at'),
215+
)
216+
217+
@staticmethod
218+
def _evidence_need_from_payload(item: dict, tenant) -> RequirementEvidenceNeedBridgeItem:
219+
requirement = EvidenceRustClient._requirement_from_payload(item)
220+
if requirement is None:
221+
raise RuntimeError('Rust-Evidenzpflicht hat keine Requirement-Daten geliefert.')
222+
return RequirementEvidenceNeedBridgeItem(
223+
id=EvidenceRustClient._int_field(item, 'id'),
224+
tenant=tenant,
225+
tenant_id=EvidenceRustClient._int_field(item, 'tenant_id'),
226+
session_id=EvidenceRustClient._optional_int_field(item, 'session_id'),
227+
requirement=requirement,
228+
requirement_id=EvidenceRustClient._int_field(item, 'requirement_id'),
229+
title=str(item.get('title') or ''),
230+
description=str(item.get('description') or ''),
231+
is_mandatory=bool(item.get('is_mandatory')),
232+
status=str(item.get('status') or RequirementEvidenceNeed.Status.OPEN),
233+
status_label=str(item.get('status_label') or dict(RequirementEvidenceNeed.Status.choices).get(item.get('status'), 'Offen')),
234+
rationale=str(item.get('rationale') or ''),
235+
covered_count=EvidenceRustClient._int_field(item, 'covered_count'),
236+
created_at=EvidenceRustClient._datetime_field(item, 'created_at'),
237+
updated_at=EvidenceRustClient._datetime_field(item, 'updated_at'),
238+
)
239+
240+
@staticmethod
241+
def _requirement_from_payload(item: dict) -> EvidenceRequirementRef | None:
242+
requirement_id = EvidenceRustClient._optional_int_field(item, 'requirement_id')
243+
framework = str(item.get('requirement_framework') or '').strip()
244+
code = str(item.get('requirement_code') or '').strip()
245+
title = str(item.get('requirement_title') or '').strip()
246+
if not requirement_id or not framework or not code:
247+
return None
248+
249+
mapping_version = None
250+
mapping_program_name = str(item.get('mapping_program_name') or '').strip()
251+
mapping_version_value = str(item.get('mapping_version') or '').strip()
252+
if mapping_program_name and mapping_version_value:
253+
mapping_version = EvidenceMappingVersionRef(
254+
program_name=mapping_program_name,
255+
framework=framework,
256+
version=mapping_version_value,
257+
)
258+
259+
primary_source = None
260+
source_authority = str(item.get('source_authority') or '').strip()
261+
source_citation = str(item.get('source_citation') or '').strip()
262+
source_title = str(item.get('source_title') or '').strip()
263+
if source_authority or source_citation or source_title:
264+
primary_source = EvidenceRegulatorySourceRef(
265+
authority=source_authority,
266+
citation=source_citation,
267+
title=source_title,
268+
)
269+
270+
return EvidenceRequirementRef(
271+
id=requirement_id,
272+
framework=framework,
273+
code=code,
274+
title=title,
275+
mapping_version=mapping_version,
276+
primary_source=primary_source,
277+
)
278+
279+
@staticmethod
280+
def _need_summary_from_payload(payload: dict) -> dict[str, int]:
281+
return {
282+
'open': int(payload.get('open') or 0),
283+
'partial': int(payload.get('partial') or 0),
284+
'covered': int(payload.get('covered') or 0),
285+
}
286+
287+
@staticmethod
288+
def _authenticated_request(request, tenant, url: str) -> Request:
289+
user = getattr(request, 'user', None)
290+
user_id = getattr(user, 'id', None)
291+
if not user_id:
292+
raise RuntimeError('Evidence-Rust-Bridge braucht einen authentifizierten User.')
293+
294+
rust_request = Request(url)
295+
rust_request.add_header('Accept', 'application/json')
296+
rust_request.add_header('X-ISCY-Tenant-ID', str(tenant.id))
297+
rust_request.add_header('X-ISCY-User-ID', str(user_id))
298+
user_email = (getattr(user, 'email', '') or '').strip()
299+
if user_email:
300+
rust_request.add_header('X-ISCY-User-Email', user_email)
301+
return rust_request
302+
303+
@staticmethod
304+
def _int_field(payload: dict, key: str) -> int:
305+
return int(payload.get(key) or 0)
306+
307+
@staticmethod
308+
def _optional_int_field(payload: dict, key: str) -> int | None:
309+
value = payload.get(key)
310+
if value in (None, ''):
311+
return None
312+
return int(value)
313+
314+
@staticmethod
315+
def _optional_str_field(payload: dict, key: str) -> str | None:
316+
value = str(payload.get(key) or '').strip()
317+
return value or None
318+
319+
@staticmethod
320+
def _datetime_field(payload: dict, key: str) -> datetime | None:
321+
value = str(payload.get(key) or '').strip()
322+
if not value:
323+
return None
324+
normalized = value.replace('Z', '+00:00')
325+
return datetime.fromisoformat(normalized)
326+
327+
328+
class EvidenceRegisterBridge:
329+
@staticmethod
330+
def fetch_overview(request, tenant, session_id: str | int | None = None):
331+
if tenant is None:
332+
return None
333+
334+
backend = str(getattr(settings, 'EVIDENCE_REGISTER_BACKEND', 'rust_service') or '').strip().lower()
335+
if backend != 'rust_service':
336+
return None
337+
338+
if not EvidenceRustClient._base_url():
339+
if EvidenceRegisterBridge._strict_rust_mode():
340+
raise RuntimeError('Rust evidence register backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.')
341+
return None
342+
343+
try:
344+
return EvidenceRustClient.fetch_overview(request, tenant, session_id=session_id)
345+
except Exception as exc:
346+
if EvidenceRegisterBridge._strict_rust_mode():
347+
raise RuntimeError('Rust evidence register backend ist aktiv, aber nicht erreichbar.') from exc
348+
return None
349+
350+
@staticmethod
351+
def _strict_rust_mode() -> bool:
352+
return bool(getattr(settings, 'RUST_STRICT_MODE', False))
353+
354+
8355
class EvidenceNeedService:
9356
DOMAIN_KEYWORDS = {
10357
'GOV': ['governance', 'scope', 'policy', 'roles', 'management'],

0 commit comments

Comments
 (0)