From 43c72cf9e8853cd5b2f59d4a4acd87729b0ddae0 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 25 Sep 2026 13:58:40 +0200 Subject: fix(hub): make group revoke admin-only and route it through the broadcasting path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled gaps in the moderation surface (docs/MESHBAY_DESIGN.md §7.5): admin_patch_group let a moderator set a group to "revoked", while the user handler makes revoke admin-only; and a "revoked" set through either PATCH was never broadcast to nodes — unlike POST /v1/admin/revoke and account deletion — so it behaved like "suspended" on nodes while claiming to be the signed, node-enforced state H4 promises. PATCH now refuses "revoked" on both users and groups (400, pointing at POST /v1/admin/revoke, which signs and broadcasts), a moderator setting a group to revoked is refused with 403 as on the user handler, and moving an entity out of "revoked" requires admin — a moderator flipping the hub row back to active would only disagree with the nodes still enforcing the broadcast. Revoke has one door and it broadcasts. The admin SPA already revokes through POST /v1/admin/revoke, so this is not a UI-visible change. test_revoke_is_one_path.py holds the refusals and the working path; verified red against the pre-fix admin.py and green after. Co-Authored-By: Claude Opus 4.8 --- packages/meshbay-hub/src/meshbay_hub/api/admin.py | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) (limited to 'packages/meshbay-hub/src/meshbay_hub/api/admin.py') diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py index b4b2f4f..381378c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py @@ -329,6 +329,25 @@ async def admin_patch_user( raise HTTPException( status_code=422, detail="status must be active, suspended, or revoked") + # Revoking is not a status write. It is signed and broadcast to every + # node so the account is refused there at once; PATCH would change only + # the hub row and leave the nodes unaware — a "revoked" that is not the + # revoked the design promises (§7.5). One door, and it broadcasts. + # (A moderator was already refused above by `privileged`; this is the + # admin, who is told the right door rather than given a broken one.) + if body.status == "revoked": + raise HTTPException( + status_code=400, + detail="Revoke through POST /v1/admin/revoke — it signs the " + "revocation and broadcasts it to every node.") + # Leaving `revoked` undoes a signed, node-enforced action, so it is an + # admin's call, never a moderator's: the nodes still deny this account + # from the broadcast, and a moderator flipping the hub row back to + # active would only disagree with them. + if user.status == "revoked" and not user_is_admin(current_user): + raise HTTPException( + status_code=403, + detail="Only an admin can change a revoked account.") user.status = body.status log.info("User %s status changed to %s by %s", user.username, body.status, current_user.username) @@ -489,6 +508,29 @@ async def admin_patch_group( raise HTTPException( status_code=422, detail="status must be active, suspended, or revoked") + # Suspend/unsuspend is a moderator's reversible, hub-only lever (§7.5). + # Revoke is neither: it is signed and broadcast to every node, and it is + # an administrative act — the same line `admin_patch_user` draws. Two + # gaps used to sit here: a moderator could set `revoked`, and a + # `revoked` set through this PATCH was never broadcast, so it behaved + # like `suspended` on nodes while claiming to be the signed, enforced + # state. Revoke has one door, `POST /v1/admin/revoke`, and it broadcasts. + if body.status == "revoked": + if not user_is_admin(current_user): + raise HTTPException( + status_code=403, + detail="Revoking a group requires admin rights.") + raise HTTPException( + status_code=400, + detail="Revoke a group through POST /v1/admin/revoke — it signs " + "the revocation and broadcasts it to every node.") + # Leaving `revoked` undoes that broadcast and is an admin's call: the + # nodes still enforce the revocation, and a moderator flipping the hub + # row back would only disagree with them. + if group.status == "revoked" and not user_is_admin(current_user): + raise HTTPException( + status_code=403, + detail="Only an admin can change a revoked group.") group.status = body.status log.info("Group %s status changed to %s by %s", group.name, body.status, current_user.username) -- cgit v1.2.3