M35: harden the shared public demo

This commit is contained in:
NuklearRabbit
2026-08-10 22:32:58 +02:00
parent c7492bf6ad
commit 809ba0ddcc
9 changed files with 142 additions and 22 deletions
+3
View File
@@ -51,6 +51,9 @@ PRIVACY_AUDIT_EXPORT_MAX_ROWS=10000
DEMO_ORGANIZATION_NAME=Northstar Mobility
DEMO_TIMEZONE=Europe/Brussels
DEMO_ALLOW_RESET=true
# Prevent public visitors from repeatedly rebuilding the shared dataset. Concurrent
# resets are always rejected using both process and PostgreSQL advisory locks.
DEMO_RESET_COOLDOWN_SECONDS=60
# Operational mode: set MOBILITYOPS_DEMO_MODE=false and provide the first manager.
# Keep these values in a secret store or an untracked production .env file.
+18
View File
@@ -2775,3 +2775,21 @@ evidence yet."
direct invalid-state writes were rejected by their named constraints; Ruff passed.
- Exact next action: serialize and rate-limit shared demo reset, add production web guards
and cache MCP reachability evidence.
## M35 — shared public demo and edge hardening (2026-08-10)
- Demo resets now use a non-blocking process guard plus a PostgreSQL transaction advisory
lock, and enforce a configurable post-success cooldown with a standards-based
`Retry-After`. Test deployments explicitly disable only the cooldown, never locking.
- Nginx rate-limits public demo login/reset endpoints and adds CSP, anti-framing, MIME,
referrer and browser capability headers. Hashed assets receive long-lived caching while
the application shell is revalidated. Production FastAPI deployments no longer expose
Swagger, ReDoc or OpenAPI routes.
- MCP Hub reachability probes are synchronized and cached for 60 seconds, removing a
remote network call from every Integration Management page load while retaining honest
failure evidence.
- Validation: authentication/reset suite **15 passed**; Ruff and mypy passed across 58
source files; production web image built and `nginx -t` passed; production API docs-off
assertion passed.
- Exact next action: improve mobile Data Quality operations, technical evidence labels,
RAG scope clarity and sticky resolution actions.
+38 -16
View File
@@ -1,10 +1,11 @@
from __future__ import annotations
import threading
import time
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db, require_operations_manager
@@ -19,6 +20,9 @@ from app.services.sessions import revoke_session
router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
settings = get_settings()
_reset_guard = threading.Lock()
_last_reset_monotonic = 0.0
_RESET_ADVISORY_LOCK_ID = 706_533_149
@router.get("/manifest", response_model=DemoManifestOut)
@@ -106,26 +110,44 @@ def demo_reset(
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> dict:
global _last_reset_monotonic
if not settings.demo_allow_reset:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Demo reset is disabled on this deployment.",
)
result = reset_and_seed(db, preserve_integration_telemetry=True)
integrity = scenario_integrity_report(db)
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="demo_reset",
entity_type="system",
metadata={
"counts": result.counts,
"anchor_date": result.anchor_date.isoformat(),
"scenario_integrity": integrity,
},
)
db.commit()
if not _reset_guard.acquire(blocking=False):
raise HTTPException(status_code=409, detail="A demo reset is already running.")
try:
elapsed = time.monotonic() - _last_reset_monotonic
if _last_reset_monotonic and elapsed < settings.demo_reset_cooldown_seconds:
retry_after = max(1, int(settings.demo_reset_cooldown_seconds - elapsed + 0.999))
raise HTTPException(
status_code=429,
detail=f"Demo reset is cooling down. Retry in {retry_after} seconds.",
headers={"Retry-After": str(retry_after)},
)
locked = db.scalar(select(func.pg_try_advisory_xact_lock(_RESET_ADVISORY_LOCK_ID)))
if not locked:
raise HTTPException(status_code=409, detail="A demo reset is already running.")
result = reset_and_seed(db, preserve_integration_telemetry=True)
integrity = scenario_integrity_report(db)
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="demo_reset",
entity_type="system",
metadata={
"counts": result.counts,
"anchor_date": result.anchor_date.isoformat(),
"scenario_integrity": integrity,
},
)
db.commit()
_last_reset_monotonic = time.monotonic()
finally:
_reset_guard.release()
response.delete_cookie(settings.session_cookie_name)
return {
"status": "reset",
+2
View File
@@ -48,6 +48,8 @@ class Settings(BaseSettings):
demo_organization_name: str = "Northstar Mobility"
demo_timezone: str = "Europe/Brussels"
demo_allow_reset: bool = True
demo_reset_cooldown_seconds: int = 60
mcp_hub_health_cache_seconds: int = 60
initial_admin_email: str = ""
initial_admin_password: str = ""
initial_admin_display_name: str = "Operations Manager"
+10 -1
View File
@@ -55,7 +55,15 @@ async def lifespan(_app: FastAPI):
stop_background_dispatcher()
app = FastAPI(title=f"{PRODUCT_NAME} API", version="0.1.0", lifespan=lifespan)
production = settings.mobilityops_env.lower() == "production"
app = FastAPI(
title=f"{PRODUCT_NAME} API",
version="0.1.0",
lifespan=lifespan,
docs_url=None if production else "/docs",
redoc_url=None if production else "/redoc",
openapi_url=None if production else "/openapi.json",
)
app.add_middleware(
SessionMiddleware,
@@ -126,6 +134,7 @@ def handle_http_exception(request: Request, exc: HTTPException) -> JSONResponse:
correlation_id=getattr(request.state, "correlation_id", str(uuid.uuid4())),
details={},
),
headers=exc.headers,
)
+17 -5
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import threading
import time
from datetime import UTC, datetime, timedelta
from typing import Literal
@@ -18,6 +20,9 @@ from app.schemas import (
)
settings = get_settings()
_hub_health_lock = threading.Lock()
_hub_health_cached_at = 0.0
_hub_health_cached_value: bool | None = None
# The 4 canonical Fleet Ops n8n workflows (see n8n/workflows/MANIFEST.md). All 4 are
# built (all with their full node set saved).
@@ -278,8 +283,15 @@ def _check_hub_reachable() -> bool | None:
`None` means not configured / not checked, never a guess."""
if not settings.mcp_hub_base_url:
return None
try:
response = httpx.get(f"{settings.mcp_hub_base_url.rstrip('/')}/health", timeout=1.5)
return response.status_code == 200
except httpx.HTTPError:
return False
global _hub_health_cached_at, _hub_health_cached_value
now = time.monotonic()
with _hub_health_lock:
if now - _hub_health_cached_at < settings.mcp_hub_health_cache_seconds:
return _hub_health_cached_value
try:
response = httpx.get(f"{settings.mcp_hub_base_url.rstrip('/')}/health", timeout=1.5)
_hub_health_cached_value = response.status_code == 200
except httpx.HTTPError:
_hub_health_cached_value = False
_hub_health_cached_at = time.monotonic()
return _hub_health_cached_value
+22
View File
@@ -67,6 +67,28 @@ def test_operations_manager_can_reset_demo(ops_client):
assert body["scenario_integrity"]["not_ready"] == []
def test_demo_reset_rejects_concurrent_rebuild(ops_client):
import app.api.routers.demo as demo_router
assert demo_router._reset_guard.acquire(blocking=False)
try:
response = ops_client.post("/api/v1/demo/reset")
finally:
demo_router._reset_guard.release()
assert response.status_code == 409
def test_demo_reset_cooldown_returns_retry_after(ops_client, monkeypatch):
import app.api.routers.demo as demo_router
monkeypatch.setattr(demo_router.settings, "demo_reset_cooldown_seconds", 60)
monkeypatch.setattr(demo_router, "_last_reset_monotonic", demo_router.time.monotonic())
response = ops_client.post("/api/v1/demo/reset")
assert response.status_code == 429
assert int(response.headers["retry-after"]) >= 1
monkeypatch.setattr(demo_router, "_last_reset_monotonic", 0.0)
def test_reset_is_rejected_when_demo_allow_reset_is_disabled(ops_client, monkeypatch):
import app.api.routers.demo as demo_router
+1
View File
@@ -15,6 +15,7 @@ services:
KNOWLEDGE_PROVIDER: demo
MCP_HUB_REGISTRATION_ENABLED: "false"
DEMO_ALLOW_RESET: "true"
DEMO_RESET_COOLDOWN_SECONDS: "0"
ports: !reset []
web:
+31
View File
@@ -1,3 +1,6 @@
limit_req_zone $binary_remote_addr zone=demo_login:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=demo_reset:10m rate=2r/m;
server {
listen 80;
server_name _;
@@ -5,6 +8,28 @@ server {
root /usr/share/nginx/html;
index index.html;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
location = /api/v1/demo/login {
limit_req zone=demo_login burst=5 nodelay;
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /api/v1/demo/reset {
limit_req zone=demo_reset burst=1 nodelay;
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ {
proxy_pass http://api:8000;
proxy_set_header Host $host;
@@ -17,6 +42,12 @@ server {
}
location / {
expires -1;
try_files $uri /index.html;
}
location ~* ^/assets/.+\.[a-fA-F0-9_-]+\.(css|js|woff2|png|svg)$ {
expires 1y;
try_files $uri =404;
}
}