alepha@docs:~/framework/docs/guides/security$
cat 7-audit-log.md | pretty
4 min read
Last commit:

#Audit Log

$audit records who did what, in a database table you can filter and search. A service declares a type of event with the actions it allows, and logs them as they happen. Retention, an admin API and a page in @alepha/ui come with it.

typescript
1import { $audit } from "alepha/api/audits";

It needs an ORM connection, because every event is a row in the audits table. Declaring a $audit registers the AlephaApiAudits module. Register it yourself for the admin API in an app that declares no type of its own:

typescript
1import { AlephaApiAudits } from "alepha/api/audits";2 3alepha.with(AlephaApiAudits);

#Declaring a type

typescript
 1import { $audit } from "alepha/api/audits"; 2  3class OrderAudits { 4  order = $audit({ 5    type: "order", 6    description: "Order management events", 7    actions: ["create", "cancel", "refund"], 8  }); 9 10  async cancelled(orderId: string, reason: string) {11    await this.order.logSuccess("cancel", {12      resourceType: "order",13      resourceId: orderId,14      description: `Order cancelled: ${reason}`,15    });16  }17 18  async refundFailed(orderId: string, error: string) {19    await this.order.logFailure("refund", error, {20      resourceType: "order",21      resourceId: orderId,22    });23  }24}
Option What it does
type The event type, such as order.
actions The actions this type allows.
description What the type covers.
retentionDays How long to keep this type, see Retention.
coalesce Which actions merge into one row, see Merging bursts.

#Logging an event

Method Records
.log(action, options) An event, with whatever options says.
.logSuccess(action, options) A success.
.logFailure(action, errorMessage, options) A failure, with its message.

⚠️ An action the type does not declare throws. The admin filters are built from that list, so a typo would otherwise be recorded and never found.

During a request, the event fills in what it can by itself, unless you pass it:

Field From the request
userId, userEmail, userRealm The signed-in user.
ipAddress, userAgent, requestId The request.

options also takes resourceType, resourceId, description and metadata (a JSON object). description, errorMessage, ipAddress, userAgent and requestId are cut to fit their columns rather than failing the write.

Severity is info, warning or critical. Left out, it is info for a success and warning for a failure.

#Scopes

An event belongs to one of two layers. An app-layer event, a sign-in for instance, belongs to the deployment. A scoped event happened inside one tenant:

typescript
1await this.order.logSuccess("create", {2  scopeType: "shop",3  scopeId: shop.id,4  resourceType: "order",5  resourceId: order.id,6});

⚠️ Set scopeType and scopeId together. The indexes lead on the pair, and a scopeId alone is missed by every query that names a scopeType.

#Merging bursts

Ten edits of one document in twenty minutes are ten true events and ten near-identical rows. coalesce folds them into one row with a count:

typescript
1import { $audit } from "alepha/api/audits";2 3class DocumentAudits {4  document = $audit({5    type: "document",6    actions: ["create", "update", "delete"],7    coalesce: { actions: ["update"], window: "5m" },8  });9}
  • actions must be declared by the type, and window is <n>s, <n>m or <n>h. Anything else fails when the type registers, not at the first write.
  • Events merge when their scope, type, action, user and resource are the same.
  • Failures never merge.
  • The row keeps eventCount, and updatedAt holds when the last event landed.
  • The window runs from the row's createdAt, not from its last event, so a long burst becomes several rows.

#Reading the log

AuditService answers from code:

Method Answers
find(query) A filtered, paginated page.
findByUser(userId) One user's events.
findByResource(type, id) One resource's events.
getById(id) One event.
getStats() Counts for a period.
getRegisteredTypes() Every type registered in this process.

find filters on type and action (comma-separated for several), severity, scopeType, scopeId, layer (app or scoped), userId, userRealm, resourceType, resourceId, success, and a date range.

#The admin API

AdminAuditController serves the same reads under /api/audits, with /stats, /types, /actions and /filters, plus a create and a delete.

Permission Allows
admin:audit:read Every read.
admin:audit:create Writing an entry.
admin:audit:delete Deleting entries.

AdminAudits in @alepha/ui/admin is the page on top of it.

#Retention

A daily job at 03:00 deletes what has outlived its window.

  • The default is 90 days, from retentionDays on the auditOptions atom. 0 keeps every type without a window of its own.
  • retentionDays on a $audit overrides it for that type. 0 keeps that type forever, whatever the default says.

The job reads these from the types registered in its own process. A type whose class is not loaded there falls under the default.

typescript
1import { $audit } from "alepha/api/audits";2 3class ComplianceAudits {4  compliance = $audit({5    type: "compliance",6    actions: ["export"],7    retentionDays: 730,8  });9}

#Built-in types

These modules declare their own types:

Type Declared by Covers Active
auth, security alepha/api/users Sign-ins, tokens, MFA, blocked access. With a $realm.
user alepha/api/users Accounts. With a $realm.
api-key alepha/api/keys API keys. With the module.
parameter alepha/api/parameters Runtime parameter changes. With alepha.with(ParameterAudits).