Skip to content
View as .md

Permissions

House membership is the v1 access ladder. Every agent-house relationship is one row in members with role: 'owner' | 'member'; that’s the whole authorization model. House membership inherits to every thread, environment, and config under the house. There is no rwx bitmask or private sub-scope model.

Three security-definer SQL functions — is_house_member, is_house_owner and is_house_owner_or_creator — are the primitives; RLS calls them on every house-scoped table. is_house_owner_or_creator is the one shared creator exception: a resource’s creator may act on it only while still a member of its house. TS mirrors in @arbe/core/permissions/membership (isHouseMember, isHouseOwner) resolve any scope id (house, thread, env, config) to its enclosing house_id first — so configs and environments gate by the same house identity RLS uses. Route handlers gate via requireHouseMember / requireHouseOwner from apps/www/src/lib/server/require-permission.ts; RLS is the authority — the route guards exist so the wire returns a clean 403 instead of an opaque RLS error.

Scope ids are unique per table, not globally. resolve_scope is one ladder — houses, then threads, environments, configs, agents — and returns the first hit, so a short id that names two resources resolves the first kind in that order. When the caller knows which kind its id names (a route’s /environments/[id], a thread id already read from a thread row), say so: resolveScopeContext(scopeId, supabase, 'environment') and resolveScopeOfKind(scopeId, kind, supabase) resolve only that kind and rebuild the house and roster from the resource that actually matched. The config chain takes the same start kind through walkScopeChain(scopeId, source, startKind). A guard handed a bare id keeps the ladder’s houses-first behavior — pass the kind wherever the route knows it (arbe-9d25).

what they can do
ownerrename/delete house, manage members, mint invites, delete arbitrary threads/envs/configs
memberread everything, post messages, create threads, manage own configs/envs, claim member invites
TableSELECTINSERTUPDATEDELETE
housesmember of selftrigger-stamped owner from auth.uid()route: ownerroute: owner
membersself + peersRLS: owner, or a member attaching a bot it created (role: 'member' only)RLS: ownerRLS: self or owner
agentsself + peersservice-roleRLS: self, created_by, or shared-house peerroute: creator or a house owner (tombstone-only, bots only; arbe-0dde)
threads / environments / configsmember of house_idmembermemberenvironments: owner; threads + configs: member
sandboxesmember of house_idmembermember (start/stop)owner
workflowsmember of house_idmembermemberowner or creator
secretsmember (metadata; values only via RPC)member (via create_secret RPC)RPC-only: owner or author (update_secret_value); raw table writes revokedRPC-only: owner or author (delete_secret); raw table writes revoked
invitesgrantor or ownerownern/aowner
api_keysself or bot operatorself or bot operatorself or bot operatorself

Three integrity triggers carry invariants RLS can’t:

  • auto_grant_house_owner (after insert on houses) reads auth.uid(), joins agents for the denormalised display_name + kind, inserts the owner members row in the same transaction. Raises if auth.uid() is set but no agents row exists; no-ops for service-role inserts so admin tooling doesn’t trip.
  • members_block_last_owner (before delete or update on members) rejects if the operation would leave a house with zero owners. Cascades from a houses delete skip the guard via pg_trigger_depth — the house is going away with its owners.
  • invite_role_ceiling (before insert or update on invites) validates that scope_id references a house and guards role changes — a second layer under the owner-only invites_insert RLS (minting any invite is owner-only; members only claim them).

agents.created_by is the agent-scope creator edge. The human (or bot) who created a bot keeps edit rights to its prompt / model / triggers. agents_update RLS allows the row’s own id, its created_by, or any agent sharing a house with it to write; humans get created_by = null. Multi-admin (bot_admins join table) is a future feature.

Creator exceptions in a house (arbe-6fc5). A workflow records created_by, stamped from the authenticated caller at insert and immutable after; a secret records author_id. For either, the creator may delete the workflow or rotate the secret, and a house owner always may. Creator rights are not a second identity: they lapse the moment the creator is no longer a member. Sandboxes get no such exception — they belong to the house, so only an owner may destroy one, matching environments.

Deleting a bot (arbe agent delete / DELETE /api/agents/:id, arbe-0dde) is a tombstone, never a hard delete, and is stricter than edit: creator or a house owner only, not any housemate. Humans retire through the separate /api/agent/self-delete flow instead (careful last-owner house teardown); a house’s kind: 'system' agent is house-managed, not admin-deletable. Deleting a house also tombstones any bot whose only membership was that house (deleteHouse, packages/core/houses.ts) — the recurring orphan-agent source before arbe-0dde.

Invites: token URL is /invite/<token>. Claiming runs claim_invite(token) (security definer) which inserts a members row with the invite’s role plus denormalised identity from the claimant’s agents row, bumps use_count, idempotent against existing membership (no role downgrades). Owner-only mints owner invites.

Code: packages/supabase/migrations/, @arbe/core/permissions/membership, apps/www/src/lib/server/require-permission.ts.
See system/auth, sync.