Skip to content

Audit log

Anvil writes security relevant events and all changes of Anvil models to an append-only, tamper evident audit log.

Field Content
uuid UUID of the entry
tenant_id tenant of the context, if any
occurred_at UTC timestamp with microseconds
event e.g. record.created, record.updated, record.deleted
actor_* type (user, system, api), id and label of the actor
subject_* type (default: table name) and UUID of the affected record
changes {"field": [old, new]}, sensitive values masked
context channel, IP address, HTTP method, path, user agent
key_id identifier of the key used for the hash
previous_hash, hash hash chain, see below

Query strings and request bodies are never logged, they may contain secrets.

The entry is written in the transaction of the change: if the change is rolled back, its audit entry is too.

Every Anvil model (Anvil\Data\Model) is audited when Config\Anvil::$audit is true (default). Per model:

final class OrderModel extends Model
{
protected ?bool $auditable = true; // null: Config\Anvil::$audit
protected string $auditSubject = 'orders'; // subject type, default: table name
protected array $auditIgnore = ['view_count']; // not in the change set
}

Config\Anvil::$auditIgnoredFields (default created_at, updated_at) are left out of every change set. Inserts, updates and deletes are logged as record.created, record.updated and record.deleted; the history of a record in the panel (permission <resource>.history) reads these entries.

The actor is resolved for every entry by the actorResolver service, so a shared logger always names the current actor:

Situation Actor
logged in user user, user id, username (or email)
no user logged in system, anonymous
CLI command system, cli
API request before the client is known system, api
API request of a client api, client UUID, api:<client name>
Anvil\Security\RunAs::user() (e.g. queue jobs) user of the job

Replace the resolver for own jobs with a Anvil\Security\Audit\FixedActorResolver:

use Anvil\Security\Audit\Actor;
use Anvil\Security\Audit\FixedActorResolver;
use CodeIgniter\Config\Services;
Services::injectMock('actorResolver', new FixedActorResolver(Actor::system('import')));
service('auditLogger')->record(
'invoice.sent',
'invoices',
$invoiceUuid,
context: ['recipient_domain' => 'example.com'],
);

Event names may contain a-z, 0-9, ., _, : and - (at most 100 characters); other names throw an AuditException.

Security events that should also reach the application log go through Anvil\Security\Audit\SecurityEvents::record($event, $context, $throttle).

Besides changes of records, the log contains the security events of the application. They are also written to the application log with level warning (forward it to your SIEM).

Event When
auth.login, auth.login_failed password login (identifier only, never the password)
auth.magic_link_used login with a magic link
auth.logout logout; by: user, provider, login method not allowed, too many wrong codes, second factor locked
auth.password_changed password change on the profile page
auth.sso.*, auth.mfa.* see Single sign-on, Two-factor authentication
access.denied refused endpoint requests (permission, policy, invalid token, panel or tenant mismatch, rate limit; not expired tokens), forbidden pages and refused API requests (401, 403, 429)
tenant.switch_denied switch to a tenant the user does not belong to
auth.sso.throttled too many requests to the single sign-on routes (30 per minute and IP)
api.* API clients and tokens, see API
tenant.* tenants and memberships, see Tenancy

Events an attacker can cause in bulk (rate limit hits, unknown API tokens, throttled single sign-on requests) are recorded at most once per minute, event and IP, so the log cannot be flooded.

Audit entries contain personal data: user names, IP addresses, user agents, search terms of exports, names of uploaded files, SSO groups. Values of fields whose names contain password, passphrase, secret, token, api_key, private_key, pin, otp, recovery or iban are masked (Config\Anvil::$auditMaskedFields); extend the list for your data.

The chain cannot delete single entries: every entry secures the following ones. Define the retention in your records of processing (e.g. the period required for evidence) and archive or remove the log as a whole after it, e.g. by exporting it, verifying the export and starting a new chain. Do not log more than needed in custom events.

Each entry stores the hash of its predecessor and its own hash:

hash = HMAC-SHA256(audit key, canonical JSON of [previous_hash, entry fields])
  • The audit key is anvil.auditKey or, without it, derived from the encryption key (see Key rotation). An attacker with write access to the database but without the key cannot recompute valid hashes.
  • A single chain head row (last hash, last entry, number of entries) is locked while an entry is written. Concurrent writers are serialized and the chain never forks.
  • The head is signed with the current audit key (HMAC). Without the key the head cannot be set back, so removing the newest entries, or all of them, is detected.

Verify the chain with php spark anvil:audit:verify (option --batch, entries per query, default 500), e.g. daily from cron, or in code:

$result = service('auditVerifier')->verify();
if (! $result->valid) {
// $result->reason: tampered | broken_link | unknown_key | key_order | head_mismatch | head_unsigned | missing_chain
// $result->entryUuid: first invalid entry, if known
}
Detected Reason
modified entry tampered
deleted or inserted entry inside the chain broken_link or tampered
deleted entries at the end, also with an adjusted head head_mismatch or head_unsigned
emptied log head_unsigned
entry signed with another key unknown_key
older key after a newer one key_order

Everything above holds as long as the current audit key is secret. Someone with write access to the database and the current key can rewrite the log undetected; only a head hash stored outside the database reveals that. Recommended:

  • export the head hash regularly to a SIEM or a write-once log (a built-in task for this is a possible future option; until then, export the last_hash and entries of anvil_audit_chain with your monitoring),
  • grant the application’s database user only INSERT and SELECT on anvil_audit_log, and SELECT, UPDATE on anvil_audit_chain.

Every entry is signed (HMAC-SHA256) with the audit key; its key_id identifies the key. Rotate the key when it may be known to others (e.g. a former administrator, a leaked .env) and at the intervals of your key management policy.

By default the audit key is derived from encryption.key. Give the audit log a key of its own, so that it can be rotated independently:

Terminal window
php spark anvil:audit:generate-key
# .env (or the secret store of every server)
anvil.auditKey = "base64:…new key…"
# the key used so far: without anvil.auditKey this is encryption.key
anvil.auditPreviousKeys = "hex2bin:…encryption.key…"
  1. php spark anvil:audit:verify: the log must be intact before the rotation.
  2. Generate a new key (anvil:audit:generate-key).
  3. Append the current key to anvil.auditPreviousKeys (comma separated, oldest first) and set anvil.auditKey to the new key, on every server at the same time.
  4. php spark anvil:audit:rotate: records audit.key_rotated (from and to key id) and audit.sealed, signs the chain head with the new key and verifies the log. Until then anvil:audit:verify reports head_unsigned; the first entry written by the application completes the rotation as well.

Keep previous keys as long as the log is kept; without them old entries report unknown_key. If encryption.key is rotated while the audit key is still derived from it, its encryption.previousKeys are previous audit keys as well, see Rotating encryption.key. When an old key leaves encryption.previousKeys, add it to anvil.auditPreviousKeys.

Why an old key is useless after the rotation

Section titled “Why an old key is useless after the rotation”
  • No new entries: the verifier accepts keys only in their order. An entry signed with an older key after a newer one fails with key_order; a change to a newer key is only accepted with the audit.key_rotated entry.
  • No rewritten history: the rotation entry is signed with the new key and contains the hash of the last entry before it. Entries before the rotation can be re-signed with the old key, but then the chain no longer matches the rotation entry (broken_link).
  • No cut-off rotation: removing the rotation entry and everything after it needs a new head, which must be signed with the current key (head_unsigned).

A leaked old key therefore allows no undetected change once the rotation is completed; the current key must be protected like any other secret.