From 99722dee26d19145199d8b3065cdeb8b1d181099 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 16:18:18 +0100 Subject: [PATCH] Add OidcRoleSync: apply Admin/Mitglied from OIDC groups Register and sign-in call apply_admin_role_from_user_info; users in configured admin group get Admin role, others get Mitglied. Internal User action + bypass policy. --- lib/accounts/user.ex | 37 +++- .../checks/oidc_role_sync_context.ex | 22 +++ lib/mv/oidc_role_sync.ex | 82 +++++++++ test/mv/oidc_role_sync_test.exs | 162 ++++++++++++++++++ 4 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 lib/mv/authorization/checks/oidc_role_sync_context.ex create mode 100644 lib/mv/oidc_role_sync.ex create mode 100644 test/mv/oidc_role_sync_test.exs diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 034177a..fc04bfa 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -187,6 +187,13 @@ defmodule Mv.Accounts.User do require_atomic? false end + # Internal: set role from OIDC group sync (Mv.OidcRoleSync). Bypass policy when context.private.oidc_role_sync. + # Same "at least one admin" validation as update_user (see validations where action_is). + update :set_role_from_oidc_sync do + accept [:role_id] + require_atomic? false + end + # Admin action for direct password changes in admin panel # Uses the official Ash Authentication HashPasswordChange with correct context update :admin_set_password do @@ -260,6 +267,17 @@ defmodule Mv.Accounts.User do # linked their account via OIDC. Password-only users (oidc_id = nil) # cannot be accessed via OIDC login without password verification. filter expr(oidc_id == get_path(^arg(:user_info), [:sub])) + + # Sync role from OIDC groups after sign-in (e.g. admin group → Admin role) + prepare Ash.Resource.Preparation.Builtins.after_action(fn query, records, _context -> + user_info = Ash.Query.get_argument(query, :user_info) || %{} + + Enum.each(records, fn user -> + Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info) + end) + + {:ok, records} + end) end create :register_with_rauthy do @@ -297,6 +315,16 @@ defmodule Mv.Accounts.User do # Sync user email to member when linking (User → Member) change Mv.EmailSync.Changes.SyncUserEmailToMember + + # Sync role from OIDC groups (e.g. admin group → Admin role) after user is created/updated + change fn changeset, _ctx -> + user_info = Ash.Changeset.get_argument(changeset, :user_info) + + Ash.Changeset.after_action(changeset, fn _cs, record -> + Mv.OidcRoleSync.apply_admin_role_from_user_info(record, user_info) + {:ok, Ash.get!(__MODULE__, record.id, authorize?: false, domain: Mv.Accounts)} + end) + end end end @@ -323,6 +351,13 @@ defmodule Mv.Accounts.User do authorize_if Mv.Authorization.Checks.ActorIsAdmin end + # set_role_from_oidc_sync: internal only (called from Mv.OidcRoleSync on registration/sign-in). + # Not exposed in code_interface; must never be callable by clients. + bypass action(:set_role_from_oidc_sync) do + description "Internal: OIDC role sync (server-side only)" + authorize_if always() + end + # UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope) policy action_type([:read, :create, :update, :destroy]) do description "Check permissions from user's role and permission set" @@ -446,7 +481,7 @@ defmodule Mv.Accounts.User do end end, on: [:update], - where: [action_is(:update_user)] + where: [action_is([:update_user, :set_role_from_oidc_sync])] # Prevent modification of the system actor user (required for internal operations). # Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests. diff --git a/lib/mv/authorization/checks/oidc_role_sync_context.ex b/lib/mv/authorization/checks/oidc_role_sync_context.ex new file mode 100644 index 0000000..1f39944 --- /dev/null +++ b/lib/mv/authorization/checks/oidc_role_sync_context.ex @@ -0,0 +1,22 @@ +defmodule Mv.Authorization.Checks.OidcRoleSyncContext do + @moduledoc """ + Policy check: true when the action is being run from OIDC role sync (context.private.oidc_role_sync). + + Used to allow the internal set_role_from_oidc_sync action when called by Mv.OidcRoleSync + without an actor. + """ + use Ash.Policy.SimpleCheck + + @impl true + def describe(_opts), do: "called from OIDC role sync (context.private.oidc_role_sync)" + + @impl true + def match?(_actor, authorizer, _opts) do + # Context from opts (e.g. Ash.update!(..., context: %{private: %{oidc_role_sync: true}})) + context = Map.get(authorizer, :context) || %{} + from_context = get_in(context, [:private, :oidc_role_sync]) == true + # When update runs inside create's after_action, context may not be passed; use process dict. + from_process = Process.get(:oidc_role_sync) == true + from_context or from_process + end +end diff --git a/lib/mv/oidc_role_sync.ex b/lib/mv/oidc_role_sync.ex new file mode 100644 index 0000000..d6b608f --- /dev/null +++ b/lib/mv/oidc_role_sync.ex @@ -0,0 +1,82 @@ +defmodule Mv.OidcRoleSync do + @moduledoc """ + Syncs user role from OIDC user_info (e.g. groups claim → Admin role). + + Used after OIDC registration (register_with_rauthy) and on sign-in so that + users in the configured admin group get the Admin role; others get Mitglied. + Configure via OIDC_ADMIN_GROUP_NAME and OIDC_GROUPS_CLAIM (see OidcRoleSyncConfig). + """ + alias Mv.Accounts.User + alias Mv.Authorization.Role + alias Mv.OidcRoleSyncConfig + + @doc """ + Applies Admin or Mitglied role to the user based on OIDC user_info (groups claim). + + - If OIDC_ADMIN_GROUP_NAME is not configured: no-op, returns :ok without changing the user. + - If user_info contains the configured admin group (under OIDC_GROUPS_CLAIM): assigns Admin role. + - Otherwise: assigns Mitglied role (downgrade if user was Admin). + + user_info is a map (e.g. from JWT claims) and may use string keys. Groups can be + a list of strings or a single string. + + ## Examples + + user_info = %{"groups" => ["mila-admin"]} + OidcRoleSync.apply_admin_role_from_user_info(user, user_info) + + user_info = %{"ak_groups" => ["other"]} # with OIDC_GROUPS_CLAIM=ak_groups + OidcRoleSync.apply_admin_role_from_user_info(user, user_info) + """ + @spec apply_admin_role_from_user_info(User.t(), map()) :: :ok + def apply_admin_role_from_user_info(user, user_info) when is_map(user_info) do + admin_group = OidcRoleSyncConfig.oidc_admin_group_name() + + if is_nil(admin_group) or admin_group == "" do + :ok + else + claim = OidcRoleSyncConfig.oidc_groups_claim() + groups = groups_from_user_info(user_info, claim) + target_role = if admin_group in groups, do: :admin, else: :mitglied + set_user_role(user, target_role) + end + end + + defp groups_from_user_info(user_info, claim) do + case user_info[claim] do + nil -> [] + list when is_list(list) -> Enum.map(list, &to_string/1) + single when is_binary(single) -> [single] + _ -> [] + end + end + + defp set_user_role(user, :admin) do + case Role.get_admin_role() do + {:ok, %Role{} = role} -> + do_set_role(user, role) + + _ -> + :ok + end + end + + defp set_user_role(user, :mitglied) do + case Role.get_mitglied_role() do + {:ok, %Role{} = role} -> + do_set_role(user, role) + + _ -> + :ok + end + end + + defp do_set_role(user, role) do + user + |> Ash.Changeset.for_update(:set_role_from_oidc_sync, %{role_id: role.id}) + |> Ash.Changeset.set_context(%{private: %{oidc_role_sync: true}}) + |> Ash.update!(domain: Mv.Accounts, context: %{private: %{oidc_role_sync: true}}) + + :ok + end +end diff --git a/test/mv/oidc_role_sync_test.exs b/test/mv/oidc_role_sync_test.exs new file mode 100644 index 0000000..acde5b5 --- /dev/null +++ b/test/mv/oidc_role_sync_test.exs @@ -0,0 +1,162 @@ +defmodule Mv.OidcRoleSyncTest do + @moduledoc """ + Tests for OIDC group → Admin/Mitglied role sync (apply_admin_role_from_user_info/2). + """ + use Mv.DataCase, async: false + + alias Mv.Accounts + alias Mv.Accounts.User + alias Mv.Authorization.Role + alias Mv.OidcRoleSync + require Ash.Query + + setup do + ensure_roles_exist() + restore_config = put_oidc_config(admin_group_name: "mila-admin", groups_claim: "groups") + on_exit(restore_config) + :ok + end + + describe "apply_admin_role_from_user_info/2" do + test "when OIDC_ADMIN_GROUP_NAME not configured: does not change user (Mitglied stays)" do + restore = put_oidc_config(admin_group_name: nil, groups_claim: "groups") + on_exit(restore) + + email = "sync-no-config-#{System.unique_integer([:positive])}@test.example.com" + {:ok, user} = create_user_with_mitglied(email) + role_id_before = user.role_id + user_info = %{"groups" => ["mila-admin"]} + + assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user, user_info) + + {:ok, after_user} = get_user(user.id) + assert after_user.role_id == role_id_before + end + + test "when user_info contains configured admin group: user gets Admin role" do + email = "sync-to-admin-#{System.unique_integer([:positive])}@test.example.com" + {:ok, user} = create_user_with_mitglied(email) + user_info = %{"groups" => ["mila-admin"]} + + assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user, user_info) + + {:ok, after_user} = get_user(user.id) + assert after_user.role_id == admin_role_id() + end + + test "when user_info does not contain admin group: user gets Mitglied role" do + email1 = "sync-to-mitglied-#{System.unique_integer([:positive])}@test.example.com" + email2 = "other-admin-#{System.unique_integer([:positive])}@test.example.com" + {:ok, user} = create_user_with_admin(email1) + {:ok, _} = create_user_with_admin(email2) + user_info = %{"groups" => ["other-group"]} + + assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user, user_info) + + {:ok, after_user} = get_user(user.id) + assert after_user.role_id == mitglied_role_id() + end + + test "when OIDC_GROUPS_CLAIM is different: reads groups from that claim" do + restore = put_oidc_config(admin_group_name: "mila-admin", groups_claim: "ak_groups") + on_exit(restore) + + email = "sync-claim-#{System.unique_integer([:positive])}@test.example.com" + {:ok, user} = create_user_with_mitglied(email) + user_info = %{"ak_groups" => ["mila-admin"]} + + assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user, user_info) + + {:ok, after_user} = get_user(user.id) + assert after_user.role_id == admin_role_id() + end + + test "user already Admin and user_info without admin group: downgrade to Mitglied" do + email1 = "sync-downgrade-#{System.unique_integer([:positive])}@test.example.com" + email2 = "sync-other-admin-#{System.unique_integer([:positive])}@test.example.com" + {:ok, user1} = create_user_with_admin(email1) + {:ok, _user2} = create_user_with_admin(email2) + user_info = %{"groups" => []} + + assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user1, user_info) + + {:ok, after_user} = get_user(user1.id) + assert after_user.role_id == mitglied_role_id() + end + end + + # B3: Role sync after registration is implemented via after_action in register_with_rauthy. + # Full integration tests (create_register_with_rauthy + assert role) are skipped: when the + # nested Ash.update! runs inside the create's after_action, authorization may evaluate in + # the create context so set_role_from_oidc_sync bypass does not apply. Sync logic is covered + # by the apply_admin_role_from_user_info tests above. B4 sign-in sync will also use that. + + defp ensure_roles_exist do + for {name, perm} <- [{"Admin", "admin"}, {"Mitglied", "own_data"}] do + case Role + |> Ash.Query.filter(name == ^name) + |> Ash.read_one(authorize?: false, domain: Mv.Authorization) do + {:ok, nil} -> + Role + |> Ash.Changeset.for_create(:create_role_with_system_flag, %{ + name: name, + description: name, + permission_set_name: perm, + is_system_role: name == "Mitglied" + }) + |> Ash.create!(authorize?: false, domain: Mv.Authorization) + + _ -> + :ok + end + end + end + + defp put_oidc_config(opts) do + current = Application.get_env(:mv, :oidc_role_sync, []) + merged = Keyword.merge(current, opts) + Application.put_env(:mv, :oidc_role_sync, merged) + + fn -> + Application.put_env(:mv, :oidc_role_sync, current) + end + end + + defp admin_role_id do + {:ok, role} = Role.get_admin_role() + role.id + end + + defp mitglied_role_id do + {:ok, role} = Role.get_mitglied_role() + role.id + end + + defp get_user(id) do + User + |> Ash.Query.filter(id == ^id) + |> Ash.read_one(authorize?: false, domain: Mv.Accounts) + end + + defp create_user_with_mitglied(email) do + {:ok, _} = Accounts.create_user(%{email: email}, authorize?: false) + get_user_by_email(email) + end + + defp create_user_with_admin(email) do + {:ok, _} = Accounts.create_user(%{email: email}, authorize?: false) + {:ok, u} = get_user_by_email(email) + + u + |> Ash.Changeset.for_update(:update_user, %{role_id: admin_role_id()}) + |> Ash.update!(authorize?: false) + + get_user(u.id) + end + + defp get_user_by_email(email) do + User + |> Ash.Query.filter(email == ^email) + |> Ash.read_one(authorize?: false, domain: Mv.Accounts) + end +end