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:
parent
a527ef980c
commit
cae814b967
4 changed files with 302 additions and 1 deletions
|
|
@ -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
|
||||||
|
|
@ -260,6 +267,17 @@ 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)
|
||||||
|
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
|
end
|
||||||
|
|
||||||
create :register_with_rauthy do
|
create :register_with_rauthy do
|
||||||
|
|
@ -297,6 +315,16 @@ 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)
|
||||||
|
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -323,6 +351,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; 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)
|
# 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 +481,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.
|
||||||
|
|
|
||||||
22
lib/mv/authorization/checks/oidc_role_sync_context.ex
Normal file
22
lib/mv/authorization/checks/oidc_role_sync_context.ex
Normal 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
82
lib/mv/oidc_role_sync.ex
Normal 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
|
||||||
162
test/mv/oidc_role_sync_test.exs
Normal file
162
test/mv/oidc_role_sync_test.exs
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue