Current section
Files
Jump to
Current section
Files
lib/sw4rm/secrets.ex
defmodule Sw4rm.Secrets do
@moduledoc """
Secret management with pluggable backends.
"""
@callback get_secret(backend :: term(), key :: String.t()) :: String.t() | nil
@callback set_secret(backend :: term(), key :: String.t(), value :: String.t()) ::
:ok | {:error, term()}
@callback delete_secret(backend :: term(), key :: String.t()) :: boolean()
@callback list_secrets(backend :: term()) :: [String.t()]
end
defmodule Sw4rm.Secrets.EnvBackend do
@moduledoc "Read-only secret backend reading from environment variables with a configurable prefix."
@behaviour Sw4rm.Secrets
defstruct prefix: "SW4RM_SECRET_"
@impl true
def get_secret(%__MODULE__{prefix: prefix}, key) do
System.get_env("#{prefix}#{String.upcase(key)}")
end
@impl true
def set_secret(_, _, _), do: {:error, :read_only}
@impl true
def delete_secret(_, _), do: false
@impl true
def list_secrets(_), do: []
end
defmodule Sw4rm.Secrets.FileBackend do
@moduledoc "File-based secret backend storing secrets in a JSON file. GenServer for thread safety."
@behaviour Sw4rm.Secrets
use GenServer
defstruct [:file_path, :cache]
# -- Client API --
def start_link(opts \\ []) do
name = Keyword.get(opts, :name)
file_path = Keyword.get(opts, :file_path, Path.expand("~/.secrets.json"))
GenServer.start_link(__MODULE__, file_path, name: name)
end
@impl Sw4rm.Secrets
def get_secret(server, key), do: GenServer.call(server, {:get, key})
@impl Sw4rm.Secrets
def set_secret(server, key, value), do: GenServer.call(server, {:set, key, value})
@impl Sw4rm.Secrets
def delete_secret(server, key), do: GenServer.call(server, {:delete, key})
@impl Sw4rm.Secrets
def list_secrets(server), do: GenServer.call(server, :list)
# -- Callbacks --
@impl true
def init(file_path) do
cache = load_from_file(file_path)
{:ok, %{file_path: file_path, cache: cache}}
end
@impl true
def handle_call({:get, key}, _from, s), do: {:reply, Map.get(s.cache, key), s}
def handle_call({:set, key, value}, _from, s) do
cache = Map.put(s.cache, key, value)
save_to_file(s.file_path, cache)
{:reply, :ok, %{s | cache: cache}}
end
def handle_call({:delete, key}, _from, s) do
if Map.has_key?(s.cache, key) do
cache = Map.delete(s.cache, key)
save_to_file(s.file_path, cache)
{:reply, true, %{s | cache: cache}}
else
{:reply, false, s}
end
end
def handle_call(:list, _from, s), do: {:reply, Map.keys(s.cache), s}
# -- Helpers --
defp load_from_file(path) do
case File.read(path) do
{:ok, content} ->
case Jason.decode(content) do
{:ok, data} when is_list(data) ->
Enum.into(data, %{}, fn entry ->
{Map.get(entry, "key", ""), Map.get(entry, "value", "")}
end)
_ ->
%{}
end
_ ->
%{}
end
end
defp save_to_file(path, cache) do
entries = Enum.map(cache, fn {k, v} -> %{"key" => k, "value" => v} end)
File.mkdir_p!(Path.dirname(path))
File.write!(path, Jason.encode!(entries, pretty: true))
end
end
defmodule Sw4rm.Secrets.Resolver do
@moduledoc """
Ordered backend chain that queries multiple secret backends.
First backend to return a value wins.
"""
defstruct backends: [], fallback: nil
@doc "Create a resolver with the given backends."
def new(backends, opts \\ []) do
%__MODULE__{
backends: backends,
fallback: Keyword.get(opts, :fallback)
}
end
@doc "Resolve a secret by querying backends in order."
def resolve(%__MODULE__{backends: backends, fallback: fallback}, key) do
Enum.find_value(backends, fallback, fn
%Sw4rm.Secrets.EnvBackend{} = b ->
Sw4rm.Secrets.EnvBackend.get_secret(b, key)
pid when is_pid(pid) ->
Sw4rm.Secrets.FileBackend.get_secret(pid, key)
{mod, server} ->
mod.get_secret(server, key)
end)
end
end