Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 90 additions & 2 deletions hivemind/api/rooms.py
Original file line number Diff line number Diff line change
@@ -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/<room>?token=hmq_…`` ``/r/<room>?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

Expand Down Expand Up @@ -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,
Expand Down
48 changes: 48 additions & 0 deletions hivemind/tenants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
98 changes: 98 additions & 0 deletions tests/test_capability_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <that room>`` 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