aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
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