Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
22 changes: 20 additions & 2 deletions src/cerberus/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import importlib.metadata
import json
import logging
import os
import secrets
import tempfile
Expand Down Expand Up @@ -53,6 +54,8 @@
from cerberus.state import InMemoryCooldownStore, SqliteCooldownStore
from cerberus.telemetry import TelemetryEmitter

logger = logging.getLogger(__name__)

SESSION_COOKIE = "cerberus_admin_session"


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
62 changes: 62 additions & 0 deletions tests/test_boot_bindings.py
Original file line number Diff line number Diff line change
@@ -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
Loading