0020 — Cross-tenant audit log
- Status: accepted
- Date: 2026-05-13
- Deciders: Derek
Context
ADR 0019 commits ark to recording every cross-tenant operation performed via service-role. The mechanism is org_access_audit (table created in migration 005). After PR #14 and PR #15, the rollout is complete and the system has a concrete shape worth documenting in one place — both for tenants (who can read their own audit rows) and for engineers (who need to know how to extend it).
Per ADR 0008 this document is product copy: a non-engineering reader should be able to use it to understand exactly what ark records about platform-admin actions taken in their tenant, and what they can see for themselves.
Decision
What gets audited
Every mutation a platform admin (admin of the ark meta-tenant) makes in a tenant they’re NOT a direct member of writes one row to org_access_audit. The verb taxonomy is fixed in apps/api/src/lib/audit.ts as the const-union AUDIT_OPERATIONS. Adding a new verb requires editing that file (and is a code change subject to review).
| Operation | Triggered by | Target table |
|---|---|---|
branding.update | PATCH /me/orgs/:orgId/branding | organizations |
cms.content_type.create | POST /orgs/:orgId/cms/types | cms_content_types |
cms.entry.create | POST /orgs/:orgId/cms/types/:slug/entries | cms_entries |
cms.entry.update | PATCH /orgs/:orgId/cms/entries/:id | cms_entries |
cms.entry.delete | DELETE /orgs/:orgId/cms/entries/:id | cms_entries |
collections.create | POST /orgs/:orgId/cms/types | cms_content_types |
collections.update_fields | PATCH /orgs/:orgId/cms/types/:slug | cms_content_types |
collections.delete | DELETE /orgs/:orgId/cms/types/:slug | cms_content_types |
publishing.update | PATCH /me/orgs/:orgId/publishing | organizations |
publishing.deploy | POST /me/orgs/:orgId/publishing/test | organizations |
invitations.create | POST /orgs/:orgId/invitations | org_invitations |
invitations.revoke | DELETE /orgs/:orgId/invitations/:id | org_invitations |
invitations.resend | POST /orgs/:orgId/invitations/:id/resend | org_invitations |
platform.tenant.create | POST /platform/tenants | organizations |
platform.tenant.provision_cf | POST /me/orgs/:orgId/publishing/auto-provision or pnpm tenant:provision-cf (CLI) | organizations |
pages.edit_start | POST /me/orgs/:orgId/pages/edit | organizations |
pages.publish | POST /me/orgs/:orgId/pages/publish | organizations |
pages.discard | POST /me/orgs/:orgId/pages/discard | organizations |
pages.takeover | POST /me/orgs/:orgId/pages/takeover | organizations |
forum.post_create | POST /orgs/:orgId/threads or /threads/:threadId/replies | forum_posts |
forum.post_update | PATCH /orgs/:orgId/threads/:threadId or /replies/:replyId | forum_posts |
forum.post_delete | DELETE /orgs/:orgId/threads/:threadId or /replies/:replyId | forum_posts |
forum.reaction_add | POST /orgs/:orgId/posts/:postId/reactions | forum_reactions |
forum.reaction_remove | DELETE /orgs/:orgId/posts/:postId/reactions/:kind | forum_reactions |
forum.vote_cast | PUT /orgs/:orgId/attachments/:attachmentId/votes | poll_votes |
forum.vote_withdraw | DELETE /orgs/:orgId/attachments/:attachmentId/votes | poll_votes |
forum.availability_respond | PUT /orgs/:orgId/attachments/:attachmentId/responses | availability_responses |
forum.image_upload | POST /orgs/:orgId/images | images |
forum.attachment_update | PATCH /orgs/:orgId/threads/:threadId or /replies/:replyId (with attachments[]) | post_attachments |
email.invite_sent | POST /orgs/:orgId/invitations (when delivery succeeds) | org_invitations |
email.invite_resent | POST /orgs/:orgId/invitations/:id/resend (when delivery succeeds) | org_invitations |
google.connect_start | POST /orgs/:orgId/google/connect/start | google_oauth_state |
google.connect | POST /google/callback | google_oauth_credentials |
google.disconnect | POST /orgs/:orgId/google/disconnect | google_oauth_credentials |
google.list_calendars | GET /orgs/:orgId/google/calendars | (Google API; no ark table) |
google.select_calendar | POST /orgs/:orgId/google/calendars/select | google_org_calendars |
google.view_audit | GET /orgs/:orgId/google/audit | google_audit_log |
Forum verbs note: today’s forum API rejects all platform-admin writes with a 403, so no rows are written for these verbs yet. The verbs are registered now so the audit trail is ready when platform-admin moderation lands. Direct-member writes (the common case) are not audited — by design, an admin acting in their own tenant does not cross a trust boundary.
Each row contains:
audited_org_id— your org id, when ark’s platform admin acted on data belonging to your tenant.actor_user_id— the auth.users id of the platform admin who took the action.operation— one of the verbs above.target_table— the database table that was mutated.metadata(JSONB) —{ route, target_id, actor_email, via, ...extra }. The CLI provisioner setsvia: 'platform-admin'and includescf_project_nameunderextra. PATCH on a CMS entry includes the action underextra.action(save_draft/publish/archive).created_at— UTC timestamp.
actor_user_id and audited_org_id are populated on every row produced by the current rollout. Older code paths or future cross-tenant ops may produce rows where one or both are null.
What is NOT audited
- Read events. When a platform admin merely views your tenant’s data (members list, forum threads, CMS entries), no row is written. The audit promise is about mutations. We made this trade deliberately: read traffic is high-volume and low-signal, and you can still infer that platform-admin access happened from the (audited) mutation rows when they exist. If a tenant comes to us with a regulatory requirement that demands read auditing, the table schema accepts new operation verbs without migration.
- Forum writes from platform admins. As of this ADR, ark does not let a platform admin post or moderate forum threads/replies in tenants they’re not a direct member of. Such attempts return HTTP 403 with the message “Forum writes for platform admins are not yet supported. Direct membership required.” If forum moderation from the switcher is added later, it will land with corresponding
forum.thread.create/forum.reply.delete/ etc. audit verbs. - Storage uploads. Logo, CMS image, and profile-avatar uploads via service-role are not currently audited. These are operationally rare and tied to a write that is audited (e.g. updating brand tokens after uploading a logo), but the upload itself does not produce a separate row. Tracked as a follow-on.
- Actions by direct admins of your tenant. The audit log records cross-tenant access. Edits made by your own admins through the normal UI are not in this table — they’re attributable from the row they wrote (e.g.
cms_entries.updated_atand any embedded author fields). This table is specifically about the platform-admin escape hatch. - Mutations a platform admin makes in the
arkmeta-tenant itself. When an ark admin edits the ark org’s own data, they’re acting as a direct admin (the same way you’d act in your own tenant), not via the platform-admin escape. No row written.
What is stored and what is not
- Stored: the verb, the table mutated, the affected row id, the route template, the actor’s user id and email, the access path (
via: 'platform-admin'), the timestamp, and a smallextrafield for context (e.g.cf_project_name,action: 'publish'). - Not stored: request bodies, before/after diffs, IP addresses, user agents. We chose minimal payloads on purpose — to keep tenant data out of the audit table and to avoid the table becoming a second data store of its own.
How tenants see their audit log
Per migration 005, org_access_audit has Row-Level Security enabled with one SELECT policy: admins of an org can read rows where audited_org_id matches their org. No one else (other admins, non-admin members, platform admins not directly in your org) can read your rows.
Today, the only way to read the log is via SQL — there is no transparency-dashboard UI yet. From the Supabase SQL editor, signed in as an admin of your org:
select
operation,
target_table,
metadata ->> 'route' as route,
metadata ->> 'target_id' as target_id,
metadata ->> 'actor_email' as actor_email,
metadata ->> 'via' as via,
metadata,
created_at
from org_access_audit
where audited_org_id = '<your-org-id>'
order by created_at desc
limit 50;
The same query run as an admin of a different org returns zero rows. The same query run via the public anon API returns zero rows. The keystone RLS isolation test in packages/db/test/rls-isolation.spec.ts proves this on every CI build.
If you would like an in-product UI to surface these rows on your admin dashboard, file an issue (the read policy is already in place; this is a frontend build).
How engineers extend this
When you add a new mutation that a platform admin can make in a foreign tenant, audit it. The pattern is:
- Add the verb to
AUDIT_OPERATIONSinapps/api/src/lib/audit.ts. The const-union is enforced at the type level — TypeScript will reject any string not in the list. - Call
auditCrossTenant(...)after the mutation succeeds and before the response is sent. The helper no-ops whenargs.via === 'direct-admin', so direct-admin and direct-member callers naturally produce no row. Failure to insert is logged viaconsole.error({ msg: 'audit_write_failed', ... })and swallowed — the audit never blocks a successful mutation. - Add canary tests: a direct-admin scenario asserts
auditInserts.toHaveLength(0); a platform-admin scenario assertstoHaveLength(1)with the canonical column mapping. Patterns are documented inline in the design specs (cross-tenant audit logging spec, platform-admin non-member access spec). - If the verb is new, add a row to the table in this ADR by writing a successor ADR (the ADR-supersede pattern per ADR 0008). Verb removals require the same.
Read events do not need audit calls under the current policy.
Consequences
Good:
- One file (
AUDIT_OPERATIONSinaudit.ts) is the authoritative verb taxonomy. Renames, additions, and removals are visible in code review and in CI test output. - One table (
org_access_audit) is the entire audit surface. There is no second log or alternate path. - Tenants can query their own rows under RLS today, with no infrastructure dependency on ark beyond Supabase access.
- The cross-tenant-only policy (helper no-ops on direct-admin) means the audit table doesn’t fill with noise from a tenant’s own admin activity.
Bad / accepted:
- Read events are not audited. A tenant who wants to know “did anyone from ark view our member roster yesterday?” cannot answer that from the table alone. We accept this for now; volume considerations would make it unwieldy and the threat-model bar is on mutations.
- The audit table is single-tenant in the sense that it lives in the shared Supabase project. A compromise of that project compromises every tenant’s audit log along with everything else. Self-hostable mode (ADR 0011, core tenet 6) is the long-term answer for tenants who need to own their audit data; it is not yet built.
- There is no in-product UI for reading the audit log. Tenants with non-technical admins cannot review their own data without a SQL editor.
Trip-wires
Re-open this ADR (write a 0021 superseding it) when any of the following changes:
- A tenant asks for read-event auditing and we agree to add it. The schema accepts new verbs without migration; the policy change is what would warrant the ADR refresh.
- Forum moderation from the switcher ships. The new audit verbs go into the table above.
- Self-hostable mode lands and changes where audit rows live.
- A real audit-table consumer (a transparency dashboard UI, a SOC 2 query, an incident response process) lands and shapes which fields we actually need.
2026-05-14 addendum — system-actor events
The email integration work added a third writer alongside auditCrossTenant: auditSystemActor (same file, apps/api/src/lib/audit.ts). It exists for events whose caller is neither a direct admin nor a platform admin — anonymous endpoints like POST /auth/password-reset/request that any visitor can hit.
System-actor rows are still written to org_access_audit, but with both audited_org_id and actor_user_id set to NULL (both columns have been nullable since migration 005). The metadata carries via: 'system' so cross-actor queries can scope to or exclude them. The first verb in this category:
email.password_reset_requested— written on every well-formed, not-rate-limited request, regardless of whether the email matches a user (so enumeration attempts are visible in the log).
These rows are not visible to org admins through the existing RLS policy on org_access_audit, which requires audited_org_id IS NOT NULL. They are platform-admin-only via the service role. That trade-off is intentional — a system-actor event isn’t scoped to any tenant, so no tenant has a clean claim to read it.
The other two verbs added in this work — email.invite_sent, email.invite_resent — are normal auditCrossTenant rows (written when a platform admin invites/resends for a tenant they’re not a direct admin of). They follow the existing rules unchanged.
2026-05-15 addendum — Google Calendar verbs
The Google Calendar v1 integration adds six new google.* cross-tenant verbs (listed in the table above). They follow the standard auditCrossTenant rules — rows are written only when a platform admin takes the action in a tenant they’re not a direct member of. A direct admin connecting Google for their own tenant produces no cross-tenant row.
Two design notes specific to this set:
- Read events are not cross-tenant audited. Members hitting
GET /orgs/:orgId/calendar/eventsproduce no row inorg_access_audit. This stays consistent with the read-events rule above. The org-internalgoogle_audit_logtable (introduced by migration 033) does record every Google API call ark makes — that’s the tenant-visible “what did ark send to Google” log surfaced in the admin UI at/admin/google/audit. The two logs are complementary lenses, not duplicates:org_access_auditis “who acted across tenant boundaries”;google_audit_logis “what ark did with Google’s APIs on this org’s behalf”. google.view_auditis the one read in this set that DOES write a cross-tenant row. Viewing another tenant’sgoogle_audit_logis itself a privileged action — surfacing what Google calls ark made on their behalf reveals operational state, so platform-admin access to it is recorded.
See ADR 0022 for the broader trust-model framing of the Google integration.