@devx-retailos/audit-log
v0.3.0
Published
Append-only who-did-what audit trail for retailOS. Non-blocking middleware captures actor, scope, operation, HTTP status, and payload snapshot across admin and store surfaces.
Keywords
Readme
@devx-retailos/audit-log
Append-only who-did-what audit trail for retailOS. Non-blocking middleware captures actor, scope, operation, HTTP status, and payload snapshot across admin and store surfaces.
Installation
pnpm add @devx-retailos/audit-logQuick start
// medusa-config.ts
import type { AuditRule } from "@devx-retailos/audit-log"
export default defineConfig({
plugins: [
{
resolve: "@devx-retailos/audit-log",
options: {
rules: [
{
method: "POST",
pathPattern: "/admin/retailos/orders",
module: "order",
operation_type: "create",
title: "Order created",
},
{
method: "GET",
pathPattern: "/admin/retailos/orders/:id",
module: "order",
operation_type: "read",
title: "Order viewed",
},
],
},
},
],
})How it works
The package registers a non-blocking middleware on every /admin/retailos/* and /store/retailos/* request. After the response is sent (res.finish), the middleware:
- Resolves the matching
AuditRulefrom the registry (by HTTP method + path pattern). - Reads actor context from
req.auth_contextand organization/store headers. - Redacts PII and secrets from the request body and from the request headers.
- Writes one
retailos_audit_log_entryrow — fire-and-forget, errors are swallowed.
If no rule matches, nothing is written. Routes not in the registry are invisible to the audit log.
Plugin options
| Option | Type | Default | Description |
|---|---|---|---|
| rules | AuditRule[] | [] | Rules to register at boot. |
| capturePayload | boolean | true for non-GET | Whether to snapshot the request body. |
| captureResponse | boolean | false | Snapshot the JSON response body into response_snapshot. Off by default — responses are often larger than requests and carry the same PII. See Response capture. |
| maxBodyBytes | number | 100_000 | Byte cap on stored payload; truncated payloads include { _truncated: true, _original_bytes: N }. |
| redact | (body) => unknown | built-in | Override the PII redactor. |
| retentionDays | number | — | If set, the nightly retailos-audit-log-retention job deletes entries older than this. Unset means nothing is ever deleted. See Retention. |
| getActorContext | async (req) => AuditActorContext \| null | RBAC's subjectFromRequest + headers | Custom actor resolver. The default resolves the authenticated Medusa admin actor only — mount this middleware on a surface with a different identity provider (storefront customer, API key, machine actor) and you must supply a resolver that returns that provider's subject_type, because the default returns null rather than guessing one. |
| getLocationContext | async (req) => AuditLocationContext \| null | req.ip + user-agent | Custom location resolver. ip_address comes from req.ip, never from x-forwarded-for directly. See Client IP and trust proxy. Its return value may also include metadata — an arbitrary object stored on the row's metadata column, redacted with redact() and capped by maxBodyBytes the same as payload/response_snapshot/headers; nothing populates it by default. |
Request headers are captured unconditionally (there is no captureHeaders option) into the
headers column — redacted the same way as payload/response_snapshot and subject to the same
maxBodyBytes cap. Unlike the request body, headers are small and fixed-shape, and which headers a
call carried (or omitted) is itself security-relevant even when the body is not.
Client IP and trust proxy
The default location resolver reads req.ip and never parses x-forwarded-for itself. Express
derives req.ip from the forwarded chain only when the host app has configured trust proxy,
and uses the socket address otherwise — so on a deployment that sits without a proxy a forged
header cannot become an audit IP.
If your deployment sits behind a load balancer and you want the real client IP recorded, configure
trust proxy — but scope it to the proxy hops or subnets you actually run, never true:
// The number of trusted proxy hops between the client and this app…
app.set("trust proxy", 1)
// …or the exact addresses/subnets of the load balancers themselves.
// Placeholder — substitute your balancer's own range, and only a range
// dedicated to it. Do not paste a broad private block such as 10.0.0.0/8:
// every reachable host inside it would then be trusted to set the audit IP.
app.set("trust proxy", ["<load-balancer-cidr>"])trust proxy: true trusts the left-most x-forwarded-for entry from anyone. If a client can reach
the app directly — a public container port, a VPC peer, a health-check path that bypasses the
balancer — it can write an attacker-chosen IP into the audit trail, which is exactly the field an
investigation relies on.
Two requirements go with it, and they are the host's to meet, not this plugin's:
- Every path into the app must pass through the trusted proxy. Scoping the setting is only worth as much as the network boundary behind it.
- The proxy must overwrite or strip client-supplied
x-forwarded-for, rather than appending to whatever the client sent. A balancer that appends leaves the client in control of the left-most entry.
Audit rules
An AuditRule matches a request by method + path and controls what gets written:
interface AuditRule {
method: string // "GET" | "POST" | "PATCH" | "DELETE" | "*"
pathPattern: string | RegExp // "/admin/retailos/orders/:id" or a RegExp
module: string // e.g. "order"
operation_type: string // e.g. "create" | "read" | "update" | "delete"
title: string // short human-readable label
describe?: (req: MedusaRequest, responseBody: unknown) => string | undefined // optional dynamic description
}:param placeholders in string patterns match any single path segment. First-match wins.
Rules can also be registered at runtime:
const auditService = container.resolve(AUDIT_LOG_MODULE)
auditService.registerAuditRule({
method: "POST",
pathPattern: "/admin/retailos/returns",
module: "order",
operation_type: "return",
title: "Return initiated",
})API routes
The list and single-entry routes require audit.read. The CSV export requires audit.export —
it does not also check audit.read, so a role granted only audit.export can download the
trail without being able to browse it. Grant each permission only to roles that need that action,
and both only to roles that need both.
List entries
GET /admin/retailos/audit-logsQuery params: limit, offset, subject_id, subject_type, store_id, organization_id, module, source, operation_type, from (ISO date), to (ISO date), q (free-text on title/description/path).
Every filter — including from/to and q — is applied by the database before pagination, so
count is the total number of matching rows and paging past the first page is safe. q is matched
case-insensitively as a "contains"; % and _ in the search term are treated as literal characters.
q must be at least 3 characters — shorter terms are rejected with 400. A one- or
two-character term produces no trigrams, so ILIKE '%te%' cannot use the GIN indexes and degrades
to a sequential scan over the whole trail. An empty q still means "no search", so a cleared search
box is not an error.
Get single entry
GET /admin/retailos/audit-logs/:idExport CSV
GET /admin/retailos/audit-logs/export?from=2026-01-01&to=2026-06-30Requires audit.export permission. from and to are required.
Accepts the same filters as the list route (subject_id, subject_type, store_id,
organization_id, module, source, operation_type, q) so an export matches the screen it was
launched from, including the same 3-character minimum on q. Exports larger than 10,000 rows are
refused with RETAILOS_AUDIT_EXPORT_TOO_LARGE and the actual row count — narrow the date range and
retry.
Response capture
With captureResponse: true, the JSON body a handler sends is stored on response_snapshot, so an
operator can see what the system answered and not just what was asked. It is off by default: a
response usually carries more data than the request that triggered it, including the same PII.
- Only
res.jsonis captured.res.sendalso carries CSV exports and binary downloads, which would bloat the table without telling an operator anything the request row does not already. - The body passes through the same
redactfunction andmaxBodyBytescap as the request payload, so tokens and PII are masked before storage and oversized bodies are truncated with{ _truncated: true, _original_bytes: N }. - A rule's
describe(req, responseBody)receives the redacted response, so descriptions can quote values the handler produced (a new id, a computed total). WithoutcaptureResponseit receivesnull.
Indexes
The migration adds, alongside the primary key and the soft-delete index:
| Index | Type | Serves |
|---|---|---|
| IDX_retailos_audit_log_entry_occurred_at | btree, partial on deleted_at IS NULL | the from/to range and ORDER BY occurred_at DESC |
| IDX_retailos_audit_log_entry_title_trgm | GIN trigram | q against title |
| IDX_retailos_audit_log_entry_description_trgm | GIN trigram | q against description |
| IDX_retailos_audit_log_entry_api_path_trgm | GIN trigram | q against api_path |
q runs as ILIKE '%term%'. A leading wildcard makes a btree index unusable, so the searched
columns need trigram indexes; these require the pg_trgm extension, which the migration creates.
Who can create it. pg_trgm is a trusted extension from PostgreSQL 13 onward, so the migrating
role does not need to be a superuser — CREATE privilege on the database is enough, which the
owning application user normally has. Superuser (on RDS: the master user or rds_superuser) is only
required on PostgreSQL 12 and older. Verify with:
-- Run as the same role the migration runs as.
SELECT current_user,
current_setting('server_version') AS pg_version,
(SELECT rolsuper FROM pg_roles WHERE rolname = current_user) AS is_superuser,
has_database_privilege(current_user, current_database(), 'CREATE') AS can_create_in_db,
(SELECT bool_or(trusted) FROM pg_available_extension_versions
WHERE name = 'pg_trgm') AS pg_trgm_trusted,
EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm') AS already_installed;already_installed, or pg_trgm_trusted AND can_create_in_db, means the migration will succeed. If
neither holds, have a superuser or a provider role with equivalent privileges (on RDS: the master
user or a member of rds_superuser) run CREATE EXTENSION IF NOT EXISTS pg_trgm; once beforehand;
the migration's statement is then a no-op. The extension statement is deliberately left in the
migration and is not permission-guarded — a role without the privilege must fail the migration
loudly rather than leave the table unindexed behind a green deploy.
The GIN indexes are written by hand because the model DSL cannot express a GIN index. Schema diffing does not know about them — do not let a regenerated migration drop them.
Default PII redaction
The built-in defaultRedact strips values for keys matching (case-insensitive):
password, passwd, token, secret, api_key, apikey, authorization, auth, access_token, refresh_token, credit_card, card_number, card_no, cvv, cvc, expiry, ssn, otp, pin, email, phone, mobile, phone_number, cookie, set-cookie, x-api-key, x-auth-token, x-access-token, proxy-authorization, x-csrf-token, x-xsrf-token.
The last eight are header names, redacted the same way when they show up in the captured headers
object as when they show up in a request body under those same keys.
Redacted values are replaced with "[REDACTED]". The original object is never mutated.
Permissions
| Key | Description |
|---|---|
| audit.read | List and retrieve audit log entries |
| audit.export | Export entries as CSV |
Register via RBAC:
# Grant to a role (using the RBAC API)
POST /admin/retailos/roles/:roleId/permissions
{ "permission_id": "<perm_id>" }Immutability
Entries are append-only. updateAuditLogEntries and deleteAuditLogEntries on the service throw AuditLogImmutableError. The only sanctioned deletion path is auditService.pruneOlderThan(date), intended for scheduled retention jobs.
pruneOlderThan deletes the oldest qualifying entries in batches of 10,000 and returns how many it
removed. A short batch means nothing older than the cutoff is left; a full batch does not prove the
opposite, since a backlog that is an exact multiple of the batch size fills every batch and leaves
nothing behind. Call hasEntriesOlderThan(date) when you need to know.
Retention
The plugin ships the scheduled job that calls it: retailos-audit-log-retention, nightly at 03:00,
registered automatically like any other plugin job.
It is opt-in — with retentionDays unset (the default) nothing is ever deleted, which is the
right default for an audit trail. Set it to enable retention:
{ resolve: "@devx-retailos/audit-log", options: { retentionDays: 365 } }Each run drains at most 10 batches (100,000 rows) and stops early once a batch comes back short.
The bound keeps a first run against a never-pruned table from holding the database busy for the
whole backlog; whatever is left is picked up the next night. A run that hits the bound asks the
database whether anything older than the cutoff survives, and logs more_remaining: true only when
rows actually remain — a full final batch is not on its own proof of a backlog, since the last
permitted batch can be the one that clears it. A failure mid-drain is logged, not thrown — the
rows already deleted stand and the next run continues from the same cutoff.
pruneOlderThan assumes a single pruner. Two processes racing on the same cutoff still delete
correctly, but both report the full batch. Do not run your own retention loop alongside the job.
To drive retention yourself instead, leave retentionDays unset and call the service directly:
let removed: number
do {
removed = await auditService.pruneOlderThan(cutoff)
} while (removed === 10_000)Error codes
| Code | Class | When |
|---|---|---|
| RETAILOS_AUDIT_IMMUTABLE | AuditLogImmutableError | update or delete attempted |
| RETAILOS_AUDIT_NOT_FOUND | AuditLogEntryNotFoundError | entry id does not exist |
| RETAILOS_AUDIT_EXPORT_TOO_LARGE | AuditLogExportTooLargeError | export exceeds 10,000 rows |
| RETAILOS_AUDIT_EXPORT_MISSING_DATE_RANGE | AuditLogExportMissingDateRangeError | export called without from/to |
