Docs menu
Audit log
Records who asked, what they got and why — one row per request, written from the provider's onDecision hook to a console or journal-backed durable sink (createJournalAuditSink) — plus Studio's own admin-action log.
What it's for / when to use it#
When you need compliance (SOC2/ISO-like) guarantees, or just a trustworthy answer to "who did what, when", two separate audit layers kick in. The first is @gnldev/auth-ee's AuditSink: one row per request a host reports — who asked (and what kind of caller), the final answer they got, and why. It's built into createEnterpriseAuth, so you don't write rows by hand. The second is Studio's own admin-action log: it keeps WRITE actions like organization/user create-delete, policy updates, run purge, and agent promote readable back via GET /audit.
Critical guarantee: the actor field isn't something the client claims — it's derived from the verified identity (Principal.id). If multiple people work behind a shared token, the x-gnl-actor header lets you leave a per-person trail, but no client can impersonate someone else's identity and later claim "it wasn't me" in the record.
Setup / import#
import { createEnterpriseAuth, createJournalAuditSink, consoleAuditSink } from '@gnldev/auth-ee';
import type { AuditSink, AuditEvent } from '@gnldev/auth-ee';
import { toJournal } from '@gnldev/durable';@gnldev/auth-ee is a paid/licensed package. For a journal-backed durable sink, the storage's run journal is wrapped with toJournal (@gnldev/durable).
Step-by-step usage#
1. If you want durable audit, create a sink with createJournalAuditSink and pass it to createEnterpriseAuth as the audit option — if omitted, the default ephemeral consoleAuditSink is used (writes to console only, not durable):
const auth = createEnterpriseAuth({
licenseKey,
userStore,
audit: createJournalAuditSink(toJournal(storage)), // durable: decisions are written to the journal
fallback,
});The provider writes to the sink from its onDecision hook (@gnldev/auth), not from authorize. authorize answers before the host's ownership, organization and resource gates, so its verdict is not what the caller got — the host reports the final answer to onDecision once the request is answered.
What a row says: allowed is what the caller GOT. A foreign run answered as a 404 (to hide that it exists) is allowed: false with reason: ownership and status: 404; a run that really does not exist is allowed: true with status: 404. reason is one of unauthenticated, rbac, ownership, organization, resource, policy. kind is subject, operator, application or unnamed — an end user named ops and the operator ops write different rows.
Which doors write rows: @gnldev/server (createRestApi, including mounted surfaces) records every gated request; @gnldev/studio records every gated request and the staff-only refusal. The standalone chat and AG-UI routes (createChatRoute, createAguiRoute) record only when you pass onDecision: (d) => auth.onDecision?.(d). @gnldev/mcp's createMcpServer does NOT record — it has no hook; record in allowTool if you need it. A host that calls authorize itself and never onDecision records nothing.
import { createChatRoute } from '@gnldev/chat-adapter';
import { roleAuth } from '@gnldev/auth';
const auth = roleAuth({ endUsers: { secret: process.env.GNL_END_USER_SECRET!, orgId: 'acme' } })!;
const chat = createChatRoute(config, {
identify: (req) => auth.authenticate(req),
onDecision: (d) => auth.onDecision?.(d), // @gnldev/auth-ee's provider writes its audit trail here
});If the sink throws, the shipped hosts answer 500 decision_not_recorded instead of the response. The row is written after the request was answered, so the work is usually already done: an agent run has called the model and stored the turn (a retry with the same runId, workKey, Idempotency-Key or chat message id replays it without a second model call). Studio's POST /users is the exception: the created user is removed again (rolledBack: true), because its token was never delivered.
2. To read the written decisions back, use readJournalAudit (returns newest first):
import { readJournalAudit } from '@gnldev/auth-ee';
const events = await readJournalAudit(toJournal(storage));
// [{ ts, principalId, kind, orgId, path, method, action, permission, allowed, reason, detail, status }, ...]3. For tamper evidence, give the sink a secret of at least 32 bytes (MIN_AUDIT_SECRET_BYTES; an empty or short one throws). Each sink instance is a writer with its own sequence (seq from 0), and each row's MAC covers its fields, its journal key, its writer and its seq. verifyJournalAudit(journal, secret) returns tampered (edited, forged, copied or moved rows) and gaps (a row deleted from the middle of a writer's sequence):
import { createJournalAuditSink, verifyJournalAudit } from '@gnldev/auth-ee';
const journal = toJournal(storage);
const secret = process.env.AUDIT_SECRET!; // at least 32 bytes (MIN_AUDIT_SECRET_BYTES)
const audit = createJournalAuditSink(journal, { secret });
const { tampered, gaps } = await verifyJournalAudit(journal, secret);What it cannot show: deleting the LAST rows of a writer, or all of a writer's rows — nothing comes after the deleted tail to show it is missing, and no MAC can fix that inside the journal. Ship each writer's latest seq somewhere else (a separate log, a SIEM) and compare. A failed write also leaves a gap, so a gap is something to investigate, not proof of deletion.
4. This is SEPARATE from Studio's own admin-action log. Studio drops every successful WRITE action (e.g. org.create, policy.update, run.purge, agent.promote) into its own __audit__ log and serves it from an organization-scoped, filterable endpoint:
GET /audit?limit=200&action=policy.update&q=acme
// -> { items: [{ id, at, actor, action, target, org?, detail? }, ...] } (newest first)An identity bound to an organization only sees its own organization's records (?org= is ignored); an unbound (operator) identity can optionally filter with ?org=.
API reference#
createJournalAuditSink(journal: JournalLike, opts?: { secret?: string }) => AuditSink. A durable sink that writes each row to the journal under __eeaudit__:<ts>-<writer>-<seq>. With a secret (at least 32 bytes), every row carries a MAC.
consoleAuditSink() => AuditSink. The ephemeral default sink that writes to console (used when audit isn't provided).
readJournalAudit(journal: JournalLike, orgId?: string, opts?: { secret?: string }) => Promise<AuditEvent[]>. Returns every row, newest first (requires listKeys). With orgId, only that organization's rows; with a secret, each row is marked integrity: 'ok' | 'tampered'.
verifyJournalAudit(journal: JournalLike, secret: string) => Promise<AuditVerification>. Checks the whole trail and returns { total, ok, tampered, gaps }. A writer's deleted last rows are not detected.
MIN_AUDIT_SECRET_BYTES32 — the shortest MAC secret accepted, in bytes; an empty or shorter one throws.
recordDecision(sink: AuditSink, decision: AccessDecision) => writes a request's outcome to the sink — what createEnterpriseAuth's onDecision calls.
AuditSinkThe write(event: AuditEvent): void | Promise<void> contract — implement it to write to a custom sink (DB/SIEM).
AuditEvent{ ts, principalId?, kind, orgId?, path, method, action: "read"|"write", permission?, allowed, reason?, detail?, status?, writer?, seq?, mac? }.
AUDIT_PREThe journal key prefix: '__eeaudit__:' — shared by createJournalAuditSink/readJournalAudit.
@gnldev/auth-ee and requires a valid license (licenseKey). An audit sink the license does not unlock is NOT dropped: it keeps recording and a warning says audit is unlicensed — losing the trail silently would be worse. With failClosed: true the same setup refuses to boot.consoleAuditSink is ephemeral (stdout only) — always use createJournalAuditSink for a durable, readable-back record. readJournalAudit returns an empty array if the journal doesn't support listKeys (e.g. some custom adapters).