Audit Trail — Developer Reference
Audit Trail — Developer Reference
Section titled “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.
Package
Section titled “Package”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 helpersaudit.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' },});publishAuditEvent signature
Section titled “publishAuditEvent signature”function publishAuditEvent( event: Partial<AuditEvent> & { action: AuditAction; resource_type: string }): Promise<void>AuditEvent fields
Section titled “AuditEvent fields”| Field | Type | Description |
|---|---|---|
action | AuditAction | Required. The action literal (see below) |
resource_type | string | Required. Type of the affected resource (e.g. cloud-instance, user) |
resource_id | string | number | ID of the affected resource |
actor_user_id | string | number | Strapi user ID or KC subject of the acting user/service |
actor_org_id | string | Organisation the actor belongs to |
customer_scope | string | null | Tenant that was the subject of the action; null = platform-level. customer_scope !== actor_org_id marks a cross-tenant operation |
actor_org_scope | string | null | Org from which the actor operated — used for agency/MSP cross-tenant audit trails |
elevation_grant_id | string | null | JIT elevation grant ID if the action ran under elevation |
changes | Record<string, { before, after }> | Before/after snapshot — must never contain secrets |
correlation_id | string | OTEL trace ID |
risk_level | 'low' | 'medium' | 'high' | 'critical' | Defaults to 'low' if not set anywhere in context or event |
metadata | Record<string, unknown> | Free-form extra data — no secrets, no unredacted PII |
ip_address | string | Client IP |
user_agent | string | Client user agent |
timestamp | string | ISO 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
Section titled “AuditAction”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_SNAKEactions 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.
AsyncLocalStorage context
Section titled “AsyncLocalStorage context”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.
ESLint audit gate
Section titled “ESLint audit gate”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 inpackages/audit/src/classification.tson cross-tenant data flowssencai/no-self-policy-mutation(warn) — flags code mutating its own policy/elevation/approval records outside a privileged path
CI commands
Section titled “CI commands”# Full lint (includes all three rules)npm run lint
# Isolated audit gate — fails only on sencai/require-audit-log violationsnpm run lint:auditlint:audit lives at <service>/scripts/audit-gate.mjs and runs ESLint scoped to just that rule.
ESLint gate coverage
Section titled “ESLint gate coverage”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.
Immutable hash chain
Section titled “Immutable hash chain”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_hashThis 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.
Anchoring
Section titled “Anchoring”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.
RabbitMQ
Section titled “RabbitMQ”Audit events are published to a single exchange/routing-key pair:
| Exchange | Routing key | Consumer |
|---|---|---|
audit.events (topic) | audit.write | audit-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).
Cross-tenant isolation
Section titled “Cross-tenant isolation”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.
Audit Viewer SPA
Section titled “Audit Viewer SPA”- 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.
Testing
Section titled “Testing”@sencai/audit/testing exposes mockAuditPublisher, restoreAuditPublisher, expectAuditEvent, and getCapturedEvents. There are two distinct mocking patterns because of how the audit.log alias is bound:
-
In-module patch — for tests inside the
@sencai/auditpackage itself, callingpublishAuditEventdirectly from../publisher:import { mockAuditPublisher, restoreAuditPublisher, expectAuditEvent } from '../testing';beforeEach(() => mockAuditPublisher());afterEach(() => restoreAuditPublisher()); -
jest.mockfactory — required for consumer-service tests that callaudit.log(...), sinceindex.tsbindsaudit = { 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 },};});
Distribution — manual vendoring
Section titled “Distribution — manual vendoring”@sencai/audit is distributed by manual vendoring, not published to an npm registry. After any change under packages/audit/src/:
cd packages/auditnpm 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.
Coverage: non-HTTP code paths
Section titled “Coverage: non-HTTP code paths”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.