Add Mv.Release.seed_admin for admin bootstrap from ENV
Creates/updates admin user from ADMIN_EMAIL and ADMIN_PASSWORD or ADMIN_PASSWORD_FILE. Idempotent; no fallback password in production. Called from docker entrypoint and seeds.
This commit is contained in:
parent
556c11e08c
commit
24b17b8448
2 changed files with 355 additions and 0 deletions
|
|
@ -2,9 +2,22 @@ defmodule Mv.Release do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Used for executing DB release tasks when run in production without Mix
|
Used for executing DB release tasks when run in production without Mix
|
||||||
installed.
|
installed.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- `migrate/0` - Runs all pending Ecto migrations.
|
||||||
|
- `seed_admin/0` - Ensures an admin user exists from ENV (ADMIN_EMAIL, ADMIN_PASSWORD
|
||||||
|
or ADMIN_PASSWORD_FILE). Idempotent; can be run on every deployment or via shell
|
||||||
|
to update the admin password without redeploying.
|
||||||
"""
|
"""
|
||||||
@app :mv
|
@app :mv
|
||||||
|
|
||||||
|
alias Mv.Accounts
|
||||||
|
alias Mv.Accounts.User
|
||||||
|
alias Mv.Authorization.Role
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
def migrate do
|
def migrate do
|
||||||
load_app()
|
load_app()
|
||||||
|
|
||||||
|
|
@ -18,6 +31,128 @@ defmodule Mv.Release do
|
||||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Ensures an admin user exists from ENV (ADMIN_EMAIL, ADMIN_PASSWORD or ADMIN_PASSWORD_FILE).
|
||||||
|
|
||||||
|
- If ADMIN_EMAIL is unset: no-op (idempotent).
|
||||||
|
- If ADMIN_PASSWORD (and ADMIN_PASSWORD_FILE) are unset and the user does not exist:
|
||||||
|
no user is created (no fallback password in production).
|
||||||
|
- If both ADMIN_EMAIL and ADMIN_PASSWORD are set: creates or updates the user with
|
||||||
|
Admin role and the given password. Safe to run on every deployment or via
|
||||||
|
`bin/mv eval "Mv.Release.seed_admin()"` to change the admin password without redeploying.
|
||||||
|
"""
|
||||||
|
def seed_admin do
|
||||||
|
load_app()
|
||||||
|
|
||||||
|
admin_email = get_env("ADMIN_EMAIL", nil)
|
||||||
|
admin_password = get_env_or_file("ADMIN_PASSWORD", nil)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
is_nil(admin_email) or admin_email == "" ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
is_nil(admin_password) or admin_password == "" ->
|
||||||
|
# Do not create or update any user without a password (no fallback in production)
|
||||||
|
:ok
|
||||||
|
|
||||||
|
true ->
|
||||||
|
ensure_admin_user(admin_email, admin_password)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ensure_admin_user(email, password) do
|
||||||
|
if is_nil(password) or password == "" do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
do_ensure_admin_user(email, password)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_ensure_admin_user(email, password) do
|
||||||
|
case Role.get_admin_role() do
|
||||||
|
{:ok, nil} ->
|
||||||
|
# Admin role does not exist (e.g. migrations not run); skip
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:ok, %Role{} = admin_role} ->
|
||||||
|
case get_user_by_email(email) do
|
||||||
|
{:ok, nil} ->
|
||||||
|
create_admin_user(email, password, admin_role)
|
||||||
|
|
||||||
|
{:ok, user} ->
|
||||||
|
update_admin_user(user, password, admin_role)
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_admin_user(email, password, admin_role) do
|
||||||
|
case Accounts.create_user(%{email: email}, authorize?: false) do
|
||||||
|
{:ok, user} ->
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|
||||||
|
|> Ash.update!(authorize?: false)
|
||||||
|
|> then(fn u ->
|
||||||
|
u
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|
|> Ash.update!(authorize?: false)
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp update_admin_user(user, password, admin_role) do
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|
||||||
|
|> Ash.update!(authorize?: false)
|
||||||
|
|> then(fn u ->
|
||||||
|
u
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|
|> Ash.update!(authorize?: false)
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_user_by_email(email) do
|
||||||
|
User
|
||||||
|
|> Ash.Query.filter(email == ^email)
|
||||||
|
|> Ash.read_one(authorize?: false, domain: Mv.Accounts)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_env(key, default) do
|
||||||
|
System.get_env(key, default)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_env_or_file(var_name, default) do
|
||||||
|
file_var = "#{var_name}_FILE"
|
||||||
|
|
||||||
|
case System.get_env(file_var) do
|
||||||
|
nil ->
|
||||||
|
System.get_env(var_name, default)
|
||||||
|
|
||||||
|
file_path ->
|
||||||
|
case File.read(file_path) do
|
||||||
|
{:ok, content} ->
|
||||||
|
String.trim_trailing(content)
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
default
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp repos do
|
defp repos do
|
||||||
Application.fetch_env!(@app, :ecto_repos)
|
Application.fetch_env!(@app, :ecto_repos)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
220
test/mv/release_test.exs
Normal file
220
test/mv/release_test.exs
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
defmodule Mv.ReleaseTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for release tasks (e.g. seed_admin/0).
|
||||||
|
|
||||||
|
These tests verify that the admin user is created or updated from ENV
|
||||||
|
(ADMIN_EMAIL, ADMIN_PASSWORD / ADMIN_PASSWORD_FILE) in an idempotent way.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
alias Mv.Accounts
|
||||||
|
alias Mv.Accounts.User
|
||||||
|
alias Mv.Authorization.Role
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
setup do
|
||||||
|
ensure_admin_role_exists()
|
||||||
|
clear_admin_env()
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "seed_admin/0" do
|
||||||
|
test "without ADMIN_EMAIL does nothing (idempotent), no user created" do
|
||||||
|
clear_admin_env()
|
||||||
|
user_count_before = count_users()
|
||||||
|
|
||||||
|
Mv.Release.seed_admin()
|
||||||
|
|
||||||
|
assert count_users() == user_count_before
|
||||||
|
end
|
||||||
|
|
||||||
|
test "with ADMIN_EMAIL but without ADMIN_PASSWORD and user does not exist: does not create user" do
|
||||||
|
System.delete_env("ADMIN_PASSWORD")
|
||||||
|
System.delete_env("ADMIN_PASSWORD_FILE")
|
||||||
|
|
||||||
|
email = "admin-no-password-#{System.unique_integer([:positive])}@test.example.com"
|
||||||
|
System.put_env("ADMIN_EMAIL", email)
|
||||||
|
on_exit(fn -> System.delete_env("ADMIN_EMAIL") end)
|
||||||
|
|
||||||
|
user_count_before = count_users()
|
||||||
|
Mv.Release.seed_admin()
|
||||||
|
|
||||||
|
assert count_users() == user_count_before,
|
||||||
|
"seed_admin must not create any user when ADMIN_PASSWORD is unset (expected #{user_count_before}, got #{count_users()})"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "with ADMIN_EMAIL but without ADMIN_PASSWORD and user exists: leaves user and role unchanged" do
|
||||||
|
email = "existing-admin-#{System.unique_integer([:positive])}@test.example.com"
|
||||||
|
System.put_env("ADMIN_EMAIL", email)
|
||||||
|
on_exit(fn -> System.delete_env("ADMIN_EMAIL") end)
|
||||||
|
|
||||||
|
{:ok, user} = create_user_with_mitglied_role(email)
|
||||||
|
role_id_before = user.role_id
|
||||||
|
|
||||||
|
Mv.Release.seed_admin()
|
||||||
|
|
||||||
|
{:ok, updated} = get_user_by_email(email)
|
||||||
|
assert updated.role_id == role_id_before
|
||||||
|
end
|
||||||
|
|
||||||
|
test "with ADMIN_EMAIL and ADMIN_PASSWORD: creates user with Admin role and sets password" do
|
||||||
|
email = "new-admin-#{System.unique_integer([:positive])}@test.example.com"
|
||||||
|
password = "SecurePassword123!"
|
||||||
|
System.put_env("ADMIN_EMAIL", email)
|
||||||
|
System.put_env("ADMIN_PASSWORD", password)
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
System.delete_env("ADMIN_EMAIL")
|
||||||
|
System.delete_env("ADMIN_PASSWORD")
|
||||||
|
end)
|
||||||
|
|
||||||
|
Mv.Release.seed_admin()
|
||||||
|
|
||||||
|
assert user_exists?(email),
|
||||||
|
"seed_admin must create user when ADMIN_EMAIL and ADMIN_PASSWORD are set"
|
||||||
|
|
||||||
|
{:ok, user} = get_user_by_email(email)
|
||||||
|
assert user.role_id == admin_role_id()
|
||||||
|
assert user.hashed_password != nil
|
||||||
|
assert AshAuthentication.BcryptProvider.valid?(password, user.hashed_password)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "with ADMIN_EMAIL and ADMIN_PASSWORD, user already exists: assigns Admin role and updates password" do
|
||||||
|
email = "existing-to-admin-#{System.unique_integer([:positive])}@test.example.com"
|
||||||
|
password = "NewSecurePassword456!"
|
||||||
|
System.put_env("ADMIN_EMAIL", email)
|
||||||
|
System.put_env("ADMIN_PASSWORD", password)
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
System.delete_env("ADMIN_EMAIL")
|
||||||
|
System.delete_env("ADMIN_PASSWORD")
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, user} = create_user_with_mitglied_role(email)
|
||||||
|
assert user.role_id == mitglied_role_id()
|
||||||
|
old_hashed = user.hashed_password
|
||||||
|
|
||||||
|
Mv.Release.seed_admin()
|
||||||
|
|
||||||
|
{:ok, updated} = get_user_by_email(email)
|
||||||
|
assert updated.role_id == admin_role_id()
|
||||||
|
assert updated.hashed_password != nil
|
||||||
|
assert updated.hashed_password != old_hashed
|
||||||
|
assert AshAuthentication.BcryptProvider.valid?(password, updated.hashed_password)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "with ADMIN_PASSWORD_FILE: reads password from file, same behavior as ADMIN_PASSWORD" do
|
||||||
|
email = "admin-file-#{System.unique_integer([:positive])}@test.example.com"
|
||||||
|
password = "FilePassword789!"
|
||||||
|
|
||||||
|
tmp =
|
||||||
|
Path.join(
|
||||||
|
System.tmp_dir!(),
|
||||||
|
"mv_admin_password_#{System.unique_integer([:positive])}.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
File.write!(tmp, password)
|
||||||
|
System.put_env("ADMIN_EMAIL", email)
|
||||||
|
System.put_env("ADMIN_PASSWORD_FILE", tmp)
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
System.delete_env("ADMIN_EMAIL")
|
||||||
|
System.delete_env("ADMIN_PASSWORD_FILE")
|
||||||
|
File.rm(tmp)
|
||||||
|
end)
|
||||||
|
|
||||||
|
Mv.Release.seed_admin()
|
||||||
|
|
||||||
|
assert user_exists?(email), "seed_admin must create user when ADMIN_PASSWORD_FILE is set"
|
||||||
|
{:ok, user} = get_user_by_email(email)
|
||||||
|
assert AshAuthentication.BcryptProvider.valid?(password, user.hashed_password)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "called twice: idempotent (no duplicate user, same state)" do
|
||||||
|
email = "idempotent-admin-#{System.unique_integer([:positive])}@test.example.com"
|
||||||
|
password = "IdempotentPassword123!"
|
||||||
|
System.put_env("ADMIN_EMAIL", email)
|
||||||
|
System.put_env("ADMIN_PASSWORD", password)
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
System.delete_env("ADMIN_EMAIL")
|
||||||
|
System.delete_env("ADMIN_PASSWORD")
|
||||||
|
end)
|
||||||
|
|
||||||
|
Mv.Release.seed_admin()
|
||||||
|
{:ok, user_after_first} = get_user_by_email(email)
|
||||||
|
user_count_after_first = count_users()
|
||||||
|
|
||||||
|
Mv.Release.seed_admin()
|
||||||
|
|
||||||
|
assert count_users() == user_count_after_first
|
||||||
|
{:ok, user_after_second} = get_user_by_email(email)
|
||||||
|
assert user_after_second.id == user_after_first.id
|
||||||
|
assert user_after_second.role_id == admin_role_id()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp clear_admin_env do
|
||||||
|
System.delete_env("ADMIN_EMAIL")
|
||||||
|
System.delete_env("ADMIN_PASSWORD")
|
||||||
|
System.delete_env("ADMIN_PASSWORD_FILE")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ensure_admin_role_exists do
|
||||||
|
case Role
|
||||||
|
|> Ash.Query.filter(name == "Admin")
|
||||||
|
|> Ash.read_one(authorize?: false, domain: Mv.Authorization) do
|
||||||
|
{:ok, nil} ->
|
||||||
|
Role
|
||||||
|
|> Ash.Changeset.for_create(:create_role_with_system_flag, %{
|
||||||
|
name: "Admin",
|
||||||
|
description: "Administrator with full access",
|
||||||
|
permission_set_name: "admin",
|
||||||
|
is_system_role: false
|
||||||
|
})
|
||||||
|
|> Ash.create!(authorize?: false, domain: Mv.Authorization)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp admin_role_id do
|
||||||
|
{:ok, role} =
|
||||||
|
Role
|
||||||
|
|> Ash.Query.filter(name == "Admin")
|
||||||
|
|> Ash.read_one(authorize?: false, domain: Mv.Authorization)
|
||||||
|
|
||||||
|
role.id
|
||||||
|
end
|
||||||
|
|
||||||
|
defp mitglied_role_id do
|
||||||
|
{:ok, role} = Role.get_mitglied_role()
|
||||||
|
role.id
|
||||||
|
end
|
||||||
|
|
||||||
|
defp count_users do
|
||||||
|
User
|
||||||
|
|> Ash.read!(authorize?: false, domain: Mv.Accounts)
|
||||||
|
|> length()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp user_exists?(email) do
|
||||||
|
case get_user_by_email(email) do
|
||||||
|
{:ok, _} -> true
|
||||||
|
{:error, _} -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_user_by_email(email) do
|
||||||
|
User
|
||||||
|
|> Ash.Query.filter(email == ^email)
|
||||||
|
|> Ash.read_one(authorize?: false, domain: Mv.Accounts)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_user_with_mitglied_role(email) do
|
||||||
|
{:ok, _} = Accounts.create_user(%{email: email}, authorize?: false)
|
||||||
|
get_user_by_email(email)
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Add a link
Reference in a new issue