diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-25 13:43:47 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-25 17:24:16 +0200 |
| commit | c6505677fd121265ab5cc52276ec9d0c1c73b6c9 (patch) | |
| tree | c5c6961b77424bab3168e331ca2769dcaeb89740 /packages/meshbay-hub/tests | |
| parent | 90c69477d5f701158112b3c294eff26312f89da6 (diff) | |
| download | meshbay-c6505677fd121265ab5cc52276ec9d0c1c73b6c9.tar.gz | |
fix(hub): refuse node-scoped tokens on the admin and moderator API
A node's authority and a hub role are different notions: what a node may do
is decided by its operator's roster pin on the node (NS4), while admin and
moderator are hub roles on a person's account, exercised from a browser with
a user-scoped token. The scope refusal was wired only onto require_user_scope
(group mutation), so require_admin and require_moderator accepted a
scope:"node" daemon token whenever the underlying account also held a hub
role. On a deployment where the operator is a hub admin and runs a node, the
daemon's in-memory token was therefore a full hub-admin credential — able to
revoke accounts and groups (signed, broadcast to every node), change instance
policy, and read the IP audit log.
Factor the refusal into _reject_node_scope(payload) and call it from
require_user_scope, require_moderator and require_admin alike, so a
node-scoped token is turned away with 403 on every privileged route.
test_node_scope_not_admin.py holds seven refusals, each asserting the same
account's user-scoped token still gets in; verified red against the pre-fix
deps.py and green after.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/test_node_scope_not_admin.py | 159 |
1 files changed, 159 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_node_scope_not_admin.py b/packages/meshbay-hub/tests/test_node_scope_not_admin.py new file mode 100644 index 0000000..58cabb1 --- /dev/null +++ b/packages/meshbay-hub/tests/test_node_scope_not_admin.py @@ -0,0 +1,159 @@ +"""A node-scoped daemon token must never reach the hub admin/moderator surface. + +A node's authority and a hub role are different notions (docs/MESHBAY_DESIGN.md +§7.1, NS4): what a node may do is decided by its operator's roster pin on the +node and by the deliberately narrow node scope; being an admin or moderator is a +hub role on a *person's* account, exercised from a browser with a user-scoped +token. When the operator's account also holds a hub role — which is the case on +the reference deployment, where the operator is an admin and runs a node — the +`scope:"node"` token the daemon keeps in memory used to pass `require_admin` and +`require_moderator`, because those checked only the role and not the scope. The +scope gate was wired onto `require_user_scope` (group mutation) alone. + +These are refusals: each asserts the node token is turned away with 403, and the +same account's user token is let through, so the guard is proven to bite on the +scope and not on the account. +""" + +import base64 +import time + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_hub.api.deps import set_admin_usernames + + +def _gen_ed25519(): + sk = Ed25519PrivateKey.generate() + pk_raw = sk.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + return sk, base64.b64encode(pk_raw).decode() + + +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 + + +async def _user_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"] + + +async def _node_token(client, username, user_token): + """Link a node key for `username` and return a scope:"node" token for it.""" + sk_node, pk_node = _gen_ed25519() + r = await client.put("/v1/users/me/node_key", + json={"pk_node_ed25519": pk_node}, + headers={"Authorization": f"Bearer {user_token}"}) + assert r.status_code == 200 + ts = int(time.time()) + sig = sk_node.sign(f"meshbay:node_auth:{username}:{ts}".encode()) + r = await client.post("/v1/nodes/auth", json={ + "username": username, "timestamp": ts, + "signature": base64.b64encode(sig).decode()}) + assert r.status_code == 200 + data = r.json() + # Confirm we really are holding a node-scoped token. + import jwt as _jwt + assert _jwt.decode(data["access_token"], options={"verify_signature": False} + )["scope"] == "node" + return data["access_token"] + + +async def _admin_with_node(client, username="op_admin_test"): + """An admin account that also runs a node — the reference-deployment case. + + Returns (user_token, node_token) for the same account. + """ + await _register(client, username) + set_admin_usernames([username]) + user_token = await _user_login(client, username) + node_token = await _node_token(client, username, user_token) + return user_token, node_token + + +def _bearer(token): + return {"Authorization": f"Bearer {token}"} + + +# ── require_admin ──────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_node_token_cannot_reach_admin_stats(client): + user_token, node_token = await _admin_with_node(client) + assert (await client.get("/v1/admin/stats", headers=_bearer(node_token)) + ).status_code == 403 + # The same account, from a browser, is admin and gets in. + assert (await client.get("/v1/admin/stats", headers=_bearer(user_token)) + ).status_code == 200 + + +@pytest.mark.asyncio +async def test_node_token_cannot_revoke(client): + """The sharpest one: revoke is signed and broadcast to every node.""" + _, node_token = await _admin_with_node(client) + r = await client.post("/v1/admin/revoke", headers=_bearer(node_token), + json={"target": "group", "target_id": "whatever"}) + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_node_token_cannot_change_instance_policy(client): + _, node_token = await _admin_with_node(client) + r = await client.patch("/v1/admin/settings", headers=_bearer(node_token), + json={"allow_public_groups": True}) + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_node_token_cannot_delete_account(client): + _, node_token = await _admin_with_node(client) + r = await client.delete("/v1/admin/users/some-id", headers=_bearer(node_token)) + assert r.status_code == 403 + + +# ── require_moderator (a wider set of accounts, including the IP audit log) ─── + +@pytest.mark.asyncio +async def test_node_token_cannot_read_ip_audit_log(client): + user_token, node_token = await _admin_with_node(client) + assert (await client.get("/v1/admin/logs", headers=_bearer(node_token)) + ).status_code == 403 + assert (await client.get("/v1/admin/logs", headers=_bearer(user_token)) + ).status_code == 200 + + +@pytest.mark.asyncio +async def test_node_token_cannot_list_nodes(client): + _, node_token = await _admin_with_node(client) + assert (await client.get("/v1/admin/nodes", headers=_bearer(node_token)) + ).status_code == 403 + + +@pytest.mark.asyncio +async def test_a_moderators_node_token_is_also_refused(client): + """A moderator (not admin) who runs a node: the moderation surface is still + the person's, not the machine's.""" + # An admin promotes a second account to moderator, which then links a node. + admin_user_token, _ = await _admin_with_node(client, "boss_admin_test") + await _register(client, "mod_test") + mod_user_token = await _user_login(client, "mod_test") + # promote + import jwt as _jwt + mod_id = _jwt.decode(mod_user_token, options={"verify_signature": False})["sub"] + r = await client.patch(f"/v1/admin/users/{mod_id}", json={"role": "moderator"}, + headers=_bearer(admin_user_token)) + assert r.status_code == 200 + mod_node_token = await _node_token(client, "mod_test", mod_user_token) + # user-scoped moderator token gets in; node-scoped one does not + assert (await client.get("/v1/admin/stats", headers=_bearer(mod_user_token)) + ).status_code == 200 + assert (await client.get("/v1/admin/stats", headers=_bearer(mod_node_token)) + ).status_code == 403 |