diff --git a/docs/persistence.md b/docs/persistence.md index bcbc114..a5b84c1 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -42,5 +42,8 @@ Back up with SQLite's online-backup mechanism or stop the service and capture a consistent database/WAL set. Do not copy only the main file during active WAL writes. Keep authoring files and secret-manager recovery separately. The active revision and materialized catalog are restored from SQLite; the in-process -rollback stack is not. Treat restored session material as sensitive and consider +rollback stack is not. Server, authentication, SSO, authentik and telemetry +bindings always come from the boot file, not the restored revision, so a restart +never pairs the file's listen address with an older revision's credentials; a boot +file that differs from the restored revision is logged at startup. Treat restored session material as sensitive and consider invalidating sessions after recovery. diff --git a/src/cerberus/app.py b/src/cerberus/app.py index b91b273..b794bf8 100644 --- a/src/cerberus/app.py +++ b/src/cerberus/app.py @@ -10,6 +10,7 @@ import importlib.metadata import json +import logging import os import secrets import tempfile @@ -53,6 +54,8 @@ from cerberus.state import InMemoryCooldownStore, SqliteCooldownStore from cerberus.telemetry import TelemetryEmitter +logger = logging.getLogger(__name__) + SESSION_COOKIE = "cerberus_admin_session" @@ -172,7 +175,22 @@ def create_app( state_path = document.config.state.path control_plane = SqliteControlPlane(state_path) if state_path else None lifecycle = ConfigLifecycle(document, repository=control_plane) - boot_config = lifecycle.active.config # infrastructure bindings: fixed at boot + # Infrastructure bindings (auth tokens, SSO, authentik, telemetry, docs, + # candidate roots) come from the document this process was started with — + # the same document cli.py takes the listen host and port from. They must + # never come from a revision restored out of SQLite: that revision may + # predate an operator's edit to the boot file, so the process would bind the + # file's host (say 0.0.0.0) while enforcing the old revision's auth (say no + # api token). Routing policy still follows the restored active revision. + boot_config = document.config + if lifecycle.active.checksum != document.checksum: + logger.warning( + "Boot configuration %s differs from the restored active revision %s; " + "server, auth, SSO and telemetry bindings follow the boot file, routing " + "policy follows the active revision until the boot file is activated", + document.version, + lifecycle.active.version, + ) store = SqliteCooldownStore(state_path) if state_path else InMemoryCooldownStore() # staged candidate configs from the browser editor land here — the same # writable volume as the state DB in production; a tmp fallback keeps @@ -496,7 +514,7 @@ async def admin_status(request: Request) -> dict[str, Any] | JSONResponse: # SPEC §8: fusion status on the dashboard. "configured" means every # fusion alias's backend credential is present; this read-only # endpoint never probes the backend. - "fusion": fusion_status(boot_config), + "fusion": fusion_status(lifecycle.active.config), } @app.get("/admin/health", response_model=None) diff --git a/tests/test_boot_bindings.py b/tests/test_boot_bindings.py new file mode 100644 index 0000000..fb345c7 --- /dev/null +++ b/tests/test_boot_bindings.py @@ -0,0 +1,62 @@ +"""Infrastructure bindings follow the boot file, never a revision restored from SQLite. + +cli.py binds the listen host and port from the file the process was started +with. If auth settings came from the restored active revision instead, an +operator who edited the file to a public host plus an api token and restarted +would get a public listener enforcing the old revision's (absent) token. +""" + +import httpx +import pytest + +from cerberus.app import create_app +from cerberus.registry import load_config_document +from tests.test_control import ok_upstream, raw_config, write_config + + +def _config(version, state_path, server): + raw = raw_config(version) + raw["state"] = {"path": state_path} + raw.pop("identities", None) + raw["server"] = server + return raw + + +def _client(app, host): + return httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=(host, 40001)), base_url="http://test") + + +@pytest.mark.asyncio +async def test_restart_enforces_the_boot_files_api_token(monkeypatch, tmp_path): + monkeypatch.setenv("ALPHA_KEY", "alpha-secret") + monkeypatch.setenv("API_TOKEN", "api-secret") + state_path = str(tmp_path / "state.sqlite3") + + first = _config("cerberus-2026-07-16.1", state_path, {"host": "127.0.0.1"}) + app = create_app( + load_config_document(write_config(tmp_path, "boot.yaml", first)), + http_transport=httpx.MockTransport(ok_upstream), + ) + async with app.router.lifespan_context(app): + pass + + # the operator hardens the boot file and restarts + hardened = _config( + "cerberus-2026-07-16.2", state_path, {"host": "0.0.0.0", "api_token_env": "API_TOKEN"} + ) + restarted = create_app( + load_config_document(write_config(tmp_path, "boot.yaml", hardened)), + http_transport=httpx.MockTransport(ok_upstream), + ) + alias = next(iter(hardened["aliases"])) + body = {"model": alias, "messages": [{"role": "user", "content": "hi"}]} + async with restarted.router.lifespan_context(restarted): + # routing policy still follows the restored revision + assert restarted.state.lifecycle.active.version == "cerberus-2026-07-16.1" + async with _client(restarted, "203.0.113.9") as client: + denied = await client.post("/v1/chat/completions", json=body) + assert denied.status_code == 401 + allowed = await client.post( + "/v1/chat/completions", json=body, headers={"authorization": "Bearer api-secret"} + ) + assert allowed.status_code == 200, allowed.text