Files
ai-agent/backend/core/auth.py
Gabriel Vidal 0ff9e40242 refactor(backend): split main.py into domain packages with app.state injection
main.py (5146 lines, 106 routes) becomes an assembly only: one package per
domain — core, files, settings, dashboards, conversations, diff,
notifications, forms, runs, accounts, workers, models, cron, agents,
projects, services, goals, memories, plans, templates — each exposing an
APIRouter; the flat domain modules move into their package behind a barrel
that keeps the old `import conversations` / `import projects` spellings.

The shared singletons (store, meta_store, hub, indexer, …) are built once by
core.state.build_state() and attached to app.state.ai; routes take them as
the `deps: State` dependency and helpers as an explicit `deps: AppState`.
conversations/pricing.py carries the per-model rates out of the parser.

Verified: route table and OpenAPI byte-identical; 90 read endpoints
golden-diffed against the monolith on a copy of the live data (identical);
write routes smoke-tested; 66 backend tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:55:47 +02:00

101 lines
4.4 KiB
Python

"""The trusted-caller gate: every /api request must come from Traefik, the
docker gateway, localhost or a caller holding INTERNAL_API_TOKEN."""
import os
import pathlib
import socket
import threading
import time
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
# ── trusted-caller gate ──────────────────────────────────────────────────────
# The backend has no auth of its own: the browser reaches it through Traefik
# (behind Authelia) and host tooling through the 127.0.0.1-published port. But
# the container also sits on the shared `main` docker network, where any other
# container could hit it directly — and a PUT into `.claude/` (hooks) or a
# /api/spawn is host-level code execution. So every /api request must come from
# a trusted peer: Traefik (the Authelia-guarded edge), the docker gateway (how
# host-originated connections to the published port appear), localhost, or a
# caller presenting the shared INTERNAL_API_TOKEN. Only /api/health stays open
# (deploy probes). Static assets are public-shell only, so they stay open too.
INTERNAL_API_TOKEN = os.environ.get("INTERNAL_API_TOKEN", "")
# A read-ONLY API key: a caller presenting it (X-API-Key) may hit **safe**
# (GET/HEAD/OPTIONS) /api/* routes only — every mutating method is refused. This
# is what the desk phone holds to fetch unread notifications: it must never be
# able to spawn a session or PUT into .claude/ (that's host-level code exec).
# Comma-separated to allow more than one key. Empty ⇒ feature off.
READONLY_API_KEYS = frozenset(
k.strip() for k in os.environ.get("READONLY_API_KEY", "").split(",")
if k.strip())
# Methods a read-only key is allowed to use.
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
TRAEFIK_HOST = os.environ.get("TRAEFIK_HOST", "traefik")
_TRUSTED_TTL_S = 30.0
_trusted_lock = threading.Lock()
_trusted_cache: tuple[float, frozenset[str]] = (0.0, frozenset())
def _default_gateway_ips() -> set[str]:
"""The container's default-gateway IP(s) (/proc/net/route, IPv4)."""
ips: set[str] = set()
try:
for line in pathlib.Path("/proc/net/route").read_text().splitlines()[1:]:
f = line.split()
if len(f) >= 3 and f[1] == "00000000": # default route
ips.add(socket.inet_ntoa(bytes.fromhex(f[2])[::-1]))
except (OSError, ValueError):
pass
return ips
def _trusted_ips() -> frozenset[str]:
"""Gateway + Traefik IPs, re-resolved at most every _TRUSTED_TTL_S."""
global _trusted_cache
now = time.monotonic()
with _trusted_lock:
ts, ips = _trusted_cache
if now - ts < _TRUSTED_TTL_S and ips:
return ips
fresh = {"127.0.0.1", "::1"} | _default_gateway_ips()
try:
for info in socket.getaddrinfo(TRAEFIK_HOST, None):
fresh.add(info[4][0])
except OSError:
pass
out = frozenset(fresh)
with _trusted_lock:
_trusted_cache = (now, out)
return out
async def _trusted_caller_gate(request: Request, call_next):
path = request.url.path
if path.startswith("/api/") and path != "/api/health":
# A read-only API key downgrades the caller to safe methods, whatever the
# source IP — the phone reaches us over a "trusted" host loopback but is
# only allowed to read (fetch notifications), never to spawn/mutate. So
# this check comes FIRST and, when the key matches, it decides the request
# outright (a read-only key on a POST is refused, not silently upgraded by
# a trusted IP).
api_key = request.headers.get("x-api-key", "")
if api_key and api_key in READONLY_API_KEYS:
if request.method not in _SAFE_METHODS:
return JSONResponse(
{"detail": "read-only api key: method not allowed"},
status_code=403)
return await call_next(request)
client = request.client.host if request.client else ""
if client not in _trusted_ips():
tok = request.headers.get("x-internal-auth", "")
if not (INTERNAL_API_TOKEN and tok == INTERNAL_API_TOKEN):
return JSONResponse({"detail": "forbidden"}, status_code=403)
return await call_next(request)
def install(app: FastAPI) -> None:
"""Register the gate as the app's HTTP middleware."""
app.middleware("http")(_trusted_caller_gate)