Adding Audit Events
AuditEventTable in packages/types/lib/audit-trail/event.ts is the source of truth: it maps each
resource to its actions and the metadata each may carry. The event union, the metadata lookup, the
per-resource action lists and the webapp filters all derive from it. Add the action there first and let
the compiler tell you what else to touch.
The trail is control-plane only β configuration, state and authentication. Runtime traffic (records, proxy, sync execution) is data plane and stays out; audit it and you get millions of rows a month.
Workflow
Vocabulary β in
AuditEventTable, under the resource:<action>: <MetadataShape>;or<action>: never;when the action carries no metadata.Metadata shape, if any β
packages/types/lib/audit-trail/metadata.ts. Name it for what it holds, never for one action:integration.createdandintegration.deletedshareIntegrationProviderMetadatabecause both record only the provider.Endpoint type β every customer-facing endpoint declares one:
Audit: AuditPolicy<'<resource>', '<action>', 'account' | 'environment'>Audit: { kind: 'no-audit'; reason: '<why>' }β use'data-plane operation'for runtime traffic.
Spec β
packages/server/lib/middleware/audit/<resource>.middleware.ts:export const auditThingDone = auditable<PostThing>({ policy: Audit.auditable({ resource: 'thing', action: 'done', scope: 'environment' }), target: (req) => makeTarget('thing', nonEmptyString(req.body.id)), metadata: (req) => omitUndefined<ThingDoneMetadata>({ β¦ }) });Mounted middleware first, in the vocabulary's action order, private spec then public for the same action; multi-action emitters last; helpers below all of them.
Barrel β add the export to
audit/index.ts. Hand-written on purpose: it declares what has scope beyond the folder, so neverexport *.Mount β
routes.public.ts/routes.private.ts, beforewithScope:.post(apiAuth, auditThingDone, withScope('β¦'), handler). After the scope check, denials are lost.Test β
<resource>.middleware.unit.test.ts, scaffolding from./testing.js.Webapp β add the action to
actionsByResourceinpackages/webapp/src/pages/Audit/constants.ts. A type check pins it to the vocabulary, so omitting it is a build error. Labels are derived.New kind of target β add it to
AuditTargetType.
Gotchas
- Resolvers run before zod.
req.body/params/queryare raw at that point, whatever the endpoint type says. Use the guards ininput.ts:nonEmptyString,positiveInt,param,query,bodyField. targetandmetadataresolve beforenext(), so a value the handler generates isn't available yet. UsetargetFromResponse/metadataFromResponse.- A 403 means no handler ran, so anything only the handler knows is missing from exactly the rows an auditor cares about most. Prefer reading from the request.
scope: 'account'nulls the event's environment, whateverres.localsholds.- On the MCP path only (
defineManagementMcpTool), a stray metadata key alongside a valid one is accepted β the audit type is a union over the vocabulary. A stray key alone, a wrong type, and metadata on aneveraction all fail. - Grep
input.tsandlookups.tsbefore adding a helper. The target and metadata builders usually exist, and near-identicalβ¦Targetfunctions get flagged in review.
Review Checklist
-
npm run ts-buildclean β it catches a missing webapp entry, metadata that doesn't fit the action, and metadata on an action declarednever - Unit test asserts the common fields (
accountId,environment,actor,outcome), not only the one it is named for - Test break-checked: remove the target or a metadata key and confirm it goes red
- Audit middleware sits before
withScopeon every new mount - Denial and failure paths still identify the event β check what a 403 records, not just the 200