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
+26 -1
View File
@@ -119,11 +119,36 @@ async def delete_node(node_id: str, _: dict = Depends(require_admin_token)):
@router.post("/{node_id}/reject")
async def reject_node(node_id: str, _: dict = Depends(require_admin_token)):
"""Reject = revoke (server-26#195). Before this, reject only flipped
``approval_status``, so an approved-then-rejected node kept its MQTT login
(dynsec client), its ``/upload`` key and ``/systems`` read access (both
keyed off ``node_keys``).
Order is fail-closed, the opposite of approve's: the status flips to
``rejected`` FIRST, so even if the broker call fails, ``/upload`` (which
now checks approval_status) and ``GET /nodes/{id}/credentials`` already
refuse the node, and ``dynsec.reconcile_all()`` will no longer re-create
its login on the next c2-core restart (it also deletes rejected nodes'
logins there). Then the broker login is deleted, then the key. A dynsec
failure returns 502 with node_keys left intact, and the admin retries:
every step is idempotent. Re-approving later mints a fresh key, because
node_keys is gone; the revoked key is never handed back.
"""
node = await fstore.doc_get("nodes", node_id)
if not node:
raise HTTPException(404, f"Node '{node_id}' not found.")
await fstore.doc_update("nodes", node_id, {"approval_status": "rejected"})
return {"ok": True}
try:
await dynsec.delete_node_client(node_id)
except dynsec.DynsecError as e:
logger.error(f"Reject {node_id!r}: dynsec deleteClient failed, node_keys NOT deleted: {e}")
raise HTTPException(
502,
f"Node marked rejected, but its MQTT login could not be revoked: {e}. Retry reject.",
)
await fstore.doc_delete("node_keys", node_id)
logger.info(f"Reject {node_id!r}: status rejected, MQTT login and API key revoked")
return {"ok": True, "revoked": True}
@router.post("/{node_id}/command")