Writable source store: seed sources from config plus API-managed, tenant-scoped sources persisted to disk.
Seeds (sources: in the store opts, the same shape Ankusa.SourceStore.Static
takes) are resolved at boot and are read-only: they never appear in
list_tenant/2 and cannot be written through put/5. Sources created or
updated through the admin API are kept in ETS and written to this node's
Ankusa.Store (one key per source), so they survive a restart. They are this
node's alone: a fleet that manages sources through the API on more than one
node shares them through Ankusa.SourceStore.Redis (ankusa_redis) instead.
Reads and writes
Reads (fetch/2, get/3, list_tenant/2) go straight to a :protected ETS
table, so they never serialize behind the GenServer. Writes go through the
GenServer, which is the table's owner and the only process that may modify it,
and persist the one changed key, synced, before replying.
The decoder
The store does not know how to turn a JSON spec into a Ankusa.Source — that
is the operator's schema (AnkusaServer.Config.source_from_map!/2 in the
server). It takes a decoder: function of (source_id, spec_map -> keyword)
and calls it on every write, and on every entry loaded from disk. A decoder
that raises fails the write with {:error, :invalid, Exception.message(e)},
and a persisted entry that no longer decodes is skipped with a warning rather
than taken down the boot.
Secrets at rest
With secrets.kek (or secrets.kek_provider) configured, the secret-bearing
fields of a spec (Ankusa.Admin.Redact.reduce_hidden/3: verify secrets, sink
credentials, header values) are encrypted before the row is written, so
neither the node's store files nor a backup of them hold a secret in the
clear (Ankusa.Crypto.Envelope). Rows are decrypted as they are loaded; ETS
and the admin API's merge-the-stored-secret update see plaintext, as before.
A row this node cannot decrypt (no KEK, a key it was not given, a damaged
value) refuses the boot: skipping it would 404 every hook for that
source. rewrap/1 (and mix ankusa.secrets.rewrap for a stopped node)
moves every row to the current key, and encrypts rows written before a KEK
existed.
Summary
Functions
Returns a specification to start this module under a supervisor.
Rewrite every stored row under the current KEK: rows still in plaintext, and
rows encrypted under a secrets.kek_previous key. Returns how many rows were
rewritten and how many were already current.
rewrap/1 on a store nobody has open as a source store (the
mix ankusa.secrets.rewrap task, a test): Ankusa.Store must be running for
instance. dry_run: true counts and writes nothing.
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec rewrap(atom()) :: {:ok, %{rewrapped: non_neg_integer(), current: non_neg_integer()}} | {:error, term()}
Rewrite every stored row under the current KEK: rows still in plaintext, and
rows encrypted under a secrets.kek_previous key. Returns how many rows were
rewritten and how many were already current.
@spec rewrap_rows(atom(), Ankusa.Crypto.Envelope.keyring(), keyword()) :: {:ok, %{rewrapped: non_neg_integer(), current: non_neg_integer()}} | {:error, term()}
rewrap/1 on a store nobody has open as a source store (the
mix ankusa.secrets.rewrap task, a test): Ankusa.Store must be running for
instance. dry_run: true counts and writes nothing.
@spec start_link(keyword()) :: GenServer.on_start()