Audit service
Audit Service stores tenant-scoped audit records: who did what, when, to which resource, and with what outcome. Applications register action definitions, then explicitly submit events. Registering an action does not create an audit record.
Use the auditService capability in @abgov/adsp-service-sdk, or call the REST API directly. There is no separate Audit SDK package. Action catalogs and retention settings live in Configuration Service; committed events live in PostgreSQL.
Prepare access
Start with the Node.js SDK service-account setup. Use an SDK release that includes auditActions, auditService.record() and auditService.query(). Keep client secrets in approved secret storage, never in source code.
| Identifier | Example | Purpose |
|---|---|---|
| Application client ID | urn:ads:case-team:case-service | Keycloak client and SDK serviceId. |
| Audit namespace | case-service | The service component of the client ID: serviceId.service. |
| Audit Service client | urn:ads:platform:audit-service | Owns the Audit roles and required token audience. |
| API discovery ID | urn:ads:platform:audit-service:v1 | Resolves the environment’s Audit API URL. |
| Action catalog | audit-service:actions-case-service | Configuration namespace and document name. |
Audit namespaces must be lowercase kebab-case, at most 40 characters. A tenant writer can write only to the namespace derived from its verified client identity (azp). Readers can read all namespaces within their tenant. Core callers need the appropriate role and an explicit target tenant for event writes and history reads.
Roles and audience
These client roles belong to urn:ads:platform:audit-service:
| Role | Permission | Included in tenant-admin |
|---|---|---|
audit-reader | Read action definitions and audit history. | Yes, after provisioning. |
audit-writer | Submit audit events. | No; assign explicitly. |
audit-admin | Administer failed events and access protected actor identity. | No; assign only when needed. |
audit-admin does not replace reader or writer permissions.
- In Tenant Management App > Access > Service roles, find Audit Service, including under Core service roles. Use Action > + to provision missing roles in the tenant realm.
- In Keycloak, open the calling application’s client. Under Client scopes, open its dedicated scope and add an Audience mapper with Included Client Audience set to
urn:ads:platform:audit-service. Include it in the access token. - Under Service account roles, assign
audit-writerfrom the Audit Service client. Also assignaudit-readerif the application needs to query records. - Obtain a fresh client-credentials token. Verify its audience, roles and
azplocally; never paste tokens into public decoding services.
Provisioning a role is not the same as assigning it to an application. Redeploying Audit Service alone does not provision missing roles in existing realms. If the service is absent, ask an administrator to check the environment and tenant/Core registration views.
Action registration separately requires Configuration Service access and audience. The standard SDK registration path uses configured-service; creating and activating revisions requires an authorized configuration-admin. audit-writer alone cannot publish action definitions.
Integrate with the Node.js SDK
The examples run in asynchronous application code. config is validated deployment configuration and logger is a Winston logger. Replace the illustrative client, actor and resource identifiers with your application’s values.
Define the actions
Use base action names without a terminal .succeeded or .failed suffix.
import {
AdspId,
adspId,
AuditCategories,
AuditSeverities,
BuiltInRetentionClasses,
initializePlatform,
initializeService,
} from '@abgov/adsp-service-sdk';
import type { AuditAction } from '@abgov/adsp-service-sdk';
const ACTIONS = {
UPDATE_CASE: {
name: 'case.update',
version: 1,
category: AuditCategories.Administrative,
severity: AuditSeverities.Info,
retentionClass: BuiltInRetentionClasses.Compliance,
resourceType: 'case',
captureChangeSet: true,
metadataSchema: {
type: 'object',
properties: { reason: { type: 'string' } },
required: ['reason'],
additionalProperties: false,
},
},
} satisfies Record<string, AuditAction>;
Tenant application: initializeService
A tenant application supplies its realm. Its service-account token determines the tenant.
const { auditService } = await initializeService(
{
serviceId: adspId`urn:ads:case-team:case-service`,
displayName: 'Case service',
description: 'Case management',
realm: config.realm,
clientSecret: config.clientSecret,
accessServiceUrl: new URL(config.accessServiceUrl),
directoryUrl: new URL(config.directoryUrl),
auditActions: ACTIONS,
},
{ logger },
);
Core platform service: initializePlatform
Use this instead of the tenant initialization above for a Core service. It automatically uses the core realm and registers a shared Core catalog; do not pass a realm option. Use credentials for the Core client, not the tenant application’s client.
const { auditService } = await initializePlatform(
{
serviceId: adspId`urn:ads:platform:case-service`,
displayName: 'Case service',
description: 'Multi-tenant case management',
clientSecret: config.clientSecret,
accessServiceUrl: new URL(config.accessServiceUrl),
directoryUrl: new URL(config.directoryUrl),
auditActions: ACTIONS,
},
{ logger },
);
const tenantId = AdspId.parse(config.tenantId);
const outcome = await auditService.record(ACTIONS.UPDATE_CASE, {
tenantId,
actor: { id: 'onboarding-test-user', type: 'user' },
resource: { type: 'case', id: 'onboarding-case-001' },
metadata: { reason: 'Onboarding verification' },
});
if (outcome.status !== 'recorded') {
throw new Error(`Audit write failed: ${outcome.status}`);
}
const history = await auditService.query(
{ resourceType: 'case', resourceId: 'onboarding-case-001' },
{ tenantId, top: 20 },
);
config.tenantId must be a tenant URN such as urn:ads:platform:tenant-service:v2:/tenants/<tenant-id>, taken from trusted tenant context. Core callers must pass this AdspId in record details and query options, as shown above. Tenant applications use the same calls but omit tenantId.
Record another application’s events
A Core service that bridges events from another application, such as Keycloak events forwarded for the access service, can record them in that application’s namespace. Create a separate client with createAuditService; the namespace is the service part of serviceId. auditActions only registers the caller’s own catalog, so register the bridged application’s catalog through the serviceConfigurations initialization option, using the actions-<application> document shown in Register without the SDK below.
import { adspId, createAuditService } from '@abgov/adsp-service-sdk';
const accessAudit = createAuditService({
isCore: true,
logger,
directory,
tokenProvider,
serviceId: adspId`urn:ads:platform:access-service`,
meterProvider,
});
await accessAudit.record(USER_DELETED, { tenantId, actor, resource });
The client writes as a Core writer, so every record needs an explicit tenantId taken from the bridged event. writtenBy is still the calling service’s client (for example urn:ads:platform:event-service), which needs the audit-writer role.
Record from an authenticated request
In an Express handler behind SDK authentication, auditDetailsFromRequest(req, details) sets actor from req.user and tenantId from req.tenant (or the user’s tenant). Values passed in details take precedence; for example, a Core service acting on a different tenant passes that tenant’s tenantId. toAuditActor(user) converts a user on its own: Keycloak service accounts (service-account-*) become service actors, other users user actors. Both throw when there is no user and no explicit actor, so events are never attributed to an unknown actor.
import { auditDetailsFromRequest, toAuditActor } from '@abgov/adsp-service-sdk';
router.patch('/cases/:id', async (req, res, next) => {
try {
const updated = await updateCase(req.params.id, req.body);
// The SDK logs any event that is not recorded; no outcome handling is needed here.
await auditService.record(
ACTIONS.UPDATE_CASE,
auditDetailsFromRequest(req, {
resource: { type: 'case', id: updated.id },
metadata: { reason: req.body.reason },
}),
);
res.send(updated);
} catch (err) {
next(err);
}
});
// Outside a request, for example in a queue consumer:
await auditService.record(ACTIONS.UPDATE_CASE, { tenantId, actor: toAuditActor(user), resource });
When the operation must not succeed without its audit record, check the outcome and fail the request instead:
router.post('/cases/:id/close', async (req, res, next) => {
try {
const closed = await closeCase(req.params.id);
const outcome = await auditService.record(
ACTIONS.UPDATE_CASE,
auditDetailsFromRequest(req, {
resource: { type: 'case', id: closed.id },
metadata: { reason: 'Case closed' },
}),
);
if (outcome.status !== 'recorded') {
throw new Error(`Audit event ${outcome.eventId} was not recorded.`);
}
res.send(closed);
} catch (err) {
next(err);
}
});
Event content and delivery
record() supplies a UUID eventId, the current occurredAt, and business status: 'success' when omitted. Supply these explicitly when preserving an event for later delivery. Business status can be success, failure, blocked or error; it is separate from the delivery outcome.
To capture changes, set captureChangeSet: true on the action and add changeSet: { before: { state: 'draft' }, after: { state: 'submitted' } } to the record details. Otherwise the SDK drops the change set before sending. Record only necessary data; never include credentials or unnecessary personal information.
record() never rejects. The SDK logs every event that is not recorded, with its action, event ID and tenant: rejections and local failures at warn, delivery failures at error. Inspect the outcome only when the operation needs a different policy, such as failing the request or resending with the same eventId.
| Outcome | Meaning | Response |
|---|---|---|
recorded | A committed audit row or an existing idempotent duplicate was confirmed. | Continue. |
rejected | The service returned a per-event code and reason, or recording is turned off (code: 'audit_disabled') and nothing was sent. | Investigate; the SDK does not resubmit it automatically. |
failed | Delivery failed or could not be confirmed; includes reason and error. | Apply an explicit recovery policy; the event may already be stored. |
Turn audit recording off
Audit recording is on by default. Set the environment variable AUDIT_ENABLED=false (or 0) to turn it off for every SDK audit client in the process, including clients from createAuditService. Each record() then resolves { status: 'rejected', eventId, code: 'audit_disabled', reason } without contacting Audit Service, and the event is not counted as dropped. Any other value, or no value, leaves recording on. To decide in code instead, pass audit: { enabled: false } to either initializer or enabled: false to createAuditService; the option takes precedence over the variable. Action catalogs are still registered and query() still works, so turning recording back on needs only a restart.
The SDK sends events immediately by default. It retries network errors, timeouts and HTTP 429/502/503/504 with the same IDs. For application-level retries, preserve the original event, including its eventId and occurredAt; a new ID can create a duplicate after a lost response.
Optional client batching is enabled with audit: { batchSize: 100, batchIntervalMs: 500 } in either initializer. Pending, in-flight and retrying work defaults to a maximum of 1,000 events or 8 MiB of serialized data; excess events fail with buffer_full. This buffer is in memory, not durable recovery storage.
Monitor auditService.droppedEventCount and the audit.client.dropped_events metric. Before shutdown, stop accepting new work and call await auditService.flush() if unawaited records or batches remain. No separate flush is needed once all record() calls have settled. Forced termination can lose pending work.
Register and publish actions
auditActions replaces the complete audit-service:actions-<serviceId.service> catalog. Include every action that must remain registered and coordinate deployments of the same application: concurrent replacements are last-writer-wins. Other applications’ catalogs are separate. Only trusted deployment identities should publish catalogs; Configuration Service permissions do not enforce application-level write isolation.
Each definition requires a positive integer version, category (administrative, runtime, security, data-access, diagnostic), severity (info, warning, critical) and a kebab-case retentionClass. Optional fields include description, resourceType, metadataSchema, captureChangeSet and forwardToSiem. Both flags default to false; SIEM forwarding is a separate capability. Shared Core definitions win over tenant definitions with the same action name.
The diagnostic category is for debugging-oriented records (as opposed to audit records of who did what). Applications register diagnostic actions in their own catalog like any other category, and the events are filterable with category=diagnostic when reading or exporting.
Register without the SDK
Through Configuration Service, PATCH the document in namespace audit-service, name actions-case-service, using this complete-document replacement:
{
"operation": "REPLACE",
"configuration": {
"kind": "actionCatalog",
"application": "case-service",
"actions": {
"case.update": {
"version": 1,
"category": "administrative",
"severity": "info",
"retentionClass": "compliance",
"resourceType": "case",
"captureChangeSet": true,
"metadataSchema": {
"type": "object",
"properties": { "reason": { "type": "string" } },
"required": ["reason"],
"additionalProperties": false
}
}
}
}
}
Confirm publication
If an active revision exists, updating latest alone does not publish the change. Create and activate the intended revision through Configuration Service.
Registration is asynchronous. SDK writes wait for registration to settle, not for proof that it succeeded or every Audit Service instance has refreshed. The current cache TTL is 900 seconds (15 minutes), with invalidation notifications allowing earlier refresh. This setting is provisional, not a propagation guarantee.
Before emitting a new action, check registration logs and use an audit-reader token to call GET /audit/v1/case-service/actions. Confirm the expected name and version, then submit and query a controlled event. Do not use a fixed sleep as a readiness check. Configuration-read failures can appear as missing catalogs or unknown actions; a 404 alone does not prove that registration is absent.
Use the REST API
Resolve urn:ads:platform:audit-service:v1 through the target environment’s Directory Service. The SDK handles discovery automatically. For full schemas, follow the service root’s _links.docs.href to its OpenAPI document (/swagger/docs/v1).
Adding an action
POST /audit/v1/{namespace}/actions
Authorization: Bearer <token with audit-admin>
Content-Type: application/json
{ "name": "case.update", "version": 1, "category": "runtime", "severity": "info", "retentionClass": "standard" }
Adds the action to the caller’s tenant catalog (actions-<namespace>, created if missing). The caller’s token is forwarded to the configuration service, so configuration write permission is required as well. The name is compared in full and case-insensitively with the namespace’s own catalog and every Core catalog, read directly from the configuration service rather than the cache. 409 is returned when the name already exists in either, or when the namespace is itself a Core namespace; 503 when the check cannot be made, in which case nothing is written.
Write an event
Use a bearer token with the Audit audience and audit-writer. Replace the placeholders with a UUID and the current ISO 8601 UTC timestamp; preserve both when retrying. Core callers must also supply the URL-encoded tenant URN as the tenantId query parameter.
POST /audit/v1/case-service/events
Authorization: Bearer
Content-Type: application/json
{
"events": [
{
"eventId": "",
"occurredAt": "",
"action": "case.update",
"actionVersion": 1,
"status": "success",
"actor": { "id": "onboarding-test-user", "type": "user" },
"resource": { "type": "case", "id": "onboarding-case-001" },
"metadata": { "reason": "Onboarding verification" }
}
]
}
A confirmed single-event write returns HTTP 202:
{ "accepted": 1, "duplicates": 0, "rejected": [] }
202 alone does not mean every event succeeded. Check all fields: accepted + duplicates + rejected.length accounts for the submitted events. Rejections include the zero-based input index, code, reason, and eventId when available.
| Constraint | Limit |
|---|---|
| Events per request | 1-500 |
| Occurrence time | Previous 7 days through 5 minutes into the future |
| Metadata and change set | 16 KiB UTF-8 JSON each |
| HTTP request body | 20 MiB; enriched batches must also fit the smaller service buffer |
| Action version | Omit to use the current catalog version; an explicit mismatch is rejected |
Read and export
History reads require audit-reader and tenant context. The writer role alone is insufficient.
GET /audit/v1/case-service/events?resourceType=case&resourceId=onboarding-case-001&top=20
Authorization: Bearer
Both REST lists and SDK query() return { results, page }. Pass page.next as after for the next page, keeping the same filters; URL-encode it for REST. Results are newest first. The detail endpoint uses the returned record’s id, not the caller’s eventId. Unlike record(), SDK query failures reject the promise.
REST filters include actor, resourceType, resourceId, action, status, category, severity, scope, correlationId, sessionId, occurredFrom and occurredTo. Filters combine with AND; time bounds are inclusive. Free-text search is not supported. The SDK’s typed criteria cover a subset; see SDK types. SDK query options also accept another readable namespace or an authorized user token.
All paths below are relative to /audit/v1.
| Method and path | Role | Purpose |
|---|---|---|
GET /{namespace}/actions | audit-reader | Effective action definitions. |
GET /actions | audit-reader | All effective application catalogs. |
GET /retention | audit-reader | Configured retention settings, excluding implicit defaults. |
POST /{namespace}/events | audit-writer | Submit a batch of events. |
GET /events | audit-reader | History across the tenant’s namespaces. |
GET /{namespace}/events | audit-reader | History in one namespace. |
GET /{namespace}/events/{id} | audit-reader | One stored record. |
GET /namespaces | audit-reader | Namespaces with stored events. |
GET /events/export | audit-reader | Stream matching events as CSV. |
GET /{namespace}/events/export | audit-reader | Stream one namespace’s events as CSV. |
GET /failed-events | audit-admin | List failed events. |
POST /failed-events/{id}/replay | audit-admin | Replay an open failed event; no body required. |
CSV exports use the same filters but ignore top and after; they are not capped to a page. Actor name/email columns require audit-admin in addition to reader access. An interrupted export may be incomplete. Removing an action definition does not delete or hide its stored history.
Retention and administration
Actions reference a retention class, not a number of days. Built-in defaults are 90 days for standard and 365 days for security and compliance; confirm the appropriate policy for your application.
Settings in audit-service:retention-<application> override resolved shared settings in audit-service:retention, which override built-in defaults. Custom classes require a configured period before use. RETENTION_MIN_DAYS and RETENTION_MAX_DAYS bound configured periods (currently 1-3650 days, pending policy confirmation). Missing custom periods or resolved periods outside the bounds fail writes with 503.
Expiry is calculated from occurredAt. Changes apply only to newly enriched events after configuration refresh; existing stored expiry values are not recalculated.
The Tenant Management App > Services > Audit (audit feature flag) provides Overview, Logs, Catalogs and Retention tabs. The Sandbox Audit page also supports controlled writes and failed-event inspection. Successful history reads are themselves audited; a self-audit failure is logged and counted but does not fail the read.
Failed-event replay validates the stored event against current configuration, while allowing events older than seven days. It atomically saves the recovered record, replay status and replay audit. Invalid data returns 400, an absent row 404, and a non-open row 409. Investigate before replaying.
Troubleshooting
| Result | What to check or do |
|---|---|
| 401 | Token expiry, issuer, environment and Audit audience. Obtain a fresh token after access changes. |
| 403 | Actual caller’s role assignment, namespace ownership and target tenant. Provisioning alone does not grant access. |
| 400 / validation rejection | Correct the envelope, action version, timestamp, actor, resource or metadata. |
| Unknown action / catalog 404 | Check catalog publication, active revision, tenant/Core scope, cache visibility and configuration errors. |
| 413 | Reduce the payload or split the batch; enriched events must fit the service buffer too. |
| 429 | Respect Retry-After, reduce load and resend the same events with unchanged IDs. |
| 503 / timeout | Check configuration and storage dependencies. Use bounded retries and preserve IDs; delivery may be uncertain. |
Rejection storage_failed | The event was parked in failed-event storage, not normal history. Use the authorized investigation/replay process. |
SDK failed / buffer_full | Apply backpressure and durable recovery where required; do not silently discard events. |
Configuration fetch/conversion failures can be indistinguishable from missing configuration and may cause partial catalogs or fallback settings. An empty role/schema listing from a development tool is also not proof of missing registration; compare the tenant and Core configuration views.
Operational notes
- Rate limits: per tenant and namespace, counting events rather than requests. Defaults are 500 events/second and a burst of 500; Core
audit-service:rate-limitconfiguration can override them within bounds (1-500 events/second, burst 500-1,000). Limits are per pod, not cluster-wide. A 429 refuses the whole request; admitted events consume allowance even if later rejected. - Storage: the service batches independently of the SDK (200 events or 200 ms), with a per-pod buffer of 1,000 events / 8 MiB. A committed row is required for acceptance. Neither buffer provides durable storage during a forced process exit.
- Health:
/health/livechecks process responsiveness;/health/readychecks database and write availability and returns 503 during shutdown. History database queries allow 60 seconds; the deployed Route allows 120 seconds. Client deadlines must also allow the intended duration. - Retention job: a separate, single-replica
audit-service-jobruns daily at 02:00 UTC. Keep retention disabled in API replicas; drain old API schedulers before starting the job. ConfigureRETENTION_SCHEDULE,RETENTION_BATCH_SIZEandRETENTION_MAX_BATCHESon the job. To suspend it, scale the job to zero or change its explicitRETENTION_ENABLEDsetting. Cleanup uses stored expiry; missed schedules are not replayed. - Database permissions: the current deployment shares a database account across migrations, requests and retention. It does not provide database-enforced append-only isolation.
- Monitoring: configure
OTEL_EXPORTER_OTLP_ENDPOINT. Checkaudit_failed_events_available = 1,audit_failed_events_open, rate-limit metrics and SDK dropped-event metrics. Missing metrics are not a successful zero result.
For implementation and deployment details, see the Audit Service source, deployment manifest and environment settings.