Audit log
Anvil writes security relevant events and all changes of Anvil models to an append-only, tamper evident audit log.
What is logged
Section titled “What is logged”| 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.
Models
Section titled “Models”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.
Actors
Section titled “Actors”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')));Writing custom events
Section titled “Writing custom events”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).
Security events
Section titled “Security events”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.
Personal data and retention
Section titled “Personal data and retention”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.
Tamper evidence
Section titled “Tamper evidence”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.auditKeyor, 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 |
Limits
Section titled “Limits”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_hashandentriesofanvil_audit_chainwith your monitoring), - grant the application’s database user only
INSERTandSELECTonanvil_audit_log, andSELECT, UPDATEonanvil_audit_chain.
Key rotation
Section titled “Key rotation”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.
Its own key
Section titled “Its own key”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:
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.keyanvil.auditPreviousKeys = "hex2bin:…encryption.key…"Rotating
Section titled “Rotating”php spark anvil:audit:verify: the log must be intact before the rotation.- Generate a new key (
anvil:audit:generate-key). - Append the current key to
anvil.auditPreviousKeys(comma separated, oldest first) and setanvil.auditKeyto the new key, on every server at the same time. php spark anvil:audit:rotate: recordsaudit.key_rotated(from and to key id) andaudit.sealed, signs the chain head with the new key and verifies the log. Until thenanvil:audit:verifyreportshead_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 theaudit.key_rotatedentry. - 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.