GNL 0.17: early and moving fast — APIs can change between releasesJoin us

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 with listKeys, putIfAbsent and putIfMatch — the last two keep the seat count and email uniqueness correct under concurrent requests; toJournal(storage) fits (every first-party journal has all three). createPostgresUserStore(pool) keeps the same directory in its own tables and answers the same way.

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));
const auth = createEnterpriseAuth({
  licenseKey,
  failClosed: true,
  userStore,
  audit: createJournalAuditSink(toJournal(storage)),
  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.mount('/studio', createStudioApp({
  reader: toJournal(storage),
  gnl,
  auth,
  users: userStore, // the Studio "Users" view — add/remove; the token is shown once
  userErasure: { storage }, // DELETE /users/:id also erases the person's data; without it → 409
}));

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. The user's id is never the email: every user gets a random id (usr_…) and the email is a separate, unique field. Runs and threads record that id as their owner, so an id reused for the next person with the same email would hand them the old person's data. Records written before this keep their email-shaped id:

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. A revoked user frees their seat.

Seats come from the licence. createEnterpriseAuth binds the licence's seats to the userStore you pass it, and the store writes the count into its own data (__eelicence__:seats in the journal, the gnl_ee_licence table in Postgres). Every store over the same data enforces it on create — a second store object handed to Studio, or one built by another process, cannot create past the licence. A seat is a slot claimed atomically, so two concurrent creates cannot take the last seat together. Over the limit, create throws SeatsExceededError. A store's own seats option may be lower than the licence (your own cap); a higher one is refused at boot.

Deleting in Studio deletes the person, not only the directory entry. DELETE /users/:id revokes the token, erases their runs, threads, memory and documents with @gnldev/durable's eraseSubject, then removes the entry — so Studio needs userErasure (the storage your agents run on, plus optional erasers and memory). Without it the delete is refused with 409: revoke the user instead, or delete with ?keepData=true when keeping the data is deliberate (a legal hold, or an erasure you run elsewhere). A turn that is already running is waited for, up to userErasure.settleMs (default 10 000 ms); runs still writing at the deadline are cancelled and the delete answers 409 user_runs_in_flight, erasing nothing — the user stays listed and revoked, and the same delete sent again finishes it. userStore.remove(id) on its own removes only the directory entry (record, token, seat, email).

API reference#

fncreateJournalUserStore

(journal: JournalLike, opts?: { seats? }) => JournalUserStore. The journal must support listKeys, putIfAbsent and putIfMatch; throws otherwise. seats is your own cap, never above the licence's.

typeJournalUserStore

The authenticate(token, now?), list(), create(input), update(id, patch), remove(id), revoke(id), seats, bindLicenceSeats(seats) contract. create() returns { user, token } and throws SeatsExceededError at the seat limit; remove() deletes only the directory entry.

typeEeUserRecord

The full record stored in the journal: id (usr_…), email?, name?, roles, kind?, orgId?, permissions?, tokenHash, createdAt, expiresAt?, lastUsedAt?, revoked?, seat?.

typeEeUserPublic

EeUserRecord minus tokenHash and seat, with kind always resolved (a missing kind reads as subject) — the safe view exposed by list() and to Studio.

typeCreateUserInput

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

constStudioAppOptions.users

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

constStudioAppOptions.userErasure

{ storage, erasers?, memory?, settleMs? } — lets Studio's DELETE /users/:id erase the person's data with the entry. Without it the delete answers 409 (unless ?keepData=true).

Warning
@gnldev/auth-ee is paid/licensed. A licenseKey that is given but invalid stops boot by default; with no key, or failClosed: false, createEnterpriseAuth falls back to fallback and userStore never kicks in. A licence whose features list leaves out users ignores the userStore for login, with a warning (a boot error under failClosed: true).
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.