Audit Logging
How a state change becomes an audit entry, and the gates that keep it that way. The user-facing description is Audit Log; the generated audit events reference lists every route's decision.
The contract
An audit entry answers six questions: when (ts), who (actor_sub, actor_username, client IP and user agent), object (resource_type, resource_id), action, details (details_json), and diff (the before and after snapshots inside the details). A route that changes durable state records an entry that answers all six, or the registry says why it does not.
The vocabulary is fixed: AuditAction, AuditOutcome, and AuditResourceType in packages/core/enums.py, plus the event-type namespaces in packages/core/audit.py. emit_audit_log accepts the enum members or their string values; an unknown outcome always raises, and an unknown action, resource type, or event type raises under HEGEMONY_AUDIT_STRICT_VOCABULARY (on in the test suite) and is logged as drift in production.
Auditing a route
- Snapshot the object with
entity_to_dictand the router's*_AUDIT_FIELDSlist. Add every column the handler can change; leave out secret values (references such aspassword_refstay in, they are what an auditor needs to see change). - Capture
beforefrom the loaded row before mutating it. Never hard-code a before state. - Call
emit_audit_logafter the last point that can raise and before the commit. The entry joins the caller's transaction, so it is committed exactly when the change is. Passentity=so the id, display name, and, on request-less paths, the org come from the row; passorg_id=explicitly only when the entry concerns a different org than the row. For a fact that must survive whatever the transaction does (a post-commit side effect that failed, an attempt rejected as stale, an access denial) useemit_audit_log_now, which writes through an independent session and never raises. - Register the route in
apps/api/audit_coverage.yamlunderaudited:with itsresource_type,actions, andsnapshot(before,after,both, ornone). - Add a test that drives the route and asserts on the row: resource id, action, and that
beforeandafterdiffer on the changed field.
A POST that changes nothing durable (a validation, a probe such as a file repository's test-connection or a registry credential's login test, a query carried in a body) goes under exempt: with a reason. So do the worker's grants of a step's short-lived tokens (for one Terraform state, or for the platform registry): the token is the run's, and what the step does with it is audited on the state routes or recorded in the run's events. A route that does change state and cannot be audited in the same change goes under known_gaps: naming the change that will close it; the suite enforces that this list only shrinks.
A GET that changes durable state as a side effect (the object-types listing of a NetBox provider adopts a refreshed shared schema, which bumps the config version and can stale rows) goes under audited: too, so the emitter gate covers it; its entry is recorded under a system actor (system:inventory-discovery) because no person asked for the change. Reads are never required to carry a decision -- the completeness gate walks only mutating methods -- and exempt: and known_gaps: do not accept them.
When the handler reaches emit_audit_log through something the static walk cannot follow (a class method, a callback), add emitter: to its entry with the dotted name of the function to treat as the terminal. Every emitter named this way, and every entry of AUDIT_EMITTERS in apps/api/audit_coverage.py, must itself call emit_audit_log.
Vocabulary notes
Some spellings are kept on purpose:
deleteis the hard delete of a resource with no soft-delete lifecycle;soft_delete,permanent_delete, andrestoreare the three steps of that lifecycle. They are different events, not synonyms.- A run start is always
start; the trigger (manual, run-now, schedule, webhook) is the entry'strigger_sourcedetail, not a separate verb. syncanddiscoverare different verbs for an inventory provider.syncpulls rows from the source into the local tables;discoverasks the source which object types it serves and stores only that answer. A provider that cannot answer refuses the route, so adiscoverentry means the source was actually asked.- An inbound webhook records two entries because it touches two objects:
webhook.triggeredon the endpoint andrun.startedon the run. conflictis the outcome of a request rejected as a duplicate or stale.authz.denied(actionaccess) is a known caller refused a route;auth.failed(actionauthenticate) is a credential that established no caller. Only failures that still name an identity (a revoked or expired personal access token) reach the table; anonymous ones are log-only. Both are written by the middleware (apps/api/auth/access_audit.py), not by a route, so the coverage registry does not list them.
Rows written before the vocabulary existed were migrated onto it; each keeps its original spelling under a legacy_outcome, legacy_action, or legacy_event_type key in its details.
The gates
| Gate | Where | What it catches | What it cannot see |
|---|---|---|---|
| Registry completeness | tests/api/audit/test_audit_coverage.py | A mutating route with no decision; an entry for a route that no longer exists; a growing known_gaps list | Whether the decision is right |
| Emitter reachability | tests/api/audit/test_audit_coverage.py | An audited route whose handler has no call path to an emitter | A call behind a condition a test never takes; calls through instances (use emitter:) |
| Vocabulary (static) | tests/api/audit/test_audit_vocabulary.py | A literal action, outcome, resource type, or event type outside the enums and namespaces, at any call site | Values computed at runtime |
| Vocabulary (runtime) | emit_audit_log | The same, on every path the suite executes | Paths no test executes; in production it warns instead of failing |
| Docs freshness | docs/docs-map.yaml, audit-log-backend | Changing the audit backend or the registry without touching the audit docs | Whether the prose is true |
| Session guards (runtime) | apps/api/audit_session.py | An entry lost to a rollback or an unclosed session (logged with its payload); application code modifying or deleting an entry (refused at flush) | A handler that never emits |
| Route contracts | tests/api/audit/test_router_audit_contracts*.py | For the routes in the case tables: the wrong resource type or action, a missing snapshot the registry promises, an update with no changes, an entry left by a failed request | Routes no case covers |
| Advisory AI review | .github/workflows/audit-pr-review.md | Emit placement, hard-coded or late snapshots, incomplete field lists, details that do not say what changed, untrue registry reasons | It advises; only the gates above block |
Run them with task test:audit:coverage (the two static gates, also a pre-commit hook and a CI step beside the auth boundary check) or task test:audit (everything under tests/api/audit). The coverage gate has no override label: a route is audited, exempt with a reason, or a named gap.
The deterministic gates cannot judge the quality of an entry: an under-detailed field list, a before captured after the mutation, an extra_details that would not tell an auditor what changed. The route contract tests catch that for the routes they drive; for everything else an advisory agentic workflow (.github/workflows/audit-pr-review.md, run on pull requests that touch handlers, services, or the audit machinery) reads the changed handlers and comments once when it finds something. Its compiled .lock.yml is regenerated with gh aw compile after the Markdown changes, as for the docs review workflows.