Skip to content

Commit b800a85

Browse files
Add Rust catalog and requirement bridges
1 parent a42f3ae commit b800a85

17 files changed

Lines changed: 2448 additions & 13 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 EVIDENCE_REGISTER_BACKEND=local ASSESSMENT_REGISTER_BACKEND=local ROADMAP_REGISTER_BACKEND=local WIZARD_RESULTS_BACKEND=local IMPORT_CENTER_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 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 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.evidence apps.assessments apps.roadmap apps.wizard apps.import_center apps.vulnerability_intelligence
23+
$(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
2424

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

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ Der funktionierende Referenzpfad auf Ubuntu 24.04 ist:
158158
- `REPORT_SUMMARY_BACKEND=rust_service`
159159
- `REPORT_SNAPSHOT_BACKEND=rust_service`
160160
- `DASHBOARD_SUMMARY_BACKEND=rust_service`
161+
- `CATALOG_BACKEND=rust_service`
162+
- `REQUIREMENTS_BACKEND=rust_service`
161163
- `ASSET_INVENTORY_BACKEND=rust_service`
162164
- `PROCESS_REGISTER_BACKEND=rust_service`
163165
- `RISK_REGISTER_BACKEND=rust_service`

apps/catalog/services.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import json
2+
from dataclasses import dataclass, field
3+
from urllib.request import Request, urlopen
4+
5+
from django.conf import settings
6+
7+
8+
class CatalogRelatedList:
9+
def __init__(self, items=None):
10+
self._items = list(items or [])
11+
12+
def all(self):
13+
return list(self._items)
14+
15+
def count(self):
16+
return len(self._items)
17+
18+
def add(self, item):
19+
self._items.append(item)
20+
21+
def __iter__(self):
22+
return iter(self._items)
23+
24+
def __len__(self):
25+
return len(self._items)
26+
27+
28+
@dataclass(frozen=True)
29+
class CatalogQuestionBridgeItem:
30+
id: int
31+
domain_id: int | None
32+
code: str
33+
text: str
34+
help_text: str
35+
why_it_matters: str
36+
question_kind: str
37+
question_kind_label: str
38+
wizard_step: str
39+
wizard_step_label: str
40+
weight: int
41+
is_required: bool
42+
applies_to_iso27001: bool
43+
applies_to_nis2: bool
44+
applies_to_cra: bool
45+
applies_to_ai_act: bool
46+
applies_to_iec62443: bool
47+
applies_to_iso_sae_21434: bool
48+
applies_to_product_security: bool
49+
sort_order: int
50+
created_at: str
51+
updated_at: str
52+
53+
@property
54+
def pk(self) -> int:
55+
return self.id
56+
57+
def get_question_kind_display(self) -> str:
58+
return self.question_kind_label
59+
60+
def get_wizard_step_display(self) -> str:
61+
return self.wizard_step_label
62+
63+
64+
@dataclass
65+
class CatalogDomainBridgeItem:
66+
id: int
67+
code: str
68+
name: str
69+
description: str
70+
weight: int
71+
sort_order: int
72+
question_count: int
73+
created_at: str
74+
updated_at: str
75+
questions: CatalogRelatedList = field(default_factory=CatalogRelatedList)
76+
77+
@property
78+
def pk(self) -> int:
79+
return self.id
80+
81+
def __str__(self) -> str:
82+
return self.name
83+
84+
85+
@dataclass(frozen=True)
86+
class CatalogDomainLibraryBridgeResult:
87+
domains: list[CatalogDomainBridgeItem]
88+
question_count: int
89+
90+
91+
class CatalogRustClient:
92+
@staticmethod
93+
def _base_url() -> str:
94+
base = (getattr(settings, 'RUST_BACKEND_URL', '') or '').strip()
95+
return base.rstrip('/')
96+
97+
@staticmethod
98+
def fetch_domain_library(request, timeout: int = 8) -> CatalogDomainLibraryBridgeResult:
99+
base = CatalogRustClient._base_url()
100+
if not base:
101+
raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.')
102+
103+
rust_request = CatalogRustClient._authenticated_request(request, f'{base}/api/v1/catalog/domains')
104+
with urlopen(rust_request, timeout=timeout) as response:
105+
payload = json.loads(response.read().decode('utf-8'))
106+
107+
domains = [
108+
CatalogRustClient._domain_from_payload(item)
109+
for item in payload.get('domains', [])
110+
if isinstance(item, dict)
111+
]
112+
return CatalogDomainLibraryBridgeResult(
113+
domains=domains,
114+
question_count=int(payload.get('question_count') or 0),
115+
)
116+
117+
@staticmethod
118+
def _domain_from_payload(item: dict) -> CatalogDomainBridgeItem:
119+
domain = CatalogDomainBridgeItem(
120+
id=int(item.get('id') or 0),
121+
code=str(item.get('code') or ''),
122+
name=str(item.get('name') or ''),
123+
description=str(item.get('description') or ''),
124+
weight=int(item.get('weight') or 0),
125+
sort_order=int(item.get('sort_order') or 0),
126+
question_count=int(item.get('question_count') or 0),
127+
created_at=str(item.get('created_at') or ''),
128+
updated_at=str(item.get('updated_at') or ''),
129+
)
130+
for question_payload in item.get('questions', []):
131+
if isinstance(question_payload, dict):
132+
domain.questions.add(CatalogRustClient._question_from_payload(question_payload))
133+
return domain
134+
135+
@staticmethod
136+
def _question_from_payload(item: dict) -> CatalogQuestionBridgeItem:
137+
return CatalogQuestionBridgeItem(
138+
id=int(item.get('id') or 0),
139+
domain_id=CatalogRustClient._optional_int(item.get('domain_id')),
140+
code=str(item.get('code') or ''),
141+
text=str(item.get('text') or ''),
142+
help_text=str(item.get('help_text') or ''),
143+
why_it_matters=str(item.get('why_it_matters') or ''),
144+
question_kind=str(item.get('question_kind') or ''),
145+
question_kind_label=str(item.get('question_kind_label') or ''),
146+
wizard_step=str(item.get('wizard_step') or ''),
147+
wizard_step_label=str(item.get('wizard_step_label') or ''),
148+
weight=int(item.get('weight') or 0),
149+
is_required=bool(item.get('is_required')),
150+
applies_to_iso27001=bool(item.get('applies_to_iso27001')),
151+
applies_to_nis2=bool(item.get('applies_to_nis2')),
152+
applies_to_cra=bool(item.get('applies_to_cra')),
153+
applies_to_ai_act=bool(item.get('applies_to_ai_act')),
154+
applies_to_iec62443=bool(item.get('applies_to_iec62443')),
155+
applies_to_iso_sae_21434=bool(item.get('applies_to_iso_sae_21434')),
156+
applies_to_product_security=bool(item.get('applies_to_product_security')),
157+
sort_order=int(item.get('sort_order') or 0),
158+
created_at=str(item.get('created_at') or ''),
159+
updated_at=str(item.get('updated_at') or ''),
160+
)
161+
162+
@staticmethod
163+
def _authenticated_request(request, url: str) -> Request:
164+
user = getattr(request, 'user', None)
165+
user_id = getattr(user, 'id', None)
166+
tenant_id = getattr(getattr(user, 'tenant', None), 'id', None)
167+
if not user_id or not tenant_id:
168+
raise RuntimeError('Catalog-Rust-Bridge braucht authentifizierten Tenant-Kontext.')
169+
170+
rust_request = Request(url)
171+
rust_request.add_header('Accept', 'application/json')
172+
rust_request.add_header('X-ISCY-Tenant-ID', str(tenant_id))
173+
rust_request.add_header('X-ISCY-User-ID', str(user_id))
174+
user_email = (getattr(user, 'email', '') or '').strip()
175+
if user_email:
176+
rust_request.add_header('X-ISCY-User-Email', user_email)
177+
return rust_request
178+
179+
@staticmethod
180+
def _optional_int(value) -> int | None:
181+
if value in (None, ''):
182+
return None
183+
return int(value)
184+
185+
186+
class CatalogBridge:
187+
@staticmethod
188+
def fetch_domain_library(request):
189+
backend = str(getattr(settings, 'CATALOG_BACKEND', 'rust_service') or '').strip().lower()
190+
if backend != 'rust_service':
191+
return None
192+
193+
if not CatalogRustClient._base_url():
194+
if CatalogBridge._strict_rust_mode():
195+
raise RuntimeError('Rust catalog backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.')
196+
return None
197+
198+
try:
199+
return CatalogRustClient.fetch_domain_library(request)
200+
except Exception as exc:
201+
if CatalogBridge._strict_rust_mode():
202+
raise RuntimeError('Rust catalog backend ist aktiv, aber nicht erreichbar.') from exc
203+
return None
204+
205+
@staticmethod
206+
def _strict_rust_mode() -> bool:
207+
return bool(getattr(settings, 'RUST_STRICT_MODE', False))

apps/catalog/tests.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import json
2+
from unittest.mock import Mock, patch
3+
4+
from django.contrib.auth import get_user_model
5+
from django.test import TestCase, override_settings
6+
from django.urls import reverse
7+
8+
from apps.organizations.models import Tenant
9+
10+
from .models import AssessmentDomain, AssessmentQuestion
11+
12+
13+
User = get_user_model()
14+
15+
16+
@override_settings(CATALOG_BACKEND='local', RUST_BACKEND_URL='')
17+
class CatalogViewTests(TestCase):
18+
def setUp(self):
19+
self.tenant = Tenant.objects.create(name='Tenant A', slug='tenant-a', country='DE')
20+
self.user = User.objects.create_user(
21+
username='catalog-user',
22+
email='catalog@example.test',
23+
password='testpass123',
24+
tenant=self.tenant,
25+
)
26+
self.domain = AssessmentDomain.objects.create(
27+
code='GOV',
28+
name='Governance',
29+
description='Governance controls',
30+
sort_order=1,
31+
)
32+
AssessmentQuestion.objects.create(
33+
domain=self.domain,
34+
code='GOV-APP-1',
35+
text='Ist der Scope geklaert?',
36+
question_kind=AssessmentQuestion.Kind.APPLICABILITY,
37+
wizard_step=AssessmentQuestion.Step.APPLICABILITY,
38+
sort_order=1,
39+
)
40+
41+
def test_catalog_view_uses_local_catalog_by_default(self):
42+
self.client.force_login(self.user)
43+
44+
response = self.client.get(reverse('catalog:domains'))
45+
46+
self.assertEqual(response.status_code, 200)
47+
self.assertEqual(response.context['catalog_source'], 'django')
48+
self.assertEqual(response.context['question_count'], 1)
49+
self.assertContains(response, 'Governance')
50+
51+
@patch('apps.catalog.services.urlopen')
52+
def test_catalog_view_can_use_rust_bridge(self, mock_urlopen):
53+
self._mock_rust_response(mock_urlopen, {
54+
'api_version': 'v1',
55+
'question_count': 1,
56+
'domains': [{
57+
'id': self.domain.id,
58+
'code': 'GOV',
59+
'name': 'Rust Governance',
60+
'description': 'Rust catalog',
61+
'weight': 10,
62+
'sort_order': 1,
63+
'question_count': 1,
64+
'created_at': '2026-04-19T10:00:00Z',
65+
'updated_at': '2026-04-19T11:00:00Z',
66+
'questions': [{
67+
'id': 10,
68+
'domain_id': self.domain.id,
69+
'code': 'GOV-RUST-1',
70+
'text': 'Rust-Frage',
71+
'help_text': '',
72+
'why_it_matters': '',
73+
'question_kind': 'MATURITY',
74+
'question_kind_label': 'Reifegrad',
75+
'wizard_step': 'maturity',
76+
'wizard_step_label': 'Reifegrad',
77+
'weight': 10,
78+
'is_required': True,
79+
'applies_to_iso27001': True,
80+
'applies_to_nis2': True,
81+
'applies_to_cra': False,
82+
'applies_to_ai_act': False,
83+
'applies_to_iec62443': False,
84+
'applies_to_iso_sae_21434': False,
85+
'applies_to_product_security': False,
86+
'sort_order': 1,
87+
'created_at': '2026-04-19T10:00:00Z',
88+
'updated_at': '2026-04-19T11:00:00Z',
89+
}],
90+
}],
91+
})
92+
self.client.force_login(self.user)
93+
94+
with self.settings(CATALOG_BACKEND='rust_service', RUST_BACKEND_URL='http://rust-backend:9000'):
95+
response = self.client.get(reverse('catalog:domains'))
96+
97+
self.assertEqual(response.status_code, 200)
98+
rust_request = mock_urlopen.call_args.args[0]
99+
self.assertEqual(rust_request.full_url, 'http://rust-backend:9000/api/v1/catalog/domains')
100+
self.assertEqual(rust_request.get_header('X-iscy-tenant-id'), str(self.tenant.id))
101+
self.assertEqual(response.context['catalog_source'], 'rust_service')
102+
self.assertEqual(response.context['question_count'], 1)
103+
self.assertContains(response, 'Rust Governance')
104+
self.assertContains(response, 'Rust-Frage')
105+
106+
@patch('apps.catalog.services.urlopen', side_effect=OSError('backend down'))
107+
def test_catalog_view_falls_back_to_django_when_rust_unavailable_in_non_strict_mode(self, _mock_urlopen):
108+
self.client.force_login(self.user)
109+
110+
with self.settings(
111+
CATALOG_BACKEND='rust_service',
112+
RUST_BACKEND_URL='http://rust-backend:9000',
113+
RUST_STRICT_MODE=False,
114+
):
115+
response = self.client.get(reverse('catalog:domains'))
116+
117+
self.assertEqual(response.status_code, 200)
118+
self.assertEqual(response.context['catalog_source'], 'django')
119+
self.assertContains(response, 'Governance')
120+
121+
def test_catalog_view_raises_in_strict_mode_without_rust_backend_url(self):
122+
self.client.force_login(self.user)
123+
124+
with self.settings(CATALOG_BACKEND='rust_service', RUST_BACKEND_URL='', RUST_STRICT_MODE=True):
125+
with self.assertRaises(RuntimeError):
126+
self.client.get(reverse('catalog:domains'))
127+
128+
def _mock_rust_response(self, mock_urlopen, payload):
129+
response_mock = Mock()
130+
response_mock.read.return_value = json.dumps(payload).encode('utf-8')
131+
response_ctx = Mock()
132+
response_ctx.__enter__ = Mock(return_value=response_mock)
133+
response_ctx.__exit__ = Mock(return_value=False)
134+
mock_urlopen.return_value = response_ctx

0 commit comments

Comments
 (0)