diff --git a/services/api/Dockerfile b/services/api/Dockerfile index 409c49d..306fb88 100644 --- a/services/api/Dockerfile +++ b/services/api/Dockerfile @@ -1,6 +1,12 @@ # 0pt data engine — API + worker image (runs on a VPS, NOT Vercel; long jobs). FROM python:3.12-slim +# Commit-kedjan (samma mönster som 0pt-motor/scripts/build-images.sh): samma +# SHA i image-taggen (sätts av byggaren), OCI-label och GET /health. +ARG GIT_SHA=unknown +LABEL org.opencontainers.image.revision=${GIT_SHA} +ENV GIT_SHA=${GIT_SHA} + WORKDIR /app ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 diff --git a/services/api/orchestrator/main.py b/services/api/orchestrator/main.py index 66f1d88..fe59485 100644 --- a/services/api/orchestrator/main.py +++ b/services/api/orchestrator/main.py @@ -7,6 +7,7 @@ is the customer-facing edge; this service runs the actual pipeline. from __future__ import annotations import asyncio +import hmac import os import uuid from pathlib import Path @@ -15,6 +16,7 @@ from typing import Any import yaml from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from pydantic import BaseModel from sources.connectors import opensanctions_screen, rdw_vehicle @@ -99,9 +101,58 @@ class OrderIn(BaseModel): config: dict[str, Any] = {} +# ── internal-token gate (fail-closed) ──────────────────────────────────────── +# The edge sends `X-Internal-Token` on every engineFetch (lib/engine.ts), but +# until 2026-09-15 this service never VERIFIED it — a deployed engine accepted +# anonymous traffic from anyone who could reach the port. The gate below +# enforces it when INTERNAL_TOKEN is set: every route except /health (the +# liveness probe) must carry the exact token or gets 401. When INTERNAL_TOKEN +# is unset the gate is inert (local dev / docker compose without auth wiring); +# the production deployment ALWAYS sets it (see infra/docker-compose.yml). +_INTERNAL_TOKEN = os.getenv("INTERNAL_TOKEN", "").strip() +_PUBLIC_PATHS = {"/health", "/docs", "/openapi.json", "/redoc"} + + +class _InternalTokenGate: + """Raw-ASGI middleware: constant-time X-Internal-Token check, 401 on + missing/wrong token. Written as a plain ASGI class (not + @app.middleware('http')) so behavior does not depend on the + BaseHTTPMiddleware dispatch quirks of the installed starlette.""" + + def __init__(self, app) -> None: + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] == "http" and _INTERNAL_TOKEN and scope["path"] not in _PUBLIC_PATHS: + headers = {} + for k, v in scope.get("headers") or []: + headers.setdefault(k.decode("latin-1").lower(), v.decode("latin-1")) + supplied = headers.get("x-internal-token", "") + if not hmac.compare_digest(supplied, _INTERNAL_TOKEN): + resp = JSONResponse( + status_code=401, + content={"error": {"code": "unauthorized", "message": "missing or invalid X-Internal-Token"}}, + ) + await resp(scope, receive, send) + return + await self.app(scope, receive, send) + + +app.add_middleware(_InternalTokenGate) + + @app.get("/health") async def health(): - return {"ok": True, "service": "0pt-data-engine", "db": bool(os.getenv("DATABASE_URL")), "sources": len(_SOURCES)} + return { + "ok": True, + "service": "0pt-data-engine", + "db": bool(os.getenv("DATABASE_URL")), + "sources": len(_SOURCES), + # Commit-kedjan: samma SHA som image-taggen/OCI-labeln (Dockerfile ARG + # GIT_SHA). 'unknown' gör ett oprovenienserat image synligt istället + # för tyst falskt — samma policy som 0pt-motor/scripts/verify-image.sh. + "revision": os.getenv("GIT_SHA", "unknown"), + } @app.get("/sources") diff --git a/services/api/tests/test_internal_token_gate.py b/services/api/tests/test_internal_token_gate.py new file mode 100644 index 0000000..e6d031a --- /dev/null +++ b/services/api/tests/test_internal_token_gate.py @@ -0,0 +1,70 @@ +"""The engine must not accept anonymous traffic when INTERNAL_TOKEN is set. + +Background (2026-09-15): lib/engine.ts on the edge has always SENT +`X-Internal-Token`, but the engine never verified it — a deployed engine would +have accepted anyone who could reach the port. The gate is fail-closed: + + * INTERNAL_TOKEN set -> every route except /health requires the EXACT + token; wrong/missing = 401. + * INTERNAL_TOKEN unset -> gate inert (local dev); production compose always + sets it. + +`/health` stays public on purpose: it is the liveness probe that +`lib/health-checks.ts` calls from the edge, and it leaks nothing but booleans. + +The token is read at module import (the same value the running process serves +with), so each test reloads the module with the env it wants — and the +teardown reloads it one more time with the env CLEARED so later test files in +the same session never inherit a baked-in token. +""" + +from __future__ import annotations + +import importlib +import os + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def engine(monkeypatch): + monkeypatch.setenv("INTERNAL_TOKEN", "s3cret") + import orchestrator.main as main + + importlib.reload(main) + yield TestClient(main.app) + os.environ.pop("INTERNAL_TOKEN", None) + importlib.reload(main) + + +@pytest.fixture() +def engine_no_token(monkeypatch): + monkeypatch.delenv("INTERNAL_TOKEN", raising=False) + import orchestrator.main as main + + importlib.reload(main) + yield TestClient(main.app) + os.environ.pop("INTERNAL_TOKEN", None) + importlib.reload(main) + + +def test_gate_off_when_internal_token_unset(engine_no_token): + assert engine_no_token.get("/health").status_code == 200 + assert engine_no_token.get("/sources").status_code == 200 + + +def test_health_stays_public_but_sources_require_token(engine): + # liveness probe is public by design + assert engine.get("/health").status_code == 200 + # anonymous call to a data route -> 401 (was 200 before 2026-09-15) + r = engine.get("/sources") + assert r.status_code == 401 + assert r.json()["error"]["code"] == "unauthorized" + + +def test_wrong_token_is_401_and_correct_token_passes(engine): + wrong = engine.get("/estimates?markets=SE", headers={"X-Internal-Token": "nope"}) + assert wrong.status_code == 401 + right = engine.get("/sources", headers={"X-Internal-Token": "s3cret"}) + assert right.status_code == 200