Remove is_system_role from accept lists in create_role and update_role actions. This field should only be set via seeds or internal actions to prevent users from creating unkillable roles through the public API.
389 lines
15 KiB
Elixir
389 lines
15 KiB
Elixir
defmodule Mv.Accounts.User do
|
|
@moduledoc """
|
|
The ressource for keeping user-specific data related to the login process. It is used by AshAuthentication to handle the Authentication strategies like SSO.
|
|
"""
|
|
use Ash.Resource,
|
|
domain: Mv.Accounts,
|
|
data_layer: AshPostgres.DataLayer,
|
|
extensions: [AshAuthentication]
|
|
|
|
# authorizers: [Ash.Policy.Authorizer]
|
|
|
|
postgres do
|
|
table "users"
|
|
repo Mv.Repo
|
|
|
|
references do
|
|
# When a member is deleted, set the user's member_id to NULL
|
|
# This allows users to continue existing even if their linked member is removed
|
|
reference :member, on_delete: :nilify
|
|
|
|
# When a role is deleted, prevent deletion if users are assigned to it
|
|
# This protects critical roles from accidental deletion
|
|
reference :role, on_delete: :restrict
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
AshAuthentication specific: Defines the strategies we want to use for authentication.
|
|
Currently password and SSO with Rauthy as OIDC provider
|
|
"""
|
|
authentication do
|
|
session_identifier Application.compile_env!(:mv, :session_identifier)
|
|
|
|
tokens do
|
|
enabled? true
|
|
token_resource Mv.Accounts.Token
|
|
|
|
require_token_presence_for_authentication? Application.compile_env!(
|
|
:mv,
|
|
:require_token_presence_for_authentication
|
|
)
|
|
|
|
store_all_tokens? true
|
|
|
|
# signing_algorithm "EdDSA" -> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87
|
|
|
|
signing_secret fn _, _ ->
|
|
{:ok, Application.get_env(:mv, :token_signing_secret)}
|
|
end
|
|
end
|
|
|
|
strategies do
|
|
oidc :rauthy do
|
|
client_id Mv.Secrets
|
|
base_url Mv.Secrets
|
|
redirect_uri Mv.Secrets
|
|
client_secret Mv.Secrets
|
|
auth_method :client_secret_jwt
|
|
code_verifier true
|
|
|
|
# Request email and profile scopes from OIDC provider (required for Authentik, Keycloak, etc.)
|
|
authorization_params scope: "openid email profile"
|
|
|
|
# id_token_signed_response_alg "EdDSA" #-> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87
|
|
end
|
|
|
|
password :password do
|
|
identity_field :email
|
|
hash_provider AshAuthentication.BcryptProvider
|
|
confirmation_required? false
|
|
end
|
|
end
|
|
end
|
|
|
|
actions do
|
|
# Default actions for framework/tooling integration:
|
|
# - :read -> Standard read used across the app and by admin tooling.
|
|
# - :destroy-> Standard delete used by admin tooling and maintenance tasks.
|
|
#
|
|
# NOTE: :create is INTENTIONALLY excluded from defaults!
|
|
# Using a default :create would bypass email-synchronization logic.
|
|
# Always use one of these explicit create actions instead:
|
|
# - :create_user (for manual user creation with optional member link)
|
|
# - :register_with_password (for password-based registration)
|
|
# - :register_with_rauthy (for OIDC-based registration)
|
|
defaults [:read, :destroy]
|
|
|
|
# Primary generic update action:
|
|
# - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix
|
|
# helpers that assume a default update action.
|
|
# - Intended for simple attribute updates (e.g., :email) and scenarios
|
|
# that do NOT need to manage the :member relationship.
|
|
# - For linking/unlinking a member (and the related validations), prefer
|
|
# the specialized :update_user action below.
|
|
update :update do
|
|
primary? true
|
|
|
|
# Required because custom validation functions (email validation, member relationship validation)
|
|
# cannot be executed atomically. These validations need to query the database and perform
|
|
# complex checks that are not supported in atomic operations.
|
|
require_atomic? false
|
|
|
|
# Sync email changes to linked member (User → Member)
|
|
# Only runs when email is being changed
|
|
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
|
where [changing(:email)]
|
|
end
|
|
end
|
|
|
|
create :create_user do
|
|
description "Creates a new user with optional member relationship. The member relationship is managed through the :member argument."
|
|
# Only accept email directly - member_id is NOT in accept list
|
|
# This prevents direct foreign key manipulation, forcing use of manage_relationship
|
|
accept [:email]
|
|
# Allow member to be passed as argument for relationship management
|
|
argument :member, :map, allow_nil?: true
|
|
upsert? true
|
|
|
|
# Manage the member relationship during user creation
|
|
change manage_relationship(:member, :member,
|
|
# Look up existing member and relate to it
|
|
on_lookup: :relate,
|
|
# Error if member doesn't exist in database
|
|
on_no_match: :error,
|
|
# If member already linked to this user, ignore (shouldn't happen in create)
|
|
on_match: :ignore,
|
|
# If no member provided, that's fine (optional relationship)
|
|
on_missing: :ignore
|
|
)
|
|
|
|
# Sync user email to member when linking (User → Member)
|
|
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
|
end
|
|
|
|
update :update_user do
|
|
description "Updates a user and manages the optional member relationship. To change an existing member link, first remove it (set member to nil), then add the new one."
|
|
# Only accept email directly - member_id is NOT in accept list
|
|
# This prevents direct foreign key manipulation, forcing use of manage_relationship
|
|
accept [:email]
|
|
# Allow member to be passed as argument for relationship management
|
|
argument :member, :map, allow_nil?: true
|
|
|
|
# Required because custom validation functions (email validation, member relationship validation)
|
|
# cannot be executed atomically. These validations need to query the database and perform
|
|
# complex checks that are not supported in atomic operations.
|
|
require_atomic? false
|
|
|
|
# Manage the member relationship during user update
|
|
change manage_relationship(:member, :member,
|
|
# Look up existing member and relate to it
|
|
on_lookup: :relate,
|
|
# Error if member doesn't exist in database
|
|
on_no_match: :error,
|
|
# If same member provided, that's fine (allows updates with same member)
|
|
on_match: :ignore,
|
|
# If no member provided, remove existing relationship (allows member removal)
|
|
on_missing: :unrelate
|
|
)
|
|
|
|
# Sync email changes and handle linking (User → Member)
|
|
# Runs when email OR member relationship changes
|
|
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
|
where any([changing(:email), changing(:member)])
|
|
end
|
|
end
|
|
|
|
# Admin action for direct password changes in admin panel
|
|
# Uses the official Ash Authentication HashPasswordChange with correct context
|
|
update :admin_set_password do
|
|
accept [:email]
|
|
argument :password, :string, allow_nil?: false, sensitive?: true
|
|
require_atomic? false
|
|
|
|
# Set the strategy context that HashPasswordChange expects
|
|
change set_context(%{strategy_name: :password})
|
|
|
|
# Use the official Ash Authentication password change
|
|
change AshAuthentication.Strategy.Password.HashPasswordChange
|
|
end
|
|
|
|
# Action to link an OIDC account to an existing password-only user
|
|
# This is called after the user has verified their password
|
|
update :link_oidc_id do
|
|
description "Links an OIDC ID to an existing user after password verification"
|
|
accept []
|
|
argument :oidc_id, :string, allow_nil?: false
|
|
argument :oidc_user_info, :map, allow_nil?: false
|
|
require_atomic? false
|
|
|
|
change fn changeset, _ctx ->
|
|
oidc_id = Ash.Changeset.get_argument(changeset, :oidc_id)
|
|
oidc_user_info = Ash.Changeset.get_argument(changeset, :oidc_user_info)
|
|
|
|
# Get the new email from OIDC user_info
|
|
# Support both "email" (standard OIDC) and "preferred_username" (Rauthy)
|
|
new_email =
|
|
Map.get(oidc_user_info, "email") || Map.get(oidc_user_info, "preferred_username")
|
|
|
|
changeset
|
|
|> Ash.Changeset.change_attribute(:oidc_id, oidc_id)
|
|
# Update email if it differs from OIDC provider
|
|
# change_attribute/3 already checks if value matches existing value
|
|
|> then(fn cs ->
|
|
if new_email do
|
|
Ash.Changeset.change_attribute(cs, :email, new_email)
|
|
else
|
|
cs
|
|
end
|
|
end)
|
|
end
|
|
|
|
# Sync email changes to member if email was updated
|
|
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
|
where [changing(:email)]
|
|
end
|
|
end
|
|
|
|
read :get_by_subject do
|
|
description "Get a user by the subject claim in a JWT"
|
|
argument :subject, :string, allow_nil?: false
|
|
get? true
|
|
prepare AshAuthentication.Preparations.FilterBySubject
|
|
end
|
|
|
|
read :sign_in_with_rauthy do
|
|
argument :user_info, :map, allow_nil?: false
|
|
argument :oauth_tokens, :map, allow_nil?: false
|
|
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
|
|
|
|
# SECURITY: Filter by oidc_id, NOT by email!
|
|
# This ensures that OIDC sign-in only works for users who have already
|
|
# 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]))
|
|
end
|
|
|
|
create :register_with_rauthy do
|
|
argument :user_info, :map, allow_nil?: false
|
|
argument :oauth_tokens, :map, allow_nil?: false
|
|
upsert? true
|
|
# Upsert based on oidc_id (primary match for existing OIDC users)
|
|
upsert_identity :unique_oidc_id
|
|
|
|
validate &__MODULE__.validate_oidc_id_present/2
|
|
|
|
change AshAuthentication.GenerateTokenChange
|
|
|
|
change fn changeset, _ctx ->
|
|
user_info = Ash.Changeset.get_argument(changeset, :user_info)
|
|
|
|
# Support both "email" (standard OIDC like Authentik, Keycloak) and "preferred_username" (Rauthy)
|
|
email = user_info["email"] || user_info["preferred_username"]
|
|
|
|
changeset
|
|
|> Ash.Changeset.change_attribute(:email, email)
|
|
|> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"])
|
|
end
|
|
|
|
# Check for email collisions with existing accounts
|
|
# This validation must run AFTER email and oidc_id are set above
|
|
# - Raises PasswordVerificationRequired for password-protected OR passwordless users
|
|
# - The LinkOidcAccountLive will auto-link passwordless users without password prompt
|
|
validate Mv.Accounts.User.Validations.OidcEmailCollision
|
|
|
|
# Sync user email to member when linking (User → Member)
|
|
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
|
end
|
|
end
|
|
|
|
# Global validations - applied to all relevant actions
|
|
validations do
|
|
# Password strength policy: minimum 8 characters for all password-related actions
|
|
validate string_length(:password, min: 8),
|
|
where: [action_is([:register_with_password, :admin_set_password])],
|
|
message: "must have length of at least 8"
|
|
|
|
# Email uniqueness check for all actions that change the email attribute
|
|
# Validates that user email is not already used by another (unlinked) member
|
|
validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember
|
|
|
|
# Email validation with EctoCommons.EmailValidator (same as Member)
|
|
# This ensures consistency between User and Member email validation
|
|
validate fn changeset, _ ->
|
|
# Get email from attribute (Ash.CiString) and convert to string
|
|
email = Ash.Changeset.get_attribute(changeset, :email)
|
|
email_string = if email, do: to_string(email), else: nil
|
|
|
|
# Only validate if email is present
|
|
if email_string do
|
|
changeset2 =
|
|
{%{}, %{email: :string}}
|
|
|> Ecto.Changeset.cast(%{email: email_string}, [:email])
|
|
|> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow])
|
|
|
|
if changeset2.valid? do
|
|
:ok
|
|
else
|
|
{:error, field: :email, message: "is not a valid email"}
|
|
end
|
|
else
|
|
:ok
|
|
end
|
|
end
|
|
|
|
# Prevent overwriting existing member relationship
|
|
# This validation ensures race condition safety by requiring explicit two-step process:
|
|
# 1. Remove existing member (set member to nil)
|
|
# 2. Add new member
|
|
# This prevents accidental overwrites when multiple admins work simultaneously
|
|
validate fn changeset, _context ->
|
|
member_arg = Ash.Changeset.get_argument(changeset, :member)
|
|
current_member_id = changeset.data.member_id
|
|
|
|
# Only trigger if:
|
|
# - member argument is provided AND has an ID
|
|
# - user currently has a member
|
|
# - the new member ID is different from current member ID
|
|
if member_arg && member_arg[:id] && current_member_id &&
|
|
member_arg[:id] != current_member_id do
|
|
{:error,
|
|
field: :member, message: "User already has a member. Remove existing member first."}
|
|
else
|
|
:ok
|
|
end
|
|
end
|
|
end
|
|
|
|
def validate_oidc_id_present(changeset, _context) do
|
|
user_info = Ash.Changeset.get_argument(changeset, :user_info) || %{}
|
|
|
|
if is_binary(user_info["sub"]) or is_binary(user_info["id"]) do
|
|
:ok
|
|
else
|
|
{:error, [user_info: "OIDC user_info must contain a non-empty 'sub' or 'id' field"]}
|
|
end
|
|
end
|
|
|
|
attributes do
|
|
uuid_primary_key :id
|
|
|
|
# IMPORTANT: Email Synchronization
|
|
# When user and member are linked, emails are automatically synced bidirectionally.
|
|
# User.email is the source of truth - when a link is established, member.email
|
|
# is overridden to match user.email. Subsequent changes to either email will
|
|
# sync to the other resource.
|
|
# See: Mv.EmailSync.Changes.SyncUserEmailToMember
|
|
# Mv.EmailSync.Changes.SyncMemberEmailToUser
|
|
attribute :email, :ci_string do
|
|
allow_nil? false
|
|
public? true
|
|
# Same constraints as Member email for consistency
|
|
constraints min_length: 5, max_length: 254
|
|
end
|
|
|
|
attribute :hashed_password, :string, sensitive?: true, allow_nil?: true
|
|
attribute :oidc_id, :string, allow_nil?: true
|
|
end
|
|
|
|
relationships do
|
|
# 1:1 relationship - User can optionally belong to one Member
|
|
# This automatically creates a `member_id` attribute in the User table
|
|
# The relationship is optional (allow_nil? true by default)
|
|
belongs_to :member, Mv.Membership.Member
|
|
|
|
# 1:1 relationship - User belongs to a Role
|
|
# This automatically creates a `role_id` attribute in the User table
|
|
# The relationship is optional (allow_nil? true by default)
|
|
# Foreign key constraint: on_delete: :restrict (prevents deleting roles assigned to users)
|
|
belongs_to :role, Mv.Authorization.Role
|
|
end
|
|
|
|
identities do
|
|
identity :unique_email, [:email]
|
|
identity :unique_oidc_id, [:oidc_id]
|
|
identity :unique_member, [:member_id]
|
|
end
|
|
|
|
# You can customize this if you wish, but this is a safe default that
|
|
# only allows user data to be interacted with via AshAuthentication.
|
|
# policies do
|
|
# bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
|
# authorize_if(always())
|
|
# end
|
|
|
|
# policy always() do
|
|
# forbid_if(always())
|
|
# end
|
|
# end
|
|
end
|