Init an admin user in prod closes #381 #409

Merged
moritz merged 14 commits from feature/381_init_admin into main 2026-02-04 20:53:02 +01:00
19 changed files with 1025 additions and 45 deletions

View file

@ -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

View file

@ -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"],

View file

@ -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.()

View file

@ -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 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 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
- 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.

View file

@ -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
@ -251,6 +258,8 @@ 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
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
@ -260,6 +269,27 @@ 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)
# 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) || %{}
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, result}
end)
end
create :register_with_rauthy do
@ -297,6 +327,18 @@ 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)
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, oauth_tokens)
# Return original record so __metadata__.token (from GenerateTokenChange) is preserved
{:ok, record}
end)
end
end
end
@ -323,6 +365,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; 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 Mv.Authorization.Checks.OidcRoleSyncContext
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 +495,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.

View file

@ -0,0 +1,18 @@
defmodule Mv.Authorization.Checks.OidcRoleSyncContext do
@moduledoc """
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 only when called by Mv.OidcRoleSync,
which sets context.private.oidc_role_sync when performing the update.
"""
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 = Map.get(authorizer, :context) || %{}
get_in(context, [:private, :oidc_role_sync]) == true
end
end

View file

@ -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

148
lib/mv/oidc_role_sync.ex Normal file
View file

@ -0,0 +1,148 @@
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).
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").
## 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
alias Mv.OidcRoleSyncConfig
@doc """
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 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 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(), 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
:ok
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
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} ->
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
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

View file

@ -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

View file

@ -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,151 @@ 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 == "" ->
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
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

View file

@ -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

View file

@ -132,8 +132,15 @@ 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)
# 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
# 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
@ -214,39 +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
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
|> 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")
# 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"})
|> 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)}"
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
@ -747,7 +724,14 @@ 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)")
password_configured =
System.get_env("ADMIN_PASSWORD") != nil or System.get_env("ADMIN_PASSWORD_FILE") != nil
IO.puts(
" - Admin user: #{admin_email} (password: #{if password_configured, do: "set", else: "not set"})"
)
IO.puts(" - Sample members: Hans, Greta, Friedrich")
IO.puts(

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,181 @@
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
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.
# 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

222
test/mv/release_test.exs Normal file
View file

@ -0,0 +1,222 @@
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: 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)
Mv.Release.seed_admin()
{:ok, updated} = get_user_by_email(email)
assert updated.role_id == admin_role_id()
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

View file

@ -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

View file

@ -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