M41: harden trust boundaries and delivery
MobilityOps acceptance / backend (push) Failing after 47s
MobilityOps acceptance / frontend (push) Successful in 29s
MobilityOps acceptance / e2e (push) Skipped

This commit is contained in:
NuklearRabbit
2026-08-21 17:06:59 +02:00
parent a830e8a2d0
commit 24dcb3494c
38 changed files with 699 additions and 113 deletions
+4 -3
View File
@@ -38,10 +38,11 @@ _login_limiter = (
def _client_key(request: Request) -> str:
# The API sits behind the web container's reverse proxy in every documented
# deployment; honour the first hop of X-Forwarded-For when present.
# deployment. The proxy appends/overwrites the socket peer as the final hop, so an
# attacker-controlled leading value must never select a fresh limiter bucket.
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
return forwarded.split(",")[0].strip()
return forwarded.split(",")[-1].strip()
return request.client.host if request.client else "unknown"
@@ -143,7 +144,7 @@ def _allowed_oidc_email(email: str) -> bool:
def _resolve_oidc_user(db: Session, claims: dict[str, object]) -> User:
subject = str(claims.get("sub") or "").strip()
email = str(claims.get("email") or "").strip().lower()
if not subject or not email or claims.get("email_verified") is False:
if not subject or not email or claims.get("email_verified") is not True:
raise HTTPException(status_code=401, detail="Verified OIDC email and subject are required")
if not _allowed_oidc_email(email):
raise HTTPException(status_code=403, detail="Email domain is not allowed")
+33 -1
View File
@@ -1,20 +1,32 @@
from __future__ import annotations
import hashlib
import uuid
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.core.config import get_settings
from app.core.ratelimit import SlidingWindowLimiter
from app.models.audit import AuditEvent
from app.schemas import CurrentUser
from app.services.audit import record_audit_event
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledge_provider
router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"])
settings = get_settings()
_question_limiter = (
SlidingWindowLimiter(
max_requests=settings.knowledge_max_requests,
window_seconds=settings.knowledge_rate_limit_window_seconds,
)
if settings.knowledge_max_requests > 0
else None
)
SupportedLanguage = Literal["nl-BE", "en-GB", "fr-BE"]
@@ -32,9 +44,29 @@ class KnowledgeFeedbackRequest(BaseModel):
@router.post("/questions", response_model=GroundedAnswer)
def ask_question(
body: AskQuestionRequest,
request: Request,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> GroundedAnswer:
if _question_limiter is not None:
forwarded = request.headers.get("x-forwarded-for", "")
client_ip = (
forwarded.split(",")[-1].strip()
if forwarded
else request.client.host if request.client else "unknown"
)
token = request.cookies.get(settings.session_cookie_name, "")
session_key = hashlib.sha256(token.encode("utf-8")).hexdigest()
retry_after = max(
_question_limiter.consume(f"ip:{client_ip}"),
_question_limiter.consume(f"session:{session_key}"),
)
if retry_after:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many knowledge questions. Try again later.",
headers={"Retry-After": str(retry_after)},
)
correlation_id = str(uuid.uuid4())
provider = get_knowledge_provider()
answer = provider.ask(body.question, correlation_id, body.language)
+16 -8
View File
@@ -44,7 +44,7 @@ def get_correlation_id(
def _audit_service_request(
db: Session,
*,
client_id: str,
reported_client_id: str,
tool: str,
status_label: str,
correlation_id: str,
@@ -53,11 +53,19 @@ def _audit_service_request(
record_audit_event(
db,
actor_type="service",
actor_label=client_id,
# The shared service token authenticates the Hub, not the caller identity that
# the Hub reports in a header. Keep attribution authoritative and retain the
# reported value only as explicitly non-authenticated diagnostic metadata.
actor_label="itworx-mcp-hub",
action="mcp_tool_request",
entity_type="mcp_tool",
correlation_id=uuid.UUID(correlation_id),
metadata={"tool": tool, "status": status_label, **(metadata or {})},
metadata={
"tool": tool,
"status": status_label,
"reported_client_id": reported_client_id,
**(metadata or {}),
},
)
db.commit()
@@ -79,7 +87,7 @@ def operations_summary(
_set_trace_headers(response, correlation_id, client.tenant)
_audit_service_request(
db,
client_id=client.client_id,
reported_client_id=client.reported_client_id,
tool="fleet_ops_get_operations_summary",
status_label="ok",
correlation_id=correlation_id,
@@ -103,7 +111,7 @@ def attention_vehicles(
_set_trace_headers(response, correlation_id, client.tenant)
_audit_service_request(
db,
client_id=client.client_id,
reported_client_id=client.reported_client_id,
tool="fleet_ops_list_attention_vehicles",
status_label="ok",
correlation_id=correlation_id,
@@ -124,7 +132,7 @@ def vehicle_details(
if vehicle is None:
_audit_service_request(
db,
client_id=client.client_id,
reported_client_id=client.reported_client_id,
tool="fleet_ops_get_vehicle_details",
status_label="not_found",
correlation_id=correlation_id,
@@ -146,7 +154,7 @@ def vehicle_details(
_audit_service_request(
db,
client_id=client.client_id,
reported_client_id=client.reported_client_id,
tool="fleet_ops_get_vehicle_details",
status_label="ok",
correlation_id=correlation_id,
@@ -182,7 +190,7 @@ def search_knowledge(
response.headers["X-Sources-Returned"] = str(len(answer.sources))
_audit_service_request(
db,
client_id=client.client_id,
reported_client_id=client.reported_client_id,
tool="fleet_ops_search_knowledge",
status_label=answer.evidence_state,
correlation_id=correlation_id,