Reject revokes the node's MQTT login and API key (server-26#195)

reject_node only set approval_status, so an approved-then-rejected node kept
its dynsec login, its /upload key and /systems read access.

- reject_node: status -> rejected first (fail-closed), then
  dynsec.delete_node_client, then delete node_keys. dynsec failure -> 502,
  key kept, retry is idempotent. Re-approve mints a fresh key.
- upload.py: valid key alone is not enough; node must be approved (403),
  uncached read so revocation bites immediately.
- dynsec.reconcile_all: deletes rejected nodes' logins on startup, healing
  nodes rejected before this change or whose revoke failed.
- Reject confirm text says it revokes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-04 17:58:51 -04:00
co-authored by Claude Opus 5.5
parent d57738a8f6
commit 73147803ea
5 changed files with 262 additions and 7 deletions
+16 -4
View File
@@ -47,6 +47,18 @@ async def upload_call_audio(
f"(received prefix: {credentials.credentials[:8]}...)"
)
raise HTTPException(401, "Invalid node API key")
# server-26#195: a valid key is not enough, the node must still be
# approved. reject_node() deletes node_keys too, but this closes the
# window where that delete failed or was never run (a node rejected before
# reject revoked anything). Uncached read: revocation must bite on the
# next upload, not after doc_get_cached's 5-minute TTL.
node = await fstore.doc_get("nodes", node_id)
if not node or node.get("approval_status") != "approved":
logger.warning(
f"Upload 403: node_id={node_id!r} has a valid key but approval_status="
f"{(node or {}).get('approval_status')!r}"
)
raise HTTPException(403, "Node is not approved")
data = await file.read()
if not data:
@@ -63,10 +75,10 @@ async def upload_call_audio(
# org_id is stamped defensively here too (not just in
# mqtt_handler.py's call_start/call_end): key_doc above proves this
# node_id is real and authenticated, so resolving org_id from the
# node doc here covers a call whose Firestore doc was somehow
# never written by call_start (the upload is otherwise the
# authoritative record of which node this audio came from).
node = await fstore.doc_get_cached("nodes", node_id)
# node doc (fetched by the approval gate above) here covers a call
# whose Firestore doc was somehow never written by call_start (the
# upload is otherwise the authoritative record of which node this
# audio came from).
updates = {"audio_gcs_uri": gcs_uri}
if node and node.get("org_id"):
updates["org_id"] = node["org_id"]