#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.
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:
1import { AlephaApiAudits } from "alepha/api/audits";2 3alepha.with(AlephaApiAudits);
#Declaring a type
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:
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:
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}
actionsmust be declared by the type, andwindowis<n>s,<n>mor<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, andupdatedAtholds 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
retentionDayson theauditOptionsatom.0keeps every type without a window of its own. retentionDayson a$auditoverrides it for that type.0keeps 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.
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). |