Authorization

Targets, roles, capabilities, grants, the policy decision rules, and how participants fall out of the grant table.

Conceptsdocs/concepts/50-authorization.mdx
On this page

Authentication tells the server who is making a request. Authorization decides whether that request is allowed. Bubble's authorization model has five moving parts and one decision function:

  • Targets — the namespace of authority a check is asked against.
  • Roles — named, grantable bundles of capabilities.
  • Capabilities — atomic actions the policy answers about.
  • Grants — the rows that connect a principal to a role on a target.
  • Policy — the function that turns (capability, principal, target) into a boolean using the grants and a small set of inheritance rules.

Out of the same machinery falls a derived view — Participants — that the rest of the server uses for the per-thread membership UI.

This page walks the model bottom-up: targets, then roles, then capabilities, then grants, then the policy. The participant projection comes last because it depends on every layer below it.

Targets — The "What"

A target is the namespace of authority a check is about. Read "Alice holds owner on threadTarget('t-1')" as "Alice has the owner authority over the t-1 thread namespace", not as "Alice has been given a copy of the t-1 thread row".

There are exactly three target kinds:

ts
type AuthzTarget = SystemTarget | ThreadTarget | PrincipalTarget;

interface SystemTarget {
  type: 'system';
}
interface ThreadTarget {
  type: 'thread';
  threadId: string;
}
interface PrincipalTarget {
  type: 'principal';
  principalId: string;
}

Helper constructors live in @octostaff/bubble:

ts
import { systemTarget, threadTarget, principalTarget } from '@octostaff/bubble';

systemTarget(); // { type: 'system' }
threadTarget('t-1'); // { type: 'thread', threadId: 't-1' }
principalTarget('user-alice'); // { type: 'principal', principalId: 'user-alice' }

What each target governs:

TargetGoverns
systemTarget()Authority over the Bubble instance itself. Inherits selected capabilities to non-system targets.
threadTarget(id)Reading metadata, listing/appending events, opening subscriptions, managing membership of one thread.
principalTarget(id)Authority over a principal — read its profile, list its grants, revoke its grants, manage its tokens.

Two subtle but important points:

  • The system target is a singleton. There is exactly one systemTarget() per bubble instance.
  • A principal target is "what can be done to this principal", not "what this principal can do". The owner of an agent typically holds owner on the agent's principal target; the agent itself holds the narrower principal_manager on its own principal target so it can manage its own profile and tokens but cannot create more principals.

Roles and Capabilities — The Vocabulary

A capability is an atomic action the policy answers about. The list is closed and route-agnostic — the policy knows nothing about HTTP or WebSocket frames; it just answers questions like "may this principal append events here?".

ts
const Capability = {
  ThreadRead: 'thread.read',
  ThreadManage: 'thread.manage',
  ParticipantsList: 'participants.list',
  ParticipantsGet: 'participants.get',
  EventsRead: 'events.read',
  EventsSubscribe: 'events.subscribe',
  // One capability per client-appendable event shape. Server-emitted shapes
  // (`participant_joined`, `participant_left`, `thread_title_updated`) have
  // no client-facing capability and cannot be appended through the public
  // surface — the server rejects them with `invalid_request`.
  EventsRunStarted: 'events.run_started',
  EventsRunFinished: 'events.run_finished',
  EventsMessagePart: 'events.message_part',
  EventsToolCallResultRequest: 'events.tool_call_result_request',
  EventsToolCallApproval: 'events.tool_call_approval',
  EventsToolCallResult: 'events.tool_call_result',
  EventsRunInterruptRequested: 'events.run_interrupt_requested',
  ParticipantsManageSelf: 'participants.manage.self',
  GrantsRead: 'grants.read',
  GrantsManage: 'grants.manage',
  SubthreadCreate: 'thread.create.child',
  PrincipalRead: 'principal.read',
  // Lets the holder drag the target principal into a thread despite the
  // target's `openToInvites: false` privacy gate. Held via an explicit
  // `invitor` row on the target's PrincipalTarget (the friend relationship);
  // does NOT inherit from a system grant, by design.
  PrincipalInvite: 'principal.invite',
  PrincipalManage: 'principal.manage',
  PrincipalCreate: 'principal.create',
} as const;

A role is a named, grantable bundle of capabilities. Bubble has nine roles, hard-coded:

ts
const Role = {
  Reader: 'reader',
  Invitor: 'invitor',
  Commenter: 'commenter',
  Approver: 'approver',
  ToolRunner: 'tool_runner',
  Member: 'member',
  ThreadManager: 'thread_manager',
  PrincipalManager: 'principal_manager',
  Owner: 'owner',
} as const;

Their capability sets:

RoleCapabilities
readerthread.read, participants.list, participants.get, events.read, events.subscribe, principal.read
invitorprincipal.invite — granted on a PrincipalTarget to model the friend relationship; no read, no participation, no append authority
commentereverything in reader + events.run_started, events.run_finished, events.message_part, events.tool_call_result_request, participants.manage.self, thread.create.child
approvereverything in reader + events.tool_call_approval, events.run_interrupt_requested, participants.manage.self
tool_runnereverything in reader + events.tool_call_result, participants.manage.self
memberunion of commenter ∪ approver ∪ tool_runner (every events.* append capability) + participants.manage.self, thread.create.child
thread_managereverything in reader + grants.read, grants.manage, thread.manage, thread.create.child
principal_managereverything in reader + grants.read, grants.manage, principal.manage, principal.invite
ownereverything in member + grants.read, grants.manage, thread.manage, principal.manage, principal.invite, principal.create

The append capability set is intentionally split per event type so that integrators can grant narrow authoring rights without over-granting. The three intermediate participant roles —commenter, approver, and tool_runner — exist for exactly that reason:

  • commenter is the chat-only contributor — a human or bot that authors runs and posts content, but should not decide approvals or return tool results.
  • approver is the reviewer — a compliance officer, oncall, or manager that decides paused tool calls and can ask in-flight runs to stop, without injecting content of their own.
  • tool_runner is the delegated fulfiller — a human-in-the-loop worker or out-of-loop sandbox that emits tool_call_result for tool calls that another principal initiated, without authoring runs.

member remains the union role: fully participating principals that can do all of the above.

Two facts to internalise:

  • Roles are target-agnostic. All five roles are grantable on every target kind. principal_manager on a thread target satisfies any capability the role carries (e.g. grants.manage for that thread) even though the name reads as principal-flavoured.
  • The role name is a hint, not a constraint. Authorization is capability-driven: what matters at decision time is whether the granted role's capability set covers the capability being checked. Conversely, thread_manager on a principal target does satisfy grants.manage for that principal target, but it does not satisfy principal.manage because the role does not carry that capability.

Grants — The Table That Connects Them

A grant is one row in the store linking a principal to a role on a target:

ts
interface Grant {
  target: AuthzTarget;
  principalId: string;
  role: Role;
  grantedAt: string; // RFC 3339
}

Grants are the only data the policy reads. BubbleStore exposes the operations that mutate and query them:

ts
interface BubbleStore {
  // Reads
  listGrants(target: AuthzTarget): Promise<Grant[]>;
  listGrantsForPrincipal(principalId: string): Promise<Grant[]>;
  getGrant(target: AuthzTarget, principalId: string): Promise<Grant | null>;

  // Writes — both idempotent
  grantRole(target: AuthzTarget, principalId: string, role: Role): Promise<BubbleGrantResult>;
  revokeRole(target: AuthzTarget, principalId: string): Promise<BubbleRevokeResult>;
  // ...
}

grantRole is idempotent with three observable outcomes:

Pre-existing rowReturned createdReturned previousBehaviour
nonetrueundefinedNew row inserted, grantedAt of now
same (target, principal, role)falseundefinedReturned unchanged
same target/principal, different rolefalsethe prior rowReplaced; grantedAt bumped to now

revokeRole is similarly idempotent: removed: false when no row existed, removed: true with the deleted row in previous when one did. The server uses these flags to decide whether to emit participant_joined / participant_left events on the thread stream (see Participants).

The Policy — How Decisions Get Made

The AuthzPolicy is the only piece of authorization logic the rest of the codebase calls. It exposes four methods:

ts
interface AuthzPolicy {
  can(capability: Capability, principalId: string, target: AuthzTarget): Promise<boolean>;
  canGrantRole(args: { callerId; target; principalId; role }): Promise<boolean>;
  canRevokeGrant(args: { callerId; target; principalId }): Promise<boolean>;
  rolesOn(principalId: string, target: AuthzTarget): Promise<Role[]>; // for whoami UI hints, not decisions
}

The default implementation, createStoreAuthzPolicy(store), applies exactly the rules below. Anything not covered by them denies — there is no negative grant, no priority ordering, and no hidden self-role rule beyond the rows the registration paths create.

Rule 1 — Direct Target Grant

A row on the requested target for the caller, whose role's capability set contains the requested capability, satisfies the check.

ts
// "Alice holds member on threadTarget('t-1')" → can(EventsMessagePart, 'alice', threadTarget('t-1'))
//   `member` includes `events.message_part` → true

Rule 2 — Inherited Capability

When no direct row satisfies the check, two narrow inheritance paths are considered. Both are capability-only: no inherited grant row ever materializes on the inheriting target, and inheritance never creates participant presence.

System inheritance. A grant on systemTarget() applies to every non-system target for any capability the role carries — except the per-event-type append capabilities (events.run_started, events.run_finished, events.message_part, events.tool_call_result_request, events.tool_call_approval, events.tool_call_result, events.run_interrupt_requested), which are intentionally non-inheritable so that a system operator does not silently become the author, reviewer, or fulfiller of every thread's events, and principal.invite, which is non-inheritable so that a single system-level grant cannot silently override every principal's openToInvites privacy gate. Inviting a closed-invite principal requires an explicit row on their PrincipalTarget — usually the invitor role granted via the friend UI.

ts
// "Root holds owner on systemTarget()" → can(GrantsManage, 'root', threadTarget('t-1'))
//   no row on threadTarget('t-1'); the system row's `owner` carries `grants.manage` → true
//
// → can(EventsMessagePart, 'root', threadTarget('t-1'))
//   per-event capabilities are non-inheritable from system → false (root must hold an explicit thread grant)

Parent-thread inheritance. A grant on a thread's parent subthread chain applies to the child thread for any capability the role carries — again except the per-event-type append capabilities. Inheritance walks immediate parents outward; no row is copied.

ts
// child has parentThreadId 'thread-root'
// "Bob holds thread_manager on threadTarget('thread-root')"
//   → can(GrantsManage, 'bob', threadTarget('thread-child')) → true
//   → can(EventsMessagePart, 'bob', threadTarget('thread-child')) → false

Rule 3 — Grant-Mutation Decisions

canGrantRole and canRevokeGrant express the policy for grant-table mutations themselves. They do not introduce new authority — they only decide whether the caller can edit the row.

canGrantRole allows the change when any of the following holds, provided the invitee's privacy gate (next paragraph) does not block it first:

  • The caller satisfies grants.manage on the target (Rule 1 or 2 may be in play).
  • The target is a thread, the caller is granting themselves the member role, and the caller satisfies participants.manage.self on the thread (the thread self-join path).

Privacy gate — invitee openToInvites: false. When target is a thread, role is a participant role, and the invitee is not the caller, canGrantRole first checks the invitee's openToInvites flag. If the invitee has it set to false and the caller does not hold principal.invite on principalTarget(invitee), the request is rejected with forbidden even when the caller would otherwise satisfy the rules above. This is the gate the friend / invitor relationship is built around.

canRevokeGrant allows the change when any of the following holds:

  • The caller satisfies grants.manage on the target.
  • The caller satisfies principal.manage on principalTarget(principalId) — the kill-switch held by a principal manager: it cannot grant new access, but it can remove the managed principal from anywhere they appear.
  • The target is a thread, the caller is revoking their own grant on it, and the caller satisfies participants.manage.self on the thread (the thread self-leave path).
  • The target is a PrincipalTarget and the caller is the subject of the grant — i.e. someone extended me an invitor role and I am declining (or removing) it. Scoped to principal targets so it cannot be used as a back-door thread-leave path for callers without participants.manage.self.

The thread self-join path is intentionally narrower than full target management: it only lets the caller hand themselves member. Any self-promotion to owner or self-downgrade to reader / thread_manager must go through the grants-manage path.

Capability → Route Map

The full HTTP route requirements live in the HTTP Protocol doc. Two examples to make the mapping concrete:

  • POST /v1/threads/:threadId/events dispatches on the request body's event.type and requires the matching events.* capability on the thread target — e.g. events.message_part for a message_part event, events.tool_call_approval for a tool_call_approval event, and so on. Server-emitted shapes (participant_joined, participant_left, thread_title_updated) are rejected at the parser with invalid_request.
  • PUT /v1/grants with body.target = threadTarget('t-1') and body.role = 'member' for body.principalId = 'me' runs through canGrantRole: it's the thread-self-join branch, so a caller with participants.manage.self (which member carries) can join t-1 without any grant-management authority.

Participants — The Derived View

A participant is not a separate concept and not a separate row. It is a projection over the grant table:

A principal is a participant of thread T if and only if it holds an explicit grant on threadTarget(T) whose role carries at least one of the per-event-type append capabilities (events.run_started, events.run_finished, events.message_part, events.tool_call_result_request, events.tool_call_approval, events.tool_call_result, events.run_interrupt_requested).

Today that means roles commenter, approver, tool_runner, member, and owner (isParticipantRole checks the participant set derived from the role table at module load).

Three consequences of this rule:

  • Reader / thread_manager / principal_manager grants are not participants. They appear in the grant table but not in the participant projection.
  • Inherited authority is not participation. A system owner who has never been granted on a thread can read it (via inheritance) but is not visible as a participant and cannot author events there.
  • Subthread participation is independent of the parent. A member of the parent does not become a member of the child unless explicitly granted on the child.

The participant projection is exposed as Participant rows on the thread snapshot, on GET /v1/threads/:threadId/participants, and on GET /v1/threads/:threadId/participants/:principalId.

Participant Lifecycle Events

Bubble surfaces participant changes on the thread's event stream so live subscribers see them through the same channel they already replay. The server emits these events from the grant routes:

  • PUT /v1/grants on a thread target → participant_joined is appended iff the role being granted is a participant role and the row either did not exist or did not previously carry a participant role.
  • PUT /v1/grants on a thread target with a non-participant role replacing a participant role → participant_left is appended.
  • DELETE /v1/grants on a thread target → participant_left is appended iff a row was actually removed and its role was a participant role.

byPrincipalId on each event is the caller (the actor that mutated the grant). The shapes themselves live in Events.

Putting It Together

A worked example tying every piece together. Setup:

text
principals:        users alice, bob   |   bots research-bot   |   system root
grants:            (system,   alice, owner)
                   (thread-1, bob,   member)
threads:           thread-1, thread-1/sub-a (parent: thread-1)

Decisions:

QuestionResultWhy
can(thread.read, alice, threadTarget('thread-1'))truesystem row inherits thread.read to threads
can(events.message_part, alice, threadTarget('thread-1'))falsesystem inheritance excludes every per-event-type append capability
can(events.message_part, bob, threadTarget('thread-1'))truedirect member row carries events.message_part
can(events.message_part, bob, threadTarget('thread-1/sub-a'))falseparent-thread inheritance excludes per-event-type append capabilities
can(grants.manage, bob, threadTarget('thread-1/sub-a'))falsebob's role is member, which has neither grants.manage directly nor inherited
canGrantRole(callerId=bob, threadTarget('thread-1'), bob, member)truethread self-join branch (participants.manage.self on member)
canGrantRole(callerId=bob, threadTarget('thread-1'), alice, member)falsenon-self join requires grants.manage, which member does not carry
Visible participants of thread-1[bob]only bob holds an explicit thread grant whose role carries an append capability

Everything past this point — registration of new principals, seeding from config, the whoami route, every HTTP and WS surface — is built on top of these primitives. Continue to Bootstrapping to see how an empty store becomes a functional system at first launch.