Skip to content

Commit 0728707

Browse files
Add tenant context gate for Rust app migration
1 parent d7f7abb commit 0728707

4 files changed

Lines changed: 243 additions & 2 deletions

File tree

docs/RUST_REWRITE_ROADMAP.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,37 @@ ISCY schrittweise von Django/Python auf Rust ueberfuehren, ohne Fachfunktionalit
3939
3. Performance-Benchmark dokumentiert
4040
4. Security-Review abgeschlossen
4141
5. Rollback getestet
42+
43+
## Stand 2026-04-18
44+
45+
- Rust-Backend ist in CI mit Format, Clippy und Tests verdrahtet.
46+
- NVD-CVE-Normalisierung, Collection/Recent-Import und Einzel-CVE-Upsert laufen ueber Rust.
47+
- Rust schreibt CVERecord-Daten direkt in die bestehende Django-Tabelle `vulnerability_intelligence_cverecord`.
48+
- Django bleibt aktuell noch Kompatibilitaets-/Web-Schicht und liest nach Rust-Upserts bestehende Models zurueck.
49+
- Rust hat einen ersten Request-Kontext-Vertrag fuer Tenant/User-Header:
50+
- `GET /api/v1/context/whoami` fuer Diagnose/Bridge-Kontext
51+
- `GET /api/v1/context/tenant` als Muster fuer geschuetzte tenantgebundene Rust-Routen
52+
53+
## App-Migrationsreihenfolge
54+
55+
Die Apps werden nicht alle gleichzeitig ersetzt. Jede Zeile bekommt eigene Rust-Routen, Tests, Datenzugriff und danach erst Django-Cutover.
56+
57+
| Reihenfolge | Apps | Ziel in Rust | Warum diese Reihenfolge |
58+
| --- | --- | --- | --- |
59+
| 1 | `core`, `accounts`, `organizations` | Auth-/Tenant-Kontext, Berechtigungen, Tenant-Read-APIs | Ohne diese Basis waeren alle weiteren App-Endpunkte unsicher oder doppelt implementiert. |
60+
| 2 | `vulnerability_intelligence`, `reports`, `dashboard` | Read-/Summary-APIs, CVE-Importe, Reporting | Bereits angefangen; hoher Nutzen, gut testbar, wenig Form-UI-Abhaengigkeit. |
61+
| 3 | `guidance`, `requirements_app`, `catalog` | Regel-/Katalogdaten, Guided Journey Evaluation | Viel Fachlogik, aber vergleichsweise klare Datenmodelle. |
62+
| 4 | `product_security`, `assets_app`, `processes`, `risks` | Tenant-gebundene CRUD-APIs und Bewertungslogik | Stark miteinander verknuepft; profitiert von fertiger Tenant-Schicht. |
63+
| 5 | `assessments`, `evidence`, `roadmap`, `import_center`, `wizard` | Workflows, Writes, Importjobs, Evidence/Assessment-Flows | Hohe UI-/Form- und Workflow-Dichte; braucht stabile Rust-Basis. |
64+
| 6 | Django-Web/Admin-Reste | Abschalten oder durch Rust-Web/Frontend ersetzen | Erst nach Funktionsparitaet sinnvoll. |
65+
66+
## Dauer grob
67+
68+
Diese Schaetzung gilt fuer einen kontrollierten Strangler-Umbau mit Tests und laufender CI:
69+
70+
- Rust-Core + Auth/Tenant/App-API-Basis: ca. 2-4 Arbeitstage
71+
- Read-only APIs fuer Dashboard, Reports, CVE, Guidance: ca. 1-2 Wochen
72+
- Schreibende APIs fuer Assets, Prozesse, Risiken, Assessments, Evidence: ca. 2-4 Wochen
73+
- Weboberflaeche/Admin-Ersatz und finaler Django-Cutover: ca. 2-4 Wochen
74+
75+
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.

rust/iscy-backend/src/lib.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ pub struct ContextWhoamiResponse {
7171
pub user_email: Option<String>,
7272
}
7373

74+
#[derive(Debug, Serialize)]
75+
pub struct TenantContextResponse {
76+
pub api_version: &'static str,
77+
pub authenticated: bool,
78+
pub tenant_id: i64,
79+
pub user_id: i64,
80+
pub user_email: Option<String>,
81+
pub authorization_model: &'static str,
82+
}
83+
7484
#[derive(Debug, Deserialize)]
7585
pub struct LlmGenerateRequest {
7686
pub prompt: String,
@@ -228,6 +238,33 @@ async fn context_whoami(headers: HeaderMap) -> Response {
228238
}
229239
}
230240

241+
async fn context_tenant(headers: HeaderMap) -> Response {
242+
match RequestContext::authenticated_tenant_from_headers(&headers) {
243+
Ok(context) => (
244+
StatusCode::OK,
245+
Json(TenantContextResponse {
246+
api_version: "v1",
247+
authenticated: true,
248+
tenant_id: context.tenant_id,
249+
user_id: context.user_id,
250+
user_email: context.user_email,
251+
authorization_model: "header-bridged-django-context-v1",
252+
}),
253+
)
254+
.into_response(),
255+
Err(err) => (
256+
err.status_code(),
257+
Json(ApiErrorResponse {
258+
accepted: false,
259+
api_version: "v1",
260+
error_code: err.error_code(),
261+
message: err.message().to_string(),
262+
}),
263+
)
264+
.into_response(),
265+
}
266+
}
267+
231268
async fn web_index() -> Html<String> {
232269
Html(
233270
r#"<!doctype html>
@@ -590,6 +627,7 @@ pub fn app_router_with_state(state: AppState) -> Router {
590627
.route("/health/ready", get(health_live))
591628
.route("/health/live", get(health_live))
592629
.route("/api/v1/context/whoami", get(context_whoami))
630+
.route("/api/v1/context/tenant", get(context_tenant))
593631
.route("/", get(web_index))
594632
.route("/login/", get(web_login))
595633
.route("/navigator/", get(web_navigator))

rust/iscy-backend/src/request_context.rs

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use axum::http::HeaderMap;
1+
use axum::http::{HeaderMap, StatusCode};
22
use serde::Serialize;
33

44
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -9,12 +9,26 @@ pub struct RequestContext {
99
pub user_email: Option<String>,
1010
}
1111

12+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
13+
pub struct AuthenticatedTenantContext {
14+
pub tenant_id: i64,
15+
pub user_id: i64,
16+
pub user_email: Option<String>,
17+
}
18+
1219
#[derive(Debug, Clone, PartialEq, Eq)]
1320
pub enum RequestContextError {
1421
InvalidTenantId,
1522
InvalidUserId,
1623
}
1724

25+
#[derive(Debug, Clone, PartialEq, Eq)]
26+
pub enum RequiredTenantContextError {
27+
InvalidHeaders(RequestContextError),
28+
MissingUser,
29+
MissingTenant,
30+
}
31+
1832
impl RequestContext {
1933
pub fn from_headers(headers: &HeaderMap) -> Result<Self, RequestContextError> {
2034
let tenant_id = optional_i64_header(headers, "x-iscy-tenant-id")
@@ -30,6 +44,30 @@ impl RequestContext {
3044
user_email,
3145
})
3246
}
47+
48+
pub fn authenticated_tenant_from_headers(
49+
headers: &HeaderMap,
50+
) -> Result<AuthenticatedTenantContext, RequiredTenantContextError> {
51+
Self::from_headers(headers)
52+
.map_err(RequiredTenantContextError::InvalidHeaders)?
53+
.require_authenticated_tenant()
54+
}
55+
56+
pub fn require_authenticated_tenant(
57+
self,
58+
) -> Result<AuthenticatedTenantContext, RequiredTenantContextError> {
59+
let user_id = self
60+
.user_id
61+
.ok_or(RequiredTenantContextError::MissingUser)?;
62+
let tenant_id = self
63+
.tenant_id
64+
.ok_or(RequiredTenantContextError::MissingTenant)?;
65+
Ok(AuthenticatedTenantContext {
66+
tenant_id,
67+
user_id,
68+
user_email: self.user_email,
69+
})
70+
}
3371
}
3472

3573
impl RequestContextError {
@@ -48,6 +86,32 @@ impl RequestContextError {
4886
}
4987
}
5088

89+
impl RequiredTenantContextError {
90+
pub fn status_code(&self) -> StatusCode {
91+
match self {
92+
Self::InvalidHeaders(_) => StatusCode::BAD_REQUEST,
93+
Self::MissingUser => StatusCode::UNAUTHORIZED,
94+
Self::MissingTenant => StatusCode::FORBIDDEN,
95+
}
96+
}
97+
98+
pub fn error_code(&self) -> &'static str {
99+
match self {
100+
Self::InvalidHeaders(err) => err.error_code(),
101+
Self::MissingUser => "missing_user_context",
102+
Self::MissingTenant => "missing_tenant_context",
103+
}
104+
}
105+
106+
pub fn message(&self) -> &'static str {
107+
match self {
108+
Self::InvalidHeaders(err) => err.message(),
109+
Self::MissingUser => "Authentifizierte Rust-App-Routen benoetigen X-ISCY-User-ID.",
110+
Self::MissingTenant => "Tenant-gebundene Rust-App-Routen benoetigen X-ISCY-Tenant-ID.",
111+
}
112+
}
113+
}
114+
51115
fn optional_i64_header(headers: &HeaderMap, name: &'static str) -> Result<Option<i64>, ()> {
52116
let Some(raw) = headers.get(name) else {
53117
return Ok(None);
@@ -76,7 +140,7 @@ fn optional_string_header(headers: &HeaderMap, name: &'static str) -> Option<Str
76140
mod tests {
77141
use axum::http::{HeaderMap, HeaderValue};
78142

79-
use super::{RequestContext, RequestContextError};
143+
use super::{RequestContext, RequestContextError, RequiredTenantContextError};
80144

81145
#[test]
82146
fn parses_empty_context_as_anonymous() {
@@ -115,4 +179,38 @@ mod tests {
115179
RequestContextError::InvalidTenantId
116180
);
117181
}
182+
183+
#[test]
184+
fn requires_authenticated_user_for_tenant_context() {
185+
let mut headers = HeaderMap::new();
186+
headers.insert("x-iscy-tenant-id", HeaderValue::from_static("42"));
187+
188+
assert_eq!(
189+
RequestContext::authenticated_tenant_from_headers(&headers).unwrap_err(),
190+
RequiredTenantContextError::MissingUser
191+
);
192+
}
193+
194+
#[test]
195+
fn requires_tenant_for_authenticated_tenant_context() {
196+
let mut headers = HeaderMap::new();
197+
headers.insert("x-iscy-user-id", HeaderValue::from_static("7"));
198+
199+
assert_eq!(
200+
RequestContext::authenticated_tenant_from_headers(&headers).unwrap_err(),
201+
RequiredTenantContextError::MissingTenant
202+
);
203+
}
204+
205+
#[test]
206+
fn builds_authenticated_tenant_context() {
207+
let mut headers = HeaderMap::new();
208+
headers.insert("x-iscy-tenant-id", HeaderValue::from_static("42"));
209+
headers.insert("x-iscy-user-id", HeaderValue::from_static("7"));
210+
211+
let context = RequestContext::authenticated_tenant_from_headers(&headers).unwrap();
212+
213+
assert_eq!(context.tenant_id, 42);
214+
assert_eq!(context.user_id, 7);
215+
}
118216
}

rust/iscy-backend/tests/http_tests.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,77 @@ async fn context_whoami_rejects_invalid_tenant_header() {
100100
assert_eq!(payload["error_code"], "invalid_tenant_id");
101101
}
102102

103+
#[tokio::test]
104+
async fn context_tenant_requires_user_header() {
105+
let response = app_router()
106+
.oneshot(
107+
Request::builder()
108+
.uri("/api/v1/context/tenant")
109+
.header("x-iscy-tenant-id", "42")
110+
.body(Body::empty())
111+
.unwrap(),
112+
)
113+
.await
114+
.unwrap();
115+
116+
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
117+
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
118+
let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
119+
assert_eq!(payload["accepted"], false);
120+
assert_eq!(payload["api_version"], "v1");
121+
assert_eq!(payload["error_code"], "missing_user_context");
122+
}
123+
124+
#[tokio::test]
125+
async fn context_tenant_requires_tenant_header() {
126+
let response = app_router()
127+
.oneshot(
128+
Request::builder()
129+
.uri("/api/v1/context/tenant")
130+
.header("x-iscy-user-id", "7")
131+
.body(Body::empty())
132+
.unwrap(),
133+
)
134+
.await
135+
.unwrap();
136+
137+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
138+
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
139+
let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
140+
assert_eq!(payload["accepted"], false);
141+
assert_eq!(payload["api_version"], "v1");
142+
assert_eq!(payload["error_code"], "missing_tenant_context");
143+
}
144+
145+
#[tokio::test]
146+
async fn context_tenant_returns_authenticated_tenant_context() {
147+
let response = app_router()
148+
.oneshot(
149+
Request::builder()
150+
.uri("/api/v1/context/tenant")
151+
.header("x-iscy-tenant-id", "42")
152+
.header("x-iscy-user-id", "7")
153+
.header("x-iscy-user-email", "security@example.test")
154+
.body(Body::empty())
155+
.unwrap(),
156+
)
157+
.await
158+
.unwrap();
159+
160+
assert_eq!(response.status(), StatusCode::OK);
161+
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
162+
let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
163+
assert_eq!(payload["api_version"], "v1");
164+
assert_eq!(payload["authenticated"], true);
165+
assert_eq!(payload["tenant_id"], 42);
166+
assert_eq!(payload["user_id"], 7);
167+
assert_eq!(payload["user_email"], "security@example.test");
168+
assert_eq!(
169+
payload["authorization_model"],
170+
"header-bridged-django-context-v1"
171+
);
172+
}
173+
103174
#[tokio::test]
104175
async fn rust_web_surface_routes_return_ok() {
105176
let paths = vec![

0 commit comments

Comments
 (0)