import { AuditActor } from '@voltro/database'; import { ColumnDefinition } from '@voltro/database'; import { Effect } from 'effect'; import { MixinDefinition } from '@voltro/database'; import { Subject } from '@voltro/protocol'; import { TableIndex } from '@voltro/database'; import { TableLike } from '@voltro/database'; import { VoltroPlugin } from '@voltro/protocol'; export declare const audit: () => MixinDefinition<{ readonly createdAt: ColumnDefinition; readonly updatedAt: ColumnDefinition; readonly createdBy: ColumnDefinition; readonly updatedBy: ColumnDefinition; }>; export declare const AUDIT_LOG_TABLE = "_voltro_audit_log"; export { AuditActor } /** * One actor's audit rows, newest first — the `byAuditSubjectStatus` index's * caller. `status: 'error'` is the question asked under pressure ("every * refusal by X"), which is why it is a first-class argument rather than * something you filter in JS after fetching everything. * * `limit` defaults to 100: an actor's history is unbounded, and an entry point * that returns all of it by default is one you call once in production. */ export declare const auditBySubject: (store: AuditQueryStore, subjectId: string, options?: { readonly status?: "ok" | "error"; readonly limit?: number; }) => Promise>>; /** * Every audit row for ONE call — the other half of the correlation join. * * `byAuditTrace` existed with no caller, which is the same defect the versioning * side had: an index nobody can enter is a query you still have to hand-write. * Pair it with `historyByTrace` from `@voltro/plugin-versioning` on the same * `traceId` and you have "who called, whether they were refused, and what they * changed" in two reads. */ export declare const auditByTrace: (store: AuditQueryStore, traceId: string) => Promise>>; /** A writer's chain state. One per `dataStoreAuditSink`, i.e. one per process. */ export declare interface AuditChain { readonly chainId: string; /** * Allocate this row's link and stamp it onto the row. SYNCHRONOUS and * `await`-free by construction — that is the whole concurrency argument (see * the header). Do not make it async. */ readonly link: (row: Record) => Record; } export declare interface AuditChainIssue { readonly kind: AuditChainIssueKind; readonly chainId: string; readonly seq: number; /** The offending row's id, when it has one (a `gap` names the MISSING seq, * so it has no row and no id). */ readonly rowId?: string; readonly detail: string; } export declare type AuditChainIssueKind = 'tampered' | 'broken-link' | 'gap'; /** The columns the chain adds to `_voltro_audit_log`. */ export declare interface AuditChainLink { /** Which writer's chain this row belongs to. Null for an unchained row. */ readonly chainId: string; /** 1-based position within `chainId`. */ readonly seq: number; /** The previous row's `hash`, or null for the first row of a chain. */ readonly prevHash: string | null; /** `H(chainId, seq, prevHash, content)` — hex. */ readonly hash: string; } export declare interface AuditChainSummary { readonly chainId: string; /** Lowest `seq` present. `> 1` means the retention sweep pruned a prefix. */ readonly from: number; /** Highest `seq` present. */ readonly to: number; /** The tip hash — publish this somewhere append-only to defeat tail * truncation and to bind an unkeyed chain to a point in time. */ readonly tip: string; /** Whether the chain starts above `seq` 1 (a pruned prefix, not an issue). */ readonly prunedPrefix: boolean; } export declare interface AuditChainVerification { readonly ok: boolean; readonly rowsChecked: number; /** Rows carrying no `hash` — written before chaining shipped, or stripped. * Not an `issue` (a pre-chain deployment is not tampering) but never * silently dropped either. */ readonly unchainedRows: number; readonly chains: ReadonlyArray; readonly issues: ReadonlyArray; /** Whether the digest was keyed. An `ok: true` from an UNKEYED run is a * weaker statement — surfaced so a report cannot quietly overstate it. */ readonly keyed: boolean; } /** Narrow slice of the framework DataStore the durable sink needs. */ export declare interface AuditDataStore { insert: (table: string, row: Record) => Promise; } export declare interface AuditEvent { readonly ts: number; readonly tag: string; readonly subject: Subject; /** * The acting identity, snapshotted at write time — see the `actor` column. * * Absent when no resolver is configured or the subject has no actors row * (anonymous, system). Absent is honest; a fabricated placeholder would be * the thing this field exists to prevent. */ readonly actor?: AuditActor | undefined; /** The app's own scoping dimension — opaque, stored verbatim, filterable. * `.with(tenant())` is one level too coarse for a per-team trail. */ readonly scope?: unknown; /** The app's own note about what happened. Opaque, never interpreted. */ readonly metadata?: unknown; /** * The impersonation mark, when the acting session was an impersonated one — * absent otherwise. * * **A FIRST-CLASS FIELD, on purpose, and no redactor can reach it.** The mark * is minted into `subject.metadata` by `@voltro/plugin-auth`, and the audit * default `redactSubject: 'metadata'` replaces that whole bag — correctly, it * is where a per-user provider credential lands. The consequence was that on * DEFAULTS an impersonated action was indistinguishable from the user's own, * which is the one distinction an audit trail exists to make. * * Fixing that with a keep-these-keys option would have left it opt-in, and * "who really did this" is not the app's metadata to configure away — it is a * property of the event. So it is lifted out of the subject BEFORE any * redaction runs, and `redactSubject` is not given a say. * * Opaque here (`unknown`): `@voltro/plugin-audit` copies the mark, it does not * interpret it. `impersonationOf()` from `@voltro/plugin-auth` is what turns * it back into a typed `ImpersonationMark`. */ readonly impersonation?: unknown; readonly traceId: string; readonly input: unknown; readonly outcome: { readonly kind: 'ok'; readonly value: unknown; readonly durationMs: number; } | { readonly kind: 'error'; readonly error: unknown; readonly durationMs: number; }; } /** Map an {@link AuditEvent} to an audit-log row. */ export declare const auditEventToRow: (event: AuditEvent) => Record; /** * The slice of a built `Table` this package exposes — annotated explicitly so * @voltro/database's private column-builder class doesn't leak across the * package boundary (TS4094). Only name + columns + indexes are needed. */ export declare interface AuditLogTable extends TableLike { readonly fields: Record>; readonly appliedIndexes: ReadonlyArray; } /** * The audit-log table: one append-only row per recorded mutation. `subject`, * `input`, and `outcome` are portable `json()` columns (JSONB on pg, JSON on * mysql/mariadb, NVARCHAR(MAX) on mssql, TEXT on sqlite) — never a pg-only * type. Indexed by `tag` and `traceId` for the common audit queries. */ export declare const auditLogTable: AuditLogTable; /** The audit-log table, ready to spread into `extendSchema.tables`. */ export declare const auditLogTables: ReadonlyArray; /** * Build the audit plugin. Returns the VoltroPlugin you pass into * app.config.ts's `plugins: [...]` array. Safe to call multiple times * (each call gets its own filter + sink), but pushing several * instances of this plugin into one app produces duplicate audit * events — use the `include`/`exclude` filters to scope distinct * instances instead. */ export declare const auditPlugin: (options?: AuditPluginOptions) => VoltroPlugin; export declare interface AuditPluginOptions { /** * Namespace for this plugin's inspect surface + any rpc tags it contributes. Default `audit`. * * Set it when your app already publishes under that name — an exact tag * collision is fatal at codegen, and this is the way out. */ readonly alias?: string; /** * Contribute this plugin's tables via `extendSchema.tables`. Default `true`. * * Set `false` when your app ALREADY declares equivalent tables and you want to * keep them — the seam this exists for. The plugin then contributes no DDL and * the declarative differ never proposes its tables; everything else (routes, * inspect, interceptors) is unchanged. * * **What you take over, exactly:** a table named `_voltro_audit_log` with the shape * `auditLogTable` declares (exported from this package, so declare it with * `.renamedFrom()` or copy its columns). The `datastore` sink writes to it BY * NAME through the bound store; nothing validates that it exists, so a missing * or mis-shaped table fails at the first audited mutation, not at boot. * * It is not offered on every plugin, and the omissions are deliberate rather * than unfinished: a `tables: false` that quietly disables a table carrying an * AUTHORIZATION or SAFETY decision — the SAML replay cache, SCIM provisioning * state, billing's usage counters, cdc-out's outbox — is a security regression * shipped as an ergonomics feature. Those plugins need a named store seam * first, not a boolean. */ readonly tables?: boolean; /** * Where to record audit events: * - 'console' (default) — pretty-prints to stdout for local dev. * - 'memory' — keeps the last 1000 events in-process; * read them with {@link readAuditBuffer}. * Useful for tests that need to assert * "this mutation emitted that audit event". * - 'datastore' — DURABLE, queryable trail. Contributes the * `_voltro_audit_log` table (via `extendSchema`, * under `store:write`) and appends one row per * mutation. Survives restarts, shared across * replicas, queryable via * `ctx.store.select('_voltro_audit_log')`. * The production choice. * - a function — custom sink. Receives every event and may * return `void` / `Promise` / * `Effect`. Persist events wherever you * like — e.g. an `Effect` sink that * writes rows to your own audit table on top * of `@effect/sql`. * * **A function sink gets NEITHER the table NOR the retention sweep**, and the * cliff is invisible until the next environment. Both are gated on `sink` * being the literal `'datastore'`, so a function that redacts and then * delegates to `dataStoreAuditSink` still writes rows on a database where * `_voltro_audit_log` already exists — while creating the table nowhere and * arming the TTL policy nowhere. It works where you tested it and fails on * the next `voltro dev` against a fresh database. * * Reported from a real deployment who reached for exactly that composition to redact a * field, before {@link AuditPluginOptions.redactOutcome} existed. If you want * the durable trail with different redaction, use `sink: 'datastore'` plus * `redactInput` / `redactSubject` / `redactOutcome` — those compose; the sink * does not. */ readonly sink?: 'console' | 'memory' | 'datastore' | AuditSink; /** * Derive the app's own scoping dimension for each recorded call. * * The framework cannot guess this: it does not know what a team, a project or * a workspace is, which is exactly why the column is opaque. The app knows — * from the subject, or from the call's INPUT: * * (ctx) => ({ teamId: ctx.subject.metadata?.teamId }) // subject-shaped app * (ctx) => typeof ctx.input?.teamId === 'string' // most apps * ? { teamId: ctx.input.teamId } : undefined * * **`input` is here because the subject-only version covered the wrong half.** * A reporter's users belong to MANY teams, so their session carries no * "current team" and cannot without inventing a concept their product does not * have. A mutation's team comes from its input or from the row it loads. Their * API-key subjects DO carry a `teamId` — which made the subject-only resolver * worse than useless for them: it would have populated for key-authenticated * calls and been null for every human one, so a filtered view would look like * it worked. * * **The input here is RAW — it is not what `redactInput` will store.** That is * required (a scope derived from a redacted payload is not derivable at all) * and it is a hazard worth stating: whatever you return lands in `scope`, * which is NOT redacted. Return the dimension, never the payload. * * Absent ⇒ `scope` stays null and the column costs nothing. Present ⇒ it is * written verbatim and can be filtered on equality, which is the difference * between an indexed read path and stuffing `teamId` into `metadata` and * scanning — a column documented as a free-form note is not a read path. * * Throwing here never fails the mutation being recorded: a scope that cannot * be derived is null, the same answer as not configuring one. */ readonly resolveScope?: (ctx: { readonly subject: Subject; readonly tag: string; /** The call's raw input — before `redactInput`. */ readonly input?: unknown; }) => unknown; /** * Record QUERIES too. * * Off by default and deliberately: a read-heavy app writes one audit row per * read, and a trail that drowns in reads is worse than one missing them — * nobody searches it. Turn it on for the surfaces where a READ is the * sensitive act (a GDPR export, a salary view), usually together with * `include` so it stays targeted. */ readonly recordQueries?: boolean; /** * If set, only mutations matching this RegExp are recorded. Useful * for narrowing audit cost to write-heavy domain tags only. Default: * record every mutation. */ readonly include?: RegExp; /** * Mutations matching this RegExp are SKIPPED — overrides `include`. * Typically `/^debug\./` or similar. Default: skip nothing. */ readonly exclude?: RegExp; /** * Which OUTCOMES are recorded. `include`/`exclude` filter by tag, before the * call runs; this filters by what happened, after. * * - `'all'` (default) — every invocation. * - `'errors'` — refusals only. The forensic core: a denied guard, a revoked * key, a validation rejection. Pairs with `plugin-versioning`, which * records the SUCCESSFUL writes, so the two together still cover * everything while this table stays small enough for retention to be a * footnote. * - a predicate — anything else (`(e) => e.outcome.kind === 'error' || * e.tag.startsWith('auth.')`). * * **`'all'` is the default deliberately, though `'errors'` is often the right * choice.** Defaulting to errors would silently stop recording successes for * every app that upgrades, and "what did this compromised account touch" is * answered by successes. Shrinking the trail is a decision an app makes with * its eyes open, not one an upgrade makes for it. */ readonly record?: 'all' | 'errors' | ((event: AuditEvent) => boolean); /** * What happens to `AuditEvent.input` before it is handed to the sink. * * - `'all'` (DEFAULT) — the payload is replaced by `{ __redacted: 'all' }`. * The row still proves a payload existed; it just does not carry it. * - `'shape'` — the payload's STRUCTURE, no value from it: * `{ __redacted: { token: 'string(113)', limit: 'number' } }`. See * `redactionShape.ts` for the rules and for the one real trade (a * string's LENGTH is disclosed). * - `'none'` — the raw input, verbatim. What every sink did before this * option existed. * - a function — `(event) => unknown`, for field-level control. * * **Why the default is `'all'`, and why it is NOT marker-driven.** The obvious * design — redact using `.serverOnly()` / `.sensitive()` — cannot work: those * markers live on TABLE COLUMNS, and this is a mutation's INPUT. A * `changePassword({ oldPassword, newPassword })` has no column to consult, so * a marker-driven default would cover exactly 0% of the case it exists for * while reading, to anyone configuring it, like protection. (`.sensitive()` is * also the EXPORT axis, not "unsafe to log" — treating one as the other is the * category error the three-marker table warns about.) * * So the choice is between recording credentials by default and recording no * payload by default. A password change, an API key at issuance and a PAT all * land in `input`, and an audit table is the one place nobody thinks to look * for a credential. Losing payload detail is visible the first time you read a * row; leaking a credential is not visible at all. Opt in per app with a * function once you know your own inputs. */ readonly redactInput?: 'all' | 'shape' | 'none' | ((event: AuditEvent) => unknown); /** * What happens to `AuditEvent.subject` before it is handed to the sink. * * - `'metadata'` (DEFAULT) — `subject.metadata` is replaced by * `{ __redacted: 'all' }`. `type`, `id`, `tenantId` and `scopes` survive, * which is everything the trail is actually read for. * - `'metadata-shape'` — the same, but `metadata`'s STRUCTURE survives * instead of nothing. Useful for the case this whole option exists for: * seeing THAT a credential-shaped value sits in the bag, and how long it * is, without recording it. * - `'none'` — the subject verbatim. What every sink did before this option * existed. * - a function — `(subject) => unknown`, for field-level control. * * **This exists because the durable sink wrote a live credential.** A reporter * found a working Jira Personal Access Token in plaintext in 12 of 23 rows of * their `_voltro_audit_log`, and neither plugin involved was wrong on its own: * * - `@voltro/plugin-atlassian`'s `credentialsResolver` takes a `Subject` and * NOTHING else, so an app doing per-user Atlassian auth has no place to * put the caller's PAT except `subject.metadata`; * - this plugin serialised the subject verbatim into a json column. * * Two correct contracts that disagree about what a Subject IS — an identity, * or a credential envelope — with nothing reconciling them. * * **The reasoning is `redactInput`'s, word for word, applied to the field it * did not cover.** `metadata` is not a table column either, so no schema * marker protects it; it is app-controlled, so its contents cannot be reasoned * about here; and the framework's own per-user-credential mechanism puts a * credential in it. The choice is between recording credentials by default and * recording an app-controlled bag by default. Losing that bag is visible the * first time you read a row; leaking a credential is not visible at all. * * `resolveScope` still sees the LIVE subject, so a scope derived from * `metadata` keeps working — redaction applies to what is STORED, not to what * the plugin can compute. * * KNOWN DEBT, on the DEFAULT. An impersonation mark lives in `metadata`, so * under `'metadata'` a durable trail records an impersonated action * indistinguishably from the user's own — the one distinction an audit trail * exists to make. `impersonationAuditRedactor()` plus an always-on grant row * mitigates it, and `'metadata-shape'` narrows it further (the KEY survives, * so the row at least says an impersonation mark was there). Neither is the * fix. The fix is a keep-these-keys option, or a first-class * `AuditEvent.impersonation` field that the redactors cannot reach — because * "who really did this" is not app-controlled metadata, it is the event. */ readonly redactSubject?: 'metadata' | 'metadata-shape' | 'none' | ((subject: Subject) => unknown); /** * What happens to `AuditEvent.outcome`'s payload before it is handed to the * sink. * * - `'all'` (DEFAULT) — `outcome.value` on success, and `outcome.error` on * failure, are replaced by `{ __redacted: 'all' }`. `kind`, `durationMs` * and the error's TAG survive, which is what the trail is read for. * - `'shape'` — the STRUCTURE survives instead of nothing. This is the * option a deployment asked for after a day spent on a bug their own trail * could have ended: a value arrived as 113 characters where 44 were due, * and the row that would have said so read `{"__redacted":"all"}`. * - `'none'` — the outcome verbatim. What every sink did before this * option existed. * - a function — `(event) => unknown`, for field-level control. * * **This is `redactInput`'s reasoning applied to the field it structurally * cannot cover.** `redactInput`'s own docstring names "an API key at issuance" * as its motivating case — and for a credential-ISSUING call the secret is * never in the input: * * apiKeys.createPersonalApiKey({ name, scopes }) // input: nothing sensitive * → { keyValue: '' } // outcome: the whole point * * webhooks.create({ url, subscribedEvents }) // input: nothing sensitive * → { signingSecret: '' } // outcome: returned once * * The option that existed covered the field those calls leave empty; the * field they fill had none. Found by a deployment on the first run of * `voltro db scan-credentials` after we widened its columns: nine rows of * `_voltro_audit_log.outcome` matched, four of them `webhooks.create` carrying * a live 64-character signing secret in full. They had BOTH existing options * on — `redactInput: 'all'`, `redactSubject: 'metadata'` — and no way to * reach this field. * * The default is `'all'` for the same reason the other two default to * redacting: losing the payload is visible the first time you read a row, * leaking a credential is not visible at all. Opt out per app once you know * your own outcomes. * * A `record` predicate still sees the LIVE outcome, so a filter that keys on * the result keeps working — redaction applies to what is STORED. */ readonly redactOutcome?: 'all' | 'shape' | 'none' | ((event: AuditEvent) => unknown); } /** The narrow read surface the entry points need. */ export declare interface AuditQueryStore { query: (descriptor: unknown) => Promise>>; } /** `H(chainId, seq, prevHash, content)` for one row. Pure — the verifier and * the writer call the SAME function, which is what makes the check mean * anything. */ export declare const auditRowHash: (row: Record, link: { readonly chainId: string; readonly seq: number; readonly prevHash: string | null; }) => string; export declare type AuditSink = (event: AuditEvent) => void | Promise | Effect.Effect; /** Drop everything in the in-process buffer. Useful between smoke phases. */ export declare const clearAuditBuffer: () => void; /** * A DataStore-backed audit sink — appends each event to `_voltro_audit_log`. * Returns a `(event) => Promise` so it plugs straight into the plugin's * function-sink path (which lifts it into an Effect + swallows failures, so a * write hiccup never masks the mutation). */ export declare const dataStoreAuditSink: (store: AuditDataStore) => (event: AuditEvent) => Promise; /** Mint a fresh chain. `chainId` is 96 bits of CSPRNG — collisions across * replicas would merge two chains into one apparent fork. */ export declare const makeAuditChain: (chainId?: string) => AuditChain; /** * Read the in-process audit buffer. Returns a stable snapshot copy — * mutating it has no effect on the running buffer. Empty array when * the plugin isn't using the 'memory' sink. */ export declare const readAuditBuffer: () => ReadonlyArray; /** * Recompute every chain in `_voltro_audit_log` and report what does not add up. * * Reads through the same `AuditQueryStore` the other read entry points use, so * it works against any dialect and inside a transaction. Ordered by * `(chainId, seq)` — the `byAuditChain` index exists for exactly this scan. */ export declare const verifyAuditChain: (store: AuditQueryStore, options?: VerifyAuditChainOptions) => Promise; export declare interface VerifyAuditChainOptions { /** Verify one writer's chain instead of all of them. */ readonly chainId?: string; /** Cap the rows read. Unset = every row, which is the honest default for a * completeness check; pass one for a spot check on a very large table. */ readonly limit?: number; } export { VoltroPlugin } export { }