|
1 | 1 | """V20: Risk-Matrix-Service fuer 5x5 Heatmap.""" |
2 | 2 | from collections import defaultdict |
| 3 | +import json |
| 4 | +from dataclasses import dataclass |
| 5 | +from datetime import date, datetime |
| 6 | +from urllib.error import HTTPError |
| 7 | +from urllib.request import Request, urlopen |
| 8 | + |
| 9 | +from django.conf import settings |
| 10 | + |
| 11 | +from .models import Risk |
| 12 | + |
| 13 | + |
| 14 | +IMPACT_LABELS = dict(Risk.IMPACT_CHOICES) |
| 15 | +LIKELIHOOD_LABELS = dict(Risk.LIKELIHOOD_CHOICES) |
| 16 | +STATUS_LABELS = dict(Risk.Status.choices) |
| 17 | +TREATMENT_LABELS = dict(Risk.Treatment.choices) |
| 18 | + |
| 19 | + |
| 20 | +@dataclass(frozen=True) |
| 21 | +class RiskRelatedRef: |
| 22 | + id: int | None |
| 23 | + name: str |
| 24 | + |
| 25 | + def __str__(self) -> str: |
| 26 | + return self.name |
| 27 | + |
| 28 | + |
| 29 | +@dataclass(frozen=True) |
| 30 | +class RiskBridgeItem: |
| 31 | + id: int |
| 32 | + tenant: object |
| 33 | + tenant_id: int |
| 34 | + category: RiskRelatedRef | None |
| 35 | + category_id: int | None |
| 36 | + process: RiskRelatedRef | None |
| 37 | + process_id: int | None |
| 38 | + asset: RiskRelatedRef | None |
| 39 | + asset_id: int | None |
| 40 | + owner: RiskRelatedRef | None |
| 41 | + owner_id: int | None |
| 42 | + title: str |
| 43 | + description: str |
| 44 | + threat: str |
| 45 | + vulnerability: str |
| 46 | + impact: int |
| 47 | + impact_label: str |
| 48 | + likelihood: int |
| 49 | + likelihood_label: str |
| 50 | + residual_impact: int | None |
| 51 | + residual_impact_label: str | None |
| 52 | + residual_likelihood: int | None |
| 53 | + residual_likelihood_label: str | None |
| 54 | + status: str |
| 55 | + status_label: str |
| 56 | + treatment_strategy: str |
| 57 | + treatment_strategy_label: str |
| 58 | + treatment_plan: str |
| 59 | + treatment_due_date: date | None |
| 60 | + accepted_by: RiskRelatedRef | None |
| 61 | + accepted_by_id: int | None |
| 62 | + accepted_at: datetime | None |
| 63 | + review_date: date | None |
| 64 | + created_at: str |
| 65 | + updated_at: str |
| 66 | + |
| 67 | + @property |
| 68 | + def pk(self) -> int: |
| 69 | + return self.id |
| 70 | + |
| 71 | + @property |
| 72 | + def score(self) -> int: |
| 73 | + return self.impact * self.likelihood |
| 74 | + |
| 75 | + @property |
| 76 | + def residual_score(self) -> int | None: |
| 77 | + if self.residual_impact and self.residual_likelihood: |
| 78 | + return self.residual_impact * self.residual_likelihood |
| 79 | + return None |
| 80 | + |
| 81 | + @property |
| 82 | + def risk_level(self) -> str: |
| 83 | + score = self.score |
| 84 | + if score >= 20: |
| 85 | + return 'CRITICAL' |
| 86 | + if score >= 12: |
| 87 | + return 'HIGH' |
| 88 | + if score >= 6: |
| 89 | + return 'MEDIUM' |
| 90 | + return 'LOW' |
| 91 | + |
| 92 | + @property |
| 93 | + def risk_level_label(self) -> str: |
| 94 | + return {'CRITICAL': 'Kritisch', 'HIGH': 'Hoch', 'MEDIUM': 'Mittel', 'LOW': 'Niedrig'}.get( |
| 95 | + self.risk_level, |
| 96 | + '–', |
| 97 | + ) |
| 98 | + |
| 99 | + @property |
| 100 | + def severity(self) -> int: |
| 101 | + return self.impact |
| 102 | + |
| 103 | + def get_impact_display(self) -> str: |
| 104 | + return self.impact_label or IMPACT_LABELS.get(self.impact, str(self.impact)) |
| 105 | + |
| 106 | + def get_likelihood_display(self) -> str: |
| 107 | + return self.likelihood_label or LIKELIHOOD_LABELS.get(self.likelihood, str(self.likelihood)) |
| 108 | + |
| 109 | + def get_status_display(self) -> str: |
| 110 | + return self.status_label or STATUS_LABELS.get(self.status, self.status) |
| 111 | + |
| 112 | + def get_treatment_strategy_display(self) -> str: |
| 113 | + return self.treatment_strategy_label or TREATMENT_LABELS.get(self.treatment_strategy, self.treatment_strategy) |
| 114 | + |
| 115 | + |
| 116 | +class RiskRustClient: |
| 117 | + @staticmethod |
| 118 | + def _base_url() -> str: |
| 119 | + base = (getattr(settings, 'RUST_BACKEND_URL', '') or '').strip() |
| 120 | + return base.rstrip('/') |
| 121 | + |
| 122 | + @staticmethod |
| 123 | + def fetch_risks(request, tenant, timeout: int = 8) -> list[RiskBridgeItem]: |
| 124 | + base = RiskRustClient._base_url() |
| 125 | + if not base: |
| 126 | + raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') |
| 127 | + |
| 128 | + rust_request = RiskRustClient._authenticated_request(request, tenant, f'{base}/api/v1/risks') |
| 129 | + with urlopen(rust_request, timeout=timeout) as response: |
| 130 | + payload = json.loads(response.read().decode('utf-8')) |
| 131 | + |
| 132 | + tenant_id = RiskRustClient._int_field(payload, 'tenant_id') |
| 133 | + if tenant_id != int(tenant.id): |
| 134 | + raise RuntimeError('Rust-Risikoliste gehoert nicht zum angefragten Tenant.') |
| 135 | + |
| 136 | + return [ |
| 137 | + RiskRustClient._risk_from_payload(item, tenant) |
| 138 | + for item in payload.get('risks', []) |
| 139 | + if isinstance(item, dict) |
| 140 | + ] |
| 141 | + |
| 142 | + @staticmethod |
| 143 | + def fetch_risk_detail(request, tenant, risk_id: int, timeout: int = 8): |
| 144 | + base = RiskRustClient._base_url() |
| 145 | + if not base: |
| 146 | + raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.') |
| 147 | + |
| 148 | + rust_request = RiskRustClient._authenticated_request(request, tenant, f'{base}/api/v1/risks/{int(risk_id)}') |
| 149 | + try: |
| 150 | + with urlopen(rust_request, timeout=timeout) as response: |
| 151 | + payload = json.loads(response.read().decode('utf-8')) |
| 152 | + except HTTPError as exc: |
| 153 | + if exc.code == 404: |
| 154 | + return None |
| 155 | + raise |
| 156 | + |
| 157 | + risk = payload.get('risk') or {} |
| 158 | + if not isinstance(risk, dict): |
| 159 | + raise RuntimeError('Rust-Risikodetail hat kein risk-Objekt geliefert.') |
| 160 | + risk_tenant_id = RiskRustClient._int_field(risk, 'tenant_id') |
| 161 | + if risk_tenant_id != int(tenant.id): |
| 162 | + raise RuntimeError('Rust-Risikodetail gehoert nicht zum angefragten Tenant.') |
| 163 | + return RiskRustClient._risk_from_payload(risk, tenant) |
| 164 | + |
| 165 | + @staticmethod |
| 166 | + def _risk_from_payload(item: dict, tenant) -> RiskBridgeItem: |
| 167 | + category_id = RiskRustClient._optional_int_field(item, 'category_id') |
| 168 | + process_id = RiskRustClient._optional_int_field(item, 'process_id') |
| 169 | + asset_id = RiskRustClient._optional_int_field(item, 'asset_id') |
| 170 | + owner_id = RiskRustClient._optional_int_field(item, 'owner_id') |
| 171 | + accepted_by_id = RiskRustClient._optional_int_field(item, 'accepted_by_id') |
| 172 | + category_name = str(item.get('category_name') or '').strip() |
| 173 | + process_name = str(item.get('process_name') or '').strip() |
| 174 | + asset_name = str(item.get('asset_name') or '').strip() |
| 175 | + owner_display = str(item.get('owner_display') or '').strip() |
| 176 | + accepted_by_display = str(item.get('accepted_by_display') or '').strip() |
| 177 | + impact = RiskRustClient._int_field(item, 'impact') |
| 178 | + likelihood = RiskRustClient._int_field(item, 'likelihood') |
| 179 | + status = str(item.get('status') or Risk.Status.IDENTIFIED) |
| 180 | + treatment_strategy = str(item.get('treatment_strategy') or '') |
| 181 | + return RiskBridgeItem( |
| 182 | + id=RiskRustClient._int_field(item, 'id'), |
| 183 | + tenant=tenant, |
| 184 | + tenant_id=RiskRustClient._int_field(item, 'tenant_id'), |
| 185 | + category=RiskRelatedRef(category_id, category_name) if category_name else None, |
| 186 | + category_id=category_id, |
| 187 | + process=RiskRelatedRef(process_id, process_name) if process_name else None, |
| 188 | + process_id=process_id, |
| 189 | + asset=RiskRelatedRef(asset_id, asset_name) if asset_name else None, |
| 190 | + asset_id=asset_id, |
| 191 | + owner=RiskRelatedRef(owner_id, owner_display) if owner_display else None, |
| 192 | + owner_id=owner_id, |
| 193 | + title=str(item.get('title') or ''), |
| 194 | + description=str(item.get('description') or ''), |
| 195 | + threat=str(item.get('threat') or ''), |
| 196 | + vulnerability=str(item.get('vulnerability') or ''), |
| 197 | + impact=impact, |
| 198 | + impact_label=str(item.get('impact_label') or IMPACT_LABELS.get(impact, impact)), |
| 199 | + likelihood=likelihood, |
| 200 | + likelihood_label=str(item.get('likelihood_label') or LIKELIHOOD_LABELS.get(likelihood, likelihood)), |
| 201 | + residual_impact=RiskRustClient._optional_int_field(item, 'residual_impact'), |
| 202 | + residual_impact_label=RiskRustClient._optional_str_field(item, 'residual_impact_label'), |
| 203 | + residual_likelihood=RiskRustClient._optional_int_field(item, 'residual_likelihood'), |
| 204 | + residual_likelihood_label=RiskRustClient._optional_str_field(item, 'residual_likelihood_label'), |
| 205 | + status=status, |
| 206 | + status_label=str(item.get('status_label') or STATUS_LABELS.get(status, status)), |
| 207 | + treatment_strategy=treatment_strategy, |
| 208 | + treatment_strategy_label=str( |
| 209 | + item.get('treatment_strategy_label') or TREATMENT_LABELS.get(treatment_strategy, treatment_strategy) |
| 210 | + ), |
| 211 | + treatment_plan=str(item.get('treatment_plan') or ''), |
| 212 | + treatment_due_date=RiskRustClient._date_field(item, 'treatment_due_date'), |
| 213 | + accepted_by=RiskRelatedRef(accepted_by_id, accepted_by_display) if accepted_by_display else None, |
| 214 | + accepted_by_id=accepted_by_id, |
| 215 | + accepted_at=RiskRustClient._datetime_field(item, 'accepted_at'), |
| 216 | + review_date=RiskRustClient._date_field(item, 'review_date'), |
| 217 | + created_at=str(item.get('created_at') or ''), |
| 218 | + updated_at=str(item.get('updated_at') or ''), |
| 219 | + ) |
| 220 | + |
| 221 | + @staticmethod |
| 222 | + def _authenticated_request(request, tenant, url: str) -> Request: |
| 223 | + user = getattr(request, 'user', None) |
| 224 | + user_id = getattr(user, 'id', None) |
| 225 | + if not user_id: |
| 226 | + raise RuntimeError('Risk-Rust-Bridge braucht einen authentifizierten User.') |
| 227 | + |
| 228 | + rust_request = Request(url) |
| 229 | + rust_request.add_header('Accept', 'application/json') |
| 230 | + rust_request.add_header('X-ISCY-Tenant-ID', str(tenant.id)) |
| 231 | + rust_request.add_header('X-ISCY-User-ID', str(user_id)) |
| 232 | + user_email = (getattr(user, 'email', '') or '').strip() |
| 233 | + if user_email: |
| 234 | + rust_request.add_header('X-ISCY-User-Email', user_email) |
| 235 | + return rust_request |
| 236 | + |
| 237 | + @staticmethod |
| 238 | + def _int_field(payload: dict, key: str) -> int: |
| 239 | + return int(payload.get(key) or 0) |
| 240 | + |
| 241 | + @staticmethod |
| 242 | + def _optional_int_field(payload: dict, key: str) -> int | None: |
| 243 | + value = payload.get(key) |
| 244 | + if value in (None, ''): |
| 245 | + return None |
| 246 | + return int(value) |
| 247 | + |
| 248 | + @staticmethod |
| 249 | + def _optional_str_field(payload: dict, key: str) -> str | None: |
| 250 | + value = str(payload.get(key) or '').strip() |
| 251 | + return value or None |
| 252 | + |
| 253 | + @staticmethod |
| 254 | + def _date_field(payload: dict, key: str) -> date | None: |
| 255 | + value = str(payload.get(key) or '').strip() |
| 256 | + if not value: |
| 257 | + return None |
| 258 | + return date.fromisoformat(value[:10]) |
| 259 | + |
| 260 | + @staticmethod |
| 261 | + def _datetime_field(payload: dict, key: str) -> datetime | None: |
| 262 | + value = str(payload.get(key) or '').strip() |
| 263 | + if not value: |
| 264 | + return None |
| 265 | + normalized = value.replace('Z', '+00:00') |
| 266 | + return datetime.fromisoformat(normalized) |
| 267 | + |
| 268 | + |
| 269 | +class RiskRegisterBridge: |
| 270 | + @staticmethod |
| 271 | + def fetch_list(request, tenant): |
| 272 | + if tenant is None: |
| 273 | + return None |
| 274 | + |
| 275 | + backend = str(getattr(settings, 'RISK_REGISTER_BACKEND', 'rust_service') or '').strip().lower() |
| 276 | + if backend != 'rust_service': |
| 277 | + return None |
| 278 | + |
| 279 | + if not RiskRustClient._base_url(): |
| 280 | + if RiskRegisterBridge._strict_rust_mode(): |
| 281 | + raise RuntimeError('Rust risk register backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') |
| 282 | + return None |
| 283 | + |
| 284 | + try: |
| 285 | + return RiskRustClient.fetch_risks(request, tenant) |
| 286 | + except Exception as exc: |
| 287 | + if RiskRegisterBridge._strict_rust_mode(): |
| 288 | + raise RuntimeError('Rust risk register backend ist aktiv, aber nicht erreichbar.') from exc |
| 289 | + return None |
| 290 | + |
| 291 | + @staticmethod |
| 292 | + def fetch_detail(request, tenant, risk_id: int): |
| 293 | + if tenant is None: |
| 294 | + return None |
| 295 | + |
| 296 | + backend = str(getattr(settings, 'RISK_REGISTER_BACKEND', 'rust_service') or '').strip().lower() |
| 297 | + if backend != 'rust_service': |
| 298 | + return None |
| 299 | + |
| 300 | + if not RiskRustClient._base_url(): |
| 301 | + if RiskRegisterBridge._strict_rust_mode(): |
| 302 | + raise RuntimeError('Rust risk register backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.') |
| 303 | + return None |
| 304 | + |
| 305 | + try: |
| 306 | + return RiskRustClient.fetch_risk_detail(request, tenant, risk_id) |
| 307 | + except Exception as exc: |
| 308 | + if RiskRegisterBridge._strict_rust_mode(): |
| 309 | + raise RuntimeError('Rust risk register backend ist aktiv, aber nicht erreichbar.') from exc |
| 310 | + return None |
| 311 | + |
| 312 | + @staticmethod |
| 313 | + def _strict_rust_mode() -> bool: |
| 314 | + return bool(getattr(settings, 'RUST_STRICT_MODE', False)) |
3 | 315 |
|
4 | 316 |
|
5 | 317 | class RiskMatrixService: |
|
0 commit comments