From 8516f6e83311e79d5cc0441aec4b7905c00ac5e8 Mon Sep 17 00:00:00 2001 From: sxysun Date: Fri, 15 May 2026 15:46:57 -0600 Subject: [PATCH] DELETE /v1/rooms: cascade-revoke invites and share link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A room was shareable in two ways — hmq_… invites (auto-minted at room create) and the hms_… share link — but DELETE /v1/rooms only flipped revoked_at on the room itself. Every invite and the share link kept working at the auth layer, which surfaced in the website as orphaned "4 live invites" rows on /app/settings for rooms the owner had already revoked. Cascade both: - TenantRegistry.revoke_capabilities_for_room sweeps every live capability whose constraints.room_id matches. Filter is Python-side (constraints is TEXT, not jsonb) so it stays storage-agnostic. - The DELETE handler in api/rooms.py revokes the room first, then best-effort cascades the capabilities and disable_room_share_link. Cascade failures don't undo the room revoke — worst case the owner has to revoke an orphan from /app/settings. Also adds a top-of-file docstring on api/rooms.py describing the two sharing primitives (invite vs share link) side by side so the distinction is obvious from the source, and three regression tests for the capability cascade (targets only matching rooms, idempotent, unknown room is a no-op). Co-Authored-By: Claude Opus 4.7 (1M context) --- hivemind/api/rooms.py | 92 ++++++++++++++++++++++++++++++- hivemind/tenants.py | 48 ++++++++++++++++ tests/test_capability_tokens.py | 98 +++++++++++++++++++++++++++++++++ 3 files changed, 236 insertions(+), 2 deletions(-) diff --git a/hivemind/api/rooms.py b/hivemind/api/rooms.py index 16110df..6c10eb5 100644 --- a/hivemind/api/rooms.py +++ b/hivemind/api/rooms.py @@ -1,13 +1,44 @@ -"""Public room lifecycle, vault, trust, attestation, and run routes.""" +"""Public room lifecycle, vault, trust, attestation, and run routes. + +## Room sharing — two distinct primitives + +A room can be shared in exactly two ways. These have different auth and +billing semantics and are *not* interchangeable. Anything that touches a +room (create, revoke, list) must keep them consistent. + +================ =========================== ============================== + Invite (``hmq_…``) Share link (``hms_…``) +================ =========================== ============================== +Storage ``_capability_tokens`` ``_room_share_links`` +Mint trigger ``POST /v1/rooms`` (auto, ``POST /v1/rooms/{id}/share- + one per created room) link`` (explicit, idempotent) +Bearer payment Issuing tenant pays Bearer's own tenant pays +Bearer needs *Nothing* — the token IS A valid ``hmk_…`` tenant key +own tenant key? the credential +URL shape ``/r/?token=hmq_…`` ``/r/?share=hms_…`` + (single-use semantics) (rotate to invalidate copies) +Revoke action ``DELETE /v1/tenant/ ``DELETE /v1/rooms/{id}/ + tokens/{id}`` share-link`` +================ =========================== ============================== + +Both are reachable for the *same* room. ``DELETE /v1/rooms/{id}`` +revokes the room and cascades both: invites for the room are marked +revoked, the share link row is dropped. Without that cascade, a +"revoked" room would still have live invites and a live share-link +URL — which is the opposite of what owners expect from a revoke. +""" from __future__ import annotations import asyncio import base64 +import logging import time from collections.abc import Callable from uuid import uuid4 +logger = logging.getLogger(__name__) + from cryptography.hazmat.primitives import serialization from fastapi import Depends, FastAPI, HTTPException, Request @@ -342,12 +373,69 @@ async def list_room_vault_items( @app.delete("/v1/rooms/{room_id}") async def revoke_room( room_id: str, + request: Request, caller: Caller = Depends(requires_role("owner")), ): + """Revoke a room and cascade-retire every credential pointing at it. + + A room in Hivemind is reachable by exactly two sharing primitives: + + * ``hmq_…`` capability tokens (room "invites") — minted at room + create time. One row per invite in ``_capability_tokens`` + with ``constraints.room_id`` set. + * ``hms_…`` share link — at most one row per (tenant, room) in + ``_room_share_links``; "Google-Docs URL" model. + + Revoking the room without also retiring these would leave both + kinds dangling: invites still resolve at the auth layer (only + the run itself would fail), and the share link still exists in + ``_room_share_links`` so the next call to GET share-link would + keep returning it. We cascade so revoke is actually revocation. + + The cascade is best-effort: if the share-link delete or + capability-token sweep fails for any reason, the room itself + stays revoked (the primary check). Worst case the owner sees + orphaned rows and can revoke them manually from + /app/settings — better than leaving the room half-revoked. + """ ok = await asyncio.to_thread(caller.hive.room_store.revoke, room_id) if not ok: raise HTTPException(404, f"room '{room_id}' not found") - return {"status": "ok", "room_id": room_id} + + registry = request.app.state.registry + invites_revoked = 0 + share_disabled = False + try: + invites_revoked = await asyncio.to_thread( + registry.revoke_capabilities_for_room, + caller.tenant_id, + room_id, + ) + except Exception as exc: + logger.warning( + "room %s revoke: invite cascade failed: %s", + room_id, + exc, + ) + try: + share_disabled = await asyncio.to_thread( + registry.disable_room_share_link, + caller.tenant_id, + room_id, + ) + except Exception as exc: + logger.warning( + "room %s revoke: share-link cascade failed: %s", + room_id, + exc, + ) + + return { + "status": "ok", + "room_id": room_id, + "invites_revoked": invites_revoked, + "share_link_disabled": share_disabled, + } def _share_link_payload( request: Request, diff --git a/hivemind/tenants.py b/hivemind/tenants.py index 36eb6e8..0f8e393 100644 --- a/hivemind/tenants.py +++ b/hivemind/tenants.py @@ -864,6 +864,54 @@ def revoke_capability(self, tenant_id: str, token_id_prefix: str) -> bool: ) return bool(rowcount) + def revoke_capabilities_for_room( + self, tenant_id: str, room_id: str, + ) -> int: + """Revoke every live invite (``hmq_…`` capability token) whose + constraints bind it to this room. + + Used by the room-revoke cascade so that deleting a room also + retires every invite that pointed at it — otherwise revoked + rooms leave orphaned invites in /app/settings, which is + confusing to owners and means revocation isn't really + revocation. The share link (``hms_…``) for the room is handled + separately by :meth:`disable_room_share_link`. + + Returns the number of tokens revoked. The filter is done in + Python because ``constraints`` is stored as TEXT (JSON-encoded) + and we don't want to rely on a Postgres-specific cast. + """ + room_id = (room_id or "").strip() + if not room_id: + return 0 + rows = self._control_db.execute( + "SELECT token_hash, constraints FROM _capability_tokens " + "WHERE tenant_id = %s AND revoked_at IS NULL", + [tenant_id], + ) + targets: list[str] = [] + for row in rows: + token_hash = row[0] if not hasattr(row, "keys") else row["token_hash"] + constraints_text = ( + row[1] if not hasattr(row, "keys") else row["constraints"] + ) + try: + c = _json.loads(constraints_text or "{}") + except Exception: + continue + if isinstance(c, dict) and c.get("room_id") == room_id: + targets.append(token_hash) + if not targets: + return 0 + now = time.time() + for h in targets: + self._control_db.execute_commit( + "UPDATE _capability_tokens SET revoked_at = %s " + "WHERE token_hash = %s AND revoked_at IS NULL", + [now, h], + ) + return len(targets) + # ── Admin operations ──────────────────────────────────────────── def _find_tenants_by_name(self, name: str) -> list[dict]: diff --git a/tests/test_capability_tokens.py b/tests/test_capability_tokens.py index 5c34000..3fe96f7 100644 --- a/tests/test_capability_tokens.py +++ b/tests/test_capability_tokens.py @@ -457,3 +457,101 @@ def test_resolve_any_wrong_owner_after_eviction_stays_sealed(registry): # A bogus hmk_ that doesn't match any tenant row — 401, no thaw. assert registry.resolve_any("hmk_bogus_nonexistent_owner_key") is None assert not registry.sealer.is_unsealed(t["tenant_id"]) + + +# ── room-cascade revoke ────────────────────────────────────────────── + + +def test_revoke_capabilities_for_room_only_targets_matching_room(registry): + """Cascade revoke must hit invites for *this* room only, leave others. + + Backs the ``DELETE /v1/rooms/{id}`` cascade: when a room is revoked, + every ``hmq_…`` capability that was minted with + ``constraints.room_id = `` is retired in one sweep. + Other rooms' invites (and invites with no room constraint, if such + a thing existed) stay live. + """ + t = registry.provision("room_cascade") + registry._test_created_dbs.append(t["db_name"]) + tenant_id = t["tenant_id"] + + a_room = "room_targeted_aaa111" + b_room = "room_other_bbb222" + + inv_a1 = registry.mint_capability( + tenant_id, + "query", + "invite-a-1", + {"scope_agent_id": "sc", "room_id": a_room}, + ) + inv_a2 = registry.mint_capability( + tenant_id, + "query", + "invite-a-2", + {"scope_agent_id": "sc", "room_id": a_room}, + ) + inv_b = registry.mint_capability( + tenant_id, + "query", + "invite-b", + {"scope_agent_id": "sc", "room_id": b_room}, + ) + + n = registry.revoke_capabilities_for_room(tenant_id, a_room) + assert n == 2 + + by_id = {row["token_id"]: row for row in registry.list_capabilities(tenant_id)} + assert by_id[inv_a1["token_id"]]["revoked_at"] is not None + assert by_id[inv_a2["token_id"]]["revoked_at"] is not None + assert by_id[inv_b["token_id"]]["revoked_at"] is None + + +def test_revoke_capabilities_for_room_idempotent(registry): + """Calling the cascade twice doesn't double-revoke or error. + + Live → revoked on the first call, then revoked → revoked is a no-op + (returns 0) on the second. Important because the API handler can be + retried by clients without producing weird side effects. + """ + t = registry.provision("room_cascade_idem") + registry._test_created_dbs.append(t["db_name"]) + tenant_id = t["tenant_id"] + + room = "room_idem_ccc333" + inv = registry.mint_capability( + tenant_id, + "query", + "only", + {"scope_agent_id": "sc", "room_id": room}, + ) + + assert registry.revoke_capabilities_for_room(tenant_id, room) == 1 + assert registry.revoke_capabilities_for_room(tenant_id, room) == 0 + + row = next( + r for r in registry.list_capabilities(tenant_id) + if r["token_id"] == inv["token_id"] + ) + assert row["revoked_at"] is not None + + +def test_revoke_capabilities_for_room_unknown_room_returns_zero(registry): + """No invites match → nothing changes. Confirms we don't fall back to + revoking all tenant invites when the room id is wrong.""" + t = registry.provision("room_cascade_miss") + registry._test_created_dbs.append(t["db_name"]) + tenant_id = t["tenant_id"] + + inv = registry.mint_capability( + tenant_id, + "query", + "kept", + {"scope_agent_id": "sc", "room_id": "room_real"}, + ) + + assert registry.revoke_capabilities_for_room(tenant_id, "room_ghost") == 0 + row = next( + r for r in registry.list_capabilities(tenant_id) + if r["token_id"] == inv["token_id"] + ) + assert row["revoked_at"] is None