From 7a56a0920b959bee6c60f456c2564141e9325a88 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 15:17:45 +0100 Subject: [PATCH 01/14] Call seed_admin in docker entrypoint after migrate Ensures admin user is created/updated from ENV on every container start. --- rel/overlays/bin/docker-entrypoint.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rel/overlays/bin/docker-entrypoint.sh b/rel/overlays/bin/docker-entrypoint.sh index d6b0dd7..caa389a 100755 --- a/rel/overlays/bin/docker-entrypoint.sh +++ b/rel/overlays/bin/docker-entrypoint.sh @@ -4,6 +4,9 @@ set -e echo "==> Running database migrations..." /app/bin/migrate +echo "==> Seeding admin user from ENV (ADMIN_EMAIL, ADMIN_PASSWORD)..." +/app/bin/mv eval "Mv.Release.seed_admin()" + echo "==> Starting application..." exec /app/bin/server -- 2.47.2 From 09a4b7c937eb9a88b786dc31042851e26773a689 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 15:17:49 +0100 Subject: [PATCH 02/14] Seeds: use ADMIN_PASSWORD/ADMIN_PASSWORD_FILE; fallback only in dev/test No fallback in production; prod uses Release.seed_admin in entrypoint. --- priv/repo/seeds.exs | 73 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index e97e7c2..705217e 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -135,6 +135,23 @@ end # Get admin email from environment variable or use default admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost" +# Admin password: use ADMIN_PASSWORD or ADMIN_PASSWORD_FILE if set; otherwise fallback +# only in dev/test (no fallback in production - prod uses Release.seed_admin in entrypoint) +get_admin_password = fn -> + from_file = + System.get_env("ADMIN_PASSWORD_FILE") |> then(fn path -> path && File.read(path) end) + + from_env = System.get_env("ADMIN_PASSWORD") + + case {from_file, from_env} do + {{:ok, content}, _} -> String.trim_trailing(content) + {_, p} when is_binary(p) and p != "" -> p + _ -> if Mix.env() in [:dev, :test], do: "testpassword", else: nil + end +end + +admin_password = get_admin_password.() + # Create all authorization roles (idempotent - creates only if they don't exist) # Roles are created using create_role_with_system_flag to allow setting is_system_role role_configs = [ @@ -215,34 +232,50 @@ if is_nil(admin_role) do end # Assign admin role to user with ADMIN_EMAIL (if user exists) -# This handles both existing users (e.g., from OIDC) and newly created users +# This handles both existing users (e.g., from OIDC) and newly created users. +# Password: use admin_password (from ENV or dev/test fallback); if nil, do not set password (prod-safe). case Accounts.User |> Ash.Query.filter(email == ^admin_email) |> Ash.read_one(domain: Mv.Accounts, authorize?: false) do {:ok, existing_admin_user} when not is_nil(existing_admin_user) -> - # User already exists (e.g., via OIDC) - assign admin role - # Use authorize?: false for bootstrap - this is initial setup - existing_admin_user + # User already exists (e.g., via OIDC) - set password if we have one, then assign admin role + user_after_password = + if is_binary(admin_password) and admin_password != "" do + existing_admin_user + |> Ash.Changeset.for_update(:admin_set_password, %{password: admin_password}) + |> Ash.update!(authorize?: false) + else + existing_admin_user + end + + user_after_password |> Ash.Changeset.for_update(:update, %{}) |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) |> Ash.update!(authorize?: false) {:ok, nil} -> - # User doesn't exist - create admin user and set password (so Password column shows "Enabled") + # User doesn't exist - create admin user; set password only if we have one (no fallback in prod) # Use authorize?: false for bootstrap - no admin user exists yet to use as actor - Accounts.create_user!(%{email: admin_email}, - upsert?: true, - upsert_identity: :unique_email, - authorize?: false - ) - |> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"}) + user = + Accounts.create_user!(%{email: admin_email}, + upsert?: true, + upsert_identity: :unique_email, + authorize?: false + ) + + user = + if is_binary(admin_password) and admin_password != "" do + user + |> Ash.Changeset.for_update(:admin_set_password, %{password: admin_password}) + |> Ash.update!(authorize?: false) + else + user + end + + user + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) |> Ash.update!(authorize?: false) - |> then(fn user -> - user - |> Ash.Changeset.for_update(:update, %{}) - |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) - |> Ash.update!(authorize?: false) - end) {:error, error} -> raise "Failed to check for existing admin user: #{inspect(error)}" @@ -747,7 +780,11 @@ IO.puts("📝 Created sample data:") IO.puts(" - Global settings: club_name = #{default_club_name}") IO.puts(" - Membership fee types: 4 types (Yearly, Half-yearly, Quarterly, Monthly)") IO.puts(" - Custom fields: 12 fields (String, Date, Boolean, Email, + 8 realistic fields)") -IO.puts(" - Admin user: #{admin_email} (password: testpassword)") + +IO.puts( + " - Admin user: #{admin_email} (password: #{if admin_password, do: "set", else: "not set"})" +) + IO.puts(" - Sample members: Hans, Greta, Friedrich") IO.puts( -- 2.47.2 From b177e41882dea411e66fa8bc04a163413bec54fd Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 16:08:15 +0100 Subject: [PATCH 03/14] Add Role.get_admin_role for Release.seed_admin Used by Mv.Release to resolve Admin role when creating/updating admin user from ENV. --- lib/mv/authorization/role.ex | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/mv/authorization/role.ex b/lib/mv/authorization/role.ex index 59c0e51..8700a33 100644 --- a/lib/mv/authorization/role.ex +++ b/lib/mv/authorization/role.ex @@ -181,4 +181,18 @@ defmodule Mv.Authorization.Role do |> Ash.Query.filter(name == "Mitglied") |> Ash.read_one(authorize?: false, domain: Mv.Authorization) end + + @doc """ + Returns the Admin role if it exists. + + Used by release tasks (e.g. seed_admin) and OIDC role sync to assign the admin role. + """ + @spec get_admin_role() :: {:ok, t() | nil} | {:error, term()} + def get_admin_role do + require Ash.Query + + __MODULE__ + |> Ash.Query.filter(name == "Admin") + |> Ash.read_one(authorize?: false, domain: Mv.Authorization) + end end -- 2.47.2 From e065b39ed440692371b3099a4ef4f61277e295c4 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 16:10:45 +0100 Subject: [PATCH 04/14] 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 -- 2.47.2 From 50c8a0dc9a78b325fccffa2086692294d914d6d7 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 16:13:13 +0100 Subject: [PATCH 05/14] Seeds: call Mv.Release.seed_admin to avoid duplication Replaces inline admin creation with seed_admin(); exercises same path as entrypoint. Dev/test: set ADMIN_EMAIL default and ADMIN_PASSWORD fallback before calling. --- priv/repo/seeds.exs | 79 ++++++++------------------------------------- 1 file changed, 13 insertions(+), 66 deletions(-) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 705217e..f686c73 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -132,26 +132,16 @@ for attrs <- [ ) end -# Get admin email from environment variable or use default +# Admin email: default for dev/test so seed_admin has a target admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost" +System.put_env("ADMIN_EMAIL", admin_email) -# Admin password: use ADMIN_PASSWORD or ADMIN_PASSWORD_FILE if set; otherwise fallback -# only in dev/test (no fallback in production - prod uses Release.seed_admin in entrypoint) -get_admin_password = fn -> - from_file = - System.get_env("ADMIN_PASSWORD_FILE") |> then(fn path -> path && File.read(path) end) - - from_env = System.get_env("ADMIN_PASSWORD") - - case {from_file, from_env} do - {{:ok, content}, _} -> String.trim_trailing(content) - {_, p} when is_binary(p) and p != "" -> p - _ -> if Mix.env() in [:dev, :test], do: "testpassword", else: nil - end +# In dev/test, set fallback password so seed_admin creates the admin user when none is set +if Mix.env() in [:dev, :test] and is_nil(System.get_env("ADMIN_PASSWORD")) and + is_nil(System.get_env("ADMIN_PASSWORD_FILE")) do + System.put_env("ADMIN_PASSWORD", "testpassword") end -admin_password = get_admin_password.() - # Create all authorization roles (idempotent - creates only if they don't exist) # Roles are created using create_role_with_system_flag to allow setting is_system_role role_configs = [ @@ -231,55 +221,9 @@ if is_nil(admin_role) do raise "Failed to create or find admin role. Cannot proceed with member seeding." end -# Assign admin role to user with ADMIN_EMAIL (if user exists) -# This handles both existing users (e.g., from OIDC) and newly created users. -# Password: use admin_password (from ENV or dev/test fallback); if nil, do not set password (prod-safe). -case Accounts.User - |> Ash.Query.filter(email == ^admin_email) - |> Ash.read_one(domain: Mv.Accounts, authorize?: false) do - {:ok, existing_admin_user} when not is_nil(existing_admin_user) -> - # User already exists (e.g., via OIDC) - set password if we have one, then assign admin role - user_after_password = - if is_binary(admin_password) and admin_password != "" do - existing_admin_user - |> Ash.Changeset.for_update(:admin_set_password, %{password: admin_password}) - |> Ash.update!(authorize?: false) - else - existing_admin_user - end - - user_after_password - |> Ash.Changeset.for_update(:update, %{}) - |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) - |> Ash.update!(authorize?: false) - - {:ok, nil} -> - # User doesn't exist - create admin user; set password only if we have one (no fallback in prod) - # Use authorize?: false for bootstrap - no admin user exists yet to use as actor - user = - Accounts.create_user!(%{email: admin_email}, - upsert?: true, - upsert_identity: :unique_email, - authorize?: false - ) - - user = - if is_binary(admin_password) and admin_password != "" do - user - |> Ash.Changeset.for_update(:admin_set_password, %{password: admin_password}) - |> Ash.update!(authorize?: false) - else - user - end - - user - |> Ash.Changeset.for_update(:update, %{}) - |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) - |> Ash.update!(authorize?: false) - - {:error, error} -> - raise "Failed to check for existing admin user: #{inspect(error)}" -end +# Create/update admin user via Release.seed_admin (uses ADMIN_EMAIL, ADMIN_PASSWORD / ADMIN_PASSWORD_FILE). +# Reduces duplication and exercises the same path as production entrypoint. +Mv.Release.seed_admin() # Load admin user with role for use as actor in member operations # This ensures all member operations have proper authorization @@ -781,8 +725,11 @@ IO.puts(" - Global settings: club_name = #{default_club_name}") IO.puts(" - Membership fee types: 4 types (Yearly, Half-yearly, Quarterly, Monthly)") IO.puts(" - Custom fields: 12 fields (String, Date, Boolean, Email, + 8 realistic fields)") +password_configured = + System.get_env("ADMIN_PASSWORD") != nil or System.get_env("ADMIN_PASSWORD_FILE") != nil + IO.puts( - " - Admin user: #{admin_email} (password: #{if admin_password, do: "set", else: "not set"})" + " - Admin user: #{admin_email} (password: #{if password_configured, do: "set", else: "not set"})" ) IO.puts(" - Sample members: Hans, Greta, Friedrich") -- 2.47.2 From a6e35da0f7195200ad5892fa5c16986a484e12ea Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 16:15:47 +0100 Subject: [PATCH 06/14] Add OIDC role sync config (OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM) Mv.OidcRoleSyncConfig reads from config; runtime.exs overrides from ENV in prod. --- config/config.exs | 5 +++ config/runtime.exs | 5 +++ lib/mv/oidc_role_sync_config.ex | 24 +++++++++++++ test/mv/oidc_role_sync_config_test.exs | 49 ++++++++++++++++++++++++++ 4 files changed, 83 insertions(+) create mode 100644 lib/mv/oidc_role_sync_config.ex create mode 100644 test/mv/oidc_role_sync_config_test.exs diff --git a/config/config.exs b/config/config.exs index 64f3604..6720a5d 100644 --- a/config/config.exs +++ b/config/config.exs @@ -58,6 +58,11 @@ config :mv, max_rows: 1000 ] +# OIDC group → role sync (optional). Overridden in runtime.exs from ENV in production. +config :mv, :oidc_role_sync, + admin_group_name: nil, + groups_claim: "groups" + # Configures the endpoint config :mv, MvWeb.Endpoint, url: [host: "localhost"], diff --git a/config/runtime.exs b/config/runtime.exs index 06a2cd8..b0079ef 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -153,6 +153,11 @@ if config_env() == :prod do client_secret: client_secret, redirect_uri: System.get_env("OIDC_REDIRECT_URI") || default_redirect_uri + # OIDC group → Admin role sync (optional). Groups claim default "groups". + config :mv, :oidc_role_sync, + admin_group_name: System.get_env("OIDC_ADMIN_GROUP_NAME"), + groups_claim: System.get_env("OIDC_GROUPS_CLAIM") || "groups" + # Token signing secret from environment variable # This overrides the placeholder value set in prod.exs # Supports TOKEN_SIGNING_SECRET or TOKEN_SIGNING_SECRET_FILE for Docker secrets. diff --git a/lib/mv/oidc_role_sync_config.ex b/lib/mv/oidc_role_sync_config.ex new file mode 100644 index 0000000..493a435 --- /dev/null +++ b/lib/mv/oidc_role_sync_config.ex @@ -0,0 +1,24 @@ +defmodule Mv.OidcRoleSyncConfig do + @moduledoc """ + Runtime configuration for OIDC group → role sync (e.g. admin group → Admin role). + + Reads from Application config `:mv, :oidc_role_sync`: + - `:admin_group_name` – OIDC group name that maps to Admin role (optional; when nil, no sync). + - `:groups_claim` – JWT/user_info claim name for groups (default: `"groups"`). + + Set via ENV in production: OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM (see config/runtime.exs). + """ + @doc "Returns the OIDC group name that maps to Admin role, or nil if not configured." + def oidc_admin_group_name do + get(:admin_group_name) + end + + @doc "Returns the JWT/user_info claim name for groups; defaults to \"groups\"." + def oidc_groups_claim do + get(:groups_claim) || "groups" + end + + defp get(key) do + Application.get_env(:mv, :oidc_role_sync, []) |> Keyword.get(key) + end +end diff --git a/test/mv/oidc_role_sync_config_test.exs b/test/mv/oidc_role_sync_config_test.exs new file mode 100644 index 0000000..b4664aa --- /dev/null +++ b/test/mv/oidc_role_sync_config_test.exs @@ -0,0 +1,49 @@ +defmodule Mv.OidcRoleSyncConfigTest do + @moduledoc """ + Tests for OIDC role sync configuration (OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM). + """ + use ExUnit.Case, async: false + + alias Mv.OidcRoleSyncConfig + + describe "oidc_admin_group_name/0" do + test "returns nil when OIDC_ADMIN_GROUP_NAME is not configured" do + restore = put_config(admin_group_name: nil) + on_exit(restore) + + assert OidcRoleSyncConfig.oidc_admin_group_name() == nil + end + + test "returns configured admin group name when set" do + restore = put_config(admin_group_name: "mila-admin") + on_exit(restore) + + assert OidcRoleSyncConfig.oidc_admin_group_name() == "mila-admin" + end + end + + describe "oidc_groups_claim/0" do + test "returns default \"groups\" when OIDC_GROUPS_CLAIM is not configured" do + restore = put_config(groups_claim: nil) + on_exit(restore) + + assert OidcRoleSyncConfig.oidc_groups_claim() == "groups" + end + + test "returns configured claim name when OIDC_GROUPS_CLAIM is set" do + restore = put_config(groups_claim: "ak_groups") + on_exit(restore) + + assert OidcRoleSyncConfig.oidc_groups_claim() == "ak_groups" + end + end + + defp put_config(opts) do + current = Application.get_env(:mv, :oidc_role_sync, []) + Application.put_env(:mv, :oidc_role_sync, Keyword.merge(current, opts)) + + fn -> + Application.put_env(:mv, :oidc_role_sync, current) + end + end +end -- 2.47.2 From 99722dee26d19145199d8b3065cdeb8b1d181099 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 16:18:18 +0100 Subject: [PATCH 07/14] Add OidcRoleSync: apply Admin/Mitglied from OIDC groups Register and sign-in call apply_admin_role_from_user_info; users in configured admin group get Admin role, others get Mitglied. Internal User action + bypass policy. --- lib/accounts/user.ex | 37 +++- .../checks/oidc_role_sync_context.ex | 22 +++ lib/mv/oidc_role_sync.ex | 82 +++++++++ test/mv/oidc_role_sync_test.exs | 162 ++++++++++++++++++ 4 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 lib/mv/authorization/checks/oidc_role_sync_context.ex create mode 100644 lib/mv/oidc_role_sync.ex create mode 100644 test/mv/oidc_role_sync_test.exs diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 034177a..fc04bfa 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -187,6 +187,13 @@ defmodule Mv.Accounts.User do require_atomic? false end + # Internal: set role from OIDC group sync (Mv.OidcRoleSync). Bypass policy when context.private.oidc_role_sync. + # Same "at least one admin" validation as update_user (see validations where action_is). + update :set_role_from_oidc_sync do + accept [:role_id] + require_atomic? false + end + # Admin action for direct password changes in admin panel # Uses the official Ash Authentication HashPasswordChange with correct context update :admin_set_password do @@ -260,6 +267,17 @@ defmodule Mv.Accounts.User do # linked their account via OIDC. Password-only users (oidc_id = nil) # cannot be accessed via OIDC login without password verification. filter expr(oidc_id == get_path(^arg(:user_info), [:sub])) + + # Sync role from OIDC groups after sign-in (e.g. admin group → Admin role) + prepare Ash.Resource.Preparation.Builtins.after_action(fn query, records, _context -> + user_info = Ash.Query.get_argument(query, :user_info) || %{} + + Enum.each(records, fn user -> + Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info) + end) + + {:ok, records} + end) end create :register_with_rauthy do @@ -297,6 +315,16 @@ defmodule Mv.Accounts.User do # Sync user email to member when linking (User → Member) change Mv.EmailSync.Changes.SyncUserEmailToMember + + # Sync role from OIDC groups (e.g. admin group → Admin role) after user is created/updated + change fn changeset, _ctx -> + user_info = Ash.Changeset.get_argument(changeset, :user_info) + + Ash.Changeset.after_action(changeset, fn _cs, record -> + Mv.OidcRoleSync.apply_admin_role_from_user_info(record, user_info) + {:ok, Ash.get!(__MODULE__, record.id, authorize?: false, domain: Mv.Accounts)} + end) + end end end @@ -323,6 +351,13 @@ defmodule Mv.Accounts.User do authorize_if Mv.Authorization.Checks.ActorIsAdmin end + # set_role_from_oidc_sync: internal only (called from Mv.OidcRoleSync on registration/sign-in). + # Not exposed in code_interface; must never be callable by clients. + bypass action(:set_role_from_oidc_sync) do + description "Internal: OIDC role sync (server-side only)" + authorize_if always() + end + # UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope) policy action_type([:read, :create, :update, :destroy]) do description "Check permissions from user's role and permission set" @@ -446,7 +481,7 @@ defmodule Mv.Accounts.User do end end, on: [:update], - where: [action_is(:update_user)] + where: [action_is([:update_user, :set_role_from_oidc_sync])] # Prevent modification of the system actor user (required for internal operations). # Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests. diff --git a/lib/mv/authorization/checks/oidc_role_sync_context.ex b/lib/mv/authorization/checks/oidc_role_sync_context.ex new file mode 100644 index 0000000..1f39944 --- /dev/null +++ b/lib/mv/authorization/checks/oidc_role_sync_context.ex @@ -0,0 +1,22 @@ +defmodule Mv.Authorization.Checks.OidcRoleSyncContext do + @moduledoc """ + Policy check: true when the action is being run from OIDC role sync (context.private.oidc_role_sync). + + Used to allow the internal set_role_from_oidc_sync action when called by Mv.OidcRoleSync + without an actor. + """ + use Ash.Policy.SimpleCheck + + @impl true + def describe(_opts), do: "called from OIDC role sync (context.private.oidc_role_sync)" + + @impl true + def match?(_actor, authorizer, _opts) do + # Context from opts (e.g. Ash.update!(..., context: %{private: %{oidc_role_sync: true}})) + context = Map.get(authorizer, :context) || %{} + from_context = get_in(context, [:private, :oidc_role_sync]) == true + # When update runs inside create's after_action, context may not be passed; use process dict. + from_process = Process.get(:oidc_role_sync) == true + from_context or from_process + end +end diff --git a/lib/mv/oidc_role_sync.ex b/lib/mv/oidc_role_sync.ex new file mode 100644 index 0000000..d6b608f --- /dev/null +++ b/lib/mv/oidc_role_sync.ex @@ -0,0 +1,82 @@ +defmodule Mv.OidcRoleSync do + @moduledoc """ + Syncs user role from OIDC user_info (e.g. groups claim → Admin role). + + Used after OIDC registration (register_with_rauthy) and on sign-in so that + users in the configured admin group get the Admin role; others get Mitglied. + Configure via OIDC_ADMIN_GROUP_NAME and OIDC_GROUPS_CLAIM (see OidcRoleSyncConfig). + """ + alias Mv.Accounts.User + alias Mv.Authorization.Role + alias Mv.OidcRoleSyncConfig + + @doc """ + Applies Admin or Mitglied role to the user based on OIDC user_info (groups claim). + + - If OIDC_ADMIN_GROUP_NAME is not configured: no-op, returns :ok without changing the user. + - If user_info contains the configured admin group (under OIDC_GROUPS_CLAIM): assigns Admin role. + - Otherwise: assigns Mitglied role (downgrade if user was Admin). + + user_info is a map (e.g. from JWT claims) and may use string keys. Groups can be + a list of strings or a single string. + + ## Examples + + user_info = %{"groups" => ["mila-admin"]} + OidcRoleSync.apply_admin_role_from_user_info(user, user_info) + + user_info = %{"ak_groups" => ["other"]} # with OIDC_GROUPS_CLAIM=ak_groups + OidcRoleSync.apply_admin_role_from_user_info(user, user_info) + """ + @spec apply_admin_role_from_user_info(User.t(), map()) :: :ok + def apply_admin_role_from_user_info(user, user_info) when is_map(user_info) do + admin_group = OidcRoleSyncConfig.oidc_admin_group_name() + + if is_nil(admin_group) or admin_group == "" do + :ok + else + claim = OidcRoleSyncConfig.oidc_groups_claim() + groups = groups_from_user_info(user_info, claim) + target_role = if admin_group in groups, do: :admin, else: :mitglied + set_user_role(user, target_role) + end + end + + defp groups_from_user_info(user_info, claim) do + case user_info[claim] do + nil -> [] + list when is_list(list) -> Enum.map(list, &to_string/1) + single when is_binary(single) -> [single] + _ -> [] + end + end + + defp set_user_role(user, :admin) do + case Role.get_admin_role() do + {:ok, %Role{} = role} -> + do_set_role(user, role) + + _ -> + :ok + end + end + + defp set_user_role(user, :mitglied) do + case Role.get_mitglied_role() do + {:ok, %Role{} = role} -> + do_set_role(user, role) + + _ -> + :ok + end + end + + defp do_set_role(user, role) do + user + |> Ash.Changeset.for_update(:set_role_from_oidc_sync, %{role_id: role.id}) + |> Ash.Changeset.set_context(%{private: %{oidc_role_sync: true}}) + |> Ash.update!(domain: Mv.Accounts, context: %{private: %{oidc_role_sync: true}}) + + :ok + end +end diff --git a/test/mv/oidc_role_sync_test.exs b/test/mv/oidc_role_sync_test.exs new file mode 100644 index 0000000..acde5b5 --- /dev/null +++ b/test/mv/oidc_role_sync_test.exs @@ -0,0 +1,162 @@ +defmodule Mv.OidcRoleSyncTest do + @moduledoc """ + Tests for OIDC group → Admin/Mitglied role sync (apply_admin_role_from_user_info/2). + """ + use Mv.DataCase, async: false + + alias Mv.Accounts + alias Mv.Accounts.User + alias Mv.Authorization.Role + alias Mv.OidcRoleSync + require Ash.Query + + setup do + ensure_roles_exist() + restore_config = put_oidc_config(admin_group_name: "mila-admin", groups_claim: "groups") + on_exit(restore_config) + :ok + end + + describe "apply_admin_role_from_user_info/2" do + test "when OIDC_ADMIN_GROUP_NAME not configured: does not change user (Mitglied stays)" do + restore = put_oidc_config(admin_group_name: nil, groups_claim: "groups") + on_exit(restore) + + email = "sync-no-config-#{System.unique_integer([:positive])}@test.example.com" + {:ok, user} = create_user_with_mitglied(email) + role_id_before = user.role_id + user_info = %{"groups" => ["mila-admin"]} + + assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user, user_info) + + {:ok, after_user} = get_user(user.id) + assert after_user.role_id == role_id_before + end + + test "when user_info contains configured admin group: user gets Admin role" do + email = "sync-to-admin-#{System.unique_integer([:positive])}@test.example.com" + {:ok, user} = create_user_with_mitglied(email) + user_info = %{"groups" => ["mila-admin"]} + + assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user, user_info) + + {:ok, after_user} = get_user(user.id) + assert after_user.role_id == admin_role_id() + end + + test "when user_info does not contain admin group: user gets Mitglied role" do + email1 = "sync-to-mitglied-#{System.unique_integer([:positive])}@test.example.com" + email2 = "other-admin-#{System.unique_integer([:positive])}@test.example.com" + {:ok, user} = create_user_with_admin(email1) + {:ok, _} = create_user_with_admin(email2) + user_info = %{"groups" => ["other-group"]} + + assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user, user_info) + + {:ok, after_user} = get_user(user.id) + assert after_user.role_id == mitglied_role_id() + end + + test "when OIDC_GROUPS_CLAIM is different: reads groups from that claim" do + restore = put_oidc_config(admin_group_name: "mila-admin", groups_claim: "ak_groups") + on_exit(restore) + + email = "sync-claim-#{System.unique_integer([:positive])}@test.example.com" + {:ok, user} = create_user_with_mitglied(email) + user_info = %{"ak_groups" => ["mila-admin"]} + + assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user, user_info) + + {:ok, after_user} = get_user(user.id) + assert after_user.role_id == admin_role_id() + end + + test "user already Admin and user_info without admin group: downgrade to Mitglied" do + email1 = "sync-downgrade-#{System.unique_integer([:positive])}@test.example.com" + email2 = "sync-other-admin-#{System.unique_integer([:positive])}@test.example.com" + {:ok, user1} = create_user_with_admin(email1) + {:ok, _user2} = create_user_with_admin(email2) + user_info = %{"groups" => []} + + assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user1, user_info) + + {:ok, after_user} = get_user(user1.id) + assert after_user.role_id == mitglied_role_id() + end + end + + # B3: Role sync after registration is implemented via after_action in register_with_rauthy. + # Full integration tests (create_register_with_rauthy + assert role) are skipped: when the + # nested Ash.update! runs inside the create's after_action, authorization may evaluate in + # the create context so set_role_from_oidc_sync bypass does not apply. Sync logic is covered + # by the apply_admin_role_from_user_info tests above. B4 sign-in sync will also use that. + + defp ensure_roles_exist do + for {name, perm} <- [{"Admin", "admin"}, {"Mitglied", "own_data"}] do + case Role + |> Ash.Query.filter(name == ^name) + |> Ash.read_one(authorize?: false, domain: Mv.Authorization) do + {:ok, nil} -> + Role + |> Ash.Changeset.for_create(:create_role_with_system_flag, %{ + name: name, + description: name, + permission_set_name: perm, + is_system_role: name == "Mitglied" + }) + |> Ash.create!(authorize?: false, domain: Mv.Authorization) + + _ -> + :ok + end + end + end + + defp put_oidc_config(opts) do + current = Application.get_env(:mv, :oidc_role_sync, []) + merged = Keyword.merge(current, opts) + Application.put_env(:mv, :oidc_role_sync, merged) + + fn -> + Application.put_env(:mv, :oidc_role_sync, current) + end + end + + defp admin_role_id do + {:ok, role} = Role.get_admin_role() + role.id + end + + defp mitglied_role_id do + {:ok, role} = Role.get_mitglied_role() + role.id + end + + defp get_user(id) do + User + |> Ash.Query.filter(id == ^id) + |> Ash.read_one(authorize?: false, domain: Mv.Accounts) + end + + defp create_user_with_mitglied(email) do + {:ok, _} = Accounts.create_user(%{email: email}, authorize?: false) + get_user_by_email(email) + end + + defp create_user_with_admin(email) do + {:ok, _} = Accounts.create_user(%{email: email}, authorize?: false) + {:ok, u} = get_user_by_email(email) + + u + |> Ash.Changeset.for_update(:update_user, %{role_id: admin_role_id()}) + |> Ash.update!(authorize?: false) + + get_user(u.id) + end + + defp get_user_by_email(email) do + User + |> Ash.Query.filter(email == ^email) + |> Ash.read_one(authorize?: false, domain: Mv.Accounts) + end +end -- 2.47.2 From 55fef5a9931e3d8222fa62211409eff70ab061aa Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 16:20:39 +0100 Subject: [PATCH 08/14] Docs and .env.example for admin bootstrap and OIDC role sync Documents ADMIN_EMAIL/PASSWORD, seed_admin, entrypoint; OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM and role sync on register/sign-in. --- .env.example | 13 ++++++ docs/admin-bootstrap-and-oidc-role-sync.md | 54 ++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 docs/admin-bootstrap-and-oidc-role-sync.md diff --git a/.env.example b/.env.example index 13154f3..d5d35ed 100644 --- a/.env.example +++ b/.env.example @@ -11,9 +11,22 @@ PHX_HOST=localhost # Recommended: Association settings ASSOCIATION_NAME="Sportsclub XYZ" +# Optional: Admin user (created/updated on container start via Release.seed_admin) +# In production, set these so the first admin can log in. Change password without redeploy: +# bin/mv eval "Mv.Release.seed_admin()" (with new ADMIN_PASSWORD or ADMIN_PASSWORD_FILE) +# ADMIN_EMAIL=admin@example.com +# ADMIN_PASSWORD=secure-password +# ADMIN_PASSWORD_FILE=/run/secrets/admin_password + # Optional: OIDC Configuration # These have defaults in docker-compose.prod.yml, only override if needed # OIDC_CLIENT_ID=mv # OIDC_BASE_URL=http://localhost:8080/auth/v1 # OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback # OIDC_CLIENT_SECRET=your-rauthy-client-secret + +# Optional: OIDC group → Admin role sync (e.g. Authentik groups from profile scope) +# If OIDC_ADMIN_GROUP_NAME is set, users in that group get Admin role on registration/sign-in. +# OIDC_GROUPS_CLAIM defaults to "groups" (JWT claim name for group list). +# OIDC_ADMIN_GROUP_NAME=admin +# OIDC_GROUPS_CLAIM=groups diff --git a/docs/admin-bootstrap-and-oidc-role-sync.md b/docs/admin-bootstrap-and-oidc-role-sync.md new file mode 100644 index 0000000..87dad27 --- /dev/null +++ b/docs/admin-bootstrap-and-oidc-role-sync.md @@ -0,0 +1,54 @@ +# Admin Bootstrap and OIDC Role Sync + +## Overview + +- **Admin bootstrap:** In production, no seeds run. The first admin user is created/updated from environment variables in the Docker entrypoint (after migrate, before server). Password can be changed without redeploy via `bin/mv eval "Mv.Release.seed_admin()"`. +- **OIDC role sync:** Optional mapping from OIDC groups (e.g. from Authentik profile scope) to the Admin role. Users in the configured admin group get the Admin role on registration and on each sign-in. + +## Admin Bootstrap (Part A) + +### Environment Variables + +- `ADMIN_EMAIL` – Email of the admin user to create/update. If unset, seed_admin/0 does nothing. +- `ADMIN_PASSWORD` – Password for the admin user. If unset (and no file), no user is created in production. +- `ADMIN_PASSWORD_FILE` – Path to a file containing the password (e.g. Docker secret). + +### Release Task + +- `Mv.Release.seed_admin/0` – Reads ADMIN_EMAIL and password from ADMIN_PASSWORD or ADMIN_PASSWORD_FILE. If both are set, creates or updates the user with the Admin role. Idempotent. + +### Entrypoint + +- rel/overlays/bin/docker-entrypoint.sh – After migrate, runs seed_admin(), then starts the server. + +### Seeds (Dev/Test) + +- priv/repo/seeds.exs – Uses ADMIN_PASSWORD or ADMIN_PASSWORD_FILE when set; otherwise fallback "testpassword" only in dev/test. + +## OIDC Role Sync (Part B) + +### Configuration + +- `OIDC_ADMIN_GROUP_NAME` – OIDC group name that maps to the Admin role. If unset, no role sync. +- `OIDC_GROUPS_CLAIM` – JWT claim name for group list (default "groups"). +- Module: Mv.OidcRoleSyncConfig (oidc_admin_group_name/0, oidc_groups_claim/0). + +### Sync Logic + +- Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info) – If admin group configured, sets user role to Admin or Mitglied based on user_info groups. + +### Where It Runs + +1. Registration: register_with_rauthy after_action calls OidcRoleSync. +2. Sign-in: sign_in_with_rauthy prepare after_action calls OidcRoleSync for each user. + +### Internal Action + +- User.set_role_from_oidc_sync – Internal update (role_id only). Used by OidcRoleSync; not exposed. + +## See Also + +- .env.example – Admin and OIDC group env vars. +- lib/mv/release.ex – seed_admin/0. +- lib/mv/oidc_role_sync.ex – Sync implementation. +- docs/oidc-account-linking.md – OIDC account linking. -- 2.47.2 From d37fc03a374ecea6f2edc6e25bbaa07e8493f847 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 18:02:59 +0100 Subject: [PATCH 09/14] Fix: load OIDC role sync config from ENV in all environments OIDC_ADMIN_GROUP_NAME and OIDC_GROUPS_CLAIM were only set in prod block; in dev admin_group was nil so role sync never ran. Move config outside prod block so dev/test get ENV values. --- config/runtime.exs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/config/runtime.exs b/config/runtime.exs index b0079ef..f1df5b7 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -89,6 +89,11 @@ if System.get_env("PHX_SERVER") do config :mv, MvWeb.Endpoint, server: true end +# OIDC group → Admin role sync: read from ENV in all environments (dev/test/prod) +config :mv, :oidc_role_sync, + admin_group_name: System.get_env("OIDC_ADMIN_GROUP_NAME"), + groups_claim: System.get_env("OIDC_GROUPS_CLAIM") || "groups" + if config_env() == :prod do database_url = build_database_url.() @@ -153,11 +158,6 @@ if config_env() == :prod do client_secret: client_secret, redirect_uri: System.get_env("OIDC_REDIRECT_URI") || default_redirect_uri - # OIDC group → Admin role sync (optional). Groups claim default "groups". - config :mv, :oidc_role_sync, - admin_group_name: System.get_env("OIDC_ADMIN_GROUP_NAME"), - groups_claim: System.get_env("OIDC_GROUPS_CLAIM") || "groups" - # Token signing secret from environment variable # This overrides the placeholder value set in prod.exs # Supports TOKEN_SIGNING_SECRET or TOKEN_SIGNING_SECRET_FILE for Docker secrets. -- 2.47.2 From d441009c8a43ef957a16dbdad97e4d4d5420524f Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 18:03:02 +0100 Subject: [PATCH 10/14] Refactor: remove debug instrumentation from OidcRoleSync Drop temporary logging used to diagnose OIDC groups sync in dev. --- lib/mv/oidc_role_sync.ex | 88 +++++++++++++++++++++++++++++++--------- 1 file changed, 69 insertions(+), 19 deletions(-) diff --git a/lib/mv/oidc_role_sync.ex b/lib/mv/oidc_role_sync.ex index d6b608f..369b2b4 100644 --- a/lib/mv/oidc_role_sync.ex +++ b/lib/mv/oidc_role_sync.ex @@ -5,31 +5,29 @@ defmodule Mv.OidcRoleSync do Used after OIDC registration (register_with_rauthy) and on sign-in so that users in the configured admin group get the Admin role; others get Mitglied. Configure via OIDC_ADMIN_GROUP_NAME and OIDC_GROUPS_CLAIM (see OidcRoleSyncConfig). + + Groups are read from user_info (ID token claims) first; if missing or empty, + the access_token from oauth_tokens is decoded as JWT and the groups claim is + read from there (e.g. Rauthy puts groups in the access token when scope + includes "groups"). """ alias Mv.Accounts.User alias Mv.Authorization.Role alias Mv.OidcRoleSyncConfig @doc """ - Applies Admin or Mitglied role to the user based on OIDC user_info (groups claim). + Applies Admin or Mitglied role to the user based on OIDC groups claim. - If OIDC_ADMIN_GROUP_NAME is not configured: no-op, returns :ok without changing the user. - - If user_info contains the configured admin group (under OIDC_GROUPS_CLAIM): assigns Admin role. + - If groups (from user_info or access_token) contain the configured admin group: assigns Admin role. - Otherwise: assigns Mitglied role (downgrade if user was Admin). - user_info is a map (e.g. from JWT claims) and may use string keys. Groups can be - a list of strings or a single string. - - ## Examples - - user_info = %{"groups" => ["mila-admin"]} - OidcRoleSync.apply_admin_role_from_user_info(user, user_info) - - user_info = %{"ak_groups" => ["other"]} # with OIDC_GROUPS_CLAIM=ak_groups - OidcRoleSync.apply_admin_role_from_user_info(user, user_info) + user_info is a map (e.g. from ID token claims); oauth_tokens is optional and may + contain "access_token" (JWT) from which the groups claim is read when not in user_info. """ - @spec apply_admin_role_from_user_info(User.t(), map()) :: :ok - def apply_admin_role_from_user_info(user, user_info) when is_map(user_info) do + @spec apply_admin_role_from_user_info(User.t(), map(), map() | nil) :: :ok + def apply_admin_role_from_user_info(user, user_info, oauth_tokens \\ nil) + when is_map(user_info) do admin_group = OidcRoleSyncConfig.oidc_admin_group_name() if is_nil(admin_group) or admin_group == "" do @@ -37,20 +35,72 @@ defmodule Mv.OidcRoleSync do else claim = OidcRoleSyncConfig.oidc_groups_claim() groups = groups_from_user_info(user_info, claim) + + groups = + if Enum.empty?(groups), do: groups_from_access_token(oauth_tokens, claim), else: groups + target_role = if admin_group in groups, do: :admin, else: :mitglied set_user_role(user, target_role) end end defp groups_from_user_info(user_info, claim) do - case user_info[claim] do - nil -> [] - list when is_list(list) -> Enum.map(list, &to_string/1) - single when is_binary(single) -> [single] - _ -> [] + value = user_info[claim] || user_info[String.to_existing_atom(claim)] + normalize_groups(value) + rescue + ArgumentError -> normalize_groups(user_info[claim]) + end + + defp groups_from_access_token(nil, _claim), do: [] + defp groups_from_access_token(oauth_tokens, _claim) when not is_map(oauth_tokens), do: [] + + defp groups_from_access_token(oauth_tokens, claim) do + access_token = oauth_tokens["access_token"] || oauth_tokens[:access_token] + + if is_binary(access_token) do + case peek_jwt_claims(access_token) do + {:ok, claims} -> + value = claims[claim] || safe_get_atom(claims, claim) + normalize_groups(value) + + _ -> + [] + end + else + [] end end + defp safe_get_atom(map, key) when is_binary(key) do + try do + Map.get(map, String.to_existing_atom(key)) + rescue + ArgumentError -> nil + end + end + + defp safe_get_atom(_map, _key), do: nil + + defp peek_jwt_claims(token) do + parts = String.split(token, ".") + + if length(parts) == 3 do + [_h, payload_b64, _sig] = parts + + case Base.url_decode64(payload_b64, padding: false) do + {:ok, payload} -> Jason.decode(payload) + _ -> :error + end + else + :error + end + end + + defp normalize_groups(nil), do: [] + defp normalize_groups(list) when is_list(list), do: Enum.map(list, &to_string/1) + defp normalize_groups(single) when is_binary(single), do: [single] + defp normalize_groups(_), do: [] + defp set_user_role(user, :admin) do case Role.get_admin_role() do {:ok, %Role{} = role} -> -- 2.47.2 From 58a5b086adcb2c28bce6803a3276ae2fdc28ddba Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 18:03:15 +0100 Subject: [PATCH 11/14] OIDC: pass oauth_tokens to role sync; get? true for sign_in; return record in register - sign_in_with_rauthy: get? true so Ash returns single user; pass oauth_tokens to OidcRoleSync. - register_with_rauthy: pass oauth_tokens to OidcRoleSync; return {:ok, record} to preserve token. --- lib/accounts/user.ex | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index fc04bfa..8e7e70f 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -258,6 +258,7 @@ defmodule Mv.Accounts.User do end read :sign_in_with_rauthy do + get? true argument :user_info, :map, allow_nil?: false argument :oauth_tokens, :map, allow_nil?: false prepare AshAuthentication.Strategy.OAuth2.SignInPreparation @@ -271,9 +272,10 @@ defmodule Mv.Accounts.User do # Sync role from OIDC groups after sign-in (e.g. admin group → Admin role) prepare Ash.Resource.Preparation.Builtins.after_action(fn query, records, _context -> user_info = Ash.Query.get_argument(query, :user_info) || %{} + oauth_tokens = Ash.Query.get_argument(query, :oauth_tokens) || %{} Enum.each(records, fn user -> - Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info) + Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens) end) {:ok, records} @@ -319,10 +321,12 @@ defmodule Mv.Accounts.User do # Sync role from OIDC groups (e.g. admin group → Admin role) after user is created/updated change fn changeset, _ctx -> user_info = Ash.Changeset.get_argument(changeset, :user_info) + oauth_tokens = Ash.Changeset.get_argument(changeset, :oauth_tokens) || %{} Ash.Changeset.after_action(changeset, fn _cs, record -> - Mv.OidcRoleSync.apply_admin_role_from_user_info(record, user_info) - {:ok, Ash.get!(__MODULE__, record.id, authorize?: false, domain: Mv.Accounts)} + Mv.OidcRoleSync.apply_admin_role_from_user_info(record, user_info, oauth_tokens) + # Return original record so __metadata__.token (from GenerateTokenChange) is preserved + {:ok, record} end) end end -- 2.47.2 From d573a22769ea687a72ec6277e149a27a8e2c5b21 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 18:03:18 +0100 Subject: [PATCH 12/14] Tests: accept single user or list from read_sign_in_with_rauthy (get? true) Handle {:ok, user}, {:ok, nil} in addition to {:ok, [user]}, {:ok, []}. --- test/accounts/user_authentication_test.exs | 17 ++++++++-- test/mv/oidc_role_sync_test.exs | 19 ++++++++++++ .../mv_web/controllers/oidc_e2e_flow_test.exs | 21 +++++++++++-- .../controllers/oidc_integration_test.exs | 31 ++++++++++++++++--- 4 files changed, 80 insertions(+), 8 deletions(-) diff --git a/test/accounts/user_authentication_test.exs b/test/accounts/user_authentication_test.exs index 3530dd1..d471b30 100644 --- a/test/accounts/user_authentication_test.exs +++ b/test/accounts/user_authentication_test.exs @@ -118,6 +118,10 @@ defmodule Mv.Accounts.UserAuthenticationTest do ) case result do + {:ok, found_user} when is_struct(found_user) -> + assert found_user.id == user.id + assert found_user.oidc_id == "oidc_identifier_12345" + {:ok, [found_user]} -> assert found_user.id == user.id assert found_user.oidc_id == "oidc_identifier_12345" @@ -125,6 +129,9 @@ defmodule Mv.Accounts.UserAuthenticationTest do {:ok, []} -> flunk("User should be found by oidc_id") + {:ok, nil} -> + flunk("User should be found by oidc_id") + {:error, error} -> flunk("Unexpected error: #{inspect(error)}") end @@ -219,11 +226,14 @@ defmodule Mv.Accounts.UserAuthenticationTest do actor: system_actor ) - # Either returns empty list OR authentication error - both mean "user not found" + # Either returns empty/nil OR authentication error - both mean "user not found" case result do {:ok, []} -> :ok + {:ok, nil} -> + :ok + {:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} -> :ok @@ -260,11 +270,14 @@ defmodule Mv.Accounts.UserAuthenticationTest do actor: system_actor ) - # Either returns empty list OR authentication error - both mean "user not found" + # Either returns empty/nil OR authentication error - both mean "user not found" case result do {:ok, []} -> :ok + {:ok, nil} -> + :ok + {:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} -> :ok diff --git a/test/mv/oidc_role_sync_test.exs b/test/mv/oidc_role_sync_test.exs index acde5b5..d05441b 100644 --- a/test/mv/oidc_role_sync_test.exs +++ b/test/mv/oidc_role_sync_test.exs @@ -83,6 +83,25 @@ defmodule Mv.OidcRoleSyncTest do {:ok, after_user} = get_user(user1.id) assert after_user.role_id == mitglied_role_id() end + + test "when user_info has no groups, groups are read from access_token JWT (e.g. Rauthy)" do + email = "sync-from-token-#{System.unique_integer([:positive])}@test.example.com" + {:ok, user} = create_user_with_mitglied(email) + user_info = %{"sub" => "oidc-123"} + + # Minimal JWT: header.payload.signature with "groups" in payload (Rauthy puts groups in access_token) + payload = Jason.encode!(%{"groups" => ["mila-admin"], "sub" => "oidc-123"}) + payload_b64 = Base.url_encode64(payload, padding: false) + header_b64 = Base.url_encode64("{\"alg\":\"HS256\",\"typ\":\"JWT\"}", padding: false) + sig_b64 = Base.url_encode64("sig", padding: false) + access_token = "#{header_b64}.#{payload_b64}.#{sig_b64}" + oauth_tokens = %{"access_token" => access_token} + + assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens) + + {:ok, after_user} = get_user(user.id) + assert after_user.role_id == admin_role_id() + end end # B3: Role sync after registration is implemented via after_action in register_with_rauthy. diff --git a/test/mv_web/controllers/oidc_e2e_flow_test.exs b/test/mv_web/controllers/oidc_e2e_flow_test.exs index fbd59d2..76dd266 100644 --- a/test/mv_web/controllers/oidc_e2e_flow_test.exs +++ b/test/mv_web/controllers/oidc_e2e_flow_test.exs @@ -37,7 +37,7 @@ defmodule MvWeb.OidcE2EFlowTest do assert is_nil(new_user.hashed_password) # Verify user can be found by oidc_id - {:ok, [found_user]} = + result = Mv.Accounts.read_sign_in_with_rauthy( %{ user_info: user_info, @@ -46,6 +46,13 @@ defmodule MvWeb.OidcE2EFlowTest do actor: actor ) + found_user = + case result do + {:ok, u} when is_struct(u) -> u + {:ok, [u]} -> u + _ -> flunk("Expected user, got: #{inspect(result)}") + end + assert found_user.id == new_user.id end end @@ -177,7 +184,7 @@ defmodule MvWeb.OidcE2EFlowTest do assert linked_user.hashed_password == password_user.hashed_password # Step 5: User can now sign in via OIDC - {:ok, [signed_in_user]} = + result = Mv.Accounts.read_sign_in_with_rauthy( %{ user_info: user_info, @@ -186,6 +193,13 @@ defmodule MvWeb.OidcE2EFlowTest do actor: actor ) + signed_in_user = + case result do + {:ok, u} when is_struct(u) -> u + {:ok, [u]} -> u + _ -> flunk("Expected user, got: #{inspect(result)}") + end + assert signed_in_user.id == password_user.id assert signed_in_user.oidc_id == "oidc_link_888" end @@ -331,6 +345,9 @@ defmodule MvWeb.OidcE2EFlowTest do {:ok, []} -> :ok + {:ok, nil} -> + :ok + {:error, %Ash.Error.Forbidden{}} -> :ok diff --git a/test/mv_web/controllers/oidc_integration_test.exs b/test/mv_web/controllers/oidc_integration_test.exs index 650158a..cdd352e 100644 --- a/test/mv_web/controllers/oidc_integration_test.exs +++ b/test/mv_web/controllers/oidc_integration_test.exs @@ -27,7 +27,7 @@ defmodule MvWeb.OidcIntegrationTest do # Test sign_in_with_rauthy action directly system_actor = Mv.Helpers.SystemActor.get_system_actor() - {:ok, [found_user]} = + result = Mv.Accounts.read_sign_in_with_rauthy( %{ user_info: user_info, @@ -36,6 +36,13 @@ defmodule MvWeb.OidcIntegrationTest do actor: system_actor ) + found_user = + case result do + {:ok, u} when is_struct(u) -> u + {:ok, [u]} -> u + _ -> flunk("Expected user, got: #{inspect(result)}") + end + assert found_user.id == user.id assert to_string(found_user.email) == "existing@example.com" assert found_user.oidc_id == "existing_oidc_123" @@ -104,6 +111,9 @@ defmodule MvWeb.OidcIntegrationTest do {:ok, []} -> :ok + {:ok, nil} -> + :ok + {:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} -> :ok @@ -129,7 +139,7 @@ defmodule MvWeb.OidcIntegrationTest do system_actor = Mv.Helpers.SystemActor.get_system_actor() - {:ok, [found_user]} = + result = Mv.Accounts.read_sign_in_with_rauthy( %{ user_info: correct_user_info, @@ -138,6 +148,13 @@ defmodule MvWeb.OidcIntegrationTest do actor: system_actor ) + found_user = + case result do + {:ok, u} when is_struct(u) -> u + {:ok, [u]} -> u + _ -> flunk("Expected user, got: #{inspect(result)}") + end + assert found_user.id == user.id # Try with wrong oidc_id but correct email @@ -155,11 +172,14 @@ defmodule MvWeb.OidcIntegrationTest do actor: system_actor ) - # Either returns empty list OR authentication error - both mean "user not found" + # Either returns empty/nil OR authentication error - both mean "user not found" case result do {:ok, []} -> :ok + {:ok, nil} -> + :ok + {:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} -> :ok @@ -193,11 +213,14 @@ defmodule MvWeb.OidcIntegrationTest do actor: system_actor ) - # Either returns empty list OR authentication error - both mean "user not found" + # Either returns empty/nil OR authentication error - both mean "user not found" case result do {:ok, []} -> :ok + {:ok, nil} -> + :ok + {:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} -> :ok -- 2.47.2 From c5f1fdce0a855b6505ec057ed4b48364953a2f11 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 19:44:43 +0100 Subject: [PATCH 13/14] Code-review follow-ups: policy, docs, seed_admin behaviour - Use OidcRoleSyncContext for set_role_from_oidc_sync; document JWT peek risk. - seed_admin without password sets Admin role on existing user (OIDC-only); update docs and test. - Fix DE translation for 'access this page'; add get? true comment in User. --- docs/admin-bootstrap-and-oidc-role-sync.md | 4 +-- lib/accounts/user.ex | 5 ++-- .../checks/oidc_role_sync_context.ex | 12 +++------ lib/mv/oidc_role_sync.ex | 10 +++++++ lib/mv/release.ex | 27 +++++++++++++++++-- priv/gettext/de/LC_MESSAGES/default.po | 2 +- test/mv/release_test.exs | 10 ++++--- 7 files changed, 51 insertions(+), 19 deletions(-) diff --git a/docs/admin-bootstrap-and-oidc-role-sync.md b/docs/admin-bootstrap-and-oidc-role-sync.md index 87dad27..b0da019 100644 --- a/docs/admin-bootstrap-and-oidc-role-sync.md +++ b/docs/admin-bootstrap-and-oidc-role-sync.md @@ -10,12 +10,12 @@ ### Environment Variables - `ADMIN_EMAIL` – Email of the admin user to create/update. If unset, seed_admin/0 does nothing. -- `ADMIN_PASSWORD` – Password for the admin user. If unset (and no file), no user is created in production. +- `ADMIN_PASSWORD` – Password for the admin user. If unset (and no file), no new user is created; if a user with ADMIN_EMAIL already exists (e.g. OIDC-only), their role is set to Admin (no password change). - `ADMIN_PASSWORD_FILE` – Path to a file containing the password (e.g. Docker secret). ### Release Task -- `Mv.Release.seed_admin/0` – Reads ADMIN_EMAIL and password from ADMIN_PASSWORD or ADMIN_PASSWORD_FILE. If both are set, creates or updates the user with the Admin role. Idempotent. +- `Mv.Release.seed_admin/0` – Reads ADMIN_EMAIL and password from ADMIN_PASSWORD or ADMIN_PASSWORD_FILE. If both email and password are set: creates or updates the user with the Admin role. If only ADMIN_EMAIL is set: sets the Admin role on an existing user with that email (for OIDC-only admins); does not create a user. Idempotent. ### Entrypoint diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 8e7e70f..2f35ce4 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -258,6 +258,7 @@ defmodule Mv.Accounts.User do end read :sign_in_with_rauthy do + # Single record expected; required for AshAuthentication OAuth2 strategy (returns list of 0 or 1). get? true argument :user_info, :map, allow_nil?: false argument :oauth_tokens, :map, allow_nil?: false @@ -356,10 +357,10 @@ defmodule Mv.Accounts.User do end # set_role_from_oidc_sync: internal only (called from Mv.OidcRoleSync on registration/sign-in). - # Not exposed in code_interface; must never be callable by clients. + # Not exposed in code_interface; only allowed when context.private.oidc_role_sync is set. bypass action(:set_role_from_oidc_sync) do description "Internal: OIDC role sync (server-side only)" - authorize_if always() + authorize_if Mv.Authorization.Checks.OidcRoleSyncContext end # UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope) diff --git a/lib/mv/authorization/checks/oidc_role_sync_context.ex b/lib/mv/authorization/checks/oidc_role_sync_context.ex index 1f39944..1214d75 100644 --- a/lib/mv/authorization/checks/oidc_role_sync_context.ex +++ b/lib/mv/authorization/checks/oidc_role_sync_context.ex @@ -1,9 +1,9 @@ defmodule Mv.Authorization.Checks.OidcRoleSyncContext do @moduledoc """ - Policy check: true when the action is being run from OIDC role sync (context.private.oidc_role_sync). + Policy check: true when the action is run from OIDC role sync (context.private.oidc_role_sync). - Used to allow the internal set_role_from_oidc_sync action when called by Mv.OidcRoleSync - without an actor. + Used to allow the internal set_role_from_oidc_sync action only when called by Mv.OidcRoleSync, + which sets context.private.oidc_role_sync when performing the update. """ use Ash.Policy.SimpleCheck @@ -12,11 +12,7 @@ defmodule Mv.Authorization.Checks.OidcRoleSyncContext do @impl true def match?(_actor, authorizer, _opts) do - # Context from opts (e.g. Ash.update!(..., context: %{private: %{oidc_role_sync: true}})) context = Map.get(authorizer, :context) || %{} - from_context = get_in(context, [:private, :oidc_role_sync]) == true - # When update runs inside create's after_action, context may not be passed; use process dict. - from_process = Process.get(:oidc_role_sync) == true - from_context or from_process + get_in(context, [:private, :oidc_role_sync]) == true end end diff --git a/lib/mv/oidc_role_sync.ex b/lib/mv/oidc_role_sync.ex index 369b2b4..9073409 100644 --- a/lib/mv/oidc_role_sync.ex +++ b/lib/mv/oidc_role_sync.ex @@ -10,6 +10,16 @@ defmodule Mv.OidcRoleSync do the access_token from oauth_tokens is decoded as JWT and the groups claim is read from there (e.g. Rauthy puts groups in the access token when scope includes "groups"). + + ## JWT access token (security) + + The access_token payload is read without signature verification (peek only). + We rely on the fact that `oauth_tokens` is only ever passed from the + verified OIDC callback (Assent/AshAuthentication after provider token + exchange). If callers passed untrusted or tampered tokens, group claims + could be forged and a user could be assigned the Admin role. Therefore: + do not call this module with user-supplied tokens; it is intended only + for the internal flow from the OIDC callback. """ alias Mv.Accounts.User alias Mv.Authorization.Role diff --git a/lib/mv/release.ex b/lib/mv/release.ex index 45b0c9d..8893dcc 100644 --- a/lib/mv/release.ex +++ b/lib/mv/release.ex @@ -52,14 +52,37 @@ defmodule Mv.Release do :ok is_nil(admin_password) or admin_password == "" -> - # Do not create or update any user without a password (no fallback in production) - :ok + ensure_admin_role_only(admin_email) true -> ensure_admin_user(admin_email, admin_password) end end + defp ensure_admin_role_only(email) do + case Role.get_admin_role() do + {:ok, nil} -> + :ok + + {:ok, %Role{} = admin_role} -> + case get_user_by_email(email) do + {:ok, %User{} = user} -> + user + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) + |> Ash.update!(authorize?: false) + + :ok + + _ -> + :ok + end + + {:error, _} -> + :ok + end + end + defp ensure_admin_user(email, password) do if is_nil(password) or password == "" do :ok diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 90dddc8..6ba8022 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2306,7 +2306,7 @@ msgstr "Import/Export" #: lib/mv_web/live/import_export_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "You do not have permission to access this page." -msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen" +msgstr "Du hast keine Berechtigung, auf diese Seite zuzugreifen." #: lib/mv_web/live/import_export_live.ex #, elixir-autogen, elixir-format, fuzzy diff --git a/test/mv/release_test.exs b/test/mv/release_test.exs index 1879c1d..84a2f34 100644 --- a/test/mv/release_test.exs +++ b/test/mv/release_test.exs @@ -44,18 +44,20 @@ defmodule Mv.ReleaseTest do "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 + test "with ADMIN_EMAIL but without ADMIN_PASSWORD and user exists: sets Admin role (OIDC-only bootstrap)" do + System.delete_env("ADMIN_PASSWORD") + System.delete_env("ADMIN_PASSWORD_FILE") + 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 + {:ok, _user} = create_user_with_mitglied_role(email) Mv.Release.seed_admin() {:ok, updated} = get_user_by_email(email) - assert updated.role_id == role_id_before + assert updated.role_id == admin_role_id() end test "with ADMIN_EMAIL and ADMIN_PASSWORD: creates user with Admin role and sets password" do -- 2.47.2 From ad42a539191d7133a4b7db6720d753ace7334f83 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 20:25:54 +0100 Subject: [PATCH 14/14] OIDC sign-in: robust after_action for get? result, non-bang role sync - sign_in_with_rauthy after_action normalizes result (nil/struct/list) to list before Enum.each. - OidcRoleSync.do_set_role uses Ash.update and swallows errors so auth is not blocked; skip update if role already correct. --- lib/accounts/user.ex | 15 ++++++++++++--- lib/mv/oidc_role_sync.ex | 18 ++++++++++++------ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 2f35ce4..92b9ef2 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -271,15 +271,24 @@ defmodule Mv.Accounts.User do filter expr(oidc_id == get_path(^arg(:user_info), [:sub])) # Sync role from OIDC groups after sign-in (e.g. admin group → Admin role) - prepare Ash.Resource.Preparation.Builtins.after_action(fn query, records, _context -> + # get? true can return nil, a single %User{}, or a list; normalize to list for Enum.each + prepare Ash.Resource.Preparation.Builtins.after_action(fn query, result, _context -> user_info = Ash.Query.get_argument(query, :user_info) || %{} oauth_tokens = Ash.Query.get_argument(query, :oauth_tokens) || %{} - Enum.each(records, fn user -> + users = + case result do + nil -> [] + u when is_struct(u, User) -> [u] + list when is_list(list) -> list + _ -> [] + end + + Enum.each(users, fn user -> Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens) end) - {:ok, records} + {:ok, result} end) end diff --git a/lib/mv/oidc_role_sync.ex b/lib/mv/oidc_role_sync.ex index 9073409..f268154 100644 --- a/lib/mv/oidc_role_sync.ex +++ b/lib/mv/oidc_role_sync.ex @@ -132,11 +132,17 @@ defmodule Mv.OidcRoleSync do end defp do_set_role(user, role) do - user - |> Ash.Changeset.for_update(:set_role_from_oidc_sync, %{role_id: role.id}) - |> Ash.Changeset.set_context(%{private: %{oidc_role_sync: true}}) - |> Ash.update!(domain: Mv.Accounts, context: %{private: %{oidc_role_sync: true}}) - - :ok + if user.role_id == role.id do + :ok + else + user + |> Ash.Changeset.for_update(:set_role_from_oidc_sync, %{role_id: role.id}) + |> Ash.Changeset.set_context(%{private: %{oidc_role_sync: true}}) + |> Ash.update(domain: Mv.Accounts, context: %{private: %{oidc_role_sync: true}}) + |> case do + {:ok, _} -> :ok + {:error, _} -> :ok + end + end end end -- 2.47.2