aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py42
-rw-r--r--packages/meshbay-hub/tests/test_revoke_is_one_path.py164
2 files changed, 206 insertions, 0 deletions
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)
diff --git a/packages/meshbay-hub/tests/test_revoke_is_one_path.py b/packages/meshbay-hub/tests/test_revoke_is_one_path.py
new file mode 100644
index 0000000..2acb46c
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_revoke_is_one_path.py
@@ -0,0 +1,164 @@
+"""Revoke is one door, it is admin-only, and it broadcasts.
+
+Two coupled gaps used to sit 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;
+ * 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.
+
+Revoke now has one path, `POST /v1/admin/revoke` (admin-only, signs and
+broadcasts). PATCH refuses `revoked` and refuses to move an entity *out* of
+`revoked` unless the caller is an admin.
+"""
+
+import pytest
+
+from meshbay_hub.api.deps import set_admin_usernames
+
+
+async def _register(client, username):
+ r = await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@test.local", "auth_key": "k" * 44})
+ assert r.status_code == 201
+ return r.json()["user_id"]
+
+
+async def _login(client, username):
+ r = await client.post("/v1/users/login", json={
+ "username": username, "auth_key": "k" * 44})
+ assert r.status_code == 200
+ return r.json()["access_token"]
+
+
+def _h(token):
+ return {"Authorization": f"Bearer {token}"}
+
+
+async def _admin(client, name="admin_rev_test"):
+ await _register(client, name)
+ set_admin_usernames([name])
+ return await _login(client, name)
+
+
+async def _moderator(client, admin_token, name="mod_rev_test"):
+ uid = await _register(client, name)
+ r = await client.patch(f"/v1/admin/users/{uid}", json={"role": "moderator"},
+ headers=_h(admin_token))
+ assert r.status_code == 200
+ return uid, await _login(client, name)
+
+
+async def _a_group(client, owner="owner_rev_test"):
+ await _register(client, owner)
+ tok = await _login(client, owner)
+ r = await client.post("/v1/groups", headers=_h(tok),
+ json={"name": "g", "visibility": "private", "join_policy": "invite"})
+ assert r.status_code == 201
+ return r.json()["group_id"]
+
+
+async def _group_status(client, admin_token, group_id):
+ data = (await client.get("/v1/admin/groups?limit=200", headers=_h(admin_token))).json()
+ return next(g["status"] for g in data["groups"] if g["id"] == group_id)
+
+
+# ── PATCH cannot revoke ──────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_moderator_cannot_revoke_a_group_via_patch(client):
+ admin_token = await _admin(client)
+ _, mod_token = await _moderator(client, admin_token)
+ gid = await _a_group(client)
+ r = await client.patch(f"/v1/admin/groups/{gid}", json={"status": "revoked"},
+ headers=_h(mod_token))
+ assert r.status_code == 403
+ assert await _group_status(client, admin_token, gid) == "active"
+
+
+@pytest.mark.asyncio
+async def test_admin_patch_revoked_group_is_redirected_not_silently_applied(client):
+ admin_token = await _admin(client)
+ gid = await _a_group(client)
+ r = await client.patch(f"/v1/admin/groups/{gid}", json={"status": "revoked"},
+ headers=_h(admin_token))
+ assert r.status_code == 400
+ assert "revoke" in r.json()["detail"].lower()
+ # And it was not quietly applied.
+ assert await _group_status(client, admin_token, gid) == "active"
+
+
+@pytest.mark.asyncio
+async def test_admin_patch_revoked_user_is_redirected(client):
+ admin_token = await _admin(client)
+ victim = await _register(client, "vic_rev_test")
+ r = await client.patch(f"/v1/admin/users/{victim}", json={"status": "revoked"},
+ headers=_h(admin_token))
+ assert r.status_code == 400
+
+
+# ── The one door: /v1/admin/revoke, admin-only, and it broadcasts ────────────
+
+@pytest.mark.asyncio
+async def test_admin_revoke_group_broadcasts_and_sets_status(client):
+ admin_token = await _admin(client)
+ gid = await _a_group(client)
+ r = await client.post("/v1/admin/revoke", headers=_h(admin_token),
+ json={"target": "group", "target_id": gid})
+ assert r.status_code == 200
+ body = r.json()
+ assert body["status"] == "revoked"
+ assert "nodes_notified" in body # it went through the broadcast path
+ assert await _group_status(client, admin_token, gid) == "revoked"
+
+
+@pytest.mark.asyncio
+async def test_moderator_cannot_reach_the_revoke_endpoint(client):
+ admin_token = await _admin(client)
+ _, mod_token = await _moderator(client, admin_token)
+ gid = await _a_group(client)
+ r = await client.post("/v1/admin/revoke", headers=_h(mod_token),
+ json={"target": "group", "target_id": gid})
+ assert r.status_code == 403
+
+
+# ── Leaving `revoked` is an admin's call ─────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_moderator_cannot_unrevoke_a_group(client):
+ admin_token = await _admin(client)
+ _, mod_token = await _moderator(client, admin_token)
+ gid = await _a_group(client)
+ await client.post("/v1/admin/revoke", headers=_h(admin_token),
+ json={"target": "group", "target_id": gid})
+ r = await client.patch(f"/v1/admin/groups/{gid}", json={"status": "active"},
+ headers=_h(mod_token))
+ assert r.status_code == 403
+ assert await _group_status(client, admin_token, gid) == "revoked"
+
+
+@pytest.mark.asyncio
+async def test_moderator_cannot_unrevoke_a_user(client):
+ admin_token = await _admin(client)
+ _, mod_token = await _moderator(client, admin_token)
+ victim = await _register(client, "vic2_rev_test")
+ await client.post("/v1/admin/revoke", headers=_h(admin_token),
+ json={"target": "user", "target_id": victim})
+ r = await client.patch(f"/v1/admin/users/{victim}", json={"status": "active"},
+ headers=_h(mod_token))
+ assert r.status_code == 403
+
+
+# ── Regression: suspend/unsuspend by a moderator still works ─────────────────
+
+@pytest.mark.asyncio
+async def test_moderator_can_still_suspend_and_restore(client):
+ admin_token = await _admin(client)
+ _, mod_token = await _moderator(client, admin_token)
+ gid = await _a_group(client)
+ assert (await client.patch(f"/v1/admin/groups/{gid}", json={"status": "suspended"},
+ headers=_h(mod_token))).status_code == 200
+ assert (await client.patch(f"/v1/admin/groups/{gid}", json={"status": "active"},
+ headers=_h(mod_token))).status_code == 200