Skip to content

Audit Trail — Developer Reference

Every mutating operation in Sencai should produce an audit event. The @sencai/audit package provides the shared publishing interface used across services. Audit events are hash-chained and immutable once written, and are scoped to an actor/organisation via explicit fields — not enforced automatically by the publisher itself.

packages/audit/ # @sencai/audit — vendored, not published to an npm registry
├── src/
│ ├── index.ts # public exports, `audit = { log: publishAuditEvent, init: initAuditPublisher }`
│ ├── types.ts # AuditAction (string-literal union), AuditEvent interface
│ ├── context.ts # AsyncLocalStorage-based AuditContext
│ ├── classification.ts # data-classification contract (backs the cross-tenant-flow ESLint rule)
│ ├── publisher.ts # publishAuditEvent() — fire-and-forget RabbitMQ publish
│ └── testing.ts # mockAuditPublisher / expectAuditEvent / getCapturedEvents test helpers

audit.log is a convenience alias for publishAuditEvent, bound once at import time in index.ts. This matters for testing — see below.

publishAuditEvent() (and its audit.log() alias) takes a single object argument, not positional arguments:

import { audit } from '@sencai/audit';
await audit.log({
action: 'cloud.asset.adopt',
resource_type: 'cloud-instance',
resource_id: instanceId,
actor_user_id: kcSubjectUuid,
actor_org_id: orgDocumentId,
customer_scope: orgDocumentId,
correlation_id: traceId,
risk_level: 'medium',
changes: { status: { before: 'discovered', after: 'adopted' } },
metadata: { provider: 'hetzner', region: 'nbg1' },
});
function publishAuditEvent(
event: Partial<AuditEvent> & { action: AuditAction; resource_type: string }
): Promise<void>
FieldTypeDescription
actionAuditActionRequired. The action literal (see below)
resource_typestringRequired. Type of the affected resource (e.g. cloud-instance, user)
resource_idstring | numberID of the affected resource
actor_user_idstring | numberStrapi user ID or KC subject of the acting user/service
actor_org_idstringOrganisation the actor belongs to
customer_scopestring | nullTenant that was the subject of the action; null = platform-level. customer_scope !== actor_org_id marks a cross-tenant operation
actor_org_scopestring | nullOrg from which the actor operated — used for agency/MSP cross-tenant audit trails
elevation_grant_idstring | nullJIT elevation grant ID if the action ran under elevation
changesRecord<string, { before, after }>Before/after snapshot — must never contain secrets
correlation_idstringOTEL trace ID
risk_level'low' | 'medium' | 'high' | 'critical'Defaults to 'low' if not set anywhere in context or event
metadataRecord<string, unknown>Free-form extra data — no secrets, no unredacted PII
ip_addressstringClient IP
user_agentstringClient user agent
timestampstringISO timestamp, set by the publisher if omitted

There is no userId/orgId/requestId/ipAddress shorthand — always use the real field names above (actor_user_id, actor_org_id, correlation_id, ip_address).

publishAuditEvent() never throws and never blocks the caller. It is fire-and-forget: if RabbitMQ is unreachable at publish time, the event is dropped with a console.warn (NODE_ENV=test suppresses the warning) and the calling business logic proceeds unaffected. Publish-side completeness is therefore best-effort — durability and retry only start once a message actually reaches the consumer (see below).

AuditAction in packages/audit/src/types.ts is a single 700+-line string-literal union, not an enum. It intentionally mixes two conventions:

  • Legacy UPPER_SNAKE actions from earlier waves, e.g. ORG_CREATED, INSTANCE_PROVISIONED, CREDENTIAL_CREATED, USER_LOGGED_IN
  • Newer dot-notation actions from later waves, e.g. cloud.inventory.scan, runbook.triggered, billing.usage.synced, fleet.agent.quarantined, lumen.query.sent

Both styles are actively in use side by side and neither is being retired wholesale — some of the older literal strings are load-bearing for evidence queries (e.g. NIS2 evidence, cost-allocation reporting) that filter on the exact string, so renaming them would silently break that evidence trail. When adding a new action, follow the convention already used by the most similar existing group rather than introducing a third style.

packages/audit/src/context.ts defines the real AuditContext shape:

interface AuditContext {
actor_user_id?: string | number;
actor_org_id?: string;
customer_scope?: string | null;
actor_org_scope?: string | null;
correlation_id?: string;
}

There is no ipAddress or requestId field on the context — those are passed explicitly on the event itself (ip_address, and correlation_id covers request/trace correlation).

import { runWithAuditContext } from '@sencai/audit';
// In HTTP middleware, once per request:
app.addHook('preHandler', async (request) => {
return runWithAuditContext(
{
actor_user_id: request.user?.id,
actor_org_id: request.user?.orgId,
correlation_id: request.id,
},
async () => {
// any audit.log() call deeper in the stack picks up this context automatically
}
);
});

publisher.ts merges the final payload in this exact order:

{ risk_level: 'low', timestamp: new Date().toISOString(), ...auditLocalStorageContext, ...event }

Fields passed explicitly to audit.log(event) always win over whatever is in the ambient AsyncLocalStorage context, and risk_level falls back to 'low' only if neither the context nor the explicit event sets it.

The custom ESLint rule sencai/require-audit-log (from tooling/eslint-plugin-sencai, plugin version 0.3.0) fails CI when a mutating function has no audit call. It actually applies two independent name patterns:

  • HTTP-layer pattern (case-insensitive prefix match): functions named create*, update*, delete*, attach*, detach*, invite*, revoke*, provision*, terminate*, impersonate*, assume*
  • Non-HTTP pattern (case-insensitive exact match only): cronJob, runCron, processJob, consumeMessage, processMessage, handleMessage, processEvent, runWorker, executeJob, afterCreate, afterUpdate, afterDelete, beforeCreate, beforeUpdate, beforeDelete

Note that remove* is not part of either pattern today, despite sometimes being assumed to be covered.

A function matching either pattern must call audit.log(...) / publishAuditEvent(...) somewhere in its body, or carry an opt-out comment directly above it (or above its enclosing declaration/export) with a mandatory reason:

/** @no-audit { reason: "thin wrapper — underlying service call already audits at the domain boundary" } */

A bare /** @no-audit */ with no reason does not satisfy the intended convention documented alongside the rule.

The plugin ships two other rules in the same package:

  • sencai/cross-tenant-flow-requires-classification (error) — enforces the data-classification contract in packages/audit/src/classification.ts on cross-tenant data flows
  • sencai/no-self-policy-mutation (warn) — flags code mutating its own policy/elevation/approval records outside a privileged path
Terminal window
# Full lint (includes all three rules)
npm run lint
# Isolated audit gate — fails only on sencai/require-audit-log violations
npm run lint:audit

lint:audit lives at <service>/scripts/audit-gate.mjs and runs ESLint scoped to just that rule.

Per packages/audit/README.md, the following services vendor @sencai/audit as a real dependency and are the intended consumers of the audit-gate ESLint rule: api-manager, auth-service-consumer, cloud-connector, git-connector, graphql-gateway, opsloop-consumer, sencai-watchdog, ssh-proxy-service, workspace-connector.

scripts/sync-eslint-plugin.sh only automates the vendored-plugin sync for three of those services — api-manager, git-connector, cloud-connector. auth-service-consumer and the remaining services must have their vendored eslint-plugin-sencai copy updated manually after any rule change. This is an operational risk: nothing in CI detects a stale vendored copy, so a service can silently keep linting against an old version of the rule.

The chain is written by a module inside auth-service-consumer (audit-consumer.js), not by a standalone “audit-consumer” service.

entry_hash is a SHA-256 hash computed over a fixed, explicitly ordered set of columns — documented in audit-consumer.js as “AUDIT-HASH-CONTRACT v1” and required to stay byte-identical with the serialization used by sencai.space’s verify-chain/last-hash controller:

action, actor_user_id, actor_org_id, customer_scope, actor_org_scope,
elevation_grant_id, resource_type, resource_id, changes, correlation_id,
risk_level, metadata, ip_address, user_agent, prev_hash

This is not an eventId/UUID-based hash. Before hashing, changes and metadata (both MySQL JSON columns) go through a recursive key-canonicalization step — MySQL reorders JSON object keys on storage, so without canonicalizing first, a later recompute of the hash from the stored row would not match the hash computed at write time.

The chain head (prev_hash for the next entry) is read back through a fail-closed, service-only endpoint: GET /api/audit-logs/last-hash (requires X-Service-Secret). If that read-back fails for any reason (network error, 401, malformed response), the consumer throws rather than falling back to a null prev_hash — writing a null-prev_hash “poison link” would silently break the chain, so the message is instead nacked and retried later.

Ed25519 chain anchoring is on-demand only: POST /api/audit-logs/anchor (admin-only, requires X-Service-Secret) signs and stores an anchor over the latest entry in the chain. There is no automatic scheduled anchoring — no “every 1,000 events” batch job and no hourly cron. Anchoring happens only when this endpoint is explicitly called.

Audit events are published to a single exchange/routing-key pair:

ExchangeRouting keyConsumer
audit.events (topic)audit.writeaudit-consumer.js module inside auth-service-consumer

There is only this one routing key — there is no separate audit.event/audit.anchor split. Anchoring is triggered via the HTTP endpoint above, not via a distinct queue message.

On the consume side, failed messages are retried up to MAX_RETRIES = 5 with exponential backoff; after retries are exhausted, the message moves to the audit.fallback queue (durable: true, 14-day TTL).

Audit events carry actor_org_id/customer_scope/actor_org_scope explicitly rather than being scoped automatically by the publisher. The audit pipeline itself has been a genuine target of past findings, not a solved problem by construction — see SECURITY-AUDIT.md for the historical record, including forgery of audit entries via an under-protected write endpoint, PII leaking into event metadata, and a cross-tenant audit read via a post-mortem generation path. All of those are marked resolved there under the F3.SECREM remediation effort, but treat SECURITY-AUDIT.md as the source of truth for status rather than assuming the pipeline is permanently proven safe.

  • Service: sencai-audit/
  • Port: 3300
  • URL (local): audit.sencai.localhost
  • Auth: Keycloak OIDC (KC client sencai-audit)

The viewer is read-only — all business logic lives in Strapi and in audit-consumer.js. Today it implements a paginated log listing and a per-record detail view that shows the hash-chain fields. Filtering by organisation/action/actor/date-range, CSV/PDF export, and real-time streaming are still work-in-progress/planned, not implemented yet. Only the sencai-admin Keycloak role is wired end-to-end today; dedicated sencai-auditor/support roles are planned but currently map onto the same admin access. Whether the detail view visually flags a broken chain (e.g. a red indicator) is not confirmed in the current codebase — verify against sencai-audit/pages/logs/[id].vue before relying on that behavior, rather than assuming it is implemented.

@sencai/audit/testing exposes mockAuditPublisher, restoreAuditPublisher, expectAuditEvent, and getCapturedEvents. There are two distinct mocking patterns because of how the audit.log alias is bound:

  1. In-module patch — for tests inside the @sencai/audit package itself, calling publishAuditEvent directly from ../publisher:

    import { mockAuditPublisher, restoreAuditPublisher, expectAuditEvent } from '../testing';
    beforeEach(() => mockAuditPublisher());
    afterEach(() => restoreAuditPublisher());
  2. jest.mock factory — required for consumer-service tests that call audit.log(...), since index.ts binds audit = { log: publishAuditEvent } at import time and the in-module patch above does not affect that alias:

    jest.mock('@sencai/audit', () => {
    const testing = jest.requireActual('@sencai/audit/testing');
    return {
    ...jest.requireActual('@sencai/audit'),
    publishAuditEvent: (e: unknown) => testing._recordEvent(e),
    audit: { log: (e: unknown) => testing._recordEvent(e), init: () => undefined },
    };
    });

@sencai/audit is distributed by manual vendoring, not published to an npm registry. After any change under packages/audit/src/:

Terminal window
cd packages/audit
npm run build

…and then manually copy dist/ and package.json into vendor/@sencai/audit/ of every consuming service. No script automates this re-sync (unlike the ESLint plugin, which at least has partial automation via scripts/sync-eslint-plugin.sh). A stale vendored copy is a recurring real risk — always confirm which services were actually re-synced when reviewing a change to packages/audit/src/.

sencai.space (the Strapi backend) does not vendor this package at all. It has its own, separately maintained, loosely-typed audit publisher (action: string) that only shares the concept, not the actual code.

HTTP routes are covered by a global audit-logger middleware in Strapi. Background workers, cron jobs, and lifecycle hooks (afterCreate, beforeUpdate, consumer message handlers, etc.) must call audit.log()/publishAuditEvent() explicitly — the ESLint non-HTTP pattern above is what enforces this at lint time in the services that have the rule wired up and kept in sync.