Audit & historyAdmin audit log

Admin activity log

The admin.admin_activity_log table captures admin-tier actions: alert acknowledgements, health-endpoint changes, deployment markers, organization edits, user-role transitions, and other operations that affect platform-level state. It is intentionally narrow in scope — it is not the change-tracking table for every inventory edit (see Cut history for per-cut records).

What gets logged

Action strings are free-form (so the team can introduce new action types without a migration), but every row’s resource_type is constrained at the schema level via CHECK:

resource_typeExample actions
useruser.role.changed, user.invited, user.demoted
orgorg.settings.updated, org.branding.updated
alertalert.acknowledged, alert.muted
deploymentdeployment.marked
health_endpointhealth_endpoint.created, health_endpoint.disabled
health_checkhealth_check.acknowledged
systemWorkspace-level system actions

The schema

admin.admin_activity_log:

ColumnTypeNotes
idBIGSERIALPrimary key
actor_idUUIDFK → auth.users(id) (ON DELETE SET NULL so deleted users keep their audit trail)
actor_emailTEXT NOT NULLSnapshotted at insert time, survives user deletion
actionTEXT NOT NULLThe action string (user.role.changed etc.)
resource_typeTEXT NOT NULLConstrained via CHECK (see above)
resource_idTEXTThe affected entity’s id (UUID or composite)
metadataJSONB NOT NULL DEFAULT '{}'Action-specific context — the row that was changed, which fields, etc.
ip_addressINETRequest IP at insert time (nullable; not all call sites populate it today)
user_agentTEXTRequest UA at insert time (nullable; not all call sites populate it today)
created_atTIMESTAMPTZ NOT NULL DEFAULT NOW()UTC

There are GIN + B-tree indexes on (actor_id, created_at), (resource_type, resource_id, created_at), created_at, and metadata so audit queries by actor, resource, time, or metadata field are all fast.

metadata is a single JSONB blob today, not split into before_state / after_state columns. Splitting that — and ensuring every call site populates ip_address / user_agent — is on the SOC 2 roadmap under Q2. The columns exist; the inserter discipline is the gap.

RLS-protected

Two policies gate the table:

CREATE POLICY "admin_activity_log: admins select"
  ON admin.admin_activity_log FOR SELECT USING (admin.is_admin());
 
CREATE POLICY "admin_activity_log: admins insert"
  ON admin.admin_activity_log FOR INSERT TO authenticated
  WITH CHECK (admin.is_admin() AND actor_id = auth.uid());

Only admin users can read or insert. The insert policy also enforces that actor_id matches the calling user’s JWT — an admin cannot impersonate a different actor.

This is intentional. The audit log is a defense against bad actors, including bad-actor admins. An admin removing entries from the audit log generates its own audit-log entry that another admin can see — there is no clean way to cover tracks.

Querying

-- All role changes in the last 90 days
SELECT a.created_at, a.actor_email, a.resource_id AS target_user_id,
       a.metadata->>'old_role' AS old_role,
       a.metadata->>'new_role' AS new_role,
       a.ip_address
FROM admin.admin_activity_log a
WHERE a.action = 'user.role.changed'
  AND a.created_at > NOW() - INTERVAL '90 days'
ORDER BY a.created_at DESC;
-- All actions on a specific resource
SELECT a.created_at, a.actor_email, a.action, a.metadata
FROM admin.admin_activity_log a
WHERE a.resource_type = 'org' AND a.resource_id = $1
ORDER BY a.created_at DESC;

Retention

Rows are never auto-deleted. SOC 2 will require a documented retention policy; the planned policy is “indefinite for compliance, with archive-tiered storage after 2 years.” See SOC 2 roadmap.

⚠️

Don’t try to use this log as a general-purpose change-tracking table. The resource_type CHECK rejects rows that aren’t admin-tier. Use database history extensions (e.g., pgaudit) or cut_history for broader use cases.

See also