Skip to content

Audit History & Historical Queries

Identity Atlas v5 uses a shared _history audit table populated by PostgreSQL triggers. Every insert, update, and delete on tracked tables is recorded as a JSONB snapshot — giving you a complete, queryable change history without any application-level code.

v4 to v5 change

v4 used SQL Server system-versioned temporal tables (FOR SYSTEM_TIME syntax). v5 replaced these with a single _history table and trigger-based recording after the migration to PostgreSQL. The concept is the same — automatic change tracking — but the query syntax is different.


How It Works

A generic PostgreSQL trigger function (fg_record_history) fires on INSERT, UPDATE, and DELETE. It writes one row to _history per actual change:

Column Type Description
id bigserial Auto-incrementing primary key
tableName text Name of the source table (e.g. Principals, Resources)
rowId text The id value of the changed row
operation char(1) I = insert, U = update, D = delete
changedAt timestamptz Timestamp of the change
rowData jsonb Full row snapshot after the change (or the deleted row for D)
prevData jsonb Previous row state (NULL for inserts)

Tracked Tables

Table Tracked
Principals Yes
Resources Yes
ResourceAssignments Yes
ResourceRelationships Yes
AssignmentPolicies Yes
GovernanceCatalogs Yes
Systems Yes
PrincipalActivity No — upsert-based; daily sign-in timestamps would generate excessive audit noise
RiskScores No — recalculated on each scoring run

No-op Filtering

The UPDATE trigger uses a WHEN (OLD IS DISTINCT FROM NEW) clause, so re-ingesting unchanged rows during a sync does not generate audit entries. This is critical because the crawler upserts every row on every run.


Query Patterns

Full change history for a single entity

-- All versions of a principal, newest first
SELECT "changedAt", operation, "rowData", "prevData"
FROM "_history"
WHERE "tableName" = 'Principals'
  AND "rowId" = 'principal-guid-here'
ORDER BY "changedAt" DESC;

Changes within a time range

-- All resource assignment changes in the last 30 days
SELECT "rowId", operation, "changedAt", "rowData"
FROM "_history"
WHERE "tableName" = 'ResourceAssignments'
  AND "changedAt" >= now() - interval '30 days'
ORDER BY "changedAt" DESC;

Detecting what changed in an update

-- Compare rowData vs prevData to see what fields changed
SELECT
  "changedAt",
  "prevData"->>'department' AS old_department,
  "rowData"->>'department'  AS new_department
FROM "_history"
WHERE "tableName" = 'Principals'
  AND "rowId" = 'principal-guid-here'
  AND operation = 'U'
  AND "prevData"->>'department' IS DISTINCT FROM "rowData"->>'department'
ORDER BY "changedAt" DESC;

Deleted entities

-- Resources that were deleted (no longer in the current table)
SELECT "rowId", "changedAt", "rowData"->>'displayName' AS name
FROM "_history"
WHERE "tableName" = 'Resources'
  AND operation = 'D'
ORDER BY "changedAt" DESC;

Point-in-time reconstruction

To reconstruct the state of an entity at a specific point in time, find the most recent history row at or before that timestamp:

-- What did this principal look like on January 15, 2026?
SELECT "rowData"
FROM "_history"
WHERE "tableName" = 'Principals'
  AND "rowId" = 'principal-guid-here'
  AND "changedAt" <= '2026-01-15 23:59:59+00'
ORDER BY "changedAt" DESC
LIMIT 1;

How the UI Uses History

The entity detail pages (User Detail, Resource Detail, Business Role Detail) query _history to build a Version History section that shows diffs between sync runs. The API endpoint compares consecutive rowData / prevData snapshots and highlights changed fields.


Retention

By default, history is retained indefinitely. For environments with high churn, add a periodic cleanup job:

-- Delete history older than 2 years
DELETE FROM "_history"
WHERE "changedAt" < now() - interval '2 years';

The Admin page provides a History Retention setting (Admin > History Retention) to configure automatic cleanup.


Adding History to a New Table

To track a new table, create triggers that call the existing fg_record_history function:

-- INSERT and DELETE: always record
CREATE TRIGGER trg_history_ins_del
AFTER INSERT OR DELETE ON "MyNewTable"
FOR EACH ROW EXECUTE FUNCTION fg_record_history();

-- UPDATE: only when something actually changed
CREATE TRIGGER trg_history_upd
AFTER UPDATE ON "MyNewTable"
FOR EACH ROW
WHEN (OLD IS DISTINCT FROM NEW)
EXECUTE FUNCTION fg_record_history();

The trigger function is generic — it works with any table that has an id column.