feat(engine): fail-closed X-Internal-Token-gate + commit-kedja i services/api (ENGINE_URL-wire-förberedelse) #58
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user