Packages
ex_infisical
0.1.0
Elixir client for the Infisical secrets API. Universal Auth with skew-aware token caching, optional Cloudflare Access headers, and ergonomic Livebook / Mix.install helpers that load an entire Infisical folder into System.put_env in a single call.
Current section
Files
Jump to
Current section
Files
lib/ex_infisical.ex
defmodule ExInfisical do
@moduledoc """
Elixir client for the [Infisical](https://infisical.com) secrets API.
## Configuration
Put your Machine Identity credentials in `config/runtime.exs`:
config :ex_infisical,
api_url: System.get_env("INFISICAL_API_URL", "https://app.infisical.com/api"),
client_id: System.fetch_env!("INFISICAL_CLIENT_ID"),
client_secret: System.fetch_env!("INFISICAL_CLIENT_SECRET"),
project_slug: System.get_env("INFISICAL_PROJECT_SLUG"),
project_id: System.get_env("INFISICAL_PROJECT_ID"),
cf_access_client_id: System.get_env("CF_ACCESS_CLIENT_ID"),
cf_access_client_secret: System.get_env("CF_ACCESS_CLIENT_SECRET")
Either `:project_slug` or `:project_id` must be set. If both, `:project_id`
wins (no resolution round-trip).
## Basic usage
{:ok, secrets} = ExInfisical.get_secrets("/livebook")
# => %{"LIVEBOOK_COOKIE" => "...", "LIVEBOOK_PASSWORD" => "..."}
{:ok, key} = ExInfisical.get_secret("LB_GROQ_API_KEY", path: "/livebook")
# Livebook-style: dump every secret at a path into the process env.
:ok = ExInfisical.to_env!("/livebook")
## Options accepted by all fetch functions
* `:env` — environment slug (default `"prod"`).
* `:path` — secret path (default `"/"`).
* `:recursive` — recurse into sub-folders (default `false`).
* `:expand` — expand `${folder.KEY}` references server-side (default `true`).
* `:project_id` / `:project_slug` — override configured project for one call.
* `:client` — pre-built `ExInfisical.Client` for custom wiring/tests.
"""
alias ExInfisical.{Client, Secrets}
@type path :: String.t()
@type env_slug :: String.t()
@type secret_map :: %{optional(String.t()) => String.t()}
@type option ::
{:env, env_slug()}
| {:path, path()}
| {:recursive, boolean()}
| {:expand, boolean()}
| {:project_id, String.t()}
| {:project_slug, String.t()}
| {:client, Client.t()}
@type options :: [option()]
@doc """
Fetches every secret at `path` as a `%{key => value}` map.
Returns `{:ok, map}` on success or `{:error, %ExInfisical.Error{}}` on failure.
"""
@spec get_secrets(path(), options()) ::
{:ok, secret_map()} | {:error, ExInfisical.Error.t()}
def get_secrets(path, opts \\ []) when is_binary(path) do
opts = Keyword.put(opts, :path, path)
Secrets.list(opts)
end
@doc "Same as `get_secrets/2` but raises on error."
@spec get_secrets!(path(), options()) :: secret_map()
def get_secrets!(path, opts \\ []) do
case get_secrets(path, opts) do
{:ok, map} -> map
{:error, err} -> raise err
end
end
@doc """
Fetches a single secret by key.
The `:path` option defaults to `"/"`. Returns `{:error, :not_found}` when the
key is missing.
"""
@spec get_secret(String.t(), options()) ::
{:ok, String.t()}
| {:error, :not_found}
| {:error, ExInfisical.Error.t()}
def get_secret(key, opts \\ []) when is_binary(key) do
path = Keyword.get(opts, :path, "/")
case get_secrets(path, opts) do
{:ok, secrets} ->
case Map.fetch(secrets, key) do
{:ok, value} -> {:ok, value}
:error -> {:error, :not_found}
end
{:error, _} = err ->
err
end
end
@doc "Same as `get_secret/2` but raises on error or missing key."
@spec get_secret!(String.t(), options()) :: String.t()
def get_secret!(key, opts \\ []) do
case get_secret(key, opts) do
{:ok, value} -> value
{:error, :not_found} -> raise ExInfisical.Error.not_found(key)
{:error, err} -> raise err
end
end
@doc """
One-call setup for Livebook notebooks.
Configures the application environment from `opts`, then pulls every
secret at `opts[:path]` and exports them via `System.put_env/1`. Returns
`{:ok, count}` where `count` is the number of keys exported.
This is the recommended entry point for a `Mix.install/2` block — it
avoids the two-step "put_env config then to_env!" dance:
Mix.install([{:ex_infisical, "~> 0.1.0"}])
{:ok, _n} = ExInfisical.install(
api_url: "https://infisical.noizu.com/api",
client_id: System.fetch_env!("INFISICAL_CLIENT_ID"),
client_secret: System.fetch_env!("INFISICAL_CLIENT_SECRET"),
project_slug: "k8-infra",
path: "/livebook",
prefix: "LB_"
)
Accepts every `%Config{}` field plus every `to_env!/2` option. The
`:path` key is required.
"""
@spec install(keyword()) :: {:ok, non_neg_integer()} | {:error, ExInfisical.Error.t()}
def install(opts) when is_list(opts) do
{path, opts} = Keyword.pop(opts, :path)
unless is_binary(path) do
raise ArgumentError, "ExInfisical.install/1 requires a :path option (binary)"
end
{config_opts, rest} = split_config_opts(opts)
Enum.each(config_opts, fn {k, v} -> Application.put_env(:ex_infisical, k, v) end)
case get_secrets(path, rest) do
{:ok, secrets} ->
exported = export_env(secrets, rest)
{:ok, map_size(exported)}
{:error, _} = err ->
err
end
end
@doc "Bang variant of `install/1`."
@spec install!(keyword()) :: non_neg_integer()
def install!(opts) do
case install(opts) do
{:ok, n} -> n
{:error, err} -> raise err
end
end
@config_keys ~w(api_url client_id client_secret project_id project_slug
cf_access_client_id cf_access_client_secret
request_timeout_ms token_refresh_skew_seconds)a
defp split_config_opts(opts), do: Keyword.split(opts, @config_keys)
@doc """
Fetches every secret at `path` and pushes them into the current OS
environment via `System.put_env/1`.
Handy inside `Mix.install/2` scripts and Livebook notebooks where downstream
libraries read config from `System.get_env/1`.
Accepts every option `get_secrets/2` accepts, plus:
* `:prefix` — prepend to each key before exporting (e.g. `"LB_"`).
* `:only` — only export keys in this list.
* `:except` — exclude these keys.
* `:transform` — `(key -> key)` fun applied after `:prefix`.
"""
@spec to_env!(path(), options()) :: :ok
def to_env!(path, opts \\ []) when is_binary(path) do
{_put_opts, fetch_opts} = split_env_opts(opts)
secrets = get_secrets!(path, fetch_opts)
_ = export_env(secrets, opts)
:ok
end
@env_opt_keys [:prefix, :only, :except, :transform]
defp split_env_opts(opts), do: Keyword.split(opts, @env_opt_keys)
# Exports a secrets map into System.put_env/1 using the :prefix / :only /
# :except / :transform opts. Returns the filtered map that was exported.
defp export_env(secrets, opts) do
{put_opts, _} = split_env_opts(opts)
filtered = filter_keys(secrets, put_opts[:only], put_opts[:except])
Enum.each(filtered, fn {k, v} ->
k
|> apply_prefix(put_opts[:prefix])
|> apply_transform(put_opts[:transform])
|> System.put_env(v)
end)
filtered
end
defp filter_keys(secrets, nil, nil), do: secrets
defp filter_keys(secrets, only, except) do
secrets
|> then(fn s -> if only, do: Map.take(s, only), else: s end)
|> then(fn s -> if except, do: Map.drop(s, except), else: s end)
end
defp apply_prefix(key, nil), do: key
defp apply_prefix(key, prefix) when is_binary(prefix), do: prefix <> key
defp apply_transform(key, nil), do: key
defp apply_transform(key, fun) when is_function(fun, 1), do: fun.(key)
end