Docs menu
Open-core auth (roleAuth)
A free, opt-in identity layer: four credential classes (superAdmin / admin / client / viewer) over bearer or basic tokens. Pick by WHO HOLDS the token, not by how much it needs to do.
What it's for / when to use it#
GNL's REST API and Studio are open by default — with no auth configured, anyone can read and write. roleAuth closes that in one line. The four classes exist because one “admin” was doing two unrelated jobs: an application that only needs to run an agent was carrying the same token that cancels runs, edits budgets and reads the whole organization's history.
This layer is open-core, and the line is deliberate: isolation is free — the organization boundary, the four classes and the resourceId rules that keep one end user's conversations apart from another's are all here, and none of them switch on when you pay. What is paid is identity management: SSO, per-user accounts, an audit trail, and permissions per person rather than four fixed classes. @gnldev/auth-ee implements the same AuthProvider contract, so moving up does not change your call sites.
Setup / import#
import { roleAuth, type Cred } from '@gnldev/auth';
import { createRestApi } from '@gnldev/server';
import { createStudioApp } from '@gnldev/studio';@gnldev/auth is a standalone, independent package; both @gnldev/server (REST API) and @gnldev/studio accept the auth option as AuthProvider | undefined — the same provider is passed to REST and Studio at the same time.
Step-by-step usage#
1. Build a Cred per class from the environment. The classes are not tiers of the same thing — client is what your BACKEND carries, admin is what a PERSON carries:
const cred = (name: string): Cred | undefined =>
process.env.GNL_NO_AUTH === '1'
? undefined
: { token: process.env[`GNL_${name}_TOKEN`], orgId: 'acme' };
// The credential your APPLICATION carries — runs agents, manages nothing.
const clientAuth = roleAuth({ client: cred('CLIENT') });
// The credential a PERSON carries, for Studio.
const operatorAuth = roleAuth({ admin: cred('ADMIN'), viewer: cred('VIEWER') });2. Give each host the credential that belongs to it. This used to be one shared auth object for both; it no longer should be, because Studio is an operator console and refuses an application credential outright (403) rather than serving it one end user at a time:
// Your backend calls this one.
app.route('/api', createRestApi(config, { title: 'My API', auth: clientAuth }));
// A person opens this one. Studio refuses an application credential outright.
app.route('/studio', createStudioApp({
reader: toJournal(storage.runs),
apiBase: '/studio',
gnl: createStudioRunner(gnl, config, { toJsonSchema: aiToolSchema }),
auth: operatorAuth,
}));3. Your backend calls the REST API with the client token, and names the end user each request acts for. A client credential serves many users under one token, so resourceId is required — without it the request is refused rather than served unscoped:
# resourceId names the end user this request acts for. A client credential
# serves many of them, so it is required — without it the call is a 400.
curl -s -X POST http://localhost:3001/api/agents/support/run \
-H 'content-type: application/json' \
-H "authorization: Bearer $GNL_CLIENT_TOKEN" \
-d '{"runId":"r-882","prompt":"where is my order?","threadId":"t-ayse-1","resourceId":"u-ayse"}'
# Ask for one end user's data, and have ownership checked:
curl -s "http://localhost:3001/api/runs?resourceId=u-ayse" -H "authorization: Bearer $GNL_CLIENT_TOKEN"Studio's SSE connections cannot send an Authorization header, so the same bearer token is also accepted via ?token= (bearer only, GET only). That value can land in proxy logs and browser history — prefer Studio's short-lived ?ticket= flow where you can.
Scope, role, and the ceiling between them#
Authorization here is two axes kept deliberately apart. Scope is where an identity may act — one organization, or the whole platform. Role is what it may do — read, run, manage. Conflating them is how an identity that was only ever meant to manage one tenant ends up managing all of them.
Platform scope is an explicit grant: the reserved platform-admin role. It is never inferred from an identity happening to carry no orgId. That inference is the classic fail-open footgun — forgetting to set an organization would mint a super-admin — so the strict model treats an unbound identity without the grant as { kind: 'none' } and denies it.
import { principalScope, isPlatformAdmin } from '@gnldev/auth';
principalScope({ id: 'u1', roles: ['platform-admin'] }); // { kind: 'platform' }
principalScope({ id: 'u2', roles: ['admin'], orgId: 'acme' }); // { kind: 'org', orgId: 'acme' }
principalScope({ id: 'u3', roles: ['admin'] }); // { kind: 'none' } -> strict model denies
isPlatformAdmin({ id: 'u3', roles: ['admin'] }); // false — no orgId is NOT a platform grantPrecedence is deliberate: an explicit platform grant wins over an org binding, because a platform-admin is meant to be cross-organization. Everything else with an orgId is org-scoped, and everything else is nothing.
The ceiling. A user-management surface that validates only the target's organization leaves a hole: an org-bound admin could hand itself the platform-admin role and walk out of its own organization. assertAssignablePrivileges closes it — nobody can grant a privilege they do not themselves hold.
import { assertAssignablePrivileges } from '@gnldev/auth';
const acmeAdmin = { id: 'u2', roles: ['admin'], orgId: 'acme' };
assertAssignablePrivileges(acmeAdmin, { roles: ['member'] });
// { ok: true } — an ordinary org role, the target keeps orgId 'acme'
assertAssignablePrivileges(acmeAdmin, { roles: ['platform-admin'] });
// { ok: false, reason: "only a platform-admin can grant the 'platform-admin' role" }
assertAssignablePrivileges(acmeAdmin, { permissions: ['*'] });
// { ok: false, reason: "only a platform-admin can grant the '*' (all-permissions) grant" }The rule is minimal on purpose. A platform-admin may assign anything. Anyone else may not grant the platform-admin role, nor the '*' all-permissions grant — which is the same escalation expressed on the permission axis rather than the role axis. Ordinary organization roles stay assignable, because the target keeps its own orgId and so nothing crosses an organization boundary.
API reference#
roleAuth{ superAdmin?, admin?, client?, viewer? } → AuthProvider | undefined. Returns undefined when no class is given (opt-in). { admin, viewer } alone behaves exactly as it did before the two new classes existed.
principalScopeDerives the scope: { kind: 'platform' } | { kind: 'org', orgId } | { kind: 'none' }. An explicit platform grant outranks an org binding.
isPlatformAdminTrue only when the principal carries the explicit platform-admin role. Being unbound (no orgId) is deliberately not enough.
assertAssignablePrivilegesPrivilege ceiling for user creation and update: returns { ok: false, reason } when a non-platform-admin tries to grant the platform-admin role or the '*' permission.
PrincipalScopeThe resolved 'where' of an identity, independent of its role.
AssignabilityResult{ ok: true } | { ok: false, reason } — the ceiling check outcome.
CLIENT_WRITESThe exact set of writes a client credential may perform (agents:run, workflow:run, run:cancel). It is a WHITELIST — a write this set does not name is refused, including on routes added in later versions.
PLATFORM_ADMIN_ROLEThe reserved cross-organization grant that superAdmin carries. Never inferred from a missing orgId — that inference would turn a forgotten config line into a cross-org super-admin.
makeGateConverts an AuthProvider into a shared Hono gate (allow/deny) — REST and Studio don't reimplement this logic.
principalOfReads the Principal authenticated during allow() from the context (within the same request) — org/audit derive from this.
normalizeAuthAuthProvider | ReadWriteAuth | undefined → AuthProvider | undefined. Hosts collapse this to a single type.
fromReadWriteWraps the legacy { read, write } predicate pair (StudioAuth) into an AuthProvider — a backward-compatibility bridge.
AuthProviderStable contract: authenticate(c), authorize(principal, c, ctx), optional capabilities().
PrincipalThe authenticated subject: { id?, roles, orgId?, permissions? }. The free tier uses roles; 'permissions' is what the paid RBAC fills in.
Decision{ allow: true } | { allow: false, status?: 401 | 403, reason? } — the authorization decision.
Cred{ token?, user?, pass?, orgId?, platformAdmin? } — a class's bearer token and/or basic credentials. 'orgId' binds the identity to one organization; a request naming a different one is refused. 'platformAdmin: true' injects the reserved platform-admin role on any class — the static-config door to the cross-organization grant that the 'superAdmin' class carries by name. Prefer the class: it says which credential this IS, rather than adding a privilege to one that reads as an ordinary admin.
ReadWriteAuthA { read?, write? } predicate pair — converted into an AuthProvider via fromReadWrite.
roleAuth({}) with no credentials returns undefined and GNL keeps its open behavior — nothing breaks backward-compatibly. In production, though, an absent provider is an error rather than a silent open door.@gnldev/auth-ee implements the same AuthProvider interface for deployments that need SSO/RBAC/multi-org support — the migration requires no code changes on the REST/Studio side, just swapping the auth value.