Server-side workspace membership¶
Status: first slice built (relay enforcement + admin API + tests). Client wiring is the next step.
Lives in the private
hive-relay-enterprisecrate (repohoneyhive-ai/hive-relay-enterprise), not in this OSS repo. The openhive-relaykeeps only a generic [WriteGuard] extension seam (a hook that does nothing unless a guard is set) — no membership code, no/membersroutes. The enterprise crate depends on the OSS relay and installs a guard that enforces membership; it is a superset build, never a fork. So the paid controls aren't merely compiled out of the open binary — their source isn't in the public repo at all. Seetiering.md.
Why this is the load-bearing piece¶
The relay is content-blind: it forwards opaque (E2EE) envelopes. Until now, membership lived only in the client — the authorization evaluator ran in the client before it persisted an event, and the relay accepted any envelope POST to any room. So "removing a member" only meant re-keying (they can't read new traffic), but a modified client could keep writing ciphertext into the room, and role checks were advisory.
Member caps, capability RBAC, and "eject a bad actor" only become real once the relay itself authenticates the caller and enforces who may write. That's what this adds.
Design (as built)¶
Authentication — cached GitHub identity. The relay authenticates a caller as
a GitHub account and caches the verification, so GitHub /user is hit at most
once per token per 10-minute window (SESSION_TTL_SECS), not on every write:
write request carries: X-Hive-Github-Token: <gh token>
cache hit -> account = "github:<id>" (cheap)
cache miss -> verify via GitHub /user, cache, then account
account_id = "github:<numeric id>" — stable across GitHub renames; the client
can compute the same string from its own id. (Authorization: Bearer stays
reserved for the relay entitlement token; identity rides X-Hive-Github-Token,
as the directory already did.)
Membership is opt-in per workspace. A workspace with no membership record is
unmanaged and behaves exactly as before (open / self-host). The first
authenticated claim makes the caller Owner and turns enforcement on:
POST /v1/workspaces/:id/members/claim -> caller becomes Owner (409 if claimed)
GET /v1/workspaces/:id/members -> list (must be a member)
POST /v1/workspaces/:id/members -> add / set role {account, login, role} (Admin+)
DELETE /v1/workspaces/:id/members/:account -> remove (Admin+)
Roles mirror hive-core::authorization: Viewer < Contributor < Admin <
Owner. Contributor+ may write; Admin+ may manage membership; only an Owner
may grant/affect Owner, and the last Owner can't be removed or demoted.
Enforcement (writes + admin first; reads unchanged). For a managed
workspace, these POSTs require a member with write capability — 403 otherwise,
401 if unauthenticated:
POST …/envelopes,POST …/keyring,POST …/presence,POST …/candidates
GET …/envelopes is unchanged — a non-member only ever sees ciphertext, so
read-gating is deferred (it would break open-read + new-device backfill). The
admin endpoints enforce Admin+ via the role rules above.
Eject-a-bad-actor = remove + rotate. DELETE …/members/:account stops their
writes immediately; pair it with a workspace-key rotation (already built) so
they also lose read access to new traffic. Both together = full removal.
Where it lives¶
OSS (crates/hive-relay, this repo): only the seam — a public WriteGuard
trait + RelayState::with_write_guard, and enforce_write which delegates to the
guard (or no-ops when none is set). No membership logic. cargo test -p hive-relay
= 14 tests (incl. the seam: no-guard allows writes, a guard can reject them).
Private (honeyhive-ai/hive-relay-enterprise):
- src/membership.rs — roles + guarded claim/upsert/remove.
- src/lib.rs — EnterpriseState implements hive_relay::WriteGuard
(caller_account cached GitHub auth → role write-enforcement), the /members
admin API, and router() composing the OSS relay with the guard installed.
- cargo test = 11 (membership logic + enforcement: unmanaged stays open,
managed requires identity, viewer denied, non-member denied, removed member
loses write).
How it composes with entitlement tokens¶
Two orthogonal checks, on two headers:
| Check | Header | Question |
|---|---|---|
| Entitlement | Authorization: Bearer |
may this client use the relay at all? (plan/caps — see signed tokens) |
| Membership | X-Hive-Github-Token |
is this account a member of this workspace, with what role? |
A signed entitlement token's caps will later refine membership actions (e.g.
require a remove_member capability), and max_members will cap the membership
table — see managed-service-plan.md.
Next steps¶
- Client wiring — send
X-Hive-Github-Tokenon writes; callclaimwhen creating a team workspace; surface add/remove/role in the People rail (the IPC + UI already exist client-side — point them at the relay endpoints). - Enforce
max_membersfrom the entitlement token against the membership table. - Durable membership — persist alongside the (planned) durable event store; today it's in-memory like the rest of the reference relay.
- Session pruning + optional
/v1/sessionwarm endpoint that returns the caller'saccount_idto the client. - Capability gating — once
capsship, gate privileged ops on them rather than role rank alone.