PermDock
Concepts

Audit and observability

Every check emits a decision event with outcome, reasons, actor and delegation; permdock/otel adds a span per check; HTTP denials are RFC 9457 Problem Details.

An authorization library that cannot tell you what it decided, for whom, and why, is a liability at incident time. PermDock treats observability as part of the decision: on('decision') delivers the full Decision with the subject and the arguments, permdock/otel turns the same data into spans and a counter, and HTTP adapters emit RFC 9457 Problem Details that carry the denial reasons and alternatives to the caller.

on('decision')

const permdock = await createPermDock(policy, user, { actor, delegation });

permdock.on("decision", (event) => audit.write(event));

Every can, decide, assert, explain and filter emits one event per evaluated check (filter emits one event with the counts, not one per row; simulate emits one simulate event carrying the worst decision of the batch and the counts). The event is a frozen JSON object carrying outcome, permission, scope, resource, the subject (principal, actor, delegation), the active tenant, the full membership that supplied the matched role, via, matched, denials, alternatives, token, trusted, source and adapter, never the explain trace; the full shape and an example are on wire formats. Events are emitted on the server and on the decision endpoint, never for client-side snapshot evaluations, so the audit log records only authoritative decisions.

The event contains the result. permix's hooks gave you { path, data } before the check and nothing after, so an audit log could record that a check happened but not what it returned; issue #38 asked for the result and stayed open. PermDock's event is emitted after evaluation and includes outcome, denials, matched and alternatives, which is what an audit trail, an anomaly detector or a "why can't I see this" support tool needs.

tenant, membership and via make a tenant-scoped audit log a filter and let a sink answer "which team grant let this happen" (tenancy). A tenant-defined custom role appears through its resolved declared role in matched.role; the custom name is on the membership.

matched.name and matched.meta carry the grant's declared name and app data, and a granted event carries its obligations, so a sink can group decisions by the rule that allowed them or by an app tag such as a ticket id (extend PermDock). The OCSF projection puts the grant name and the app obligation names under unmapped, and the OTel span adds permdock.matched.name and permdock.obligations.

Handlers are fire-and-forget: they run after the decision is computed, a throwing handler is reported to on('error') and never changes the outcome, and async handlers are not awaited by can or decide. If your sink needs backpressure, buffer in the handler.

Principal and actor in every event

For agent traffic the event distinguishes the human (principal) from the agent (actor) and records the authority it used (delegation). "The reporting agent deleted post 42 for Alice under scope post:delete, matched by the admin role" is one event, not a join across three logs. See subject.

Example sink

permdock.on("decision", (event) => {
  queue.push({
    at: event.at,
    outcome: event.outcome,
    permission: event.permission,
    resourceId: event.resource.id,
    principalId: event.subject.principal?.id ?? null,
    actorId: event.subject.actor?.id ?? null,
    reasons: event.denials?.map((d) => d.reason) ?? [],
    adapter: event.adapter,
  });
});

Register the handler once where you create the PermDock (the server factory file), or pass it to the adapter's createPermDock options so every request-scoped instance inherits it.

DecisionSink

A handler is enough for one process. When events must leave the process, the adapters take a sink option typed against a small interface, with an in-memory default, so the destination is pluggable the same way the approval store is:

interface DecisionSink {
  write(events: readonly DecisionEvent[]): Promise<void> | void; // batched; never awaited by decide
  flush?(): Promise<void>; // called at request end when the runtime has waitUntil
}

import { memorySink } from "permdock";
const sink = memorySink({ capacity: 10_000, signer }); // ring buffer; `signer` signs each write as `permdock-decisions+jwt`

createPermDock(policy, { subject, sink }); // every request-scoped instance writes to it

Implementations that ship or are documented:

SinkWhereWhat it does
memorySink({ signer })coreRing buffer; the default; optional TokenSigner signs each write (typ: permdock-decisions+jwt); what permdock/testing asserts against
permdock/oteloptional entrySpans and metrics from the same events (below)
permdock/cloudoptional entryBatched delivery to the PermDock Cloud decision log with retention and per-actor queries (Cloud adapter)
Your owna few lineswrite inserts a batch into your database or queue; the recipe is the example sink above wrapped in the interface

A sink receives decision events and the approval events that the store emits when a request is created and resolved; the three share token, so a sink can reconstruct an approval flow without a second log. A sink passed to scimHandler also receives directory events (type: 'directory', source: 'scim': the provisioning operation, tenant, resource type and id, and the credential kind), which join to later decisions on via: 'group:<id>'. Group membership changes emit a membership event per affected user next to the directory event. membershipEvent() and onRoleChange({ sink }) write the same shape for in-app role changes. API keys add credential events (created, rotated and revoked from credentialEvent() or memoryCredentials({ sink }), and used from subjectFromApiKey({ sink, sample }), always marked with the sample fraction it reports), and every decision a key makes names it in subject.credential (API keys). A failing sink is reported to on('error') and never changes an outcome.

Every event is written by default. A sink receives all granted, denied and approval-required events. Sampling is opt-in per sink (sample: { granted: 0.1 } keeps one routine grant in ten and always writes the other two outcomes) and is meant for a metrics or debugging sink, never for the sink that serves as evidence: an access review asks what a principal or an agent was allowed to do, so a log that drops grants cannot answer it. Cost is controlled by retention, not by omission.

Signed batch export. A sink that hands events to a party who must be able to prove where they came from (a SIEM in another trust domain, an auditor, a compliance tool reading an export file) can sign each batch. The batch becomes one compact JWS with typ: permdock-decisions+jwt, registered claims iss, iat, exp, jti, and the CloudEvents-enveloped events under the events claim (wire formats, JOSE); the consumer verifies it with any JOSE library against the signer's JWK Set. In this package it is a TokenSigner handed to a sink, not a new sink: memorySink({ signer, audience }) signs each write, signDecisionBatch(events, signer) is the same helper for your own sink, and permdock/cloud's export endpoint serves signed batches. The recipe is signer.sign({ events }, { typ: 'permdock-decisions+jwt' }) before write posts the batch. Events inside the JWS are the same objects a plain sink receives, so a consumer verifies, unwraps and applies the OCSF projection below unchanged. Signing is evidence for the reader; nothing in PermDock reads a batch back.

Sink recipes and the OCSF projection

Sinks for Sentry, PostHog, Datadog, Axiom, Better Stack, a Postgres table or a queue are recipes, not entries: each is the interface above with write calling the vendor SDK or a driver (adapters, the general rule). The destinations fall into three groups with three different ingestion shapes:

DestinationExamplesIngestsRecipe
LLM observabilityLangfuse, LangSmith, Braintrust, Datadog LLM Observability, Sentry AI monitoring, PostHog LLM analyticsOpenTelemetry GenAI spans (execute_tool, gen_ai.tool.name)No sink at all: permdock/otel nests permdock.decide under the framework's execute_tool span, so the decision arrives with the trace (OpenTelemetry adapter). The GenAI conventions are still at Development status; the two attribute names the adapter copies are pinned and revisited on stabilisation
SIEM and security lakeSplunk, Microsoft Sentinel, Datadog Cloud SIEM, AWS Security Lake, Google SecOps, Elastic SecurityOCSF, natively or through a mappingA sink whose write applies the OCSF projection below and posts the batch; the projection is what makes one recipe serve all of them
Compliance evidenceVanta, Drata, SecureframeAccess-review evidence from connected systems, usually pulled from a queryable logQuery your own sink's table (or the Cloud export) for events by subject.principal, actor and outcome over the review window; the event already carries the fields an access review asks for

Two things make those recipes interchangeable:

  • An OCSF projection. The Open Cybersecurity Schema Framework is the vendor-neutral schema SIEMs ingest. PermDock ships one mapping from a decision event onto the OCSF 1.3.0 Authorize Session class: outcome to status_id and status, denials[].reason to status_detail, permission to privileges, subject.principal to user and actor.user, subject.actor to actor.app_name, adapter to metadata.product.feature, and the fields OCSF has no slot for under unmapped. The projection is the pure function toOcsf(event) from permdock, specified on wire formats; a sink that wants SIEM-ready output applies it before write, and the Cloud's SIEM connector runs the same function. A break-glass decision is raised to high severity with breakGlass, purpose and reason under unmapped, and accessToOcsf(event) projects a support-access lifecycle event onto the OCSF Account Change class (elevated access). The event format itself does not change.
  • A CloudEvents envelope. When events leave the process over HTTP or a queue, wrap each in a CloudEvents 1.0 envelope: type dev.permdock.decision or dev.permdock.approval, source the service, subject the permission key, data the event. Every broker and function platform accepts it, and the PermDock Cloud sink uses the same envelope on the wire.

The PermDock Cloud ingest reads the same envelope, so both are part of the wire contract. Neither is a dependency; the projection and the envelope are plain objects.

What is not logged

The event never includes the resource object itself, the parsed schema output, the closure source, or any token or secret from authInfo. Add fields in your handler if you need them; resource.id is there so you can join.

Evidence and governance

"Audit" names the mechanism above. What the log is for, and what PermDock Cloud sells it as, is compliance evidence and agent governance: the decision event already carries the fields an access review or an agent-governance review asks for (subject.principal, subject.actor, delegation, tenant, permission, outcome, matched, via), so the work is queries and exports, not new data. The list below is the contract for any sink table and for the Cloud UI; a self-hoster's Postgres sink and the Cloud answer the same questions.

Questions the log must answer

ReviewQuery over the eventsFields
Access review: what could this person do, and what did they do, in this windowsubject.principal.id and tenant over a time range, grouped by permission and outcome; joined with the roles and memberships in matched and membershipprincipal, tenant, permission, outcome, matched.role, via
Agent activity: what did this agent do, for whom, under which authoritysubject.actor.id over a time range, grouped by subject.principal.id and permission; delegation.scopes and authorizationDetails for the authority it usedactor, principal, delegation, permission, outcome
Approval chain: who approved this action and what exactly was approvedThe approval events (phase: requested, phase: resolved) and the resumed decision event sharing token; resolvedBy on the request record. Session revocation calls cancelApprovals and records resolvedBy: 'system:ssf'token, resolvedBy, permission, resource.id
Denials and drift: what is being refused, and did a policy change move outcomesoutcome: denied grouped by denials[].reason and permission; the catalog version in adapter metadata before and after a publishdenials, permission, adapter
Tenant scope: everything that happened inside one customer's tenantFilter on tenant; membership and via explain each granttenant, membership, via
Provisioning: who gave this person that roleThe membership events from SCIM, Better Auth, Clerk or membershipEvent() (roles added or removed, optional by) joined to later decisions on viamembership event, via
Machine access: which API keys exist, who made them, and what they didcredential events (created, rotated, revoked, sampled used) joined to decisions on subject.credential.idcredential event, subject.credential

Exports

Evidence leaves the log in three forms, all documented here so a reader in another trust domain can verify and ingest without PermDock code:

  • Signed batches (permdock-decisions+jwt, above): provenance for an auditor or a compliance tool; the Cloud's export endpoint serves them and a self-hosted sink can sign its own.
  • OCSF: the projection below for SIEMs and security lakes.
  • CSV with one row per event, for compliance platforms (Vanta, Drata, Secureframe) that collect access-review evidence from connected systems and for spreadsheets in an audit. The columns are fixed, in this order: time (the event's at, RFC 3339), principal (subject.principal.id, empty for anonymous), actor (subject.actor.id), tenant, permission (the key), outcome, matched.role, via, denials.reason (the reasons joined with ;), token. A list inside a cell never uses commas, so the file parses with any RFC 4180 reader. toCsvRow(event) from permdock writes one row (a field is quoted only when it contains a comma, a double quote, CR or LF) and CSV_COLUMNS is the header; rows are joined with CRLF. The Cloud export and a self-hosted sink use the same helper, so their files are identical. Columns beyond these belong in the signed batch, not in the CSV.

Access-review template

An access review asks a named owner to confirm, per person, that what they could do and what they did is still right. Run it per tenant and per review window (a quarter is common) from the sink or the Cloud export:

  1. Scope. The tenant, the window, the reviewer, and the permissions in scope (usually every permission the catalog marks destructive or approval: 'human', plus anything hostable).
  2. Population. Every principal with a granted or approval-required event on an in-scope permission in the window, from the CSV filtered on tenant and permission. Add principals whose current memberships grant an in-scope permission but who did not use it; unused access is the finding reviews most often surface.
  3. Per principal. The roles and memberships behind each grant (matched.role, via), the count of granted, denied and approval-required events per permission, and the agents that acted for them (actor).
  4. Decision. The reviewer marks each principal and permission keep, reduce or revoke, with a reason. Reduce and revoke change the role or membership at its source (the identity provider, SCIM, the application's RoleSource); PermDock records the effect on the next decisions and never applies the change itself.
  5. Evidence. The CSV rows, the signed batch covering the same window, and the reviewer's decisions, filed with the compliance platform.

Agents get the same review keyed on actor instead of principal: which delegations they used, for whom, and whether any approval-required call was approved by the principal it acted for.

Retention is the cost lever: the Cloud offers retention tiers; a self-hosted sink keeps what its table keeps. Sampling is never the lever (see above).

Governance actions, and what they are not

Governance means acting on what the log shows. The actions PermDock Cloud (or an application over its own sink) may take are exactly two, and neither is a decision:

  • Reject pending approvals for an actor, a principal or a tenant through ApprovalStore.resolve(token, 'rejected'). This stops in-flight approval-gated actions.
  • Invalidate snapshots through SnapshotSource, so cached clients refetch and a revoked membership or role takes effect at the edge.

A "freeze this agent" button in the Cloud is those two operations plus a recommendation: the application's own RoleSource or context reads a kill-switch table the application owns, so the next decide denies. The Cloud cannot deny a future check, because nothing the Cloud stores is consulted on the decision path (invariant 15). This is the same reason the log is trustworthy as evidence: it records decisions it could not have influenced.

OpenTelemetry is not the evidence path

permdock/otel (below) emits spans and metrics from the same events, and an OTLP export from the Cloud does the same for backends that want a copy next to their traces. Both are observability: collectors sample, tail-sample and drop; retention is short; a span is not signed. The evidence is the sink's log and its signed export. Do not point an auditor at a tracing backend, and do not send OTel spans to a DecisionSink (Cloud adapter).

permdock/otel

import { withOtel } from "permdock/otel";

const permdock = withOtel(await createPermDock(policy, user), {
  tracer,
  meter,
});

permdock/otel is an optional entry that subscribes to on('decision') and emits:

SignalNameAttributes
Span (one per check)permdock.checkpermdock.permission, permdock.outcome, permdock.resource.type, permdock.subject.id, permdock.actor.id, permdock.actor.kind, permdock.adapter, permdock.denials (comma-joined reasons on denied)
Counterpermdock.checkspermdock.permission, permdock.outcome, permdock.adapter
Histogrampermdock.check.durationsame as the counter

@opentelemetry/api is an optional peer of permdock/otel, never of core: @zap-studio/permit made it a required peer, which forces the dependency on every consumer; PermDock keeps core at zero runtime dependencies. The meter and tracer are resolved when withOtel is called so that module initialisation order relative to the SDK does not matter. The span is a child of whatever is active, so a check inside a Hono handler nests under the HTTP server span and a check inside an MCP tool nests under the tool call.

Problem Details

HTTP adapters answer a denied assert with the status protect sends for the same decision (403, or 401, 404, 429 or 503) as application/problem+json per RFC 9457, generated from the Decision. The denied, approval-required and validation bodies are on wire formats.

Rules the adapters follow:

  • type is a stable URI per outcome under the fixed base https://permdock.com/problems. title is constant per type; detail is human-readable and may vary.
  • The extension members permission, scope, resource, denials, alternatives, reason, token and issues are the machine-readable part. Their names match the Decision and the audit event, so a client library can parse all three with one type.
  • approval-required is a 403 with its own type, not a 401 and not a 202: the request was understood and refused pending a human. The client retries with a PermDock-Approval: <token> header once the request is approved in the store (approvals).
  • The body never contains the policy, the closure source, what a closure threw, or another subject's data. denials names roles the subject holds, which the subject already knows.
  • OpenAPI emission documents the 403 responses with these schemas on every protected operation.

Model-readable text: the detail strings are written for an LLM as much as for a person ("post.delete was denied for the current subject; permitted alternatives: post.read, post.update"), because the MCP and AI SDK adapters reuse them as refusal text. See errors.

Putting it together

QuestionWhere the answer is
Was this specific request denied and why?The Problem Details body, or the decision on the thrown PermDockDeniedError
Which agent did what for which user last week?Your DecisionSink (or the PermDock Cloud decision log), filtered on subject.actor
What could this person do in this tenant during the review window, and did they?The access-review query above over your sink or the Cloud export; signed batch or CSV for the compliance platform
Who approved that deletion, and when?The approval events sharing the decision's token, with resolvedBy on the request record
Who gave this person the editor role?The membership event whose roles.added includes editor, filtered by tenant, joined on via when the change came from a group
How often is post.delete denied per adapter?The permdock.checks counter
Why is this page slow?permdock.check spans nested in the request trace; a pending status on the client points at closure grants hitting the endpoint
What changed in who can do what?permdock usage and the policy matrix snapshot in permdock/testing, in review

Last updated on

On this page