The audit trail: one row for every change an operator (or code) makes to the node's configuration or operational state, written with the change.
What is recorded
A row says who (actor, remote_ip, request_id, from Ankusa.Audit.Context),
what (action, tenant, resource, detail), and what it did to the
subject: before_hash and after_hash, an HMAC-SHA256 of the subject before
and after, revision (the subject's own change counter, where it has one) and
result. A row never holds a secret or a spec: a hash is keyed with a
random per-store key (m:audit_key), so it shows that a source's secret
changed without giving anyone with audit:read a way to test a guess
against it, and detail carries only what the caller puts there (counts,
filters, a rate).
| action | change |
|---|---|
source.create, source.update, source.delete | an API-managed source |
rate_limit.put, rate_limit.delete | a tenant's rate-limit override |
quarantine.purge | held hooks deleted |
replay.create, replay.update, replay.cancel | a replay job |
route.create, route.update, route.delete | a route definition |
tenant.delete | a tenant erase (Ankusa.Tenant): one row, with what each stage removed as its detail; the erase's own source, route and rate-limit deletions are rows of their own |
ip_rules.put | the global IP rules |
log_level.put | the node's log level (PUT /v1/log-level): detail has the new level and the previous one |
config.reload | a config reload (POST /v1/config/reload, SIGHUP; Ankusa.Reload): detail.keys lists the dotted keys that changed, result is ok or failed; a refused reload writes none |
A request that changes nothing (a validation error, a missing subject) writes no row; one that is refused by the store writes none either.
Order and durability
Rows live in the store's audit column family under
<<?a, seq::64, at::64>>. This process hands out seq and commits the
batch, so key order is commit order whatever the wall clock does, and a
reader that remembers the last seq it saw (SEC-2.b's lifecycle outbox) can
never miss a row that lands later with a smaller key.
There are two ways in:
commit/4is for a change that lives in this node'sAnkusa.Store(API-managed sources, rate-limit overrides, quarantine, replay jobs): the change's own ops and the audit row go in onesync: truebatch. Either both survive akill -9or neither does.begin/3+finish/3(orrecord/4) is for a change that lives somewhere else (route definitions, whose store is pluggable: ETS, or Redis shared by a fleet; the Redis source store). That is a dual write, and there is no transaction across the two. The row is written first, as a pending row (<<?p, seq::64>>, synced) that says what is about to happen; the change is applied;finish/3then moves it into the trail with the outcome, in one batch. A crash in between leaves the pending row, and the next boot (or the caller dying, while this node runs) settles it asresult: "interrupted": the change may or may not have been applied;after_hashis the hash of what was about to be written. For a source or route,Ankusa.Lifecycle.reconcile/2does better than that: it reads the subject's present state and compares its hash to the row'safter_hash(the change was applied:"ok") andbefore_hash(it was not:"failed"); a state that is neither (someone else changed it meanwhile) stays"interrupted"and is flagged for a state-refresh event. A boot has to wait for the subject's store to load, so such rows are settled by a tick (every second, for a minute, then as"interrupted"). The trail never loses a change that was applied; it can show one that was not only as"interrupted".
A change whose audit row cannot be written is not made: the caller gets
{:error, :store_unavailable}.
The lifecycle outbox
When lifecycle events are on (Ankusa.Lifecycle), the row of a source or
route change that took effect is flagged publish: true, and the redacted
entity its event carries is written beside it at <<?o, seq::64>> in the
same batch. The entity is not in the row: a row never holds a spec, and even
redacted an entity is one. Ankusa.Lifecycle.Publisher reads the flagged
rows in seq order with page/3 and keeps its place in m:lifecycle_cursor.
Restores and event identity
A store that came back from a backup (Ankusa.Store leaves
m:incarnation_pending) re-issues the seq numbers of every change the lost
one made after the backup, and some of those were already published. So the
first thing this process does on such a boot is open a new incarnation:
one history entry {from seq, fresh id} in m:incarnation, written with an
instance.restore row (event io.ankusa.instance.restored, carrying the
incarnation and the restored seq) in one synced batch, before any change is
taken. incarnation/2 tells Ankusa.Lifecycle which id a row's event id is
keyed with: rows of the restored trail keep the ids they were published
under, every row from the new incarnation on gets a different one.
Retention
audit.retention_days (default 365). An hourly sweep deletes rows (and the
lifecycle entities beside them) older than that, oldest first, in bounded
batches, and emits [:ankusa, :audit, :pruned]. While lifecycle.sinks is
set it never deletes a row at or above m:lifecycle_cursor, however old: a
lifecycle sink that is down longer than the retention keeps the events it has
not seen. The expired rows it holds back are emitted as blocked_by_cursor on
[:ankusa, :audit, :retention] after every sweep. Every committed row emits
[:ankusa, :audit].
Ceilings
The trail is bounded in audit.max_rows and audit.max_bytes, from counters
kept in every batch (m:audit_usage; recounted at boot when it does not match
the trail). Before a change is accepted the trail is pruned to them, oldest
first and never past m:lifecycle_cursor; a trail that is still over a ceiling,
or a lifecycle outbox holding audit.outbox_max_backlog or more events no sink
has confirmed (m:audit_seq minus the cursor), refuses the new change with
{:error, :audit_backlog} (503 audit_backlog, Retry-After: 60 on the admin
APIs). An unpublished row is never dropped to make room, and a change already
begun is always recorded. A change the node makes itself (Context.system/0) is
not refused.
Summary
Functions
Write the pending row of a change that is about to be made elsewhere. Returns
the handle finish/3 takes. The caller is monitored: if it dies first, the
row is finished as "interrupted".
Returns a specification to start this module under a supervisor.
Commit ops (the change, for the node's own Ankusa.Store) and its audit row
in one synced batch.
The keyed hash of term an audit row carries (nil for nil). Computed by
the caller from the key in an ETS table, so a secret never travels to this
process in a message that a crash report could print.
Drop a pending row whose change turned out to be no change at all (the store rejected the request): no row reaches the trail.
The redacted entity of the row seq (a row flagged publish: true), or
:not_found when it was pruned.
Move a pending row into the trail. update may set :result ("ok",
"failed"), :after_hash, :before_hash, :revision, :detail, :tenant
and :resource; the row keeps what begin/3 recorded for anything left out.
The incarnation the audit row seq was written in: the id of the newest
restore at or before it, nil for a store that was never restored from a
backup (and for the rows before its first restore).
The last seq the trail handed out (0 for an empty trail).
The trail, newest first.
Rows after after_seq, oldest first: at most max_rows of them, as
{seq, {:ok, row} | {:error, reason}} (a row that cannot be decoded is
reported, not skipped, so the reader decides). The lifecycle outbox
(Ankusa.Lifecycle.Publisher) and its replay read the trail this way: seq
is commit order, so a reader that keeps the last one it handled cannot miss
a row.
Delete the rows older than audit.retention_days, oldest first. Runs on the
hourly tick; callable on its own. {:ok, deleted}.
Run fun, the change itself, between a pending row and its finish. fun
returns {reply, update} (see finish/3), or {reply, :discard} when it
changed nothing (see discard/2); record/4 returns reply, or
{:error, :store_unavailable} without running fun when the pending row
could not be written. A fun that raises, throws or exits leaves the row
"interrupted".
Whether a control-plane change is refused right now (503 audit_backlog,
Retry-After: 60): the trail holds more than audit.max_rows rows or
audit.max_bytes bytes (and pruning it to its ceilings, which the trail is then
asked to do, may not pass the lifecycle cursor), or the lifecycle outbox holds
audit.outbox_max_backlog events or more that no sink has confirmed. Read
from counters, never a scan; false where no trail runs.
The trail's size and the lifecycle outbox's lag, from counters: rows,
bytes, and outbox_backlog, the audit seqs the lifecycle publisher has not
yet confirmed (nil without lifecycle sinks, or before the publisher wrote
its cursor). All zero where no trail runs.
The JSON view of a row, as GET /v1/audit returns it.
Types
@type attrs() :: %{ :action => String.t(), optional(:tenant) => String.t() | nil, optional(:resource) => String.t() | nil, optional(:before_hash) => String.t() | nil, optional(:after_hash) => String.t() | nil, optional(:revision) => pos_integer() | nil, optional(:detail) => map() | nil, optional(:result) => String.t(), optional(:entity) => map() | nil }
What a caller says about its change. action is required.
Functions
@spec begin(atom(), Ankusa.Audit.Context.t(), attrs()) :: {:ok, pos_integer()} | {:error, :store_unavailable | :audit_backlog}
Write the pending row of a change that is about to be made elsewhere. Returns
the handle finish/3 takes. The caller is monitored: if it dies first, the
row is finished as "interrupted".
@spec child_spec(keyword()) :: Supervisor.child_spec()
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec commit(atom(), Ankusa.Audit.Context.t(), attrs(), [Ankusa.Store.op()]) :: :ok | {:error, :store_unavailable | :audit_backlog}
Commit ops (the change, for the node's own Ankusa.Store) and its audit row
in one synced batch.
The keyed hash of term an audit row carries (nil for nil). Computed by
the caller from the key in an ETS table, so a secret never travels to this
process in a message that a crash report could print.
@spec discard(atom(), pos_integer()) :: :ok | {:error, term()}
Drop a pending row whose change turned out to be no change at all (the store rejected the request): no row reaches the trail.
@spec entity(atom(), pos_integer()) :: {:ok, map()} | :not_found | {:error, term()}
The redacted entity of the row seq (a row flagged publish: true), or
:not_found when it was pruned.
@spec finish(atom(), pos_integer(), map()) :: :ok | {:error, term()}
Move a pending row into the trail. update may set :result ("ok",
"failed"), :after_hash, :before_hash, :revision, :detail, :tenant
and :resource; the row keeps what begin/3 recorded for anything left out.
@spec incarnation(atom(), pos_integer()) :: String.t() | nil
The incarnation the audit row seq was written in: the id of the newest
restore at or before it, nil for a store that was never restored from a
backup (and for the rows before its first restore).
@spec last_seq(atom()) :: {:ok, non_neg_integer()} | {:error, term()}
The last seq the trail handed out (0 for an empty trail).
@spec list(atom(), keyword()) :: {:ok, %{entries: [row()], next_cursor: String.t() | nil}} | {:error, term()}
The trail, newest first.
Options: :since / :until (inclusive bounds on at, ms), :actor,
:action, :tenant (exact matches), :tenants (:all, or the tenants the
reader may see: rows with no tenant need :all), :cursor (the
next_cursor of the previous page) and :limit (default 100, at most 1000).
next_cursor is nil on the last page; a page can be short when
the scan budget (10 000 rows) ran out, and next_cursor says to go on.
@spec page(atom(), non_neg_integer(), pos_integer()) :: {:ok, [{pos_integer(), {:ok, row()} | {:error, term()}}]} | {:error, term()}
Rows after after_seq, oldest first: at most max_rows of them, as
{seq, {:ok, row} | {:error, reason}} (a row that cannot be decoded is
reported, not skipped, so the reader decides). The lifecycle outbox
(Ankusa.Lifecycle.Publisher) and its replay read the trail this way: seq
is commit order, so a reader that keeps the last one it handled cannot miss
a row.
@spec prune(atom()) :: {:ok, non_neg_integer()} | {:error, term()}
Delete the rows older than audit.retention_days, oldest first. Runs on the
hourly tick; callable on its own. {:ok, deleted}.
@spec record(atom(), Ankusa.Audit.Context.t(), attrs(), (-> {reply, map() | :discard})) :: reply | {:error, :store_unavailable | :audit_backlog} when reply: term()
Run fun, the change itself, between a pending row and its finish. fun
returns {reply, update} (see finish/3), or {reply, :discard} when it
changed nothing (see discard/2); record/4 returns reply, or
{:error, :store_unavailable} without running fun when the pending row
could not be written. A fun that raises, throws or exits leaves the row
"interrupted".
Whether a control-plane change is refused right now (503 audit_backlog,
Retry-After: 60): the trail holds more than audit.max_rows rows or
audit.max_bytes bytes (and pruning it to its ceilings, which the trail is then
asked to do, may not pass the lifecycle cursor), or the lifecycle outbox holds
audit.outbox_max_backlog events or more that no sink has confirmed. Read
from counters, never a scan; false where no trail runs.
@spec start_link(keyword()) :: GenServer.on_start()
@spec stats(atom()) :: %{ rows: non_neg_integer(), bytes: non_neg_integer(), outbox_backlog: non_neg_integer() | nil }
The trail's size and the lifecycle outbox's lag, from counters: rows,
bytes, and outbox_backlog, the audit seqs the lifecycle publisher has not
yet confirmed (nil without lifecycle sinks, or before the publisher wrote
its cursor). All zero where no trail runs.
The JSON view of a row, as GET /v1/audit returns it.