Authorization
Targets, roles, capabilities, grants, the policy decision rules, and how participants fall out of the grant table.
docs/concepts/50-authorization.mdxOn 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:
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:
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:
| Target | Governs |
|---|---|
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
owneron the agent's principal target; the agent itself holds the narrowerprincipal_manageron 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?".
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:
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:
| Role | Capabilities |
|---|---|
reader | thread.read, participants.list, participants.get, events.read, events.subscribe, principal.read |
invitor | principal.invite — granted on a PrincipalTarget to model the friend relationship; no read, no participation, no append authority |
commenter | everything in reader + events.run_started, events.run_finished, events.message_part, events.tool_call_result_request, participants.manage.self, thread.create.child |
approver | everything in reader + events.tool_call_approval, events.run_interrupt_requested, participants.manage.self |
tool_runner | everything in reader + events.tool_call_result, participants.manage.self |
member | union of commenter ∪ approver ∪ tool_runner (every events.* append capability) + participants.manage.self, thread.create.child |
thread_manager | everything in reader + grants.read, grants.manage, thread.manage, thread.create.child |
principal_manager | everything in reader + grants.read, grants.manage, principal.manage, principal.invite |
owner | everything 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:
commenteris the chat-only contributor — a human or bot that authors runs and posts content, but should not decide approvals or return tool results.approveris 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_runneris the delegated fulfiller — a human-in-the-loop worker or out-of-loop sandbox that emitstool_call_resultfor 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_manageron a thread target satisfies any capability the role carries (e.g.grants.managefor 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_manageron a principal target does satisfygrants.managefor that principal target, but it does not satisfyprincipal.managebecause 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:
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:
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 row | Returned created | Returned previous | Behaviour |
|---|---|---|---|
| none | true | undefined | New row inserted, grantedAt of now |
same (target, principal, role) | false | undefined | Returned unchanged |
| same target/principal, different role | false | the prior row | Replaced; 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:
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.
// "Alice holds member on threadTarget('t-1')" → can(EventsMessagePart, 'alice', threadTarget('t-1'))
// `member` includes `events.message_part` → trueRule 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.
// "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.
// 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')) → falseRule 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.manageon the target (Rule 1 or 2 may be in play). - The target is a thread, the caller is granting themselves the
memberrole, and the caller satisfiesparticipants.manage.selfon 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.manageon the target. - The caller satisfies
principal.manageonprincipalTarget(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.selfon the thread (the thread self-leave path). - The target is a
PrincipalTargetand the caller is the subject of the grant — i.e. someone extended me aninvitorrole 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 withoutparticipants.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/eventsdispatches on the request body'sevent.typeand requires the matchingevents.*capability on the thread target — e.g.events.message_partfor amessage_partevent,events.tool_call_approvalfor atool_call_approvalevent, and so on. Server-emitted shapes (participant_joined,participant_left,thread_title_updated) are rejected at the parser withinvalid_request.PUT /v1/grantswithbody.target = threadTarget('t-1')andbody.role = 'member'forbody.principalId = 'me'runs throughcanGrantRole: it's the thread-self-join branch, so a caller withparticipants.manage.self(whichmembercarries) can joint-1without 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
Tif and only if it holds an explicit grant onthreadTarget(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
memberof the parent does not become amemberof 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/grantson a thread target →participant_joinedis 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/grantson a thread target with a non-participant role replacing a participant role →participant_leftis appended.DELETE /v1/grantson a thread target →participant_leftis 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:
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:
| Question | Result | Why |
|---|---|---|
can(thread.read, alice, threadTarget('thread-1')) | true | system row inherits thread.read to threads |
can(events.message_part, alice, threadTarget('thread-1')) | false | system inheritance excludes every per-event-type append capability |
can(events.message_part, bob, threadTarget('thread-1')) | true | direct member row carries events.message_part |
can(events.message_part, bob, threadTarget('thread-1/sub-a')) | false | parent-thread inheritance excludes per-event-type append capabilities |
can(grants.manage, bob, threadTarget('thread-1/sub-a')) | false | bob's role is member, which has neither grants.manage directly nor inherited |
canGrantRole(callerId=bob, threadTarget('thread-1'), bob, member) | true | thread self-join branch (participants.manage.self on member) |
canGrantRole(callerId=bob, threadTarget('thread-1'), alice, member) | false | non-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.