feat(engine): fail-closed X-Internal-Token-gate + commit-kedja i services/api (ENGINE_URL-wire-förberedelse) #58

Merged
admin merged 1 commits from feat/engine-internal-token into main 2026-09-15 01:45:39 +00:00
3 changed files with 128 additions and 1 deletions
+6
View File
@@ -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
+52 -1
View File
@@ -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")
@@ -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