Quoin docs

Audit log

What gets recorded on every write, who can read it, and what it deliberately leaves out.

Every write in Quoin lands one row in audit_log. A write can be a key created, an environment deleted, a member invited or a role changed. Quoin writes the row in the same atomic batch as the write itself. No path changes workspace state without also writing the row that says who changed it, when, and how.

Built end to end

The table, the paired-write mechanism, audit.list/audit.export and the ten-table quoin-admin export/import tool are real, tested code as of QN-11c. The Audit log screen (/audit-log) is built as of QN-11c-ii. The screen has these parts: a filter row (actor, object type, date window, and a key filter that matches any part of the subject key); one row for each write, with its actor, what changed and the note; a row detail that shows the stored fields; "Export CSV" for owners and admins; and "Load older" on the same cursor that the API uses. Every kind on this page renders there on the day a writer for it exists. The experiment and decision-record kinds have no writer yet. This page describes what the API records. This is exactly what the screen shows.

What's recorded

Each row carries these items:

  • What — kind (for example apiKey.revoked, member.role_changed, environment.created) and subjectKey. The subjectKey is the thing that a person can scan, and that the row is about. It is the label of an API key, the key of an environment, or the email of an invited or removed person.
  • Who — actorId and actorLabel. The actorLabel is the name of the actor at write time. The historic rows of a deleted or renamed actor still read correctly, because actorLabel is never a live join. actorId keeps the id even when the id resolves to no one. Quoin never overwrites it, and it never turns the row into an orphan.
  • How — via: web, cli, api, guard or system. When a guard pauses a flag by itself, the row has no human actor at all (actorId: null, actorLabel: 'guard:<name>'). See Guards.
  • When — createdAt.
  • A note, when the write collected one. The one-writer rule requires a note on a flag edit or revert (flag.edited/flag.reverted, real as of QN-03a). The key, environment and member events take no note.

Append-only, kept until purge

Quoin never updates or deletes an audit_log row. The one exception is the purge of a workspace (30 days after deletion, the same as every other table). Review does not enforce this rule as a convention. Two checks that fail the build enforce it:

  • An AST-checked test scans every .ts/.tsx file in the server and database packages for a query-builder update or delete on the table. The test fails if one appears anywhere outside that one purge call.
  • The test cannot see a raw sql template, so a second check covers it. The raw-sql-outside-dialect rule of the import lint confines the raw sql tag to the one dialect module of the database package. Because of this, the only file where a raw UPDATE audit_log could be written is a short file that people review with care.

Both checks are static checks over the code. They are best-effort by design. The AST test resolves the table through the imports and aliases of a file. The README of the database package lists the shapes that the test does not follow. A guarantee at the level of the database grant needs the Postgres path. The retention rule is therefore simple to state precisely: forever, until the workspace itself is purged.

Reading it

audit.list is read-gated. Every role can see it, viewer included, the same as Flags, Experiments and Metrics. It is a cursor-paginated read that you can filter. The filters are:

  • the key
  • the actor
  • the object type (flag, experiment, metric, member, apiKey, environment, audit)
  • a date window

A page has 20 rows. This is the same page size that the revision history of Q4 uses.

audit.export is gated to admin and owner (the manage-keys-members action of the matrix). It writes its own audit row, audit.exported, at the moment it runs. The row has the filter and the row count that it exported. The export of the log is itself logged. The export returns one of these formats:

  • CSV with the columns time, actor, via, kind, subject and note, and one row for each audit row. If a field begins with =, +, -, @, a tab or a carriage return, Quoin adds an apostrophe before it. Then a spreadsheet shows it as text and does not evaluate it. The exact set is FORMULA_LEAD in apps/web/server/trpc/procedures/audit.ts. This sentence follows that set, and the set does not follow this sentence.
  • JSON with every column, including the free-form fields bag, verbatim.

The "Export CSV" link of the screen calls the CSV format. The JSON format is a signed-in session's export for tools of its own. Today audit.export answers only a signed-in web session, in either format. It refuses an API key of any scope as not signed in, whatever the role of the key. This is by ruling, not by gap (07d qn11c-qa-1, affirmed again by qn03a-build-3). QN-03a opened the shared role check to operator keys. As a result, audit.list now answers the CLI key of a member, for any role, because audit.list is read-gated. QN-03a deliberately kept the session check of export, and QN-03a-ii kept it too. QN-03a-ii is the slice that ships the quoin CLI. There is no quoin audit command in v1 (07d qn03a-ii-plan-11).

What never appears

  • ip and OTP codes never reach audit_log at all. Email addresses also never reach the free-form fields bag. The console-log security channel uses the same redaction rule. Quoin applies the rule once, and both share it. For member.* rows, the email of the invited or removed person is the subjectKey of the row (see "What's recorded" above). The email is the identity of the object. Quoin keeps it in the table of the workspace itself. It is not a leak in a log line.
  • Sign-in events (auth.sign_in.succeeded/.failed, OAuth linking, session revocation, OTP rate limiting) are not in this table. This is by design, not by gap. They happen before a workspace is selected, and the workspace_id column of audit_log is NOT NULL on every other row that it carries. They stay on the structured console-log channel that Workers Logs already collects. "The same pipeline" that the sign-in audit line of QN-01 itself promises means the same structured-JSON mechanism. It does not mean the same table.
  • A rendered environment. audit_log carries no environment_id. The log is workspace-wide across every environment. This matches the rule that the sidebar switcher is the only place where you choose an environment. A write that needs environment context, such as the per-environment rollout edit of a flag, carries it inside the fields JSON of the row. It is not a column that every other kind would carry for nothing.

Next

On this page