Application-level audit logging with 30-day default retention. Captures insert/update/delete on all domain tables with field-level diffs, user attribution, and request ID correlation. Sensitive fields masked. Configurable retention, BullMQ cleanup job, admin UI for querying.
264 lines
7.3 KiB
Markdown
264 lines
7.3 KiB
Markdown
Music Store Management Platform
|
|
|
|
Audit Trail — Database Change History
|
|
|
|
Version 1.0 | Draft
|
|
|
|
|
|
|
|
# 1. Overview
|
|
|
|
The audit trail captures every data change across all domain tables — who changed what, when, and the before/after values. This provides a complete history of business operations for troubleshooting, compliance, and accountability. While not required for SOC 2 certification, the audit trail satisfies the "monitoring" control family and provides evidence of change management.
|
|
|
|
|
|
|
|
# 2. Design
|
|
|
|
## 2.1 Approach
|
|
|
|
A single `audit_log` table captures all changes via a database trigger or application-level middleware. Each row represents one field change on one record.
|
|
|
|
Pros of application-level (chosen):
|
|
- Access to the authenticated user ID (DB triggers don't know who's logged in)
|
|
- Can exclude bulk/system operations selectively
|
|
- Works with any database (no Postgres-specific trigger functions)
|
|
- Easier to test
|
|
|
|
Cons:
|
|
- Every service write must call the audit logger
|
|
- Possible to miss a write (mitigated by a shared helper)
|
|
|
|
## 2.2 What Gets Audited
|
|
|
|
Audited (all domain tables):
|
|
- account, member, member_identifier
|
|
- product, inventory_unit, category, supplier
|
|
- rental, rental_agreement, rental_payment
|
|
- repair_ticket, repair_line_item, repair_part
|
|
- transaction, transaction_line_item
|
|
- tax_exemption, account_payment_method, account_processor_link
|
|
- user, role, role_permission, user_role
|
|
- file (uploads and deletes)
|
|
|
|
NOT audited (high-volume, low-value):
|
|
- email_log (already an append-only log)
|
|
- stock_receipt (already append-only)
|
|
- price_history (already append-only)
|
|
- inventory_unit_status, item_condition (lookup tables — low risk)
|
|
- Session/token data
|
|
|
|
|
|
|
|
# 3. Database Schema
|
|
|
|
## 3.1 audit_log
|
|
|
|
Column | Type | Notes
|
|
id | uuid PK |
|
|
company_id | uuid | Tenant scoping
|
|
table_name | varchar(100) | Which table was changed
|
|
record_id | uuid | Primary key of the changed record
|
|
action | varchar(10) | insert, update, delete
|
|
field_name | varchar(100) | Nullable for insert/delete. Which column changed for updates.
|
|
old_value | text | Nullable. Previous value (JSON-encoded for complex types)
|
|
new_value | text | Nullable. New value (JSON-encoded for complex types)
|
|
changed_by | uuid | User who made the change (nullable for system operations)
|
|
changed_at | timestamptz | When the change occurred
|
|
request_id | varchar(100) | Nullable. Correlates with Pino request ID for tracing.
|
|
|
|
## 3.2 Indexes
|
|
|
|
- `(company_id, table_name, record_id)` — find all changes to a specific record
|
|
- `(company_id, changed_by)` — find all changes by a specific user
|
|
- `(company_id, changed_at)` — time-range queries for retention
|
|
- `(changed_at)` — for retention cleanup job
|
|
|
|
## 3.3 Storage Estimate
|
|
|
|
Typical music store: ~50 users, ~5000 accounts, ~200 transactions/day
|
|
- ~500 audit rows/day (updates, creates across all tables)
|
|
- ~15,000 rows/month
|
|
- ~180,000 rows/year
|
|
- At ~200 bytes/row: ~36 MB/year
|
|
|
|
Retention at 30 days: ~15,000 rows, under 5 MB. Negligible.
|
|
|
|
|
|
|
|
# 4. Audit Helper
|
|
|
|
## 4.1 Service Pattern
|
|
|
|
```typescript
|
|
import { auditLog } from '../lib/audit.js'
|
|
|
|
// In a service method:
|
|
async update(db, companyId, id, input, changedBy, requestId) {
|
|
const existing = await this.getById(db, companyId, id)
|
|
|
|
const [updated] = await db.update(table).set(input).where(...).returning()
|
|
|
|
// Log each changed field
|
|
await auditLog(db, {
|
|
companyId,
|
|
tableName: 'account',
|
|
recordId: id,
|
|
action: 'update',
|
|
changes: diffFields(existing, updated),
|
|
changedBy,
|
|
requestId,
|
|
})
|
|
|
|
return updated
|
|
}
|
|
```
|
|
|
|
## 4.2 Diff Helper
|
|
|
|
```typescript
|
|
function diffFields(before: Record<string, unknown>, after: Record<string, unknown>) {
|
|
const changes: { field: string; old: unknown; new: unknown }[] = []
|
|
for (const key of Object.keys(after)) {
|
|
if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
|
|
changes.push({ field: key, old: before[key], new: after[key] })
|
|
}
|
|
}
|
|
return changes
|
|
}
|
|
```
|
|
|
|
## 4.3 Bulk Insert
|
|
|
|
For efficiency, audit entries are batch-inserted. An update that changes 3 fields creates 3 audit_log rows in one INSERT.
|
|
|
|
|
|
|
|
# 5. Retention
|
|
|
|
## 5.1 Default: 30 Days
|
|
|
|
Audit records older than 30 days are automatically deleted by a daily cleanup job.
|
|
|
|
## 5.2 Configuration
|
|
|
|
Variable | Description | Default
|
|
AUDIT_RETENTION_DAYS | Days to keep audit records | 30
|
|
AUDIT_ENABLED | Enable/disable audit trail | true
|
|
|
|
## 5.3 Cleanup Job
|
|
|
|
A BullMQ recurring job runs daily at 3 AM (store timezone):
|
|
|
|
```sql
|
|
DELETE FROM audit_log WHERE changed_at < NOW() - INTERVAL '30 days'
|
|
```
|
|
|
|
## 5.4 Extended Retention
|
|
|
|
Stores requiring longer retention (compliance, legal) can set `AUDIT_RETENTION_DAYS=365` or higher. Storage impact is minimal (~36 MB/year).
|
|
|
|
|
|
|
|
# 6. Querying the Audit Trail
|
|
|
|
## 6.1 API Endpoints
|
|
|
|
GET /v1/audit?table=account&recordId={uuid}
|
|
- All changes to a specific record
|
|
|
|
GET /v1/audit?table=account&changedBy={userId}
|
|
- All changes by a specific user
|
|
|
|
GET /v1/audit?startDate=2026-03-01&endDate=2026-03-31
|
|
- All changes in a date range
|
|
|
|
GET /v1/audit?table=account&recordId={uuid}&field=name
|
|
- Changes to a specific field on a specific record
|
|
|
|
All endpoints require `users.admin` permission.
|
|
|
|
## 6.2 Admin UI
|
|
|
|
An "Audit Log" page in the admin panel:
|
|
- Searchable, filterable table
|
|
- Filter by: table, user, date range, record ID
|
|
- Click a record to see the full change history
|
|
- Each entry shows: timestamp, user, table, record, field, old → new value
|
|
- Export as CSV for compliance reports
|
|
|
|
|
|
|
|
# 7. What Gets Logged Per Action
|
|
|
|
## 7.1 Insert
|
|
|
|
One audit_log row with:
|
|
- action = 'insert'
|
|
- field_name = NULL
|
|
- old_value = NULL
|
|
- new_value = JSON of entire inserted record
|
|
|
|
## 7.2 Update
|
|
|
|
One audit_log row per changed field:
|
|
- action = 'update'
|
|
- field_name = the column name
|
|
- old_value = previous value
|
|
- new_value = new value
|
|
|
|
Unchanged fields are NOT logged.
|
|
|
|
## 7.3 Delete (soft)
|
|
|
|
One audit_log row with:
|
|
- action = 'delete'
|
|
- field_name = 'is_active'
|
|
- old_value = 'true'
|
|
- new_value = 'false'
|
|
|
|
## 7.4 Delete (hard)
|
|
|
|
One audit_log row with:
|
|
- action = 'delete'
|
|
- field_name = NULL
|
|
- old_value = JSON of deleted record
|
|
- new_value = NULL
|
|
|
|
|
|
|
|
# 8. Sensitive Field Masking
|
|
|
|
Some fields should not have their values stored in the audit trail:
|
|
|
|
Field | Masking
|
|
user.password_hash | Never logged. Action logged but old/new values are '***'
|
|
account_payment_method.processor_payment_method_id | Last 4 chars only
|
|
member_identifier.value | Masked except last 4 chars
|
|
|
|
|
|
|
|
# 9. Implementation Order
|
|
|
|
1. Create audit_log table and migration
|
|
2. Implement audit helper (diffFields, bulk insert)
|
|
3. Add to account and member services (highest traffic)
|
|
4. Add to tax exemption (compliance-sensitive)
|
|
5. Add to user management (security-sensitive)
|
|
6. Add retention cleanup BullMQ job
|
|
7. Add API endpoints for querying
|
|
8. Add admin UI page
|
|
9. Add to remaining domain services as they're built
|
|
|
|
|
|
|
|
# 10. Business Rules
|
|
|
|
- Audit records are append-only — never updated or manually deleted
|
|
- Retention cleanup is the only deletion mechanism
|
|
- All audit queries are company-scoped (multi-tenant isolation)
|
|
- System operations (migrations, seeds) log with changed_by = NULL
|
|
- Bulk operations (imports, batch updates) are audited per-record
|
|
- Password and payment token fields are masked in audit values
|
|
- Audit trail must not impact request latency — inserts are batched and non-blocking where possible
|
|
- The audit_log table itself is NOT audited (prevents infinite recursion)
|