Docs menu
RBAC (role-based authorization)
Authorizes every request by resource/action through a role → permission map — and, since the read axis was split, lets you say who may SEE what, not only who may DO what.
What it's for / when to use it#
Open-core roleAuth gives four FIXED classes (superAdmin/admin/client/viewer). That is a boundary, not a vocabulary: viewer reads everything inside its organization and cannot be narrowed. When you need team roles of your own (editor, auditor, operator) — or, more often, when you need one person to see runs but not the conversations inside them — this is the layer that expresses it.
RBAC by itself isn't an AuthProvider — it's passed to createEnterpriseAuth as the rbac option; authentication (SSO/user-store/fallback) is one step, authorization (this module) is a separate one.
Setup / import#
import { createRbac, createEnterpriseAuth } from '@gnldev/auth-ee';
import type { Permission, RbacProvider } from '@gnldev/auth-ee';@gnldev/auth-ee is a paid/licensed package — without a valid license key, createEnterpriseAuth falls back to fallback (typically open-core roleAuth) and RBAC never kicks in.
Step-by-step usage#
1. Call createRbac with your role→permission map — permissions follow the 'resource:action' pattern, and '*' can be used as a wildcard on either the resource or the action side:
import { createRbac, createEnterpriseAuth } from '@gnldev/auth-ee';
const rbac = createRbac({
editor: ['runs:read', 'runs:write'],
viewer: ['*:read'],
});If the map isn't given, the default is { viewer: ["*:read"], member: ["*:read", "agents:run"], admin: ["*"], "platform-admin": ["*"] }. member is the tier that matters most in practice: without it, letting someone run an agent meant making them an admin.
2. Pass rbac to createEnterpriseAuth — the resulting AuthProvider is passed to REST/Studio exactly as the auth value:
const auth = createEnterpriseAuth({ licenseKey, rbac, fallback });
// authorize(principal, ctx) -> { allow: true } | { allow: false, status: 403 }3. A principal's permissions are derived first directly from principal.permissions (if present), otherwise from the union of what its principal.roles roles map to. Unauthenticated (principal === null) requests get a straight 401, requests left without permission get 403.
Reads are named, not one grant#
The write axis always had named permissions (agents:run, users:write, budget:write …). Reads had exactly one — *:read — so “let support read runs” and “let support read every customer's messages” were the same decision. There are now seven: runs:read, threads:read, money:read, audit:read, users:read, catalog:read and payloads:read. *:read still covers all of them, so no existing grant lost access — the split is something you opt into per person.
// One person, one organization, two different answers:
{ roles: ['viewer'], permissions: ['*:read'] } // sees everything
{ roles: ['viewer'], permissions: ['runs:read', 'catalog:read'] } // sees runs, not conversations
// The seven read groups:
// runs:read runs, traces, steps, approvals, metrics
// threads:read thread messages, working memory, injected context
// money:read usage, cost, the price table, organization budgets
// audit:read who did what, and when
// users:read the organization's user list
// catalog:read agents, tools, workflows, policy, providers — no customer data
// payloads:read event bodies, trigger inputs, knowledge text, handler error textAyşe with ['runs:read', 'catalog:read'] sees that a run failed, and cannot open what the customer typed into it — nor the event body that failed, nor the knowledge text an agent was searching.
catalog:read is the configuration; payloads:read is what flows through it#
catalog:read shows agents, tools, workflows, policy, providers and the operational lists. payloads:read is a separate grant for the data behind them: a quarantined event's payload, the error text a handler produced from it, a scheduled trigger's input and lastError, and the text POST /knowledge/search returns. The error text is on the same grant as the body on purpose — a handler runs on the payload, so what it says about the data is the data.
// GET /dead-events — listed, contents withheld
[{ "id": "e1", "topic": "orders.created", "consumer": "billing",
"status": "quarantined", "attempts": 8,
"payloadRestricted": true, "errorRestricted": true }]
// GET /scheduler/triggers — 'input' dropped, 'lastError' replaced
[{ "name": "nightly-report", "lastRun": 1731..., "lastErrorRestricted": true }]
// POST /knowledge/search — refused: the whole response IS the corpus
403A caller with catalog:read alone still lists the quarantined events and still sees which trigger failed. It receives payloadRestricted: true and errorRestricted: true in place of the contents, and /knowledge/search refuses outright — the whole response there is the corpus, so there is no narrower answer to give.
API reference#
createRbac(roleGrants?: Record<string, Permission[]>) => RbacProvider. Falls back to the four-entry default above when omitted — viewer, member, admin and the reserved platform-admin.
RbacProviderA contract with permissionsFor(principal), requiredPermission(ctx), decide(principal, ctx) → Decision.
PermissionA string alias — the 'resource:action' pattern, e.g. 'runs:read', 'run:*', '*:read', '*'.
permissionMatches(granted, required) => boolean. Evaluates whether a given permission satisfies a required permission, with wildcard (*) support.
@gnldev/auth-ee and requires a valid license (licenseKey) — if the license is invalid, createEnterpriseAuth silently falls back to fallback (RBAC is disabled), and boot throws if failClosed: true was given.requiredPermission(ctx) uses the first path segment as the resource when the request has no ctx.resource field (e.g. /runs/... → runs) — this is a rough default derivation, and can be extended with a custom RbacProvider in a real deployment if needed.createEnterpriseAuth (see Audit log) — you don't need to log RBAC decisions separately.