Skip to content

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 ​

  1. Snapshot the object with entity_to_dict and the router's *_AUDIT_FIELDS list. Add every column the handler can change; leave out secret values (references such as password_ref stay in, they are what an auditor needs to see change).
  2. Capture before from the loaded row before mutating it. Never hard-code a before state.
  3. Call emit_audit_log after 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. Pass entity= so the id, display name, and, on request-less paths, the org come from the row; pass org_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) use emit_audit_log_now, which writes through an independent session and never raises.
  4. Register the route in apps/api/audit_coverage.yaml under audited: with its resource_type, actions, and snapshot (before, after, both, or none).
  5. Add a test that drives the route and asserts on the row: resource id, action, and that before and after differ 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:

  • delete is the hard delete of a resource with no soft-delete lifecycle; soft_delete, permanent_delete, and restore are 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's trigger_source detail, not a separate verb.
  • sync and discover are different verbs for an inventory provider. sync pulls rows from the source into the local tables; discover asks the source which object types it serves and stores only that answer. A provider that cannot answer refuses the route, so a discover entry means the source was actually asked.
  • An inbound webhook records two entries because it touches two objects: webhook.triggered on the endpoint and run.started on the run.
  • conflict is the outcome of a request rejected as a duplicate or stale.
  • authz.denied (action access) is a known caller refused a route; auth.failed (action authenticate) 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 ​

GateWhereWhat it catchesWhat it cannot see
Registry completenesstests/api/audit/test_audit_coverage.pyA mutating route with no decision; an entry for a route that no longer exists; a growing known_gaps listWhether the decision is right
Emitter reachabilitytests/api/audit/test_audit_coverage.pyAn audited route whose handler has no call path to an emitterA call behind a condition a test never takes; calls through instances (use emitter:)
Vocabulary (static)tests/api/audit/test_audit_vocabulary.pyA literal action, outcome, resource type, or event type outside the enums and namespaces, at any call siteValues computed at runtime
Vocabulary (runtime)emit_audit_logThe same, on every path the suite executesPaths no test executes; in production it warns instead of failing
Docs freshnessdocs/docs-map.yaml, audit-log-backendChanging the audit backend or the registry without touching the audit docsWhether the prose is true
Session guards (runtime)apps/api/audit_session.pyAn 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 contractstests/api/audit/test_router_audit_contracts*.pyFor 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 requestRoutes no case covers
Advisory AI review.github/workflows/audit-pr-review.mdEmit placement, hard-coded or late snapshots, incomplete field lists, details that do not say what changed, untrue registry reasonsIt 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.

Released as open source under the AGPL-3.0-or-later license. Development is sponsored by Rexonix s.r.o.. Contact — [email protected].