sec(api): mode-gate and eager-load JWT secret in lifespan

Refuse to start decnet.web.api when DECNET_MODE=agent (unless the
operator explicitly opts into dual-role with DECNET_DISALLOW_MASTER=
false). The Typer CLI already hides master-only commands on agents,
but a misconfigured systemd unit or a direct uvicorn invocation
would bypass that — now the lifespan itself refuses, before any
worker, DB or bus comes up.

Resolve DECNET_JWT_SECRET eagerly at startup so a missing or known-
bad value fails at boot rather than on the first auth-gated request.
The lazy-load shape stays useful for non-master CLIs.
This commit is contained in:
2026-04-27 21:26:03 -04:00
parent 1a7da33375
commit 5415e98458
2 changed files with 141 additions and 0 deletions

View File

@@ -75,6 +75,25 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Raises ValueError with an actionable message; uvicorn surfaces it.
validate_public_binding()
# Defence-in-depth on top of the CLI mode gating. Typer hides master-only
# commands when DECNET_MODE=agent, but a misconfigured systemd unit or
# a direct `python -m uvicorn decnet.web.api:app` call would bypass that.
# This raises before any worker / DB / bus comes up.
_mode = os.environ.get("DECNET_MODE", "master").lower()
_disallow = os.environ.get("DECNET_DISALLOW_MASTER", "true").lower() == "true"
if _mode == "agent" and _disallow:
raise RuntimeError(
"decnet.web.api refuses to start with DECNET_MODE=agent. "
"The master API is master-only; agents run `decnet agent` instead. "
"If this host genuinely plays both roles, set DECNET_DISALLOW_MASTER=false."
)
# Resolve DECNET_JWT_SECRET eagerly so a missing/insecure secret fails
# at boot rather than on the first request that hits an auth-gated
# endpoint. The lazy-load shape stays useful for non-master CLIs.
from decnet import env as _env
_ = _env.DECNET_JWT_SECRET # raises ValueError on missing/bad
log.info("API startup initialising database")
for attempt in range(1, 6):
try: