Packages

Bootstrap an admin account on app startup from an environment variable.

Current section

Files

Jump to
auto_admin_user lib auto_admin_user.ex
Raw

lib/auto_admin_user.ex

defmodule AutoAdminUser do
@moduledoc """
Bootstrap an admin account on app startup from an environment variable.
Runs as a oneshot `Task` in your supervision tree. On startup it reads an
environment variable (default `ADMIN_EMAIL`), checks if a user with that
email already exists, and creates one with a random password if not.
## Quick start (standard phx.gen.auth)
# application.ex
children = [
MyApp.Repo,
{AutoAdminUser, context: MyApp.Accounts},
MyAppWeb.Endpoint
]
This will:
1. Read the `ADMIN_EMAIL` environment variable
2. Call `MyApp.Accounts.get_user_by_email/1` to check if the user exists
3. If not, call `MyApp.Accounts.register_user/1` with a random 32-char password
4. Log the result
## Options
* `:context` - Your accounts context module (e.g. `MyApp.Accounts`). Required
unless both `:find` and `:create` are provided as functions.
* `:env_var` - Environment variable name to read. Default: `"ADMIN_EMAIL"`.
* `:find` - How to look up an existing user. Can be:
* An atom — called as a function on `:context` (default: `:get_user_by_email`)
* A 1-arity function — called with the email
Must return `nil` (not found) or a user struct.
* `:create` - How to create the user. Can be:
* An atom — called as a function on `:context` (default: `:register_user`)
* A 1-arity function — called with `%{email: email, password: password}`
Must return `{:ok, user}` or `{:error, reason}`.
* `:after_create` - Optional 1-arity function called with the newly created user.
Runs after successful creation, before logging.
* `:password_length` - Length of the random password. Default: `32`.
## Examples
# Custom create function name (e.g. Mack uses create_admin)
{AutoAdminUser, context: MyApp.Accounts, create: :create_admin}
# Custom after_create hook (e.g. send password reset email)
{AutoAdminUser,
context: MyApp.Accounts,
after_create: fn user ->
MyApp.Accounts.deliver_user_reset_password_instructions(
user, &"/users/reset_password/\#{&1}")
end}
# Fully custom find/create (no context module needed)
{AutoAdminUser,
env_var: "SUPERADMIN_EMAIL",
find: fn email -> MyApp.Repo.get_by(MyApp.User, email: email) end,
create: fn params -> MyApp.Repo.insert(MyApp.User.changeset(%MyApp.User{}, params)) end}
"""
use Task, restart: :temporary
require Logger
@default_env_var "ADMIN_EMAIL"
@default_find :get_user_by_email
@default_create :register_user
@default_password_length 32
@doc false
def start_link(opts) when is_list(opts) do
Task.start_link(__MODULE__, :bootstrap, [opts])
end
@doc """
Bootstraps the admin account. Called automatically by the supervision tree.
You shouldn't need to call this directly.
"""
def bootstrap(opts) do
env_var = Keyword.get(opts, :env_var, @default_env_var)
case System.get_env(env_var) do
nil ->
Logger.debug("[AutoAdminUser] #{env_var} not set, skipping")
"" ->
Logger.debug("[AutoAdminUser] #{env_var} is empty, skipping")
email ->
ensure_admin(email, opts)
end
end
defp ensure_admin(email, opts) do
case find_user(email, opts) do
nil ->
create_admin(email, opts)
_user ->
Logger.debug("[AutoAdminUser] User already exists for #{email}")
end
end
defp create_admin(email, opts) do
password_length = Keyword.get(opts, :password_length, @default_password_length)
password = random_password(password_length)
params = %{email: email, password: password}
case create_user(params, opts) do
{:ok, user} ->
run_after_create(user, opts)
Logger.info("[AutoAdminUser] Bootstrapped admin account for #{email}")
{:error, reason} ->
Logger.warning("[AutoAdminUser] Failed to create admin for #{email}: #{inspect(reason)}")
end
end
defp find_user(email, opts) do
case Keyword.get(opts, :find, @default_find) do
fun when is_function(fun, 1) ->
fun.(email)
name when is_atom(name) ->
context = fetch_context!(opts)
apply(context, name, [email])
end
end
defp create_user(params, opts) do
case Keyword.get(opts, :create, @default_create) do
fun when is_function(fun, 1) ->
fun.(params)
name when is_atom(name) ->
context = fetch_context!(opts)
apply(context, name, [params])
end
end
defp run_after_create(user, opts) do
case Keyword.get(opts, :after_create) do
nil -> :ok
fun when is_function(fun, 1) -> fun.(user)
end
end
defp fetch_context!(opts) do
Keyword.get(opts, :context) ||
raise ArgumentError,
"AutoAdminUser requires :context option (e.g. MyApp.Accounts) " <>
"or explicit :find and :create functions"
end
@doc """
Generates a cryptographically random password of the given length.
"""
def random_password(length) when length >= 16 do
:crypto.strong_rand_bytes(length)
|> Base.url_encode64(padding: false)
|> binary_part(0, length)
end
end