Ankusa.SourceStore.Persistent (ankusa v0.7.0)

Copy Markdown View Source

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

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

rewrap(instance)

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

rewrap_rows(instance, keyring, opts \\ [])

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

start_link(opts)

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