Why Audit Trails Matter for Role and Permission Changes

What "who granted this, and when" actually needs to survive an incident review — and why a generic updated_at timestamp doesn't answer it.

The question that actually gets asked after something goes wrong is rarely "what does this role currently grant" — it's "who gave this account that permission, and when did it happen." A roles table with an updated_at column answers the first question and actively obstructs the second: every update overwrites the one before it, so the row you're looking at during an incident has already lost the history that would tell you whether a specific grant was a week old or a year old, deliberate or a leftover from testing.

What a real audit entry needs

Logging "role updated" isn't enough — a role update can bundle several unrelated changes into one save, and a generic message can't tell you which one mattered. A useful audit trail needs, per change, not per save:

How Argus::Trail structures this

Actor#sync_roles! and Role#sync_permissions! are the only supported way to change role/permission assignments — diffing the requested set against the current one and writing one AuditEntry per addition and per removal:

user.sync_roles!([admin_role.id, support_role.id], changed_by: current_user)
# => writes one AuditEntry(event_type: "role_assigned") per newly-added role,
#    one AuditEntry(event_type: "role_revoked") per removed one — none at all
#    if the set is unchanged.

role.sync_permissions!(params[:permission_ids], changed_by: current_user)
# => same idea: "permission_granted" / "permission_revoked", one row each.

Each AuditEntry stores the role/permission's name at the time in its own metadata, independent of the live record — so a later rename or deletion doesn't retroactively corrupt what the historical entry says happened. subject (who was affected) and changed_by (who did it) are both polymorphic associations, so the same audit model covers an admin changing a developer's roles, a developer self-service- requesting one, or a background job doing it with no human actor at all (falls back to "System").

What this buys you at incident-review time

QuestionPlain updated_atArgus::Trail's AuditEntry
Who granted this specific permission?❌ overwritten✅ changed_by
When, exactly?Only the last change✅ one immutable row per change
Was this role touched more than once?❌ no history✅ full history, queryable
Survives a later rename?N/A✅ name captured at write time

None of this requires a separate logging system or a third-party audit gem — it's the same AuditEntry model the admin UI already renders as a paginated, filterable log (bin/rails generate argus:trail:install mounts it alongside the roles/permissions screens), queryable like any other ActiveRecord model for anything beyond that.

Further reading