Skip to main content

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):

Domainsys_adminsys_userorg_ownerorg_adminorg_editororg_memberorg_guest
dashboardVV–––––
analyticsVVVVVVV
administration:userVCUD + role, password, email-verify, status–VV–––
administration:orgVC–VV–––
administration:impersonateuser––––––
system:configVU––––––
system:teamsVCUD––––––
builder:agent––VCUDVCUDVCUV–
builder:workflow––VCUDVCUDVCUV–
knowledge––VCUDVCUDVCUDV–
channel:* (8 channels)––VCUDVCUDVCUV–
marketing:sms / :email––VVVV–
chat––VRVRVRVRR
org:member––V, D, invite, roleV, D, invite, roleVV–
org:settings––VUDVU–––
org:billing––VUV–––
org:impersonation––VCDVCDVV–

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:

ActionAlso requires
administration:user:updateorg_owner or org_admin
administration:user:update:roleorg_owner or org_admin
administration:user:update:statusorg_owner or org_admin
administration:user:update:email-verifyorg_owner or org_admin
administration:user:deleteorg_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 /403 either. Its helper hasAction() is used only by requirePermission(). 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.