Skip to content

← Writing

engineering

Audit Trails: Who Did What, When, and Prove It

· Jerwin Arnado · 7 min read ·

The first three parts of this series decide what’s allowed to happen. This part records what did happen — because the day you need an audit trail, it’s too late to start one. Three different people will come asking, and they want the same table:

  • Support/debugging: “the patient’s balance is wrong — who changed this invoice?”
  • Compliance: “show us every access to this medical record in March.”
  • Security: “this account was phished Tuesday — what did the attacker touch?”

In a multi-tenant clinic system, that third question isn’t hypothetical. Here’s the trail that can answer all three.

What a useful audit event records

The unit of an audit trail is one event answering five questions:

Question Field Example
Who causer (user id, not name — names change) user:412
Did what action verb updated, viewed, denied
To what subject (type + id) invoice:9018
What changed before → after diff, changed keys only status: draft → sent
In what context timestamp, IP, request id, tenant 2026-08-01 09:14 +0800, t:17

The diff is what separates an audit trail from an access log. “Maria updated invoice 9018” starts an argument; “Maria changed total from ₱4,500 to ₱450” ends one.

Model changes: spatie/laravel-activitylog

The same shop behind the permission package from part one ships the standard tool here. One trait per model worth auditing:

class Invoice extends Model
{
    use LogsActivity;

    public function getActivitylogOptions(): LogOptions
    {
        return LogOptions::defaults()
            ->logOnly(['status', 'total', 'due_at', 'paid_at'])  // allowlist, not everything
            ->logOnlyDirty()                                     // changed keys only
            ->dontSubmitEmptyLogs();
    }
}

Every create/update/delete now writes an activity row with the causer (the authenticated user) and the old/new values of the changed keys — the who/what/diff columns of the table above, for free. Reading it back is a query, not archaeology:

Activity::forSubject($invoice)->latest()->get();   // this invoice's whole history
Activity::causedBy($user)->whereDate('created_at', $tuesday)->get();   // the phishing question

Note the allowlist. logAll() is a trap: it happily records password hashes, remember-tokens, and API keys into a table with far looser access rules than the columns themselves. Name the fields that matter; skip the secrets and the noise (updated_at tells nobody anything).

Log the denials, not just the changes

Model events only capture what happened. For security work, what was refused matters just as much — a burst of 403s is an attacker (or a broken policy) announcing itself, and part one’s caveat already asked for this. One hook covers every policy in the app:

// AppServiceProvider::boot() — observe every gate decision that denies
Gate::after(function (User $user, string $ability, ?bool $result, array $arguments) {
    if ($result === false) {
        activity('authz')
            ->causedBy($user)
            ->withProperties([
                'ability' => $ability,
                'subject' => $arguments[0] ?? null ? class_basename($arguments[0]).':'.$arguments[0]->getKey() : null,
                'ip'      => request()->ip(),
            ])
            ->log('denied');
        // Return nothing — after() observes; returning a value would override the decision.
    }
});

Now “why can’t Maria edit this?” is a lookup instead of a debugging session, and your monitoring can alert on denial spikes per user. Pair it with the framework’s auth events — Login, Logout, Failed — via listeners, and the trail covers the authentication layer too: failed-login clusters before a successful login is the phishing timeline, reconstructed in one query.

Append-only, or it isn’t an audit log

The property that makes an audit trail evidence is that nobody — including admins, including the app — can quietly rewrite it. A log the attacker (or the disgruntled admin) can edit is a diary, not a trail. Concretely:

  • No update or delete path in the app. No model method, no admin UI button, no “fix the log entry” endpoint. The Activity model is write-and-read only.
  • Enforce it below the app if you can. A dedicated DB user for the app whose grants on the activity_log table are INSERT, SELECT only — then even an application-level bug can’t tamper. (RDS/Postgres make this a five-minute job.)
  • Retention is a policy, not a button. Pruning two-year-old rows on a schedule (Laravel’s model:prune) is fine — that’s a documented policy applied uniformly. Deleting specific rows is exactly the capability the log exists to preclude.
  • The trail is tenant-scoped too. Activity rows carry the tenant like every other model from part three — clinic A must never see clinic B’s history, and the absence of scoping here is itself a leak.

Caveats and best practices

  • Decide read-access up front. The audit log concentrates sensitive history in one table; “who can read the trail” is itself an authorization question from part one. Physicians shouldn’t browse colleagues’ activity; support sees their tenant only.
  • Keep it off the hot path if volume demands. Activity writes are one insert — usually fine inline. If a bulk import hammers it, queue the writes; an audit row that lands a second late is still true.
  • Index for the questions you’ll ask. (subject_type, subject_id) and (causer_id, created_at) cover the support and security questions. An unqueryable audit log fails silently — you find out the day you need it under pressure.
  • An audit trail is not error tracking. Errors answer “what broke”; the trail answers “who did what.” Different retention, different access rules, different tables. Resist merging them because both are “logs.”
  • Record views only where the stakes justify it. Logging every read of everything drowns the trail. Medical records: yes, log the view — that’s a compliance requirement. Invoice list pages: no.

Conclusion

Event  → who, did what, to what, diff, context — causer + subject + changed keys
Models → LogsActivity with an allowlist; logOnlyDirty; never logAll
Denials→ Gate::after logs every 403 with ability + subject; auth events for logins
Trust  → append-only: no update path, INSERT/SELECT grants, retention ≠ deletion

Authorization without an audit trail is a lock with no camera: it keeps honest people out and tells you nothing when someone gets in. Log the changes with their diffs, log the denials, make the table append-only, and index it for the three questions that are coming. Final part of the series: what happens when your permission rules stop being rules and become a graph.