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.
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:
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").
| Question | Plain updated_at | Argus::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.