GNL
Docs menu
Enterprise@gnldev/server

Multi-org isolation

Scopes every request to an isolated per-organization journal (via withOrg); runs, memory, and exactly-once guarantees are separated per organization, and an identity-bound org header always wins (403 on mismatch).

What it's for / when to use it#

When you want to serve multiple customers (organizations) from a single GNL deployment — e.g. in a SaaS product, each customer's run history, memory, and usage/budget counters must be completely separate from the others. When the org option is turned on, every request lands on a separate journal scoped via withOrg; organizations cannot see each other's keys, and the same runId is independent across organizations.

An identity bound to an organization (given via Cred.orgId, e.g. a viewer token) always overrides the x-gnl-org header: that identity gets a 403 even if it requests a different organization — so a token can never leak outside its own organization.

Setup / import#

org and OrgOptions are options of @gnldev/server's createRestApi; withOrg comes from @gnldev/durable underneath. For an identity bound to an organization (Cred.orgId), you use EE auth (@gnldev/auth-ee) or the free roleAuth's orgId field.

npm install
npm install @gnldev/server @gnldev/durable  # @gnldev/auth-ee arrives separately, under license
config + auth
import { SqliteStorage } from '@gnldev/durable/sqlite';
import { createRestApi } from '@gnldev/server';
import { createEnterpriseAuth } from '@gnldev/auth-ee';
import { roleAuth } from '@gnldev/auth';

const storage = new SqliteStorage('runs.db');
const config = { storage, agents: { starwars: { model, system: SYSTEM, tools } } };

// The 'acme-viewer' token carries an IDENTITY-BOUND organization (orgId) → it only ever sees
// acme's data; asking for another org returns 403. admin is global and can act in any
// organization via the x-gnl-org header.
const auth = createEnterpriseAuth({
  licenseKey: process.env.GNL_LICENSE_KEY,
  publicKey: process.env.GNL_EE_PUBLIC_KEY,
  failClosed: true,
  fallback: roleAuth({
    admin: { token: ADMIN_TOKEN, user: 'ops' },
    viewer: { token: ACME_TOKEN, orgId: 'acme' }, // demo viewer bound to an organization
  }),
});

Step-by-step usage#

Passing org: {} to createRestApi is enough — by default the organization is resolved from the x-gnl-org header (customizable via OrgOptions.resolve). The registry is lazily set up per organization and cached.

turn org scoping on
app.route('/api', createRestApi(config, {
  title: 'SWAPI Pro',
  auth,
  org: {},       // opt-in: every request lands in its organization's journal (withOrg)
  budgets: {},   // optional — per-organization budget/quota (separate doc: budget-quota)
}));

A global (bootstrap) identity can operate in whichever organization it requests via the header:

admin: pick an organization via x-gnl-org
# admin: acts in any organization via the x-gnl-org header
curl -s -X POST http://localhost:3002/api/agents/starwars/run \
  -H 'content-type: application/json' -H 'authorization: Bearer ${ADMIN_TOKEN}' -H 'x-gnl-org: acme' \
  -d '{"runId":"demo-1","prompt":"Who is Darth Vader?"}'

An identity bound to an organization (viewer) lands in its own organization even without the header; if it tries to request a different organization, it gets a 403:

org-bound viewer + mismatch 403
# organization-BOUND viewer: scoped to acme even without the header
curl -s http://localhost:3002/api/usage -H 'authorization: Bearer ${ACME_TOKEN}'

# the same viewer asking for another organization (x-gnl-org: globex) → 403 org mismatch
curl -s http://localhost:3002/api/runs -H 'authorization: Bearer ${ACME_TOKEN}' -H 'x-gnl-org: globex'
# -> 403 "organization mismatch: identity is bound to organization 'acme'"

The underlying isolation mechanism can also be used directly — manually scope a journal to an organization:

withOrg — direct usage
import { withOrg } from '@gnldev/durable';

// createRestApi does this AUTOMATICALLY with opt-in org, but you can also use it directly:
// EVERY key in the journal is written/read under an `org:<orgId>:` prefix runs, memory,
// queue and cache included, so EVERYTHING is isolated (same Journal interface; exactly-once
// is inherited for free).
const scoped = withOrg(baseJournal, 'acme');
// scoped.listRuns() returns ONLY acme's runs (with the prefix stripped).

API reference#

fnwithOrg

Scopes a journal to an organization (@gnldev/durable): the returned journal implements the same interface (get/put/putIfAbsent/listKeys/deletePrefix/readRun/listRuns are bridged if present), all keys are carried with an `org:<orgId>:` prefix and the prefix is stripped on read.

typeRestApiOptions.org

A createRestApi option (@gnldev/server): opt-in multi-org support — when given, every request sees a journal scoped via withOrg, and the resolved organization is injected into requestContext.

typeOrgOptions

{ resolve?: (c) => orgId, required?: boolean } — if resolve isn't given, the x-gnl-org header is read; if required is true, a request without an organization gets a 400 (if false, it runs in the shared space).

fnprincipalOf

@gnldev/auth: reads the authenticated Principal from the Hono Context; Principal.orgId comes from here and determines the bound organization inside scope().

typeCred.orgId

An optional field on roleAuth's (@gnldev/auth) credential (Cred): when given, that role's identity is bound to an organization — it's always scoped to that organization regardless of the request header, and a 403 is returned if a different organization is requested.

License required
This feature is in the Enterprise (ee) tier — a license signed with @gnldev/auth-ee is required (see Signed license). EE auth isn't required for an organization bound to an identity; it can also be bound via the free roleAuth's Cred.orgId field — but the org option itself is free in @gnldev/server; it's the full identity/role/license chain that's paid.
Tip
The per-organization registry (including model-fallback, memory) is set up lazily and cached — each request doesn't make a new createGnl call. Budget/quota is also split per organization; see the Budget & Quota doc for details.