Merge branch 'main' into feat/live-ops-wall
This commit is contained in:
@@ -312,7 +312,8 @@ async def delete_node_client(node_id: str) -> None:
|
||||
async def reconcile_all() -> None:
|
||||
"""
|
||||
Rebuild dynsec state for every approved node from Firestore
|
||||
(node_keys is the source of truth). Called once at c2-core startup,
|
||||
(node_keys is the source of truth), and delete the login of every
|
||||
rejected node. Called once at c2-core startup,
|
||||
after ensure_roles_and_c2core_grant(). Self-heals a lost/corrupted
|
||||
dynamic-security.json (e.g. volume wiped, or a prior approve/reissue's
|
||||
dynsec publish silently failed to persist for some other reason) —
|
||||
@@ -320,6 +321,20 @@ async def reconcile_all() -> None:
|
||||
dynsec store would lock out every previously-approved node until
|
||||
someone noticed and manually re-approved each one.
|
||||
"""
|
||||
# server-26#195: revoke first. A node rejected before reject_node()
|
||||
# revoked anything (or whose revoke hit a dynsec failure) still has a
|
||||
# broker login in dynamic-security.json; startup is where that heals.
|
||||
# delete_node_client() treats "not found" as success, so this is a no-op
|
||||
# for every node already revoked.
|
||||
for node in await fstore.collection_list("nodes", approval_status="rejected"):
|
||||
node_id = node.get("node_id")
|
||||
if not node_id:
|
||||
continue
|
||||
try:
|
||||
await delete_node_client(node_id)
|
||||
except DynsecError as e:
|
||||
logger.error(f"dynsec reconcile: failed to revoke rejected node {node_id!r}: {e}")
|
||||
|
||||
nodes = await fstore.collection_list("nodes", approval_status="approved")
|
||||
if not nodes:
|
||||
return
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user