Init an admin user in prod closes #381 #409
19 changed files with 1025 additions and 45 deletions
13
.env.example
13
.env.example
|
|
@ -11,9 +11,22 @@ PHX_HOST=localhost
|
||||||
# Recommended: Association settings
|
# Recommended: Association settings
|
||||||
ASSOCIATION_NAME="Sportsclub XYZ"
|
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
|
# Optional: OIDC Configuration
|
||||||
# These have defaults in docker-compose.prod.yml, only override if needed
|
# These have defaults in docker-compose.prod.yml, only override if needed
|
||||||
# OIDC_CLIENT_ID=mv
|
# OIDC_CLIENT_ID=mv
|
||||||
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
||||||
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
|
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
|
||||||
# OIDC_CLIENT_SECRET=your-rauthy-client-secret
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,11 @@ config :mv,
|
||||||
max_rows: 1000
|
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
|
# Configures the endpoint
|
||||||
config :mv, MvWeb.Endpoint,
|
config :mv, MvWeb.Endpoint,
|
||||||
url: [host: "localhost"],
|
url: [host: "localhost"],
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,11 @@ if System.get_env("PHX_SERVER") do
|
||||||
config :mv, MvWeb.Endpoint, server: true
|
config :mv, MvWeb.Endpoint, server: true
|
||||||
end
|
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
|
if config_env() == :prod do
|
||||||
database_url = build_database_url.()
|
database_url = build_database_url.()
|
||||||
|
|
||||||
|
|
|
||||||
54
docs/admin-bootstrap-and-oidc-role-sync.md
Normal file
54
docs/admin-bootstrap-and-oidc-role-sync.md
Normal 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.
|
||||||
|
|
@ -187,6 +187,13 @@ defmodule Mv.Accounts.User do
|
||||||
require_atomic? false
|
require_atomic? false
|
||||||
end
|
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
|
# Admin action for direct password changes in admin panel
|
||||||
# Uses the official Ash Authentication HashPasswordChange with correct context
|
# Uses the official Ash Authentication HashPasswordChange with correct context
|
||||||
update :admin_set_password do
|
update :admin_set_password do
|
||||||
|
|
@ -251,6 +258,8 @@ defmodule Mv.Accounts.User do
|
||||||
end
|
end
|
||||||
|
|
||||||
read :sign_in_with_rauthy do
|
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 :user_info, :map, allow_nil?: false
|
||||||
argument :oauth_tokens, :map, allow_nil?: false
|
argument :oauth_tokens, :map, allow_nil?: false
|
||||||
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
|
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)
|
# linked their account via OIDC. Password-only users (oidc_id = nil)
|
||||||
# cannot be accessed via OIDC login without password verification.
|
# cannot be accessed via OIDC login without password verification.
|
||||||
filter expr(oidc_id == get_path(^arg(:user_info), [:sub]))
|
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
|
end
|
||||||
|
|
||||||
create :register_with_rauthy do
|
create :register_with_rauthy do
|
||||||
|
|
@ -297,6 +327,18 @@ defmodule Mv.Accounts.User do
|
||||||
|
|
||||||
# Sync user email to member when linking (User → Member)
|
# Sync user email to member when linking (User → Member)
|
||||||
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -323,6 +365,13 @@ defmodule Mv.Accounts.User do
|
||||||
authorize_if Mv.Authorization.Checks.ActorIsAdmin
|
authorize_if Mv.Authorization.Checks.ActorIsAdmin
|
||||||
end
|
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)
|
# UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope)
|
||||||
policy action_type([:read, :create, :update, :destroy]) do
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
description "Check permissions from user's role and permission set"
|
description "Check permissions from user's role and permission set"
|
||||||
|
|
@ -446,7 +495,7 @@ defmodule Mv.Accounts.User do
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
on: [:update],
|
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).
|
# 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.
|
# Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests.
|
||||||
|
|
|
||||||
18
lib/mv/authorization/checks/oidc_role_sync_context.ex
Normal file
18
lib/mv/authorization/checks/oidc_role_sync_context.ex
Normal 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
|
||||||
|
|
@ -181,4 +181,18 @@ defmodule Mv.Authorization.Role do
|
||||||
|> Ash.Query.filter(name == "Mitglied")
|
|> Ash.Query.filter(name == "Mitglied")
|
||||||
|> Ash.read_one(authorize?: false, domain: Mv.Authorization)
|
|> Ash.read_one(authorize?: false, domain: Mv.Authorization)
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
148
lib/mv/oidc_role_sync.ex
Normal file
148
lib/mv/oidc_role_sync.ex
Normal 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
|
||||||
24
lib/mv/oidc_role_sync_config.ex
Normal file
24
lib/mv/oidc_role_sync_config.ex
Normal 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
|
||||||
|
|
@ -2,9 +2,22 @@ defmodule Mv.Release do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Used for executing DB release tasks when run in production without Mix
|
Used for executing DB release tasks when run in production without Mix
|
||||||
installed.
|
installed.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- `migrate/0` - Runs all pending Ecto migrations.
|
||||||
|
- `seed_admin/0` - Ensures an admin user exists from ENV (ADMIN_EMAIL, ADMIN_PASSWORD
|
||||||
|
or ADMIN_PASSWORD_FILE). Idempotent; can be run on every deployment or via shell
|
||||||
|
to update the admin password without redeploying.
|
||||||
"""
|
"""
|
||||||
@app :mv
|
@app :mv
|
||||||
|
|
||||||
|
alias Mv.Accounts
|
||||||
|
alias Mv.Accounts.User
|
||||||
|
alias Mv.Authorization.Role
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
def migrate do
|
def migrate do
|
||||||
load_app()
|
load_app()
|
||||||
|
|
||||||
|
|
@ -18,6 +31,151 @@ defmodule Mv.Release do
|
||||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Ensures an admin user exists from ENV (ADMIN_EMAIL, ADMIN_PASSWORD or ADMIN_PASSWORD_FILE).
|
||||||
|
|
||||||
|
- If ADMIN_EMAIL is unset: no-op (idempotent).
|
||||||
|
- If ADMIN_PASSWORD (and ADMIN_PASSWORD_FILE) are unset and the user does not exist:
|
||||||
|
no user is created (no fallback password in production).
|
||||||
|
- If both ADMIN_EMAIL and ADMIN_PASSWORD are set: creates or updates the user with
|
||||||
|
Admin role and the given password. Safe to run on every deployment or via
|
||||||
|
`bin/mv eval "Mv.Release.seed_admin()"` to change the admin password without redeploying.
|
||||||
|
"""
|
||||||
|
def seed_admin do
|
||||||
|
load_app()
|
||||||
|
|
||||||
|
admin_email = get_env("ADMIN_EMAIL", nil)
|
||||||
|
admin_password = get_env_or_file("ADMIN_PASSWORD", nil)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
is_nil(admin_email) or admin_email == "" ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
is_nil(admin_password) or admin_password == "" ->
|
||||||
|
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
|
defp repos do
|
||||||
Application.fetch_env!(@app, :ecto_repos)
|
Application.fetch_env!(@app, :ecto_repos)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2306,7 +2306,7 @@ msgstr "Import/Export"
|
||||||
#: lib/mv_web/live/import_export_live.ex
|
#: lib/mv_web/live/import_export_live.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "You do not have permission to access this page."
|
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
|
#: lib/mv_web/live/import_export_live.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
|
|
||||||
|
|
@ -132,8 +132,15 @@ for attrs <- [
|
||||||
)
|
)
|
||||||
end
|
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"
|
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)
|
# 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
|
# 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."
|
raise "Failed to create or find admin role. Cannot proceed with member seeding."
|
||||||
end
|
end
|
||||||
|
|
||||||
# Assign admin role to user with ADMIN_EMAIL (if user exists)
|
# Create/update admin user via Release.seed_admin (uses ADMIN_EMAIL, ADMIN_PASSWORD / ADMIN_PASSWORD_FILE).
|
||||||
# This handles both existing users (e.g., from OIDC) and newly created users
|
# Reduces duplication and exercises the same path as production entrypoint.
|
||||||
case Accounts.User
|
Mv.Release.seed_admin()
|
||||||
|> 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
|
|
||||||
|
|
||||||
# Load admin user with role for use as actor in member operations
|
# Load admin user with role for use as actor in member operations
|
||||||
# This ensures all member operations have proper authorization
|
# 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(" - Global settings: club_name = #{default_club_name}")
|
||||||
IO.puts(" - Membership fee types: 4 types (Yearly, Half-yearly, Quarterly, Monthly)")
|
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(" - 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(" - Sample members: Hans, Greta, Friedrich")
|
||||||
|
|
||||||
IO.puts(
|
IO.puts(
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ set -e
|
||||||
echo "==> Running database migrations..."
|
echo "==> Running database migrations..."
|
||||||
/app/bin/migrate
|
/app/bin/migrate
|
||||||
|
|
||||||
|
echo "==> Seeding admin user from ENV (ADMIN_EMAIL, ADMIN_PASSWORD)..."
|
||||||
|
/app/bin/mv eval "Mv.Release.seed_admin()"
|
||||||
|
|
||||||
echo "==> Starting application..."
|
echo "==> Starting application..."
|
||||||
exec /app/bin/server
|
exec /app/bin/server
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,10 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
||||||
)
|
)
|
||||||
|
|
||||||
case result 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]} ->
|
{:ok, [found_user]} ->
|
||||||
assert found_user.id == user.id
|
assert found_user.id == user.id
|
||||||
assert found_user.oidc_id == "oidc_identifier_12345"
|
assert found_user.oidc_id == "oidc_identifier_12345"
|
||||||
|
|
@ -125,6 +129,9 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
||||||
{:ok, []} ->
|
{:ok, []} ->
|
||||||
flunk("User should be found by oidc_id")
|
flunk("User should be found by oidc_id")
|
||||||
|
|
||||||
|
{:ok, nil} ->
|
||||||
|
flunk("User should be found by oidc_id")
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
flunk("Unexpected error: #{inspect(error)}")
|
flunk("Unexpected error: #{inspect(error)}")
|
||||||
end
|
end
|
||||||
|
|
@ -219,11 +226,14 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
||||||
actor: system_actor
|
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
|
case result do
|
||||||
{:ok, []} ->
|
{:ok, []} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
{:ok, nil} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
|
@ -260,11 +270,14 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
||||||
actor: system_actor
|
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
|
case result do
|
||||||
{:ok, []} ->
|
{:ok, []} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
{:ok, nil} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
|
|
||||||
49
test/mv/oidc_role_sync_config_test.exs
Normal file
49
test/mv/oidc_role_sync_config_test.exs
Normal 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
|
||||||
181
test/mv/oidc_role_sync_test.exs
Normal file
181
test/mv/oidc_role_sync_test.exs
Normal 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
222
test/mv/release_test.exs
Normal 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
|
||||||
|
|
@ -37,7 +37,7 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
assert is_nil(new_user.hashed_password)
|
assert is_nil(new_user.hashed_password)
|
||||||
|
|
||||||
# Verify user can be found by oidc_id
|
# Verify user can be found by oidc_id
|
||||||
{:ok, [found_user]} =
|
result =
|
||||||
Mv.Accounts.read_sign_in_with_rauthy(
|
Mv.Accounts.read_sign_in_with_rauthy(
|
||||||
%{
|
%{
|
||||||
user_info: user_info,
|
user_info: user_info,
|
||||||
|
|
@ -46,6 +46,13 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
actor: actor
|
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
|
assert found_user.id == new_user.id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -177,7 +184,7 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
assert linked_user.hashed_password == password_user.hashed_password
|
assert linked_user.hashed_password == password_user.hashed_password
|
||||||
|
|
||||||
# Step 5: User can now sign in via OIDC
|
# Step 5: User can now sign in via OIDC
|
||||||
{:ok, [signed_in_user]} =
|
result =
|
||||||
Mv.Accounts.read_sign_in_with_rauthy(
|
Mv.Accounts.read_sign_in_with_rauthy(
|
||||||
%{
|
%{
|
||||||
user_info: user_info,
|
user_info: user_info,
|
||||||
|
|
@ -186,6 +193,13 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
actor: actor
|
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.id == password_user.id
|
||||||
assert signed_in_user.oidc_id == "oidc_link_888"
|
assert signed_in_user.oidc_id == "oidc_link_888"
|
||||||
end
|
end
|
||||||
|
|
@ -331,6 +345,9 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
{:ok, []} ->
|
{:ok, []} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
{:ok, nil} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{}} ->
|
{:error, %Ash.Error.Forbidden{}} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ defmodule MvWeb.OidcIntegrationTest do
|
||||||
# Test sign_in_with_rauthy action directly
|
# Test sign_in_with_rauthy action directly
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
{:ok, [found_user]} =
|
result =
|
||||||
Mv.Accounts.read_sign_in_with_rauthy(
|
Mv.Accounts.read_sign_in_with_rauthy(
|
||||||
%{
|
%{
|
||||||
user_info: user_info,
|
user_info: user_info,
|
||||||
|
|
@ -36,6 +36,13 @@ defmodule MvWeb.OidcIntegrationTest do
|
||||||
actor: system_actor
|
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 found_user.id == user.id
|
||||||
assert to_string(found_user.email) == "existing@example.com"
|
assert to_string(found_user.email) == "existing@example.com"
|
||||||
assert found_user.oidc_id == "existing_oidc_123"
|
assert found_user.oidc_id == "existing_oidc_123"
|
||||||
|
|
@ -104,6 +111,9 @@ defmodule MvWeb.OidcIntegrationTest do
|
||||||
{:ok, []} ->
|
{:ok, []} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
{:ok, nil} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
|
@ -129,7 +139,7 @@ defmodule MvWeb.OidcIntegrationTest do
|
||||||
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
{:ok, [found_user]} =
|
result =
|
||||||
Mv.Accounts.read_sign_in_with_rauthy(
|
Mv.Accounts.read_sign_in_with_rauthy(
|
||||||
%{
|
%{
|
||||||
user_info: correct_user_info,
|
user_info: correct_user_info,
|
||||||
|
|
@ -138,6 +148,13 @@ defmodule MvWeb.OidcIntegrationTest do
|
||||||
actor: system_actor
|
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 found_user.id == user.id
|
||||||
|
|
||||||
# Try with wrong oidc_id but correct email
|
# Try with wrong oidc_id but correct email
|
||||||
|
|
@ -155,11 +172,14 @@ defmodule MvWeb.OidcIntegrationTest do
|
||||||
actor: system_actor
|
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
|
case result do
|
||||||
{:ok, []} ->
|
{:ok, []} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
{:ok, nil} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
|
@ -193,11 +213,14 @@ defmodule MvWeb.OidcIntegrationTest do
|
||||||
actor: system_actor
|
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
|
case result do
|
||||||
{:ok, []} ->
|
{:ok, []} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
{:ok, nil} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue