Skip to content

Commit 703ac13

Browse files
Add Rust product security roadmap task updates
1 parent 94db923 commit 703ac13

11 files changed

Lines changed: 847 additions & 6 deletions

File tree

apps/product_security/forms.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from django import forms
2+
3+
from .models import ProductSecurityRoadmapTask
4+
5+
6+
class ProductSecurityRoadmapTaskUpdateForm(forms.ModelForm):
7+
class Meta:
8+
model = ProductSecurityRoadmapTask
9+
fields = ['status', 'priority', 'owner_role', 'due_in_days', 'dependency_text']
10+
widgets = {
11+
'dependency_text': forms.Textarea(attrs={'rows': 3}),
12+
}

apps/product_security/services_rust.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
from dataclasses import dataclass
3+
from urllib.error import HTTPError
34
from urllib.request import Request, urlopen
45

56
from django.conf import settings
@@ -386,6 +387,13 @@ class ProductSecurityRoadmapBridgeResult:
386387
snapshot: ProductSecuritySnapshotListItem | None
387388

388389

390+
@dataclass(frozen=True)
391+
class ProductSecurityRoadmapTaskUpdateBridgeResult:
392+
product_id: int
393+
roadmap_id: int
394+
task: ProductSecurityRoadmapTaskItem
395+
396+
389397
class ProductSecurityRustClient:
390398
@staticmethod
391399
def _base_url() -> str:
@@ -496,6 +504,49 @@ def fetch_roadmap(request, tenant, product_id: int, timeout: int = 8) -> Product
496504
snapshot=ProductSecurityRustClient._snapshot_from_payload(snapshot_payload, tenant) if isinstance(snapshot_payload, dict) else None,
497505
)
498506

507+
@staticmethod
508+
def update_roadmap_task(
509+
request,
510+
tenant,
511+
task_id: int,
512+
data: dict,
513+
timeout: int = 8,
514+
) -> ProductSecurityRoadmapTaskUpdateBridgeResult | None:
515+
base = ProductSecurityRustClient._base_url()
516+
if not base:
517+
raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.')
518+
519+
rust_request = ProductSecurityRustClient._authenticated_json_request(
520+
request,
521+
tenant,
522+
f'{base}/api/v1/product-security/roadmap-tasks/{int(task_id)}',
523+
{
524+
'status': data.get('status'),
525+
'priority': data.get('priority') or '',
526+
'owner_role': data.get('owner_role') or '',
527+
'due_in_days': data.get('due_in_days'),
528+
'dependency_text': data.get('dependency_text') or '',
529+
},
530+
method='PATCH',
531+
)
532+
try:
533+
with urlopen(rust_request, timeout=timeout) as response:
534+
payload = json.loads(response.read().decode('utf-8'))
535+
except HTTPError as exc:
536+
if exc.code == 404:
537+
return None
538+
raise
539+
540+
task_payload = payload.get('task') or {}
541+
if not isinstance(task_payload, dict):
542+
raise RuntimeError('Rust-Product-Security-Roadmaptask-Update hat kein task-Objekt geliefert.')
543+
544+
return ProductSecurityRoadmapTaskUpdateBridgeResult(
545+
product_id=ProductSecurityRustClient._int_field(payload, 'product_id'),
546+
roadmap_id=ProductSecurityRustClient._int_field(payload, 'roadmap_id'),
547+
task=ProductSecurityRustClient._roadmap_task_from_payload(task_payload, tenant),
548+
)
549+
499550
@staticmethod
500551
def _product_from_payload(item: dict, tenant) -> ProductSecurityProductListItem:
501552
family_id = ProductSecurityRustClient._optional_int_field(item, 'family_id')
@@ -801,6 +852,14 @@ def _authenticated_request(request, tenant, url: str) -> Request:
801852
rust_request.add_header('X-ISCY-User-Email', user_email)
802853
return rust_request
803854

855+
@staticmethod
856+
def _authenticated_json_request(request, tenant, url: str, payload: dict, method: str) -> Request:
857+
rust_request = ProductSecurityRustClient._authenticated_request(request, tenant, url)
858+
rust_request.data = json.dumps(payload).encode('utf-8')
859+
rust_request.method = method
860+
rust_request.add_header('Content-Type', 'application/json')
861+
return rust_request
862+
804863
@staticmethod
805864
def _int_field(payload: dict, key: str) -> int:
806865
return int(payload.get(key) or 0)
@@ -884,6 +943,27 @@ def fetch_roadmap(request, tenant, product_id: int):
884943
raise RuntimeError('Rust product security backend ist aktiv, aber nicht erreichbar.') from exc
885944
return None
886945

946+
@staticmethod
947+
def update_roadmap_task(request, tenant, task_id: int, data: dict):
948+
if tenant is None:
949+
return None
950+
951+
backend = str(getattr(settings, 'PRODUCT_SECURITY_BACKEND', 'rust_service') or '').strip().lower()
952+
if backend != 'rust_service':
953+
return None
954+
955+
if not ProductSecurityRustClient._base_url():
956+
if ProductSecurityBridge._strict_rust_mode():
957+
raise RuntimeError('Rust product security backend ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.')
958+
return None
959+
960+
try:
961+
return ProductSecurityRustClient.update_roadmap_task(request, tenant, task_id, data)
962+
except Exception as exc:
963+
if ProductSecurityBridge._strict_rust_mode():
964+
raise RuntimeError('Rust product security backend ist aktiv, aber nicht erreichbar.') from exc
965+
return None
966+
887967
@staticmethod
888968
def _strict_rust_mode() -> bool:
889969
return bool(getattr(settings, 'RUST_STRICT_MODE', False))

apps/product_security/tests.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from apps.organizations.models import Tenant
99
from apps.product_security.models import Product, ProductFamily, ProductSecuritySnapshot
10+
from apps.product_security.services import ProductSecurityService
1011

1112

1213
User = get_user_model()
@@ -197,6 +198,103 @@ def test_product_roadmap_can_use_rust_bridge(self, mock_urlopen):
197198
self.assertContains(response, "Rust Product Roadmap")
198199
self.assertContains(response, "Rust Roadmap Task")
199200

201+
def test_product_roadmap_task_update_uses_local_backend(self):
202+
roadmap = ProductSecurityService.generate_product_roadmap(self.product_a)
203+
task = roadmap.tasks.first()
204+
self.client.force_login(self.user_a)
205+
206+
response = self.client.post(
207+
reverse("product_security:roadmap-task-edit", args=[task.pk]),
208+
{
209+
"status": "DONE",
210+
"priority": "MEDIUM",
211+
"owner_role": "Product Security Office",
212+
"due_in_days": "9",
213+
"dependency_text": "Reviewed locally",
214+
},
215+
)
216+
217+
self.assertRedirects(
218+
response,
219+
reverse("product_security:roadmap", args=[self.product_a.pk]),
220+
fetch_redirect_response=False,
221+
)
222+
task.refresh_from_db()
223+
self.assertEqual(task.status, "DONE")
224+
self.assertEqual(task.priority, "MEDIUM")
225+
self.assertEqual(task.owner_role, "Product Security Office")
226+
self.assertEqual(task.due_in_days, 9)
227+
self.assertEqual(task.dependency_text, "Reviewed locally")
228+
229+
@patch("apps.product_security.services_rust.urlopen")
230+
def test_product_roadmap_task_update_can_use_rust_bridge(self, mock_urlopen):
231+
roadmap = ProductSecurityService.generate_product_roadmap(self.product_a)
232+
task = roadmap.tasks.first()
233+
task_payload = self._rust_roadmap_task_payload()
234+
task_payload.update({
235+
"id": task.id,
236+
"roadmap_id": roadmap.id,
237+
"status": "DONE",
238+
"status_label": "Erledigt",
239+
"priority": "MEDIUM",
240+
"owner_role": "Product Security Office",
241+
"due_in_days": 9,
242+
"dependency_text": "Reviewed in Rust",
243+
})
244+
self._mock_rust_response(mock_urlopen, {
245+
"api_version": "v1",
246+
"product_id": self.product_a.id,
247+
"roadmap_id": roadmap.id,
248+
"task": task_payload,
249+
})
250+
self.client.force_login(self.user_a)
251+
252+
with self.settings(PRODUCT_SECURITY_BACKEND="rust_service", RUST_BACKEND_URL="http://rust-backend:9000"):
253+
response = self.client.post(
254+
reverse("product_security:roadmap-task-edit", args=[task.pk]),
255+
{
256+
"status": "DONE",
257+
"priority": "MEDIUM",
258+
"owner_role": "Product Security Office",
259+
"due_in_days": "9",
260+
"dependency_text": "Reviewed in Rust",
261+
},
262+
)
263+
264+
self.assertRedirects(
265+
response,
266+
reverse("product_security:roadmap", args=[self.product_a.pk]),
267+
fetch_redirect_response=False,
268+
)
269+
rust_request = mock_urlopen.call_args.args[0]
270+
body = json.loads(rust_request.data.decode("utf-8"))
271+
self.assertEqual(
272+
rust_request.full_url,
273+
f"http://rust-backend:9000/api/v1/product-security/roadmap-tasks/{task.id}",
274+
)
275+
self.assertEqual(rust_request.get_method(), "PATCH")
276+
self.assertEqual(body["status"], "DONE")
277+
self.assertEqual(body["owner_role"], "Product Security Office")
278+
self.assertEqual(body["due_in_days"], 9)
279+
280+
def test_product_roadmap_task_update_blocks_foreign_tenant_access(self):
281+
roadmap = ProductSecurityService.generate_product_roadmap(self.product_b)
282+
task = roadmap.tasks.first()
283+
self.client.force_login(self.user_a)
284+
285+
response = self.client.post(
286+
reverse("product_security:roadmap-task-edit", args=[task.pk]),
287+
{
288+
"status": "DONE",
289+
"priority": "MEDIUM",
290+
"owner_role": "Product Security Office",
291+
"due_in_days": "9",
292+
"dependency_text": "Nope",
293+
},
294+
)
295+
296+
self.assertEqual(response.status_code, 404)
297+
200298
def _rust_payload(self):
201299
return {
202300
"api_version": "v1",

apps/product_security/urls.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
from django.urls import path
2-
from .views import ProductDetailView, ProductListView, ProductRoadmapView
2+
from .views import (
3+
ProductDetailView,
4+
ProductListView,
5+
ProductRoadmapView,
6+
ProductSecurityRoadmapTaskUpdateView,
7+
)
38

49
app_name = 'product_security'
510

611
urlpatterns = [
712
path('', ProductListView.as_view(), name='list'),
813
path('<int:pk>/', ProductDetailView.as_view(), name='detail'),
914
path('<int:pk>/roadmap/', ProductRoadmapView.as_view(), name='roadmap'),
15+
path(
16+
'roadmap/tasks/<int:pk>/edit/',
17+
ProductSecurityRoadmapTaskUpdateView.as_view(),
18+
name='roadmap-task-edit',
19+
),
1020
]

apps/product_security/views.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
from django.contrib.auth.mixins import LoginRequiredMixin
2+
from django.http import HttpResponseRedirect
23
from django.shortcuts import get_object_or_404
3-
from django.views.generic import DetailView, ListView, TemplateView
4+
from django.urls import reverse
5+
from django.views.generic import DetailView, ListView, TemplateView, UpdateView
46

5-
from .models import Product, ProductSecuritySnapshot
7+
from .forms import ProductSecurityRoadmapTaskUpdateForm
8+
from .models import Product, ProductSecurityRoadmapTask, ProductSecuritySnapshot
69
from .services_rust import ProductSecurityBridge
710
from .services import ProductSecurityService
811

@@ -130,3 +133,35 @@ def get_context_data(self, **kwargs):
130133
'product_security_source': 'django',
131134
})
132135
return context
136+
137+
138+
class ProductSecurityRoadmapTaskUpdateView(LoginRequiredMixin, UpdateView):
139+
model = ProductSecurityRoadmapTask
140+
form_class = ProductSecurityRoadmapTaskUpdateForm
141+
template_name = 'product_security/roadmap_task_form.html'
142+
context_object_name = 'task'
143+
144+
def get_queryset(self):
145+
tenant = getattr(self.request, 'tenant', None)
146+
return ProductSecurityRoadmapTask.objects.for_tenant(tenant).select_related('roadmap__product')
147+
148+
def get_success_url(self):
149+
return reverse('product_security:roadmap', kwargs={'pk': self.object.roadmap.product_id})
150+
151+
def form_valid(self, form):
152+
rust_result = ProductSecurityBridge.update_roadmap_task(
153+
self.request,
154+
getattr(self.request, 'tenant', None),
155+
self.object.pk,
156+
form.cleaned_data,
157+
)
158+
if rust_result is not None:
159+
self.object.status = rust_result.task.status
160+
self.object.priority = rust_result.task.priority
161+
self.object.owner_role = rust_result.task.owner_role
162+
self.object.due_in_days = rust_result.task.due_in_days
163+
self.object.dependency_text = rust_result.task.dependency_text
164+
return HttpResponseRedirect(
165+
reverse('product_security:roadmap', kwargs={'pk': rust_result.product_id})
166+
)
167+
return super().form_valid(form)

docs/RUST_REWRITE_ROADMAP.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ ISCY schrittweise von Django/Python auf Rust ueberfuehren, ohne Fachfunktionalit
100100
- `GET /api/v1/product-security/overview` liefert Product-Security-Matrix, Posture-Kennzahlen, Produktliste und letzte Snapshots fuer den aktuellen Tenant
101101
- `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
102102
- `GET /api/v1/product-security/products/{product_id}/roadmap` liefert Roadmap, Snapshot und Aufgaben fuer die Produkt-Roadmap
103-
- Django kann Product-Security-Liste, Produktdetail und Roadmap ueber `PRODUCT_SECURITY_BACKEND=rust_service` aus Rust lesen und im Nicht-Strict-Modus auf lokale ORM-Daten zurueckfallen
103+
- `PATCH /api/v1/product-security/roadmap-tasks/{task_id}` aktualisiert Status, Prioritaet, Owner-Rolle, Ziel-Tage und Abhaengigkeitstext tenantgeschuetzt
104+
- Django kann Product-Security-Liste, Produktdetail, Roadmap und Roadmap-Task-Updates ueber `PRODUCT_SECURITY_BACKEND=rust_service` aus Rust bedienen und im Nicht-Strict-Modus auf lokale ORM-Daten zurueckfallen
104105

105106
## App-Migrationsreihenfolge
106107

0 commit comments

Comments
 (0)