Close the /admin/features side-door that needed a container shell to flip AI spend
Board minutes #62 Decision 2 (server-26#64), due 2026-08-31. CTO draft #60 finding 1 and CISO draft #61 finding 3 reached this independently. GET/PUT /admin/features accepted only a Firebase admin token, so the unattended runbook had no headless path and SSHed into the c2-core container to write config/ai_features with the admin SDK. Moving a platform-wide AI cost switch required a full container shell, and set_flags() wrote no audit entry either way, so a flag flip was unattributable however it happened. - New agent_service_key (AGENT_SERVICE_KEY), deliberately separate from the Discord bot's service_key. Sharing one key would collapse two principals into a single unattributable identity in every log line, and the bot has no business flipping AI flags regardless. - require_agent_key_or_admin accepts the agent key or a Firebase admin, and rejects the Discord key. The "key is configured" guard is load-bearing: compare_digest("", "") is a match, so a deployment that never set the key would otherwise accept an empty credential. - set_flags() writes an audit_log entry with before/after values and the actor, wrapped so an audit failure cannot lose the flag write or 500 the route. - Cascade helper sets the global doc and every system carrying an ai_flags override in one call. A global False already beats everything, but a system False beats a global True, so turning AI *on* could half-apply and leave a radio system hot after shutoff. It scans for the override rather than hardcoding the two known system IDs, so a new system cannot silently defeat it. - cascade defaults to False. PUT /systems/{id}/ai-flags and the AiFlagsPanel toggle mean a per-system override is deliberate operator intent; cascading by default would erase it on any unrelated global flip. The runbook opts in. Issue items 5 and 6 (retiring the SSH path from drb-worksession.md) are NOT done here and the runbook is untouched. The credential does not exist in production yet, so the SSH path is still the only one that works; retiring it now would break the next unattended run. Owner activation is recorded on #64. Tests 273 -> 289. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0635de8dac
commit
865b5b4317
@@ -220,6 +220,74 @@ async def require_service_key_or_admin(
|
||||
return decoded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Automation / agent principal
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identity written into audit_log when the agent key is what authenticated a
|
||||
# request. A Firebase admin gets their own uid/email instead, so the two are
|
||||
# always distinguishable after the fact — which is the point.
|
||||
AGENT_PRINCIPAL_UID = "agent-service"
|
||||
AGENT_PRINCIPAL_EMAIL = "agent-service@drb.internal"
|
||||
|
||||
|
||||
async def require_agent_key_or_admin(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||
) -> dict:
|
||||
"""Accept either the agent service key or a Firebase admin token.
|
||||
|
||||
Deliberately does NOT accept ``settings.service_key``. That key belongs to
|
||||
the Discord bot, and honouring it here would collapse two principals into
|
||||
one unattributable identity in every log line and audit entry — the exact
|
||||
thing server-26#64 exists to end. The bot has no business flipping
|
||||
platform-wide AI flags either way.
|
||||
|
||||
Exists so the unattended runbook can flip AI flags over HTTP instead of
|
||||
SSHing into the container and writing ``config/ai_features`` with the admin
|
||||
SDK, which needs a full container shell to move a cost switch.
|
||||
|
||||
The ``settings.agent_service_key and ...`` guard is load-bearing, not
|
||||
stylistic: ``secrets.compare_digest("", "")`` is a MATCH, so any form of
|
||||
``compare_digest(token, settings.agent_service_key or "")`` would turn a
|
||||
deployment that never configured the key into one that accepts an empty
|
||||
credential. Check the key is configured first and never substitute a
|
||||
placeholder. (``require_service_key`` states the same intent by raising
|
||||
503 when unset; both are correct, this one just stays open to admins.)
|
||||
"""
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||
token = credentials.credentials
|
||||
if settings.agent_service_key and secrets.compare_digest(token, settings.agent_service_key):
|
||||
return {
|
||||
"service": True,
|
||||
"principal": "agent",
|
||||
"uid": AGENT_PRINCIPAL_UID,
|
||||
"email": AGENT_PRINCIPAL_EMAIL,
|
||||
}
|
||||
try:
|
||||
decoded = firebase_auth.verify_id_token(token)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
if get_role(decoded) != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return decoded
|
||||
|
||||
|
||||
def describe_actor(principal: dict) -> tuple[str, str]:
|
||||
"""Return ``(actor_uid, actor_email)`` for an audit entry.
|
||||
|
||||
Works for any credential shape the dependencies above produce, so an audit
|
||||
call site never has to switch on principal type itself.
|
||||
"""
|
||||
if principal.get("principal") == "agent":
|
||||
return AGENT_PRINCIPAL_UID, AGENT_PRINCIPAL_EMAIL
|
||||
if principal.get("service"):
|
||||
return "service", "service@drb.internal"
|
||||
if principal.get("node"):
|
||||
node_id = principal.get("node_id") or "unknown"
|
||||
return f"node:{node_id}", ""
|
||||
return principal.get("uid") or "unknown", principal.get("email") or ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simple in-memory sliding-window rate limiter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user