GNL
Docs menu
Enterprise@gnldev/auth-ee

User management (journal-backed)

A user store that resolves bearer tokens to a Principal, storing the token HASHED in the journal; add/remove from Studio's 'Users' view, with the token shown only once at creation.

What it's for / when to use it#

The open-core roleAuth works with a handful of tokens defined in hardcoded config — adding a new user requires a deploy. createJournalUserStore instead keeps users in the journal (data-driven, like policy/budget): an operator creates a new user from Studio, the returned token is handed to that person, and the user makes requests with that token via Authorization: Bearer <token> — no deploy, no code change.

Concrete scenario: in a SaaS deployment, the operator (an organization-less admin) creates a member from Studio's "Users" view for each new customer organization (with an organization assignment), and hands the token to the customer; the customer uses the API scoped to their own organization with that token. An organization-admin, in turn, can only manage members of their own organization.

Setup / import#

package
import { createEnterpriseAuth, createJournalUserStore, createJournalAuditSink } from '@gnldev/auth-ee';
import { toJournal } from '@gnldev/durable';
import { SqliteStorage } from '@gnldev/durable/sqlite';

@gnldev/auth-ee is a paid/licensed package. createJournalUserStore requires a journal that supports listKeys to store users — toJournal(storage.runs) (Sqlite/Postgres/InMemory all provide listKeys) fits that structurally.

Step-by-step usage#

1. Set up the store on the journal and pass it to createEnterpriseAuth as userStore — it kicks in after SSO, before the open-core fallback:

src/index.ts
const userStore = createJournalUserStore(toJournal(storage.runs));
const auth = createEnterpriseAuth({
  licenseKey,
  failClosed: true,
  userStore,
  audit: createJournalAuditSink(toJournal(storage.runs)),
  fallback: roleAuth({
    admin: { token: ADMIN_TOKEN, user: 'ops' },  // bootstrap operator
  }),
});

2. Pass that same userStore to Studio via the users option — this opens the "Users" view in Studio and the /users endpoints:

src/index.ts
app.route('/studio', createStudioApp({
  reader: toJournal(storage.runs),
  gnl,
  auth,
  users: userStore, // the Studio "Users" view add/remove; the token is shown once
}));

3. When a new user is created from Studio (or programmatically via userStore.create()), the returned token is shown only at that moment — the server never stores the plain token anywhere, only its SHA-256 hash is written to the journal:

programmatic creation
const { user, token } = await userStore.create({
  email: '[email protected]',
  roles: ['viewer'],
  orgId: 'acme',
  ttlMs: 30 * 24 * 60 * 60 * 1000, // expires automatically after 30 days
});
// token: 'eeu_...' returned HERE ONLY; if it is lost, userStore.revoke() then create() again

4. The user sends that token in an Authorization: Bearer eeu_... header; authenticate() looks up the token's SHA-256 in the journal's reverse index, and if it isn't expired (expiresAt) or revoked (revoked), it returns a Principal and updates lastUsedAt on a best-effort basis.

To cut off a user's access without deleting them, use userStore.revoke(id) — the record stays (for audit/history), only the token's reverse index is removed and it's marked revoked: true.

API reference#

fncreateJournalUserStore

(journal: JournalLike) => JournalUserStore. The journal must support listKeys (Sqlite/Postgres/InMemory provide it); throws otherwise.

typeJournalUserStore

The authenticate(token, now?), list(), create(input), remove(id), revoke(id) contract.

typeEeUserRecord

The full record stored in the journal: id, roles, orgId?, tokenHash, createdAt, expiresAt?, lastUsedAt?, revoked?.

typeEeUserPublic

EeUserRecord minus tokenHash — the safe view exposed by list() and to Studio.

typeCreateUserInput

create()'s input: email?, name?, roles?, orgId?, ttlMs? (takes priority), expiresAt?.

constStudioAppOptions.users

The StudioUserStore passed to createStudioApp — if provided, it opens Studio's 'Users' view + the /users endpoints.

Warning
@gnldev/auth-ee is paid/licensed — without a valid license (licenseKey), createEnterpriseAuth falls back to fallback and userStore never kicks in; if failClosed: true is set, boot throws an error on an invalid license.
Note
Authorization is single-axis by default: a role is 'admin' (write) or 'viewer' (read), scoped to an organization. Two more exist and are easy to miss — 'member' sits between them (read plus agents:run, so someone can run an agent without becoming an admin), and the reserved 'platform-admin' is the explicit cross-organization grant. Without that last one an org-less user is fail-closed, so nobody becomes a super-admin by forgetting an orgId. A separate permissions[] overrides the role's grants entirely when you set it — that is how the six named read permissions are assigned per person.
Tip
Studio's "Users" view follows the organization model: an organization-less operator manages all members, while an organization-admin can only create/remove/revoke members of their own organization — the target can't be assigned to a nonexistent organization.