summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 12:32:24 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 12:32:24 +0200
commite0a1036e0fee15b3c6c83f3e935d50f472a2e776 (patch)
tree75d28932b3010ebe3781b4f36dffed19dae74297 /packages/meshbay-node
parenta0be6bfcac07a256949082e935e1be06753ab872 (diff)
downloadmeshbay-e0a1036e0fee15b3c6c83f3e935d50f472a2e776.tar.gz
fix(node): an operator who was a member first holds the authority at once
A member pinned on the node who then redeems the operator pairing code was written down as the operator but the node kept its "nobody can authorize" flag until it restarted: every admin operation — removing a folder, inviting, the apps' settings — was refused meanwhile. Only a key paired fresh set it. The join result now names `operator` for an account holding the node-wide operator row, whatever its row in the group says. The page and the CLI read that role to know what to offer, and refused before asking deletions the node would have accepted. Nothing is granted by it: every admin operation is still checked against the operators' keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py28
-rw-r--r--packages/meshbay-node/tests/test_roster_pairing.py44
2 files changed, 71 insertions, 1 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py
index baa8001..3a5ec0b 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py
@@ -339,6 +339,11 @@ class AdmissionMixin:
role=invite["role"], status="active",
approved_by=invite["created_by"],
)
+ # As `_pin_and_admit` does for a key paired fresh: without it, a
+ # member who pairs as the operator is refused every admin
+ # operation until the node restarts.
+ if invite["role"] == ROLE_OPERATOR:
+ self._ctx["has_admin_authority"] = True
self._audit_join(
"join_pinned",
f"group={invite['group_id'][:8]} role={invite['role']} "
@@ -352,7 +357,7 @@ class AdmissionMixin:
return
await self._join_ok(
user_id, pk_x_raw, session_group,
- role=member["role"] if member else "",
+ role=await self._announced_role(roster, user_id, member["role"]),
recognised=True,
)
return
@@ -800,6 +805,27 @@ class AdmissionMixin:
log.info("Identity pinned (%s): user=%s role=%s", via, user_id[:8], role)
self._audit_join("join_pinned", f"role={role} via={via}")
+ @staticmethod
+ async def _announced_role(roster, user_id: str, role: str) -> str:
+ """
+ The role the join result names: `operator` for an account holding the
+ node-wide operator row, whatever its row in this group says.
+
+ Operator authority is node-wide and checked on every admin operation's
+ signature (`_verify_admin_sig`), not taken from this answer — which only
+ tells the client what to offer. An operator who was a member of the group
+ before pairing has both rows, and naming the group's one hid the
+ authority: the page and the CLI refused, before asking, deletions the node
+ would have accepted.
+ """
+ if role == ROLE_OPERATOR:
+ return role
+ node_wide = await roster.get_member("", user_id)
+ if node_wide and node_wide.get("role") == ROLE_OPERATOR \
+ and node_wide.get("status") == "active":
+ return ROLE_OPERATOR
+ return role
+
async def _join_ok(
self, user_id: str, pk_x_raw: bytes, group_id: str,
*, role: str, recognised: bool,
diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py
index 5687215..b2799e9 100644
--- a/packages/meshbay-node/tests/test_roster_pairing.py
+++ b/packages/meshbay-node/tests/test_roster_pairing.py
@@ -586,6 +586,50 @@ async def test_known_device_already_a_member_still_needs_no_code(tmp_path, roste
assert unwrap_gek_aes(reply, *_x_raw(sk_x, pk_x_b64)) == gek_b
+@pytest.mark.parametrize("operator_row, announced", [
+ (None, ROLE_MEMBER),
+ ("active", ROLE_OPERATOR),
+ ("revoked", ROLE_MEMBER),
+])
+async def test_an_operator_who_was_a_member_first_is_told_so(tmp_path, roster,
+ operator_row, announced):
+ """A member of B who then paired as the node's operator holds both rows; the
+ join result names the authority, so a client offers what the node accepts."""
+ GROUP_B = "b" * 32
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ await roster.pin_identity("cbesson", "cbesson", pk_ed_b64, pk_x_b64, "code")
+ await roster.set_member(GROUP_B, "cbesson", ROLE_MEMBER, "active", "cbesson")
+ if operator_row:
+ await roster.set_member("", "cbesson", ROLE_OPERATOR, operator_row, "cli")
+
+ session = _session(tmp_path, roster, user_id="cbesson", group_id=GROUP_B,
+ gek=generate_gek(), join_policy="invite")
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, user_id="cbesson", group_id=""))
+ reply = _last(session)
+ assert reply["ok"] is True and reply["role"] == announced
+
+
+async def test_a_member_who_pairs_as_operator_has_the_authority_at_once(tmp_path, roster):
+ """The pairing code redeemed by a key already pinned here as a member: the
+ node must count an operator from then on, not from its next restart."""
+ GROUP_B = "b" * 32
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ await roster.pin_identity("cbesson", "cbesson", pk_ed_b64, pk_x_b64, "code")
+ await roster.set_member(GROUP_B, "cbesson", ROLE_MEMBER, "active", "cbesson")
+ code = await roster.create_invite("", "cbesson", ROLE_OPERATOR, "cli")
+
+ session = _session(tmp_path, roster, user_id="cbesson", group_id=GROUP_B,
+ gek=generate_gek(), join_policy="invite")
+ session._ctx["has_admin_authority"] = False # as the node started
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code, user_id="cbesson",
+ group_id=GROUP_B))
+ reply = _last(session)
+ assert reply["ok"] is True and reply["role"] == ROLE_OPERATOR
+ assert session._has_admin_authority()
+
+
async def test_unknown_group_is_invite_only(tmp_path, roster):
"""
Fail closed: a group whose policy the node cannot read is treated as