diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-15 14:24:44 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-15 14:24:44 +0200 |
| commit | 283771ff468bd9e1a60a4902a5ada4319c063073 (patch) | |
| tree | f4a43f25def7621fe26ed57eee26413dcbe7cc6f | |
| parent | 2dbff20818d171242a15e3567e14292c0d63aea2 (diff) | |
| download | meshbay-283771ff468bd9e1a60a4902a5ada4319c063073.tar.gz | |
fix(node): a removal made at the machine is in audit.db
The Node page and the CLI remove access through the loopback API, and
revoke and unpin there wrote nothing to audit.db; only the signed MNP door
did. A removed person's next connection then showed an auth_failed with
nothing before it to explain it. Both routes now record member_revoke and
member_unpin once the operation has succeeded, with the node's own account
as the actor and 127.0.0.1 as the address, which tells the two doors apart.
A refusal changes nothing and records nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | docs/MESHBAY_DESIGN.md | 6 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ui/app.py | 36 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_roster_pairing.py | 27 |
3 files changed, 66 insertions, 3 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index c7356c0..1a1f9fc 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -416,6 +416,11 @@ Four properties, each load-bearing: accepted, not after. Nothing to rotate then — they never held the key, and `member_revoke` says so by returning no reminder rather than by leaving the caller to work it out. +6. **Every removal is in `audit.db`, whichever door made it.** The signed MNP + door records its own; the loopback one (Node page, CLI) records + `member_revoke` and `member_unpin` with the node's account as the actor and + `127.0.0.1` as the address. Otherwise a person's next connection shows an + `auth_failed` with nothing before it to explain it. Node authority is established the same way, once per node: `meshbay-node operator pair` prints a code, the operator types it into their own browser, and the node @@ -4737,7 +4742,6 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows. | **The reconnect backoff only wakes on `visibilitychange`** | So a tab that stays visible through an outage — which is what a screen wake lock guarantees while a film is playing — waits out the full backoff, up to 30 s, after the network is already back. Nothing listens for `online` | | **Per-device revocation has no CLI** | A device is revoked over MNP (`roster.revoke_device`), from a device the node has already pinned. On a headless node the operator's only lever is `member unpin`, which removes **every** device of that account — so the per-device control the roster is built around is reachable from an interface and from nowhere else. §6.7 listed a `meshbay-node member device list\|revoke` verb that was never written, and that listing is how this was found: `USERGUIDE.md` was the first document written by reading the CLI rather than this specification, and the verb it copied out did not run | | **Migrations run on SQLite only** | The chain reaches head and agrees with the models there (§12), which is not where it ships. **The exposure is one revision deep, not the whole chain**: every revision behind the first packaged release was development that no installation ever ran, so nothing replays them on PostgreSQL. What is unguarded is the *next* migration — a default, an index type or a constraint PostgreSQL refuses reaches a deploy without the suite saying so | -| **The loopback path removes access without writing an audit entry** | `ops.revoke_member` and `ops.unpin_member` log to the daemon's log and nothing to `audit.db`; the MNP `member_revoke` handler doing the same work audits it (and `member_unpin` did, until it left MNP in 6.0). So a removal made from the node page or the CLI — the two doors an operator sitting at their own machine actually uses — leaves the journal showing an admission and then, whenever that person next connects, an `auth_failed` ("not admitted by the roster") with nothing in between to explain it. §5.4's signed transcript is not what is missing: a loopback caller is authorized by being on localhost with the run token and signs nothing, so the gap is the record, not the authority. Found by reading a node's audit log for a refusal whose cause was six hours earlier and unrecorded | | **The Create group wizard calls two hooks after an early return** | `CreateGroupWizard` (`create-group-page.js`) returns during node detection, before its `useRef`/`useEffect` for provisioning, so the hook count changes between renders. Preact tolerates a list that grows, and nothing is known to break; `test_hook_ordering.py` checks declaration order, not this. Found while tracing the frozen-fields report, which had another cause (`ask.js`) | | **A node key is read from the terminal or the desktop client, never a browser** | **Accepted.** `meshbay-node status` on the node's own machine and Node → Overview in the desktop client are the two places the key can be read; the Node page is Electron-only, because `platform.node` resolves to "not available" without the bridge, and no hub route exposes the key. The create-group wizard links it automatically over that same bridge, so the manual paste in **Profile → Linked nodes** exists for the operator who runs the node from a terminal and the hub from a browser — who has a terminal by definition. Anyone linking a node is already at a shell prompt, so a browser-reachable copy would buy nothing and widen what the hub knows about the node | | **A loopback eject or plug reaches open pages late** | `ops.eject_root` and `ops.plug_root` flip the live set and tell nobody; over MNP the broadcast `root_eject_ack` / `root_plug_ack` is what moves every open table. So an eject made from the desktop application or the CLI shows on members' pages only with the next index push — for a plug, the end of its rescan; for an eject, whatever changes next. `ops.update_root` had the same silence and now calls `DirectoryIndexer.publish_roots`; the same call belongs in these two. Found while moving the `writable`/`removable` switches to the loopback door (MNP 6.0) | diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index f5cd5d0..9ed014b 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -371,15 +371,47 @@ def create_ui_app(state: dict) -> FastAPI: """Map a username to an account id, through the hub.""" return await _op(lambda: ops.resolve_user(state, username)) + async def _audit_local(event: str, subject: str, group_id: str = "") -> None: + """ + Record a removal made at the machine (Node page or CLI) in audit.db. + + The signed door over WebRTC records its own (`_audit` in the session); + this one has no session. The actor is the node's own account — whoever + holds the control API's token acts as it — and the address is the + loopback, which tells the two doors apart in the log. + """ + audit = state.get("audit_store") + if not audit: + return + try: + await audit.log_event( + user_id=state.get("node_user_id") or "", + event=event, + ip="127.0.0.1", + username=getattr(getattr(state.get("config"), "hub", None), "username", "") or "", + group_id=group_id, + detail=subject, + ) + except Exception as e: + log.warning("audit: could not record %s of %s: %s", event, subject[:8], e) + @app.post("/api/members/{user_id}/revoke") async def revoke_member(user_id: str, group_id: str): """Stop serving the group key to a member.""" - return await _op(lambda: ops.revoke_member(state, user_id, group_id)) + async def run(): + result = await ops.revoke_member(state, user_id, group_id) + await _audit_local("member_revoke", user_id, group_id) + return result + return await _op(run) @app.post("/api/members/{user_id}/unpin") async def unpin_member(user_id: str): """Forget a pinned identity, so the person can pair again with a new key.""" - return await _op(lambda: ops.unpin_member(state, user_id)) + async def run(): + result = await ops.unpin_member(state, user_id) + await _audit_local("member_unpin", user_id) + return result + return await _op(run) # ── Chat encryption (operator only, localhost) ───────────────────────── diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index b2799e9..e2180ba 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -845,6 +845,33 @@ async def test_unpin_endpoint_allows_repairing(tmp_path, roster): assert client.post("/api/members/bob/unpin?t=tok").status_code == 404 +async def test_a_removal_at_the_machine_is_in_the_audit_log(tmp_path, roster): + """The Node page and the CLI remove through here; the signed WebRTC door + already wrote its own row, this one wrote none.""" + from meshbay_node.audit import AuditStore + + audit = AuditStore(db_path=tmp_path / "audit.db") + await audit.open() + try: + client, _ = _ui_client(tmp_path, roster, audit_store=audit) + _, pk_ed_b64, pk_x_b64 = _keypair() + await roster.pin_identity("bob", "bob", pk_ed_b64, pk_x_b64, "code") + await roster.set_member(GROUP, "bob", ROLE_MEMBER, "active", "grenet") + + assert client.post(f"/api/members/bob/revoke?group_id={GROUP}&t=tok").status_code == 200 + assert client.post("/api/members/bob/unpin?t=tok").status_code == 200 + # A refusal changed nothing, so it records nothing. + assert client.post("/api/members/bob/unpin?t=tok").status_code == 404 + + rows = sorted(await audit.get_entries(), key=lambda e: e.id) + assert [(e.event, e.user_id, e.ip, e.group_id, e.detail) for e in rows] == [ + ("member_revoke", "grenet", "127.0.0.1", GROUP, "bob"), + ("member_unpin", "grenet", "127.0.0.1", "", "bob"), + ] + finally: + await audit.close() + + async def test_operator_surface_needs_the_session_token(tmp_path, roster): """11.5.3 applies to every one of these: they change who may hold the key.""" client, _ = _ui_client(tmp_path, roster) |