ADR-0001: Audit Logging System
Accepted Issue TrackerDate: 2026-02-11 | Last Revised: 2026-02-20
Compliance: ISO 27001 (A.8.15 Logging, A.5.33 Protection of Records, A.8.5 Secure Authentication)
Context
The Issue Tracker operates under ISO 27001 certification. Before this decision, no audit trail existed for data mutations. An auditor couldn't answer:
- Who modified or deleted a record
- What the previous state was before a change
- When and from where the action was performed
- Whether the actor's role at the time justified the operation
The existing package owen-it/laravel-auditing was evaluated and found insufficient for strict compliance requirements — it doesn't support point-in-time user snapshots or hash chaining for tamper detection.
Decision
We built an independent, append-only audit logging system with these properties:
Per-Entity Audit Tables
Each auditable model gets its own dedicated audit table (e.g., issue_audit_logs). This replaces a polymorphic single-table design.
Why per-entity tables?
- Writes to different entity types don't contend with each other
- No polymorphic columns — cleaner indexing and simpler queries
- Sensitive field exclusion is per-entity by nature
- Retention policies can differ per entity type
- Hash chains are strongest when scoped to a single table
Trade-off: Cross-entity queries ("show me everything user X did today") require UNIONs across audit tables. Entity-specific queries are the 90% case for compliance; cross-entity aggregation can be built as a read-only view if needed.
Hash Chains for Tamper Detection
Each audit table maintains a sequential hash chain — scoped per-table, not per-record and not global. Every new record's hash includes the previous record's hash, creating an unbroken chain. If any record is modified or deleted, the chain breaks and the tampering is detectable.
- The first record in each table uses a configurable seed value as its "previous hash"
- All writes to an entity's audit table serialize against each other (via locking on the last record)
- Concurrent writes to different entity types (Issue vs Project) don't contend at all
- Verification: iterate chronologically, recompute each hash, compare against stored hash — any mismatch indicates tampering from that point onward
record.hash = SHA-256(
previous_record.hash + "|" +
record.actor_type + "|" +
record.user_id + "|" +
record.action + "|" +
record.entity_id + "|" +
record.old_values + "|" +
record.new_values + "|" +
record.created_at
)Action-Level Logging (Explicit)
Audit logging is triggered explicitly in Actions — not via model observers or traits. The Action is the authority for mutations, so it's the authority for logging those mutations.
This is consistent with our Explicit Over Implicit principle. Architecture tests enforce that every Action touching an auditable model calls the audit logger.
Point-in-Time User Snapshots
The audit record captures the actor's identity at the moment of the action. User name, email, and role are stored directly on the audit record — not resolved via foreign key join at query time.
This ensures the audit trail remains accurate even if the user's profile changes after the fact.
Explicit Actor Passing
Every audited Action receives the actor explicitly as a parameter — no ambient context, no global state. Controllers build the actor and request context and pass them to the Action.
Actor Types
Actors are identified by an ActorType enum stored as an integer:
| Value | Type | Description |
|---|---|---|
| 0 | User | Human user performing an action |
| 1 | Scheduler | Automated scheduled task |
| 2 | Cli | Command-line operation |
| 3 | GitHubWebhook | GitHub webhook trigger |
When actor_type = User, snapshot fields (name, email, role) are required. When it's a system actor, user_id must be null. This is enforced at two layers:
- Code-level — the audit logger service validates before writing
- Database-level — MySQL CHECK constraint as safety net:
CONSTRAINT chk_actor_integrity CHECK (
(actor_type = 0 AND user_id IS NOT NULL AND user_name IS NOT NULL
AND user_email IS NOT NULL AND user_role IS NOT NULL)
OR
(actor_type != 0 AND user_id IS NULL)
)Schema
-- Example: issue_audit_logs (pattern repeats per auditable entity)
id BIGINT UNSIGNED -- Auto-increment primary key
actor_type TINYINT UNSIGNED -- ActorType enum
user_id BIGINT UNSIGNED -- FK to users (nullable for system actors)
user_name VARCHAR -- Snapshot: actor name at time of action
user_email VARCHAR -- Snapshot: actor email at time of action
user_role VARCHAR -- Snapshot: actor role at time of action
action TINYINT UNSIGNED -- AuditAction enum (Created/Updated/Deleted/Restored)
issue_id BIGINT UNSIGNED -- The entity this audit belongs to (NO FK)
old_values JSON -- State before mutation (NULL on create)
new_values JSON -- State after mutation (NULL on delete)
ip_address VARCHAR -- Request origin IP
user_agent VARCHAR -- Client identification
url VARCHAR -- Request method + path
hash CHAR(64) -- SHA-256 hash for tamper detection
created_at TIMESTAMP -- Immutable (no updated_at column)Key design choices:
issue_idhas no FK constraint — the audit log must survive deletion of the entity it tracksuser_idhas an FK constraint — users are soft-deleted (per ADR-0002), so the referenced row always exists- Records are append-only — no
UPDATE, noDELETE, noSoftDeletes, noupdated_at - Sensitive field exclusion — passwords, tokens, API keys, and secrets are stripped from
old_values/new_valuesbefore storage. The Issue entity has no sensitive fields, but the pattern is established for future entities
Request Context
A RequestContext DTO captures IP address, user agent, and request URL. Built by the controller from the HTTP request and passed explicitly to the Action.
- HTTP requests:
RequestContext::fromRequest($request) - Queue workers / CLI:
RequestContext::system()— all fields null - The audit logger receives request context as a parameter, never pulls from global state
Integration with Cascade Deletion (ADR-0002)
When an auditable entity is deleted:
- Transaction opens
- Child records are hard-deleted explicitly by the Action
- Entity is hard-deleted
- Audit log records the deletion with full state snapshot in
old_values - Transaction commits
The issue_id in the audit log remains queryable even after the entity row is gone (no FK constraint).
Reconnaissance Findings
Reconnaissance identified 13 Actions that directly mutate Issue records. Only the direct CRUD trio (CreateIssueAction, UpdateIssueAction, DeleteIssueAction) is in scope for the proof of concept.
Key finding: 10 of 12 indirect Issue-mutating Actions lack actor context. The calling controllers have the authenticated user but don't pass it to the Action. As indirect mutations are instrumented, each Action will need refactoring to accept explicit actor context.
Options Considered
| Option | Verdict | Reason |
|---|---|---|
owen-it/laravel-auditing package | Rejected | No point-in-time snapshots, no hash chaining, insufficient for strict compliance |
| Model-level observers/traits | Rejected | Implicit — logging happens behind the scenes. Query-level operations bypass model events silently. Conflicts with explicit-over-implicit principle |
| Action-level explicit logging | Accepted | Consistent with existing architecture. Full control. Captures user context from parameters, not global state |
| Single polymorphic audit table | Rejected | Hash chaining requires serialized writes; single table becomes bottleneck under concurrent mutations |
| Per-record hash chains | Rejected | If all audit records for a single entity are deleted, no chain exists to detect the gap |
| Request-scoped ambient context | Rejected | More pragmatic (no Action refactoring), but conflicts with explicit-over-implicit doctrine |
Consequences
Positive
- ISO 27001 compliant audit trail for high-value entities
- Tamper-detectable via hash chaining
- Point-in-time actor snapshots eliminate "who was this person then?" ambiguity
- Per-entity tables provide concurrency isolation
- Full request context captured for forensic analysis
- System actors distinguished by type — not a generic "system"
- Pattern extends to other models as data classification is applied
Negative
- Every Action touching an auditable model must explicitly log (maintenance burden)
- Actions must accept actor context explicitly — refactoring required
- Each auditable entity requires its own audit table, model, and logger service
- Audit tables grow indefinitely (no deletion allowed) — requires retention strategy in the future
Risks
- If an Action forgets to log, there's a gap until the architecture test catches it at CI time
- Hash chain seed value must be stored securely — if lost, chain cannot be verified from genesis
- Large JSON blobs in
old_values/new_valuescould impact storage for frequently-updated entities
Scope & Progress
An information classification matrix identified 14 models requiring audit logging, totaling 13 new audit tables.
Tier 1 (completed): Issue CRUD trio (PoC, PR #321) + User, AppSetting, GithubConnection, TimeLog (PRs #357-360). AuditLogWriter + AuditLogger interface consolidated (PR #373).
Tier 2 (scoped, not started): 10 remaining entities from the classification matrix.