Audit log
The hash-chained record of every mutation, and its verifier.
packages/api/src/audit/audit.controller.ts by packages/docs/tools/generate-reference.mjs. Do not edit by hand: src/reference.spec.ts regenerates it and fails on a difference.GET /v1/audit_logs
A test principal sees the environment-agnostic history and its own test
rows, and nothing about live. Without the filter the log is the widest hole
in the test-mode boundary: api_key.created carries a live key's display
prefix, project and tenant, and secret_key.revoked carries a live
control-plane credential's prefix.
The environment comes from the key the row is about rather than from the
payload, because only the two *.created payloads carry env -- an update,
a rotation and a revocation do not, and those are the rows the reviewer read
live prefixes out of. It is also the same place assertEnvVisible reads it,
so the listing and the by-id read cannot disagree. A row naming a key that
no longer resolves is hidden rather than shown: the boundary fails closed.
seq is therefore not contiguous in a test principal's view. That is
inherent to a filtered log; GET /v1/audit_logs/chain verifies the whole
chain and discloses only counts and hashes, so it stays open to both.
A row whose actor is a person carries that person, not just their
identifier. Attributing an action to a human rather than to a program is
the whole reason the identity plane exists, and a bare UUID does not
deliver it -- the reader would have to join it back against a membership
listing that no longer names someone who has since left. Resolved from
keyring_identity.user, which discloses nothing new to a person: an
actor_id can only appear in this workspace's chain if that person acted
in it.
It discloses plenty to a krsk_test_ key, which is why this route is the
third door on the same finding. Closing GET /v1/members and
GET /v1/invites left this one open carrying strictly more of the same
data: the roster shows who is a member now, the log shows every person who
ever joined, changed role or was removed and every invitation ever issued,
with the address and the role, and revoked ones too. That is the target
list a leaked CI key phishes from.
So a test principal reads the history and not the people in it. Two withholdings, and the second is an allowlist on purpose, because this rule has now failed three times by being a denylist somebody had to extend:
actoris not resolved at all -- no address, no name.actor_idandactor_typestay, so the chain still reads as a sequence of distinct actors; a UUID is not a phishing target.payloadis served only for the target types that are product state. Everything else -- every person-plane row, and every target type added after this comment -- is withheld by default rather than remembered about.workspace_member.joinedalone carriesemailin one action andinvited_toin another, which is exactly the key list nobody keeps up to date.
The row itself stays: a test key inspecting its own test-mode activity is
a capability the product sells, and refusing the route outright would take
it away to fix a disclosure. audit-people.spec.ts asserts the class --
that no seeded address appears anywhere in the response -- rather than the
field names, so a fourth field is caught the day it is written.
Answers 200 on success.
Authentication.
- A
krsk_secret key or akrses_dashboard session, asAuthorization: Bearer.
Query. Validated by this schema, from the control plane's own source:
const ListAudit = z.object({
after_seq: z.coerce.number().int().min(0).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
});GET /v1/audit_logs/chain
Report section 8.5.3 sells the audit log as "verify our logs" rather than "trust our logs". This is the verification: it walks the workspace's chain in the database, re-deriving every hash, and names the first position that does not hold.
Answers 200 on success.
Authentication.
- A
krsk_secret key or akrses_dashboard session, asAuthorization: Bearer.