Ankusa.Audit (ankusa v0.7.0)

Copy Markdown View Source

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).

actionchange
source.create, source.update, source.deletean API-managed source
rate_limit.put, rate_limit.deletea tenant's rate-limit override
quarantine.purgeheld hooks deleted
replay.create, replay.update, replay.cancela replay job
route.create, route.update, route.deletea route definition
tenant.deletea 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.putthe global IP rules
log_level.putthe node's log level (PUT /v1/log-level): detail has the new level and the previous one
config.reloada 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/4 is for a change that lives in this node's Ankusa.Store (API-managed sources, rate-limit overrides, quarantine, replay jobs): the change's own ops and the audit row go in one sync: true batch. Either both survive a kill -9 or neither does.

  • begin/3 + finish/3 (or record/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/3 then 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 as result: "interrupted": the change may or may not have been applied; after_hash is the hash of what was about to be written. For a source or route, Ankusa.Lifecycle.reconcile/2 does better than that: it reads the subject's present state and compares its hash to the row's after_hash (the change was applied: "ok") and before_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

Types

What a caller says about its change. action is required.

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

attrs()

@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.

row()

@type row() :: %{required(atom()) => term()}

Functions

begin(instance, context, attrs)

@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".

child_spec(init_arg)

@spec child_spec(keyword()) :: Supervisor.child_spec()

Returns a specification to start this module under a supervisor.

See Supervisor.

commit(instance, context, attrs, ops)

@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.

digest(instance, term)

@spec digest(atom(), term()) :: String.t() | nil

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.

discard(instance, pending)

@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.

entity(instance, seq)

@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.

finish(instance, pending, update)

@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.

incarnation(instance, seq)

@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).

last_seq(instance)

@spec last_seq(atom()) :: {:ok, non_neg_integer()} | {:error, term()}

The last seq the trail handed out (0 for an empty trail).

list(instance, opts \\ [])

@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.

page(instance, after_seq, max_rows)

@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.

prune(instance)

@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}.

record(instance, context, attrs, fun)

@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".

refusing?(instance)

@spec refusing?(atom()) :: boolean()

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.

start_link(opts)

@spec start_link(keyword()) :: GenServer.on_start()

stats(instance)

@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.

to_json(row)

@spec to_json(row()) :: map()

The JSON view of a row, as GET /v1/audit returns it.