Console
The console (React) reads roles to decide what to render. It enforces nothing. Core (the backend) is the enforcer.
That's by design, not a flaw. Console's job is deciding what a user sees, not what they're allowed to do. So everything on this page is really about one thing: hiding or showing a button. Hiding a button doesn't stop anyone from using the feature behind it directly, with a valid login. Whether that works is decided by Core, not here.
Getting roles out of the token
context/keycloak-provider.tsx
initialises Keycloak with check-sso, PKCE S256, and the organization:*
scope, then runs syncKeycloakToStore(): once after login, and again on every
token refresh.
System role. Rather than looking for a fixed list, it filters
realm_access.roles for the sys_ prefix, excludes sys_user, prefers
sys_admin if present, and otherwise takes the first match, falling back to
sys_user. A new sys_* role in Keycloak is therefore detected with no
console change, though it will map to no permissions until SYS_PERMISSIONS
gains an entry, and core cannot enforce it at all.
Org roles. Read from organization.{name}.role, validated against the five
known values; anything unrecognised becomes undefined rather than being
passed through. Unknown roles therefore fail closed here, the same as in core,
but by a different mechanism: core keeps the string and scores it -1, while
the console discards it.
Both stores are rewritten from the fresh JWT on every refresh. The org store is
persist-backed to localStorage, and the sync deliberately overwrites the
persisted currentOrganization with the JWT's copy rather than trusting it.
Editing role or plan in localStorage survives only until the next token
refresh, and buys nothing on the server in the meantime.
The permission vocabulary
lib/permissions/actions.ts defines every action as a colon-delimited string of at most four segments:
domain : resource : operation [ : sub-operation ]
There is no implicit inheritance between segments.
administration:user:update does not grant
administration:user:update:role; the sub-operation is a separate action that
must be granted separately. This is what lets "can edit a user's name" and "can
change a user's role" be different rights. The 4-segment, sub-operation form
is only used for user management today; nothing else in the codebase needs it.
More concretely, on the 3-segment form: an org_editor can create an agent
and edit its prompt (builder:agent:create, builder:agent:update are both
granted), but the delete button never appears for them, because
builder:agent:delete isn't in their permission set; only org_admin and
org_owner hold it. Knowledge base cuts the other way: the same org_editor
also holds knowledge:delete, so they can delete a knowledge base
document. Same role, two different resources, two different answers.
In short: actions.ts is just a vocabulary, a list of names, nothing more.
It checks nothing on its own.
Role to permission
lib/permissions/policy.ts
is the rulebook: it maps each role to a list of names from that vocabulary,
via two flat tables, SYS_PERMISSIONS (2 roles) and ORG_PERMISSIONS
(5 roles).
hasPermission(action, sysRole, orgRole) is additive: it unions the sys
role's set with the org role's set and tests membership. Results are memoised
per (sysRole, orgRole) pair. A sys_admin who is also org_editor gets both
sets; neither dimension can subtract from the other.
The resulting grants, summarised by domain (View, Read, Create,
Update, Delete):
| Domain | sys_admin | sys_user | org_owner | org_admin | org_editor | org_member | org_guest |
|---|---|---|---|---|---|---|---|
dashboard | V | V | – | – | – | – | – |
analytics | V | V | V | V | V | V | V |
administration:user | VCUD + role, password, email-verify, status | – | V | V | – | – | – |
administration:org | VC | – | V | V | – | – | – |
administration:impersonate | user | – | – | – | – | – | – |
system:config | VU | – | – | – | – | – | – |
system:teams | VCUD | – | – | – | – | – | – |
builder:agent | – | – | VCUD | VCUD | VCU | V | – |
builder:workflow | – | – | VCUD | VCUD | VCU | V | – |
knowledge | – | – | VCUD | VCUD | VCUD | V | – |
channel:* (8 channels) | – | – | VCUD | VCUD | VCU | V | – |
marketing:sms / :email | – | – | V | V | V | V | – |
chat | – | – | VR | VR | VR | VR | R |
org:member | – | – | V, D, invite, role | V, D, invite, role | V | V | – |
org:settings | – | – | VUD | VU | – | – | – |
org:billing | – | – | VU | V | – | – | – |
org:impersonation | – | – | VCD | VCD | V | V | – |
Three things stand out. sys_admin on its own grants no product access:
no builder, no channels, no knowledge base; a platform admin with no org role
sees an administration console and nothing else. org_admin differs from
org_owner in exactly two places: billing update and org delete. And
org_editor is "everything except delete", except in knowledge, where
editors can delete.
Authority rules
hasAuthority() adds a second gate on top of hasPermission(). AUTHORITY_RULES
lists actions that additionally require a specific org role in the target
organization:
| Action | Also requires |
|---|---|
administration:user:update | org_owner or org_admin |
administration:user:update:role | org_owner or org_admin |
administration:user:update:status | org_owner or org_admin |
administration:user:update:email-verify | org_owner or org_admin |
administration:user:delete | org_owner or org_admin |
administration:user:view and …:update:password are deliberately absent:
a sys_admin can always view any user and always trigger a password reset.
The purpose is to narrow sys_admin where identity still follows the 1st
design intention. The UI declines
to offer these actions to a platform admin with no standing in the target org,
even though the API would accept them. Note the direction: this makes the
console stricter than the server, which is the safe direction for a
cosmetic layer to err in.
Only applied when an explicit orgId is passed to useGate, which sets
enforceAuthority, i.e. on admin detail pages, where "the target org" is
unambiguous.
Entitlements come first
lib/gate/gate.ts composes the two checks, and the order matters:
gate(action, context, feature?)
1. entitlement: does the org's PLAN include this feature?
2. permission: does the user's ROLE allow this action?
Plan tier is a third access dimension, independent of both roles. A user can
hold exactly the right role and still be denied, and the GateResult says
which check refused via deniedBy: 'entitlement' | 'permission' so the UI can
show an upgrade prompt instead of a permission error.
Entitlements live in
lib/entitlements/:
four plans (free, starter, pro, enterprise) × features of three kinds:
boolean toggles, metered limits compared against a caller-supplied
currentUsage, and config values that always pass and just return a number or
string. An org with no plan, or an unrecognised plan, is denied everything with
upgrade_required.
The tier table is currently static in tiers.ts, with both it and
entitlement.ts carrying comments marking that as Phase 1; the intended end
state is a plan_features table with a Redis-cached entitlement object served
by an API, at which point the file becomes a fallback and type definition. Note
this dimension has no server-side counterpart at all today: core has no
notion of plans, so metered limits and plan-gated features are honoured only by
the client.
Where the checks actually run
This is smaller than the machinery suggests, and worth knowing before assuming a control is protected.
Sidebar filtering.
components/layout/utils/sidebar-filter.ts
calls hasPermission() directly to drop nav items the role has no permission
for. It bypasses gate(), so the sidebar is filtered by role only, never by
plan. It also has an alwaysVisibleUrls list (the settings pages) that
skips the check entirely.
Feature components. Only eight feature components call useGate() at
all, across the whole app, mostly in user and organization management.
Two mechanisms exist but are not currently wired up:
- components/gate.tsx
exports a declarative
<Gate action=… feature=… upgradeFallback=…>wrapper. Nothing imports it. - lib/keycloak-auth-guard.ts
exports
requirePermission(), a route guard that redirects to/403. Nothing calls it, and nothing else redirects to/403either. Its helperhasAction()is used only byrequirePermission(). The one route guard that does run, on_authenticated, checks authentication only; there is no permission check at the routing layer.
So a route that is hidden from the sidebar is still reachable by typing its URL, and will render, subject only to whatever the page's own components check. Given that the console is cosmetic anyway this is not a security finding, but it does mean "it's gated in the console" should not be assumed from the presence of the action constant; the call site has to be confirmed.