Audit Log¶
Meterplex records every mutation (create, update, delete) to an immutable audit_logs table in Postgres. This provides a queryable, tamper-resistant history of who did what, when, and to which tenant.
Why Postgres over log aggregators?¶
A billing platform must prove who changed what. "Who revoked this API key?" and "Who changed the plan from Pro to Starter?" are questions your customers will ask. Log aggregators (Loki, ELK, CloudWatch) can lose data during outages or retention expiry. A database table with indexes gives you reliable, queryable history that survives infrastructure incidents.
Structured request logs still go to stdout for operational visibility (Phase 7 will route these to Loki). The audit log table is a product feature, not an infrastructure concern.
Schema¶
CREATE TABLE "audit_logs" (
"id" UUID PRIMARY KEY,
"tenant_id" UUID NOT NULL,
"actor_id" VARCHAR(255) NOT NULL,
"actor_type" audit_actor_type NOT NULL, -- USER | API_KEY | SYSTEM
"action" audit_action NOT NULL, -- CREATE | UPDATE | DELETE
"resource" VARCHAR(100) NOT NULL,
"resource_id" VARCHAR(255) NOT NULL,
"changes" JSONB NOT NULL DEFAULT '{}',
"ip_address" VARCHAR(45),
"user_agent" VARCHAR(500),
"correlation_id" VARCHAR(36),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Column reference¶
| Column | Type | Description |
|---|---|---|
id |
UUID | Primary key, auto-generated |
tenant_id |
UUID | Which tenant was affected. Not a foreign key - survives tenant deletion |
actor_id |
VARCHAR(255) | User UUID or API key UUID of who performed the action |
actor_type |
ENUM | USER (JWT auth), API_KEY (server-to-server), or SYSTEM (internal ops) |
action |
ENUM | CREATE, UPDATE, or DELETE |
resource |
VARCHAR(100) | Resource type: tenant, user, api_key, membership, etc. |
resource_id |
VARCHAR(255) | UUID of the specific record that was affected |
changes |
JSONB | What changed - shape varies by action type (see below) |
ip_address |
VARCHAR(45) | Client IP. Supports IPv4 and IPv6 |
user_agent |
VARCHAR(500) | Client user agent string (truncated to 500 chars) |
correlation_id |
VARCHAR(36) | Links to the request log via x-correlation-id header |
created_at |
TIMESTAMP | When the action occurred. Immutable - no updated_at |
Indexes¶
| Index | Columns | Use case |
|---|---|---|
audit_logs_tenant_id_idx |
tenant_id |
"Show all activity in Acme Corp" |
audit_logs_actor_id_idx |
actor_id |
"Show everything Alice did" |
audit_logs_resource_resource_id_idx |
resource, resource_id |
"Show the history of this specific API key" |
audit_logs_created_at_idx |
created_at |
"Show activity from the last 24 hours" |
Design decisions¶
- No foreign keys.
tenant_id,actor_id, andresource_idare plain strings. Audit logs must survive even if the referenced record is deleted. If a user is removed, their audit trail remains. - No
updated_atcolumn. Audit logs are append-only. Nobody edits an audit log - that defeats the purpose. - JSONB for changes. Flexible before/after diffs without schema changes per resource type.
- VARCHAR(45) for IP. Supports IPv4 (max 15 chars) and IPv6 (max 39 chars) with room for mapped addresses.
How the interceptor works¶
The AuditLogInterceptor is registered globally in main.ts. It runs on every request without any per-controller decoration.
Request lifecycle¶
Request in
→ CorrelationIdMiddleware (assigns x-correlation-id)
→ RequestLoggerMiddleware (logs method, path, status)
→ Guards (JwtAuthGuard → TenantGuard → RolesGuard)
→ Controller handler executes
→ AuditLogInterceptor observes the response (tap)
→ Response sent to client
→ Audit record written to Postgres (async, fire-and-forget)
The interceptor uses RxJS tap() to observe the response after the handler completes. The audit write happens asynchronously - it never blocks or delays the response to the client.
Why fire-and-forget?¶
Audit logging is important but not critical-path. If the database write fails (Postgres overloaded, disk full), the user's request still succeeds. The failure is logged to stdout where Loki/ELK will catch it in Phase 7. This is a deliberate trade-off: we accept the tiny risk of a missing audit record over the certainty of degraded response times.
What gets audited¶
| HTTP Method | Audit Action | Audited? |
|---|---|---|
POST |
CREATE |
Yes |
PATCH |
UPDATE |
Yes |
PUT |
UPDATE |
Yes |
DELETE |
DELETE |
Yes |
GET |
- | No (reads don't mutate) |
HEAD |
- | No |
OPTIONS |
- | No |
What gets skipped¶
| Path pattern | Reason |
|---|---|
/health |
Infrastructure check, not a business operation |
/api/v1/auth/* |
Auth endpoints (login, register, refresh, etc.) have their own security logging |
Routes with @SkipAudit() |
Explicit opt-out per handler |
Detection logic¶
Actor detection¶
| Source | Actor Type | How it's detected |
|---|---|---|
| JWT-authenticated user | USER |
request.user.id set by JwtAuthGuard |
| API key | API_KEY |
request.apiKeyId set by ApiKeyAuthGuard |
| Neither | SYSTEM |
Fallback - shouldn't happen on guarded routes |
Resource detection¶
The resource type is inferred from the URL path. The interceptor maps the first segment after /api/v1/ to a normalized resource name:
| URL path | Resource |
|---|---|
/api/v1/tenants |
tenant |
/api/v1/tenants/:id |
tenant |
/api/v1/users |
user |
/api/v1/users/:id |
user |
/api/v1/api-keys |
api_key |
/api/v1/api-keys/:id |
api_key |
The resource ID comes from route params (:id) for updates and deletes, or from the response body (id field) for creates.
Tenant detection¶
For most requests, the tenant ID comes from the x-tenant-id header or request.tenantId set by guards. For tenant creation (where the tenant doesn't exist yet), the interceptor extracts the ID from the response body.
Changes payload¶
The changes JSONB column stores different shapes depending on the action:
CREATE - full snapshot of the created resource¶
{
"after": {
"id": "91a81431-8a42-406a-bbae-2de67a3f5d12",
"name": "New Organization",
"slug": "new-org",
"status": "ACTIVE",
"metadata": {},
"createdAt": "2026-04-10T04:44:55.643Z",
"updatedAt": "2026-04-10T04:44:55.643Z"
}
}
UPDATE - requested changes + resulting state¶
{
"requestedChanges": {
"name": "Acme Corp (Updated)"
},
"after": {
"id": "91a81431-...",
"name": "Acme Corp (Updated)",
"slug": "acme-corp",
"status": "ACTIVE"
}
}
Note
The interceptor does not capture "before" state for updates. Querying the database before every update would add latency to every mutation. If specific services need before/after diffs, they can implement that at the service layer and pass the diff explicitly.
DELETE - snapshot of the deleted resource¶
{
"before": {
"id": "ec2decdf-...",
"name": "Production backend",
"keyPrefix": "mp_live_aB",
"status": "REVOKED"
}
}
Sensitive field sanitization¶
The following fields are automatically stripped from the changes payload before storage. Passwords, tokens, and key material never appear in audit logs:
passwordpasswordHashcurrentPasswordnewPasswordkey(raw API key)keyHashtokenHashrefreshTokenaccessToken
Querying audit logs¶
All activity for a tenant¶
SELECT action, resource, resource_id, actor_id, created_at
FROM audit_logs
WHERE tenant_id = '91a81431-8a42-406a-bbae-2de67a3f5d12'
ORDER BY created_at DESC
LIMIT 50;
All actions by a specific user¶
SELECT action, resource, resource_id, tenant_id, created_at
FROM audit_logs
WHERE actor_id = '466c3507-3ecd-41fe-a98e-3ec5036a6413'
ORDER BY created_at DESC;
History of a specific resource¶
SELECT action, actor_id, actor_type, changes, created_at
FROM audit_logs
WHERE resource = 'api_key'
AND resource_id = 'ec2decdf-1e5d-4229-b43a-7f557741e207'
ORDER BY created_at ASC;
Activity in a time range¶
SELECT action, resource, actor_id, created_at
FROM audit_logs
WHERE tenant_id = '91a81431-...'
AND created_at >= '2026-04-01T00:00:00Z'
AND created_at < '2026-04-11T00:00:00Z'
ORDER BY created_at DESC;
Search within changes JSONB¶
-- Find all records where a tenant's name was changed
SELECT actor_id, changes, created_at
FROM audit_logs
WHERE resource = 'tenant'
AND action = 'UPDATE'
AND changes->'requestedChanges' ? 'name';
Correlate with request logs¶
Every audit record includes a correlation_id that matches the x-correlation-id in the request logs. To trace a specific mutation:
- Find the audit record
- Copy its
correlation_id - Grep your application logs (or search in Loki when Phase 7 is live)
Skipping audit on specific routes¶
Use the @SkipAudit() decorator on any controller method that should not produce an audit record:
import { SkipAudit } from '@common/decorators';
@SkipAudit()
@Post('internal/sync')
async syncData() {
// This mutation will NOT be audited
}
Use this for endpoints where auditing is not meaningful (internal sync operations) or where audit logging is handled manually in the service layer.
Adding new resources (future phases)¶
When you add a new module (e.g., plans in Phase 2), the interceptor picks it up automatically as long as you add a mapping:
In src/common/interceptors/audit-log.interceptor.ts:
const PATH_TO_RESOURCE: Record<string, string> = {
tenants: 'tenant',
users: 'user',
'api-keys': 'api_key',
memberships: 'membership',
plans: 'plan', // ← add this
entitlements: 'entitlement', // ← add this
};
No other changes needed. The interceptor will automatically audit all POST, PATCH, PUT, DELETE requests to /api/v1/plans/* and /api/v1/entitlements/*.
Future improvements¶
These are not built yet. Listed here so the design intent is clear:
- Audit log API endpoint -
GET /api/v1/audit-logsfor tenants to query their own audit trail via the API. Requires pagination, filtering, and tenant scoping. - Retention policy - automated cleanup of audit records older than N days/months. Likely a cron job that runs
DELETE FROM audit_logs WHERE created_at < NOW() - INTERVAL '2 years'. - Before-state capture for updates - for resources where the full diff matters (e.g., plan changes), the service layer can query the record before updating and attach the before-state to the audit log.
- Bulk operation support - if a future endpoint creates or updates multiple records in one request, the interceptor should write one audit record per resource, not one for the batch.
- Export - CSV/JSON export of audit logs for compliance teams.