From 24b17b84488ac29e9c5dd872855ecb065f40c598 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 16:10:45 +0100 Subject: [PATCH] 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. --- lib/mv/release.ex | 135 ++++++++++++++++++++++++ test/mv/release_test.exs | 220 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 test/mv/release_test.exs diff --git a/lib/mv/release.ex b/lib/mv/release.ex index c0c2c8a..45b0c9d 100644 --- a/lib/mv/release.ex +++ b/lib/mv/release.ex @@ -2,9 +2,22 @@ defmodule Mv.Release do @moduledoc """ Used for executing DB release tasks when run in production without Mix 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 + alias Mv.Accounts + alias Mv.Accounts.User + alias Mv.Authorization.Role + + require Ash.Query + def migrate do load_app() @@ -18,6 +31,128 @@ defmodule Mv.Release do {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) 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 Application.fetch_env!(@app, :ecto_repos) end diff --git a/test/mv/release_test.exs b/test/mv/release_test.exs new file mode 100644 index 0000000..1879c1d --- /dev/null +++ b/test/mv/release_test.exs @@ -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