M41: harden trust boundaries and delivery
This commit is contained in:
+11
-4
@@ -1,19 +1,26 @@
|
||||
FROM python:3.12-slim
|
||||
FROM python:3.12-slim-bookworm@sha256:a116514e19457bcb7af7efe9c3dd0b9b71e85b317694e7882a1c52aa15a78134 AS runtime-base
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
WORKDIR /app
|
||||
COPY backend/requirements.lock ./
|
||||
RUN pip install --no-cache-dir -r requirements.lock
|
||||
COPY backend/requirements-prod.lock ./
|
||||
RUN pip install --no-cache-dir -r requirements-prod.lock
|
||||
COPY backend/pyproject.toml ./
|
||||
COPY backend/app ./app
|
||||
COPY backend/alembic ./alembic
|
||||
COPY backend/alembic.ini ./
|
||||
COPY backend/tests ./tests
|
||||
COPY seed ./seed
|
||||
COPY knowledge ./knowledge
|
||||
COPY backend/entrypoint.sh ./entrypoint.sh
|
||||
RUN pip install --no-cache-dir --no-deps -e . && chmod +x ./entrypoint.sh \
|
||||
&& addgroup --system app && adduser --system --ingroup app --home /app app \
|
||||
&& chown -R app:app /app
|
||||
|
||||
FROM runtime-base AS test
|
||||
COPY backend/requirements.lock ./requirements.lock
|
||||
RUN pip install --no-cache-dir -r requirements.lock
|
||||
COPY backend/tests ./tests
|
||||
USER app
|
||||
|
||||
FROM runtime-base AS runtime
|
||||
# Run migrations and the API as an unprivileged user; nothing here needs root.
|
||||
USER app
|
||||
EXPOSE 8000
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""enforce one open issue per detected condition
|
||||
|
||||
Revision ID: 4f2b9c8d7e61
|
||||
Revises: 0a4c1d2e3f5b
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "4f2b9c8d7e61"
|
||||
down_revision = "0a4c1d2e3f5b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_index(
|
||||
"uq_data_quality_one_open_condition",
|
||||
"data_quality_issues",
|
||||
["rule_type", "entity_type", "entity_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("status = 'open'"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("uq_data_quality_one_open_condition", table_name="data_quality_issues")
|
||||
@@ -67,7 +67,7 @@ def require_operations_manager(
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class McpClientContext:
|
||||
client_id: str
|
||||
reported_client_id: str
|
||||
tenant: str
|
||||
|
||||
|
||||
@@ -91,4 +91,4 @@ def require_mcp_service_token(
|
||||
)
|
||||
if x_tenant_id is not None and x_tenant_id != settings.ragcore_tenant:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Tenant mismatch")
|
||||
return McpClientContext(client_id=x_client_id, tenant=settings.ragcore_tenant)
|
||||
return McpClientContext(reported_client_id=x_client_id, tenant=settings.ragcore_tenant)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,6 +23,8 @@ class Settings(BaseSettings):
|
||||
ragcore_api_token: str = ""
|
||||
ragcore_space_id: str = ""
|
||||
ragcore_http_timeout_seconds: float = 5.0
|
||||
# Search fallback is only labelled grounded above this explicit retrieval threshold.
|
||||
ragcore_min_search_score: float = 0.05
|
||||
n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return"
|
||||
n8n_webhook_trigger_token: str = "replace-me-n8n-webhook-trigger-token"
|
||||
n8n_callback_token: str = "replace-me-n8n-callback-token"
|
||||
@@ -67,6 +69,8 @@ class Settings(BaseSettings):
|
||||
# Failed password logins per client IP before a temporary 429 (0 disables).
|
||||
login_max_failures: int = 10
|
||||
login_failure_window_seconds: int = 900
|
||||
knowledge_max_requests: int = 30
|
||||
knowledge_rate_limit_window_seconds: int = 60
|
||||
metrics_bearer_token: str = ""
|
||||
privacy_minimum_booking_retention_days: int = 30
|
||||
privacy_audit_retention_days: int = 2555
|
||||
@@ -86,13 +90,11 @@ INSECURE_DEFAULT_SECRETS: tuple[tuple[str, str], ...] = (
|
||||
def insecure_default_secrets(settings: "Settings") -> list[str]:
|
||||
"""Return the names of secret settings that still carry their placeholder value.
|
||||
|
||||
Only secrets that actually guard something in the given deployment are reported:
|
||||
``mcp_hub_service_token`` is irrelevant while MCP Hub registration is disabled.
|
||||
MCP routes are always mounted, independently of the Hub reachability-status flag, so
|
||||
their inbound token must always be non-placeholder in production.
|
||||
"""
|
||||
insecure: list[str] = []
|
||||
for name, placeholder in INSECURE_DEFAULT_SECRETS:
|
||||
if name == "mcp_hub_service_token" and not settings.mcp_hub_registration_enabled:
|
||||
continue
|
||||
value = getattr(settings, name)
|
||||
if not value or value == placeholder or value.startswith("replace-me"):
|
||||
insecure.append(name)
|
||||
@@ -112,4 +114,8 @@ def get_settings() -> Settings:
|
||||
+ ", ".join(insecure)
|
||||
+ ". Set real values in the environment (see .env.example)."
|
||||
)
|
||||
if not settings.mobilityops_public_url.lower().startswith("https://"):
|
||||
raise RuntimeError("Production MOBILITYOPS_PUBLIC_URL must use HTTPS.")
|
||||
if not settings.session_cookie_secure:
|
||||
raise RuntimeError("Production SESSION_COOKIE_SECURE must be true.")
|
||||
return settings
|
||||
|
||||
@@ -47,3 +47,26 @@ class FailedAttemptLimiter:
|
||||
def reset(self, key: str) -> None:
|
||||
with self._lock:
|
||||
self._failures.pop(key, None)
|
||||
|
||||
|
||||
class SlidingWindowLimiter:
|
||||
"""Thread-safe request limiter where every accepted request consumes capacity."""
|
||||
|
||||
def __init__(self, *, max_requests: int, window_seconds: float) -> None:
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self._requests: dict[str, deque[float]] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def consume(self, key: str) -> int:
|
||||
"""Record an accepted request, or return the seconds until capacity is available."""
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
bucket = self._requests.setdefault(key, deque())
|
||||
cutoff = now - self.window_seconds
|
||||
while bucket and bucket[0] <= cutoff:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= self.max_requests:
|
||||
return max(1, int(bucket[0] + self.window_seconds - now + 0.999))
|
||||
bucket.append(now)
|
||||
return 0
|
||||
|
||||
@@ -2,7 +2,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String
|
||||
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -37,6 +37,14 @@ class DataQualityIssue(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
name="ck_data_quality_status",
|
||||
),
|
||||
Index("ix_data_quality_work_queue", "status", "due_at", "severity"),
|
||||
Index(
|
||||
"uq_data_quality_one_open_condition",
|
||||
"rule_type",
|
||||
"entity_type",
|
||||
"entity_id",
|
||||
unique=True,
|
||||
postgresql_where=text("status = 'open'"),
|
||||
),
|
||||
)
|
||||
|
||||
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||
|
||||
@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.errors import AppError
|
||||
@@ -26,6 +26,7 @@ from app.services.vehicle_status import (
|
||||
REQUIRED_CUSTOMER_FIELDS = ("first_name", "last_name")
|
||||
REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
|
||||
DUPLICATE_THRESHOLD = 70
|
||||
DATA_QUALITY_SCAN_LOCK_ID = 6_138_493_717_091_029_491
|
||||
|
||||
|
||||
def issue_due_at(detected_at: datetime, severity: str) -> datetime:
|
||||
@@ -356,6 +357,9 @@ def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
|
||||
def run_scan(
|
||||
db: Session, *, actor_label: str | None = None, actor_type: str = "user"
|
||||
) -> ScanResult:
|
||||
# The check-then-insert work below spans several rules. Serialise whole scans at the
|
||||
# database boundary so API and n8n triggers cannot both observe an empty condition.
|
||||
db.scalar(select(func.pg_advisory_xact_lock(DATA_QUALITY_SCAN_LOCK_ID)))
|
||||
scan = ScanResult()
|
||||
_scan_duplicate_customers(db, scan)
|
||||
_scan_missing_required_fields(db, scan)
|
||||
@@ -375,8 +379,13 @@ def run_scan(
|
||||
return scan
|
||||
|
||||
|
||||
def _load_open_issue(db: Session, public_ref: str) -> DataQualityIssue:
|
||||
issue = db.scalar(select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref))
|
||||
def _load_open_issue(
|
||||
db: Session, public_ref: str, *, lock: bool = True
|
||||
) -> DataQualityIssue:
|
||||
statement = select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref)
|
||||
if lock:
|
||||
statement = statement.with_for_update()
|
||||
issue = db.scalar(statement)
|
||||
if issue is None:
|
||||
raise AppError("ISSUE_NOT_FOUND", "Data quality issue not found.", status_code=404)
|
||||
if issue.status != "open":
|
||||
@@ -707,8 +716,10 @@ def resolve_booking_overlap(
|
||||
return issue
|
||||
|
||||
|
||||
def _load_vehicle_status_conflict_issue(db: Session, public_ref: str) -> DataQualityIssue:
|
||||
issue = _load_open_issue(db, public_ref)
|
||||
def _load_vehicle_status_conflict_issue(
|
||||
db: Session, public_ref: str, *, lock: bool = True
|
||||
) -> DataQualityIssue:
|
||||
issue = _load_open_issue(db, public_ref, lock=lock)
|
||||
if issue.rule_type != "vehicle_status_conflict":
|
||||
raise AppError(
|
||||
"NOT_A_STATUS_CONFLICT_ISSUE",
|
||||
@@ -724,7 +735,7 @@ def preview_vehicle_status_recommendation(
|
||||
"""Non-mutating: computes and returns the recommendation only. Never resolves the
|
||||
issue, never writes an audit event, never queues automation -- safe to call as often
|
||||
as the UI needs (e.g. every time the panel is opened) with zero side effects."""
|
||||
issue = _load_vehicle_status_conflict_issue(db, public_ref)
|
||||
issue = _load_vehicle_status_conflict_issue(db, public_ref, lock=False)
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id))
|
||||
if vehicle is None:
|
||||
raise AppError(
|
||||
|
||||
@@ -92,6 +92,17 @@ def _rank_sources_for_concepts(sources: list[SourceCard], concepts: set[str]) ->
|
||||
)
|
||||
|
||||
|
||||
def _retrieval_score(result: dict) -> float | None:
|
||||
scores = result.get("scores")
|
||||
if not isinstance(scores, dict):
|
||||
return None
|
||||
for name in ("rerank", "fused"):
|
||||
value = scores.get(name)
|
||||
if isinstance(value, int | float) and not isinstance(value, bool):
|
||||
return float(value)
|
||||
return None
|
||||
|
||||
|
||||
class RAGcoreKnowledgeProvider:
|
||||
"""Adapter for the central RAGcore service, against its real `/v1/*` contract
|
||||
(see `docs/contracts/openapi.yaml` in the RAGcore checkout -- RAGcore is built and
|
||||
@@ -338,6 +349,8 @@ class RAGcoreKnowledgeProvider:
|
||||
|
||||
try:
|
||||
results = body.get("results", [])
|
||||
if not isinstance(results, list):
|
||||
raise TypeError("results must be a list")
|
||||
sources = [
|
||||
SourceCard(
|
||||
document_id=str(result["citation"]["document_id"]),
|
||||
@@ -351,10 +364,21 @@ class RAGcoreKnowledgeProvider:
|
||||
except (TypeError, KeyError, ValueError):
|
||||
return unavailable
|
||||
|
||||
sources = _deduplicate_sources(sources)
|
||||
all_sources = _deduplicate_sources(sources)
|
||||
concepts = _question_concepts(question)
|
||||
sources = _rank_sources_for_concepts(sources, concepts)
|
||||
if not sources:
|
||||
qualified_sources = [
|
||||
SourceCard(
|
||||
document_id=str(result["citation"]["document_id"]),
|
||||
title=result["citation"]["title"],
|
||||
version=str(result["citation"]["document_version_id"]),
|
||||
section=result["citation"].get("section") or "",
|
||||
excerpt=result["citation"]["excerpt"],
|
||||
)
|
||||
for result in results
|
||||
if (_retrieval_score(result) or 0.0) >= self._settings.ragcore_min_search_score
|
||||
]
|
||||
sources = _rank_sources_for_concepts(_deduplicate_sources(qualified_sources), concepts)
|
||||
if not all_sources:
|
||||
return GroundedAnswer(
|
||||
answer="",
|
||||
evidence_state="insufficient",
|
||||
@@ -363,11 +387,19 @@ class RAGcoreKnowledgeProvider:
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
if not concepts:
|
||||
damage_evidence = any(
|
||||
term
|
||||
in (
|
||||
f"{source.document_id} {source.title} {source.section} {source.excerpt}"
|
||||
).casefold()
|
||||
for source in sources
|
||||
for term in _DOMAIN_CONCEPTS["damage"]
|
||||
)
|
||||
if not concepts or not sources or ("damage" in concepts and not damage_evidence):
|
||||
return GroundedAnswer(
|
||||
answer="",
|
||||
evidence_state="insufficient",
|
||||
sources=sources,
|
||||
sources=all_sources,
|
||||
provider=self.name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.12
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --constraint=requirements.lock --output-file=requirements-prod.lock pyproject.toml
|
||||
#
|
||||
alembic==1.18.5
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# mobilityops-api (pyproject.toml)
|
||||
annotated-doc==0.0.5
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# fastapi
|
||||
annotated-types==0.8.0
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# pydantic
|
||||
anyio==4.14.2
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# httpx
|
||||
# starlette
|
||||
# watchfiles
|
||||
authlib==1.7.2
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# mobilityops-api (pyproject.toml)
|
||||
certifi==2026.7.22
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# httpcore
|
||||
# httpx
|
||||
cffi==2.1.1
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# cryptography
|
||||
click==8.4.2
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# uvicorn
|
||||
cryptography==50.0.0
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# authlib
|
||||
# joserfc
|
||||
fastapi==0.141.1
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# mobilityops-api (pyproject.toml)
|
||||
greenlet==3.5.4
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# sqlalchemy
|
||||
h11==0.16.0
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# httpcore
|
||||
# uvicorn
|
||||
httpcore==1.0.9
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# httpx
|
||||
httptools==0.8.0
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# uvicorn
|
||||
httpx==0.28.1
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# mobilityops-api (pyproject.toml)
|
||||
idna==3.18
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# anyio
|
||||
# httpx
|
||||
itsdangerous==2.2.0
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# mobilityops-api (pyproject.toml)
|
||||
joserfc==1.7.4
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# authlib
|
||||
mako==1.3.12
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# alembic
|
||||
markupsafe==3.0.3
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# mako
|
||||
prometheus-client==0.26.0
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# mobilityops-api (pyproject.toml)
|
||||
psycopg[binary]==3.3.4
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# mobilityops-api (pyproject.toml)
|
||||
psycopg-binary==3.3.4
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# psycopg
|
||||
pycparser==3.0
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# cffi
|
||||
pydantic==2.13.4
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# fastapi
|
||||
# pydantic-settings
|
||||
pydantic-core==2.46.4
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# pydantic
|
||||
pydantic-settings==2.14.2
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# mobilityops-api (pyproject.toml)
|
||||
python-dotenv==1.2.2
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# pydantic-settings
|
||||
# uvicorn
|
||||
pyyaml==6.0.3
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# uvicorn
|
||||
sqlalchemy==2.0.51
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# alembic
|
||||
# mobilityops-api (pyproject.toml)
|
||||
starlette==1.3.1
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# fastapi
|
||||
typing-extensions==4.16.0
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# alembic
|
||||
# anyio
|
||||
# fastapi
|
||||
# psycopg
|
||||
# pydantic
|
||||
# pydantic-core
|
||||
# sqlalchemy
|
||||
# starlette
|
||||
# typing-inspection
|
||||
typing-inspection==0.4.2
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# fastapi
|
||||
# pydantic
|
||||
# pydantic-settings
|
||||
uvicorn[standard]==0.52.1
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# mobilityops-api (pyproject.toml)
|
||||
uvloop==0.22.1
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# uvicorn
|
||||
watchfiles==1.2.0
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# uvicorn
|
||||
websockets==17.0.1
|
||||
# via
|
||||
# -c requirements.lock
|
||||
# uvicorn
|
||||
@@ -46,6 +46,29 @@ def test_oidc_callback_auto_provisions_and_logs_in(client, monkeypatch):
|
||||
assert session.json()["role"] == "rental_employee"
|
||||
|
||||
|
||||
def test_oidc_callback_rejects_missing_email_verification_claim(client, monkeypatch):
|
||||
import app.api.routers.auth as auth_router
|
||||
|
||||
class FakeClient:
|
||||
async def authorize_access_token(self, _request):
|
||||
return {"userinfo": {"sub": "unverified-subject", "email": "new@example.test"}}
|
||||
|
||||
class FakeOAuth:
|
||||
def create_client(self, _name):
|
||||
return FakeClient()
|
||||
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_enabled", True)
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_issuer_url", "https://id.example.test")
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_client_id", "client")
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_client_secret", "secret")
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_allowed_email_domains", "example.test")
|
||||
monkeypatch.setattr(auth_router, "oauth", FakeOAuth())
|
||||
|
||||
response = client.get("/api/v1/auth/oidc/callback", follow_redirects=False)
|
||||
assert response.status_code == 401
|
||||
assert client.get("/api/v1/auth/session").status_code == 401
|
||||
|
||||
|
||||
def test_demo_login_grants_access(ops_client):
|
||||
response = ops_client.get("/api/v1/dashboard")
|
||||
assert response.status_code == 200
|
||||
@@ -173,4 +196,8 @@ def test_demo_reset_preserves_integration_telemetry(ops_client, client):
|
||||
|
||||
assert client.post("/api/v1/demo/login", json={"role": "operations_manager"}).status_code == 200
|
||||
events = client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
|
||||
assert any(event["actor_label"].endswith(":reset-probe") for event in events)
|
||||
assert any(
|
||||
event["actor_label"] == "itworx-mcp-hub"
|
||||
and event["metadata"].get("reported_client_id", "").endswith(":reset-probe")
|
||||
for event in events
|
||||
)
|
||||
|
||||
@@ -3,33 +3,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime
|
||||
from threading import Barrier
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
from app.core.config import Settings, get_settings, insecure_default_secrets
|
||||
from app.core.db import SessionLocal
|
||||
from app.core.errors import AppError
|
||||
from app.core.observability import UNMATCHED_ROUTE_LABEL
|
||||
from app.core.ratelimit import FailedAttemptLimiter
|
||||
from app.core.ratelimit import FailedAttemptLimiter, SlidingWindowLimiter
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.services.data_quality import run_scan
|
||||
from app.schemas import CurrentUser
|
||||
from app.services.data_quality import defer_issue, run_scan
|
||||
from tests.test_return import _activate_booking, _return_body
|
||||
|
||||
|
||||
def test_insecure_defaults_are_detected_only_for_relevant_secrets():
|
||||
def test_insecure_defaults_include_mounted_mcp_boundary_even_when_status_check_is_disabled():
|
||||
defaults = Settings(_env_file=None)
|
||||
assert "app_secret" in insecure_default_secrets(defaults)
|
||||
assert "mcp_hub_service_token" not in insecure_default_secrets(defaults)
|
||||
assert "mcp_hub_service_token" in insecure_default_secrets(defaults)
|
||||
hardened = Settings(
|
||||
_env_file=None,
|
||||
app_secret="x" * 32,
|
||||
n8n_callback_token="c" * 32,
|
||||
mcp_hub_registration_enabled=True,
|
||||
mcp_hub_service_token="m" * 32,
|
||||
)
|
||||
assert insecure_default_secrets(hardened) == ["mcp_hub_service_token"]
|
||||
assert insecure_default_secrets(hardened) == []
|
||||
|
||||
|
||||
def test_production_refuses_to_start_with_placeholder_secrets(monkeypatch):
|
||||
@@ -47,6 +51,37 @@ def test_production_refuses_to_start_with_placeholder_secrets(monkeypatch):
|
||||
assert get_settings().mobilityops_env == "test"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("public_url", "secure_cookie", "message"),
|
||||
[
|
||||
("http://fleetops.example.test", "true", "must use HTTPS"),
|
||||
("https://fleetops.example.test", "false", "must be true"),
|
||||
],
|
||||
)
|
||||
def test_production_refuses_cleartext_or_insecure_session_cookie(
|
||||
monkeypatch, public_url, secure_cookie, message
|
||||
):
|
||||
values = {
|
||||
"MOBILITYOPS_ENV": "production",
|
||||
"APP_SECRET": "a" * 32,
|
||||
"N8N_CALLBACK_TOKEN": "c" * 32,
|
||||
"MCP_HUB_SERVICE_TOKEN": "m" * 32,
|
||||
"MOBILITYOPS_PUBLIC_URL": public_url,
|
||||
"SESSION_COOKIE_SECURE": secure_cookie,
|
||||
}
|
||||
for name, value in values.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
get_settings()
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.undo()
|
||||
get_settings.cache_clear()
|
||||
assert get_settings().mobilityops_env == "test"
|
||||
|
||||
|
||||
def test_failed_attempt_limiter_blocks_after_threshold_and_resets():
|
||||
limiter = FailedAttemptLimiter(max_failures=3, window_seconds=60)
|
||||
for _ in range(3):
|
||||
@@ -58,6 +93,29 @@ def test_failed_attempt_limiter_blocks_after_threshold_and_resets():
|
||||
assert limiter.retry_after_seconds("1.2.3.4") == 0
|
||||
|
||||
|
||||
def test_sliding_window_limiter_counts_successes_and_isolates_keys():
|
||||
limiter = SlidingWindowLimiter(max_requests=2, window_seconds=60)
|
||||
assert limiter.consume("session-a") == 0
|
||||
assert limiter.consume("session-a") == 0
|
||||
assert limiter.consume("session-a") > 0
|
||||
assert limiter.consume("session-b") == 0
|
||||
|
||||
|
||||
def test_login_client_key_uses_proxy_appended_peer_not_spoofed_leading_value():
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.api.routers.auth import _client_key
|
||||
|
||||
request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"x-forwarded-for", b"203.0.113.99, 198.51.100.7")],
|
||||
"client": ("172.20.0.3", 12345),
|
||||
}
|
||||
)
|
||||
assert _client_key(request) == "198.51.100.7"
|
||||
|
||||
|
||||
def test_audit_export_accepts_naive_datetimes(ops_client):
|
||||
response = ops_client.get(
|
||||
"/api/v1/audit/export.csv",
|
||||
@@ -194,3 +252,85 @@ def test_scan_does_not_flag_anonymised_customers_as_missing_fields(ops_client):
|
||||
db.execute(delete(AuditEvent).where(AuditEvent.entity_id == customer_id))
|
||||
db.execute(delete(Customer).where(Customer.id == customer_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_concurrent_quality_scans_leave_only_one_open_issue_per_condition():
|
||||
barrier = Barrier(2)
|
||||
|
||||
def scan() -> None:
|
||||
with SessionLocal() as db:
|
||||
barrier.wait()
|
||||
run_scan(db)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
list(executor.map(lambda _index: scan(), range(2)))
|
||||
|
||||
with SessionLocal() as db:
|
||||
duplicates = db.execute(
|
||||
select(
|
||||
DataQualityIssue.rule_type,
|
||||
DataQualityIssue.entity_type,
|
||||
DataQualityIssue.entity_id,
|
||||
func.count(DataQualityIssue.id),
|
||||
)
|
||||
.where(DataQualityIssue.status == "open")
|
||||
.group_by(
|
||||
DataQualityIssue.rule_type,
|
||||
DataQualityIssue.entity_type,
|
||||
DataQualityIssue.entity_id,
|
||||
)
|
||||
.having(func.count(DataQualityIssue.id) > 1)
|
||||
).all()
|
||||
assert duplicates == []
|
||||
|
||||
|
||||
def test_concurrent_issue_resolution_records_exactly_one_decision():
|
||||
issue_id = uuid.uuid4()
|
||||
issue_ref = f"DQ-RACE-{uuid.uuid4().hex[:8].upper()}"
|
||||
with SessionLocal() as db:
|
||||
db.add(
|
||||
DataQualityIssue(
|
||||
id=issue_id,
|
||||
public_ref=issue_ref,
|
||||
rule_type="missing_required_field",
|
||||
entity_type="customer",
|
||||
entity_id=uuid.uuid4(),
|
||||
severity="low",
|
||||
status="open",
|
||||
evidence_json={},
|
||||
proposed_action_json={},
|
||||
detected_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
actor = CurrentUser(
|
||||
public_ref="USR-RACE", display_name="Race Manager", role="operations_manager"
|
||||
)
|
||||
barrier = Barrier(2)
|
||||
|
||||
def resolve() -> str:
|
||||
with SessionLocal() as db:
|
||||
barrier.wait()
|
||||
try:
|
||||
return defer_issue(db, issue_ref, actor).status
|
||||
except AppError as exc:
|
||||
return exc.code
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
outcomes = list(executor.map(lambda _index: resolve(), range(2)))
|
||||
assert sorted(outcomes) == ["ISSUE_NOT_OPEN", "deferred"]
|
||||
with SessionLocal() as db:
|
||||
decisions = db.scalar(
|
||||
select(func.count(AuditEvent.id)).where(
|
||||
AuditEvent.entity_id == issue_id,
|
||||
AuditEvent.action == "data_quality_issue_deferred",
|
||||
)
|
||||
)
|
||||
assert decisions == 1
|
||||
finally:
|
||||
with SessionLocal() as db:
|
||||
db.execute(delete(AuditEvent).where(AuditEvent.entity_id == issue_id))
|
||||
db.execute(delete(DataQualityIssue).where(DataQualityIssue.id == issue_id))
|
||||
db.commit()
|
||||
|
||||
@@ -7,6 +7,7 @@ import httpx
|
||||
|
||||
from app.api.routers import knowledge as knowledge_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.ratelimit import SlidingWindowLimiter
|
||||
from app.services.knowledge import KnowledgeHealth
|
||||
from app.services.knowledge.demo import DemoKnowledgeProvider
|
||||
from app.services.knowledge.procedures import iter_procedure_documents
|
||||
@@ -140,6 +141,16 @@ def test_ask_question_requires_authentication(client):
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_knowledge_questions_are_bounded_per_session_and_client(ops_client, monkeypatch):
|
||||
limiter = SlidingWindowLimiter(max_requests=1, window_seconds=60)
|
||||
monkeypatch.setattr(knowledge_router, "_question_limiter", limiter)
|
||||
body = {"question": "What is the vehicle return procedure?"}
|
||||
assert ops_client.post("/api/v1/knowledge/questions", json=body).status_code == 200
|
||||
blocked = ops_client.post("/api/v1/knowledge/questions", json=body)
|
||||
assert blocked.status_code == 429
|
||||
assert int(blocked.headers["retry-after"]) >= 1
|
||||
|
||||
|
||||
def test_ask_question_is_audited_without_leaking_full_text(ops_client):
|
||||
ops_client.post(
|
||||
"/api/v1/knowledge/questions",
|
||||
@@ -695,5 +706,38 @@ def test_ragcore_search_fallback_prefers_damage_procedure(monkeypatch):
|
||||
),
|
||||
)
|
||||
answer = provider.ask("Wat moet ik doen bij schade?", "test-correlation-damage", "nl-BE")
|
||||
assert answer.evidence_state == "insufficient"
|
||||
assert answer.answer == ""
|
||||
assert any(source.document_id == "damage-procedure" for source in answer.sources)
|
||||
|
||||
|
||||
def test_ragcore_search_fallback_accepts_relevant_damage_evidence_above_threshold(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
search = _search_body()
|
||||
search["results"].append(
|
||||
{
|
||||
"citation": {
|
||||
"document_id": "damage-procedure",
|
||||
"document_version_id": "version-2",
|
||||
"title": "damage-procedure.md",
|
||||
"section": "Damage",
|
||||
"excerpt": "Record damage and keep the vehicle blocked.",
|
||||
},
|
||||
"rank": 1,
|
||||
"scores": {"fused": 0.5},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(
|
||||
post_responses={
|
||||
"/v1/answers": _FakeResponse(503, {}),
|
||||
"/v1/search": _FakeResponse(200, search),
|
||||
}
|
||||
),
|
||||
)
|
||||
answer = provider.ask("Wat moet ik doen bij schade?", "strong-damage", "nl-BE")
|
||||
assert answer.evidence_state == "grounded"
|
||||
assert answer.sources[0].document_id == "damage-procedure"
|
||||
|
||||
@@ -96,6 +96,8 @@ def test_mcp_tool_requests_are_audited(client, ops_client):
|
||||
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
|
||||
assert len(events) >= 1
|
||||
assert events[0]["actor_type"] == "service"
|
||||
assert events[0]["actor_label"] == "itworx-mcp-hub"
|
||||
assert events[0]["metadata"]["reported_client_id"].endswith(":probe-1")
|
||||
|
||||
|
||||
def test_search_knowledge_respects_requested_locale(client):
|
||||
@@ -135,7 +137,11 @@ def test_operations_summary_mints_correlation_id_when_none_supplied(client, ops_
|
||||
)
|
||||
assert response.status_code == 200
|
||||
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
|
||||
matching = [e for e in events if e["actor_label"].endswith(":no-correlation-probe")]
|
||||
matching = [
|
||||
e
|
||||
for e in events
|
||||
if e["metadata"].get("reported_client_id", "").endswith(":no-correlation-probe")
|
||||
]
|
||||
assert len(matching) >= 1
|
||||
assert matching[0]["correlation_id"] # a fresh UUID was minted, not left empty
|
||||
|
||||
|
||||
Reference in New Issue
Block a user