The Disciplr backend implements comprehensive audit logging for tracking important system events, particularly those related to vault operations and user actions.
Each audit log entry contains:
interface AuditLog {
id: string
actor_user_id: string
organization_id?: string
action: string
target_type: string
target_id: string
metadata: Record<string, unknown>
created_at: string
prev_hash?: string
row_hash?: string
}Triggered when a new vault is created.
Metadata:
creator: Vault creator addressamount: Vault amount
Example:
{
"id": "audit-1643212345-abc123",
"actor_user_id": "user123",
"action": "vault.created",
"target_type": "vault",
"target_id": "vault-uuid",
"metadata": {
"creator": "GABC...",
"amount": "1000",
"admin_id": "user123"
},
"created_at": "2026-04-25T08:52:00.000Z"
}Triggered when a vault is cancelled.
Metadata:
previous_status: Vault status before cancellationnew_status: Always set to "cancelled"reason: Cancellation reason (optional)cancelled_by: "creator" or "admin"creator: Original vault creatoramount: Vault amount
Example:
{
"id": "audit-1643212345-def456",
"actor_user_id": "admin-user",
"action": "vault.cancelled",
"target_type": "vault",
"target_id": "vault-uuid",
"metadata": {
"previous_status": "active",
"new_status": "cancelled",
"reason": "User requested cancellation",
"cancelled_by": "admin",
"creator": "GABC...",
"amount": "1000",
"admin_id": "admin-user"
},
"created_at": "2026-04-25T08:52:00.000Z"
}- Audit logs always use
req.user.userIdas the primary actor identifier - Actor information is never taken from untrusted headers when
req.useris available - For admin actions, the admin ID is automatically included in metadata
- Sensitive data (passwords, tokens, emails, IPs) is automatically redacted
- Stellar addresses are preserved as they are necessary for audit trails
- All metadata keys are normalized to snake_case
Each persisted audit row carries a SHA-256 hash chain:
prev_hash: the previous audit row hash in the sameorganization_idchain, or 64 zeroes for the first row.row_hash:sha256({ prev_hash, canonical_row }).canonical_row: stable JSON containingid,actor_user_id,organization_id,action,target_type,target_id,metadata, and normalizedcreated_at.
Verification loads rows for one organization ordered by (created_at, id), recomputes each row_hash, and confirms each prev_hash equals the prior row hash. Altered rows, removed middle rows, and reordered rows are reported as integrity failures.
GET /api/audit-logs?actor_user_id=user123&action=vault.cancelled&limit=50Query Parameters:
actor_user_id: Filter by actoraction: Filter by action typetarget_type: Filter by target typetarget_id: Filter by target IDlimit: Maximum number of results (default: 100)
GET /api/audit-logs/{audit_id}GET /api/admin/audit-logs/organizations/{organization_id}/verify
POST /api/admin/audit-logs/verifyPOST /api/admin/audit-logs/verify accepts an optional organization_id in the JSON body. If omitted, it verifies the legacy null-organization chain.
GET /api/admin/audit-logs/organizations/{organization_id}/exportThe export is scoped to the requested tenant and returns redacted audit rows plus a proof section containing each row id, prev_hash, and row_hash. Export redaction uses the shared privacy redactor so field names listed in PRIVACY.md, email-shaped values, JWTs, and nested sensitive values are not emitted.
import { createAuditLog } from '../lib/audit-logs.js'
createAuditLog({
actor_user_id: req.user.userId,
action: 'vault.cancelled',
target_type: 'vault',
target_id: vaultId,
metadata: {
previous_status: 'active',
new_status: 'cancelled',
reason: 'User requested',
cancelled_by: 'creator',
creator: vault.creator,
amount: vault.amount,
},
})- Use
clearAuditLogs()for test isolation - Audit logs are stored in-memory during testing
- Test coverage should verify both successful operations and authorization failures
For admin UI access patterns that scope queries to a specific organization and list recent events, the recommended access pattern is:
- WHERE organization_id = $ORG_ID
- ORDER BY created_at DESC
- LIMIT $N OFFSET $M
This pattern is supported by a composite index on (organization_id, created_at DESC) which ensures the database can satisfy the ordered limit efficiently without a filesort.
Example EXPLAIN ANALYZE output (captured during load testing):
Limit (cost=0.14..12.50 rows=50 width=256) (actual time=0.12..1.23 rows=50 loops=1)
-> Index Scan using idx_audit_logs_organization_created on audit_logs (cost=0.14..2534.50 rows=1000 width=256) (actual time=0.11..1.20 rows=50 loops=1)
Index Cond: (organization_id = '00000000-0000-0000-0000-000000000000'::uuid)
Planning Time: 0.20 ms
Execution Time: 1.35 ms
If your production dataset grows large, prefer paginating with LIMIT + OFFSET or use cursor-based pagination on (created_at, id) to avoid deep OFFSET scans.
- Always include audit logs for state-changing operations
- Use consistent action naming:
resource.actionformat - Include relevant metadata for context and debugging
- Never log sensitive information (PII, credentials)
- Test audit log creation in unit and integration tests
- Ensure actor identification is secure and consistent
Audit logs support:
- Accountability: Track who performed what actions
- Forensics: Debug issues and investigate incidents
- Compliance: Meet regulatory requirements for audit trails
- Security: Monitor for suspicious activity patterns