Add OidcRoleSync: apply Admin/Mitglied from OIDC groups

Register and sign-in call apply_admin_role_from_user_info; users in configured
admin group get Admin role, others get Mitglied. Internal User action + bypass policy.
This commit is contained in:
Moritz 2026-02-04 16:18:18 +01:00 committed by moritz
parent a6e35da0f7
commit 99722dee26
4 changed files with 302 additions and 1 deletions

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
@ -260,6 +267,17 @@ defmodule Mv.Accounts.User do
# linked their account via OIDC. Password-only users (oidc_id = nil)
# cannot be accessed via OIDC login without password verification.
filter expr(oidc_id == get_path(^arg(:user_info), [:sub]))
# Sync role from OIDC groups after sign-in (e.g. admin group → Admin role)
prepare Ash.Resource.Preparation.Builtins.after_action(fn query, records, _context ->
user_info = Ash.Query.get_argument(query, :user_info) || %{}
Enum.each(records, fn user ->
Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info)
end)
{:ok, records}
end)
end
create :register_with_rauthy do
@ -297,6 +315,16 @@ defmodule Mv.Accounts.User do
# Sync user email to member when linking (User → Member)
change Mv.EmailSync.Changes.SyncUserEmailToMember
# Sync role from OIDC groups (e.g. admin group → Admin role) after user is created/updated
change fn changeset, _ctx ->
user_info = Ash.Changeset.get_argument(changeset, :user_info)
Ash.Changeset.after_action(changeset, fn _cs, record ->
Mv.OidcRoleSync.apply_admin_role_from_user_info(record, user_info)
{:ok, Ash.get!(__MODULE__, record.id, authorize?: false, domain: Mv.Accounts)}
end)
end
end
end
@ -323,6 +351,13 @@ defmodule Mv.Accounts.User do
authorize_if Mv.Authorization.Checks.ActorIsAdmin
end
# set_role_from_oidc_sync: internal only (called from Mv.OidcRoleSync on registration/sign-in).
# Not exposed in code_interface; must never be callable by clients.
bypass action(:set_role_from_oidc_sync) do
description "Internal: OIDC role sync (server-side only)"
authorize_if always()
end
# UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope)
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role and permission set"
@ -446,7 +481,7 @@ defmodule Mv.Accounts.User do
end
end,
on: [:update],
where: [action_is(:update_user)]
where: [action_is([:update_user, :set_role_from_oidc_sync])]
# Prevent modification of the system actor user (required for internal operations).
# Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests.

View file

@ -0,0 +1,22 @@
defmodule Mv.Authorization.Checks.OidcRoleSyncContext do
@moduledoc """
Policy check: true when the action is being run from OIDC role sync (context.private.oidc_role_sync).
Used to allow the internal set_role_from_oidc_sync action when called by Mv.OidcRoleSync
without an actor.
"""
use Ash.Policy.SimpleCheck
@impl true
def describe(_opts), do: "called from OIDC role sync (context.private.oidc_role_sync)"
@impl true
def match?(_actor, authorizer, _opts) do
# Context from opts (e.g. Ash.update!(..., context: %{private: %{oidc_role_sync: true}}))
context = Map.get(authorizer, :context) || %{}
from_context = get_in(context, [:private, :oidc_role_sync]) == true
# When update runs inside create's after_action, context may not be passed; use process dict.
from_process = Process.get(:oidc_role_sync) == true
from_context or from_process
end
end

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

@ -0,0 +1,82 @@
defmodule Mv.OidcRoleSync do
@moduledoc """
Syncs user role from OIDC user_info (e.g. groups claim Admin role).
Used after OIDC registration (register_with_rauthy) and on sign-in so that
users in the configured admin group get the Admin role; others get Mitglied.
Configure via OIDC_ADMIN_GROUP_NAME and OIDC_GROUPS_CLAIM (see OidcRoleSyncConfig).
"""
alias Mv.Accounts.User
alias Mv.Authorization.Role
alias Mv.OidcRoleSyncConfig
@doc """
Applies Admin or Mitglied role to the user based on OIDC user_info (groups claim).
- If OIDC_ADMIN_GROUP_NAME is not configured: no-op, returns :ok without changing the user.
- If user_info contains the configured admin group (under OIDC_GROUPS_CLAIM): assigns Admin role.
- Otherwise: assigns Mitglied role (downgrade if user was Admin).
user_info is a map (e.g. from JWT claims) and may use string keys. Groups can be
a list of strings or a single string.
## Examples
user_info = %{"groups" => ["mila-admin"]}
OidcRoleSync.apply_admin_role_from_user_info(user, user_info)
user_info = %{"ak_groups" => ["other"]} # with OIDC_GROUPS_CLAIM=ak_groups
OidcRoleSync.apply_admin_role_from_user_info(user, user_info)
"""
@spec apply_admin_role_from_user_info(User.t(), map()) :: :ok
def apply_admin_role_from_user_info(user, user_info) when is_map(user_info) do
admin_group = OidcRoleSyncConfig.oidc_admin_group_name()
if is_nil(admin_group) or admin_group == "" do
:ok
else
claim = OidcRoleSyncConfig.oidc_groups_claim()
groups = groups_from_user_info(user_info, claim)
target_role = if admin_group in groups, do: :admin, else: :mitglied
set_user_role(user, target_role)
end
end
defp groups_from_user_info(user_info, claim) do
case user_info[claim] do
nil -> []
list when is_list(list) -> Enum.map(list, &to_string/1)
single when is_binary(single) -> [single]
_ -> []
end
end
defp set_user_role(user, :admin) do
case Role.get_admin_role() do
{:ok, %Role{} = role} ->
do_set_role(user, role)
_ ->
:ok
end
end
defp set_user_role(user, :mitglied) do
case Role.get_mitglied_role() do
{:ok, %Role{} = role} ->
do_set_role(user, role)
_ ->
:ok
end
end
defp do_set_role(user, role) do
user
|> Ash.Changeset.for_update(:set_role_from_oidc_sync, %{role_id: role.id})
|> Ash.Changeset.set_context(%{private: %{oidc_role_sync: true}})
|> Ash.update!(domain: Mv.Accounts, context: %{private: %{oidc_role_sync: true}})
:ok
end
end