Skip to content

Audit log ​

Audience

This article is for GCM platform operators. Church admins see a per-org slice of the same data on Settings -> Activity in their workspace β€” this page is the cross-tenant superset, restricted to staff.

The Audit log (/platform/audit) is the canonical record of every staff action and every security-relevant event in GCM. Org created, plan changed, refund issued, impersonation started, impersonation ended, sign-in failed, password reset triggered, role assigned β€” they all land in platform_audit_log with the actor, the target, the timestamp, and a JSONB metadata blob. This tab is how you ask "who did what, when, to whom".

Audit log viewer with filters

The KPI strip ​

Three counts at the top, all live:

  • Total events β€” every row in platform_audit_log, ever. Mostly useful as a sanity check that the writer is still writing.
  • Events today β€” rows since midnight in the platform timezone. A normal day is low thousands; a quiet weekend is hundreds. A sudden zero usually means the audit writer broke.
  • Impersonations (7 days) β€” rows where action ilike '%impersonat%' in the last week. This is the metric to watch β€” every impersonation start, end, and failure counts. Spikes here are worth a quick read.

The viewer ​

Below the strip sits the unified AuditLogViewer component, also used by the org-scoped Activity page. It supports:

  • Date range β€” from / to with day granularity. Defaults to the last 30 days.
  • Action β€” dropdown of distinct values in platform_audit_log.action. The list is auto-populated from the DB so it stays current as new actions are added.
  • Actor β€” partial email match. Use to scope to one staff user.
  • Organisation β€” by org id. Searches the metadata->>target_org_id jsonb path so it catches every event that mentions an org, even when the row's own organization_id is null.

Filters compose; clearing one keeps the others active.

Reading a row ​

Each row in the table shows:

  • When β€” exact timestamp.
  • Action β€” the verb. The full set is documented in the migration that adds the column constraint; common ones include org_created, plan_updated, refund_processed, subscription_overridden, impersonation_started, impersonation_failed, email_template_updated, auth_login_failed.
  • Actor β€” email of the staff user who triggered the row. Edge-function and cron rows show system@geniuschurchmanager.com.
  • Target β€” the affected org name (resolved from the metadata) or "platform" for platform-wide rows.
  • Metadata β€” truncated JSONB preview.

Click any row to expand the metadata. Refunds, for instance, store the original amount, the refund amount, the manual method (if off-platform), and the reason text. Subscription overrides store the before / after values for every changed field.

Common investigations ​

"Who refunded that $79 payment from First Church on Tuesday?" Filter action = refund_processed, set the date range to Tuesday, filter organisation = First Church. The actor column is your answer.

"Has anyone from our team logged into Second Baptist's workspace this month?" Filter action = impersonation_started, set the date range to "the month", filter organisation = Second Baptist. Each row carries the reason in metadata β€” read those next.

"Did the customer's plan really change last week or are they imagining it?" Filter action = plan_updated, filter organisation = <their org>. If a row exists, the metadata shows the before/after plan id and who did it. If no row exists, the customer is misremembering β€” but check subscription_overridden too in case the change came through the billing-ops edit dialog.

"How many failed sign-ins in the last 24 hours?" Action auth_login_failed, date range "yesterday to today". Cross-reference the actor with the Users tab to see if one account is being targeted.

Action reference (the important ones) ​

ActionWhen it firesKey metadata
org_createdNew org provisionedorg_name, org_slug, plan_id, created_by_admin
org_deletedHard deleteorg_name, org_slug, member_count, reason
plan_updatedInline plan change on the Organisations tabtarget_org_id, previous_plan, new_plan
subscription_overriddenEdit subscription dialogtarget_org_id, before, after, reason, email_sent
renewal_triggeredManual renewal (charge mode)target_org_id, mode, reason, result
refund_processedRefund modalpayment_history_id, amount, refund_type, reason
impersonation_startedOpen account from org sheettarget_org_id, org_name, reason
impersonation_endedReturn to platformtarget_org_id
impersonation_failedRPC rejected the starttarget_org_id, reason, error
email_template_updatedSave in Email Templates tabslug, before, after
landing_content_updatedSave in Landing tabsection, content_key, locale, before, after
branding_settings_savedSave in Branding tabkeys
auth_login_failedBad password or unknown emailemail, ip, user_agent

The list is not exhaustive β€” new actions are added with each feature.

Retention ​

platform_audit_log is not purged automatically. Rows live forever until someone deletes them, and we deliberately do not expose a UI to do that β€” the audit trail is the audit trail. If you need to reduce the table size for a migration, write a migration that archives to a cold-storage table and document it in docs/runbooks/.

What you cannot do here ​

The viewer is read-only. There is no "edit", no "annotate", no "mark important". If you need to attach context to a row, write a new row by performing the action it describes (e.g. add a Slack comment on the ticket the audit row references, or open the linked Sentry issue). Treat the audit log the way a flight recorder works: read-only, write-once, never the place you go to clear a problem.