Security Audit Log
The append-only trail of security-relevant actions and the shared trust boundary for audit and session client IPs.
Every security-relevant action writes one append-only audit row: who acted (a user, or the system itself), the affected organization and subject, the client IP and user agent when they are trustworthy, and a few non-secret facts about the action. Platform administrators — the same platform-administrator role described in Organizations, roles & access — read the trail on the Audit log screen under Identity, filterable by action, organization, and date range, newest first, paged by cursor. Rows expire after 180 days.
Set API_TRUSTED_PROXY=true only when the API's direct peer is the Devboxes edge proxy and that
proxy overwrites x-forwarded-for. Otherwise a direct client can forge the IP metadata stored
with audit events and sessions.
The wired action vocabulary doubles as the filter facet on the screen and the accepted values of the API's action filter:
| Actions | Written when |
|---|---|
auth.gate_violation, auth.user_created, bootstrap.operator_created | Account creation is accepted, rejected at the invite gate, or claims the first platform-administrator role |
credential.created, credential.updated, credential.deleted | An organization credential or repository connection changes |
github_app_authorization.revoked | GitHub revokes the App authorization |
invitation.created, invitation.accepted, invitation.resent, invitation.revoked | An organization invitation changes |
machine.registered, machine.deleted | A runner machine registers, revives, or is deleted |
member.role_changed, member.removed, org.renamed, org.deleted | Organization access or identity changes |
repo_connection.restored, repo_connection.revoked, repo_connection.suspended | A remembered GitHub App installation changes availability |
run.dispatched | A run is dispatched, in the same transaction as the run itself |
session.revoked, task_token.rejected | A session is revoked or a task token fails authorization |
user.email_change_requested, user.email_change_confirmed, user.password_changed | Account credentials change |
user.disabled, user.enabled, user.deleted | Platform access is disabled, restored, or erased |
How to test it
| Suite | Proves |
|---|---|
pnpm vp run @firops/api#test — audit-log.integration.test.ts | Administrator-only access; machine.registered, credential.created, credential.deleted, run.dispatched, and auth.user_created land their row with actor, subject, and first-hop IP; a rolled-back transaction leaves no phantom row; key material never lands in a row; keyset paging, filters, and the 180-day sweep |
pnpm vp run @firops/api#test — deletion-receipt.integration.test.ts | An erased account and an erased organization each leave a user.deleted / org.deleted receipt carrying its pre-erasure counts, pseudonymized so it outlives the subject it records |
pnpm vp run @firops/api#test — two-factor.integration.test.ts | A second factor is stored only after a real code confirms it, and neither the shared secret nor the backup codes are kept in the form the client received |
pnpm vp run @firops/api#test — audit-log.untrusted-proxy.integration.test.ts | Without a declared trusted edge, a forged forwarding header is ignored and the row records no client IP |
pnpm vp run @firops/api#test — session-ip.integration.test.ts | With a declared trusted edge, a Better Auth session records the first x-forwarded-for hop |
pnpm vp run @firops/api#test — session-ip.untrusted-proxy.integration.test.ts | Without a declared trusted edge, Better Auth ignores forged x-forwarded-for and x-real-ip values and records no session client IP |
pnpm vp run @firops/api#test — signup-gate.integration.test.ts | An authorization revoked during account creation rolls back the user and records auth.gate_violation without exposing invitation material |
Manual smoke: sign in as a platform administrator → Identity → Audit log; dispatch a run and watch its row appear; apply an action filter and step through pages with Next/Previous.
Decisions
- No foreign keys — audit rows must survive deletion of the user, organization, or subject they describe. A trail that disappears with its subject is not a trail.
- Audit rows commit in the mutation's own transaction — a rollback leaves no phantom row claiming something happened, and a committed mutation cannot lose its row to a later failure.
- Rows expire after 180 days — a bounded forensic window instead of unbounded retention of user IPs. The sweep rides the existing periodic mechanism: once at boot to catch downtime, then daily.
- A forwarded client IP is believed only when the deployment declares its
edge — with
API_TRUSTED_PROXY=true, audit rows and Better Auth session rows record the firstx-forwarded-forhop because the edge proxy overwrites any client-supplied value. Unset (the default), forwarding headers are attacker-controlled input on a direct connection, so audit rows record no IP and Better Auth disables session IP tracking rather than persisting a forgeable value; its IP-keyed sign-in rate limiting is also disabled. This posture is for local development only. - Metadata carries small non-secret facts only — provider ids, names, model ids; never tokens or key material. The credential test serializes the whole row and asserts the key is absent.
- Keyset pagination, with an accepted precision limit — pages follow a (time, id) cursor so new rows cannot shift pages mid-read. The cursor carries millisecond precision while rows store microseconds, so rows that share the page-boundary row's millisecond but differ below it can be skipped when stepping to the next page. Accepted deliberately as a known limitation instead of complicating the cursor.
- The sole declared action without a live writer is
run.cancelled— run cancellation remains outside the audit trail until every terminalization path can commit the same idempotent event. Sign-in failure bursts are skipped rather than building bespoke auth middleware.