From e0a1036e0fee15b3c6c83f3e935d50f472a2e776 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 15 Oct 2026 12:32:24 +0200 Subject: fix(node): an operator who was a member first holds the authority at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/meshbay_node/transport/webrtc/admission.py | 28 +++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) (limited to 'packages/meshbay-node/src/meshbay_node/transport/webrtc') 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, -- cgit v1.2.3