mitgliederverwaltung/lib/accounts/user.ex
Moritz 55f322a09b
Prevent deletion of system actor user
Add destroy validation and explicit destroy action (primary, require_atomic? false).
Validation blocks destroy when email == SystemActor.system_user_email().
2026-01-27 16:08:40 +01:00

512 lines
20 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
resettable do
sender Mv.Accounts.User.Senders.SendPasswordResetEmail
end
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 :destroy do
primary? true
# Required because custom validation (system actor protection) cannot run atomically
require_atomic? false
end
# 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
# Note: Default role is automatically assigned via attribute default (see attributes block)
# 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
# On upsert, only update email - preserve existing role_id
upsert_fields [:email]
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
# Note: Default role is automatically assigned via attribute default (see attributes block)
# upsert_fields [:email] ensures existing users' roles are preserved during upserts
# Sync user email to member when linking (User → Member)
change Mv.EmailSync.Changes.SyncUserEmailToMember
end
end
# Authorization Policies
# Order matters: Most specific policies first, then general permission check
policies do
# AshAuthentication bypass (registration/login without actor)
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
description "Allow AshAuthentication internal operations (registration, login)"
authorize_if always()
end
# READ bypass for list queries (scope :own via expr)
bypass action_type(:read) do
description "Users can always read their own account"
authorize_if expr(id == ^actor(:id))
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"
authorize_if Mv.Authorization.Checks.HasPermission
end
# Default: Ash implicitly forbids if no policy authorizes (fail-closed)
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: Mv.Constants.email_validator_checks()
)
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
# Prevent deletion of the system actor user (required for internal operations)
validate fn changeset, _context ->
if to_string(changeset.data.email) == Mv.Helpers.SystemActor.system_user_email() do
{:error,
field: :email,
message:
"Cannot delete system actor user. This user is required for internal operations."}
else
:ok
end
end,
on: [:destroy]
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
# Role assignment: Explicitly defined to enforce default value
# This ensures every user has a role, regardless of creation path
# (register_with_password, create_user, seeds, etc.)
attribute :role_id, :uuid do
allow_nil? false
default &__MODULE__.default_role_id/0
public? false
end
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
# We define role_id ourselves (above in attributes) to control default value
# Foreign key constraint: on_delete: :restrict (prevents deleting roles assigned to users)
belongs_to :role, Mv.Authorization.Role do
define_attribute? false
source_attribute :role_id
allow_nil? false
end
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
@doc """
Returns the default role ID for new users.
This function is called automatically when creating a user without an explicit role_id.
It fetches the "Mitglied" role from the database without authorization checks
(safe during user creation bootstrap phase).
The result is cached in the process dictionary to avoid repeated database queries
during high-volume user creation. The cache is invalidated on application restart.
## Bootstrap Safety
Only non-nil values are cached. If the role doesn't exist yet (e.g., before seeds run),
`nil` is not cached, allowing subsequent calls to retry after the role is created.
This prevents bootstrap issues where a process would be permanently stuck with `nil`
if the first call happens before the role exists.
## Performance Note
This function makes one database query per process (cached in process dictionary).
For very high-volume scenarios, consider using a fixed UUID from Application config
instead of querying the database.
## Returns
- UUID of the "Mitglied" role if it exists
- `nil` if the role doesn't exist (will cause validation error due to `allow_nil? false`)
## Examples
iex> Mv.Accounts.User.default_role_id()
"019bf2e2-873a-7712-a7ce-a5a1f90c5f4f"
"""
@spec default_role_id() :: Ecto.UUID.t() | nil
def default_role_id do
# Cache in process dictionary to avoid repeated queries
# IMPORTANT: Only cache non-nil values to avoid bootstrap issues.
# If the role doesn't exist yet (e.g., before seeds run), we don't cache nil
# so that subsequent calls can retry after the role is created.
case Process.get({__MODULE__, :default_role_id}) do
nil ->
role_id =
case Mv.Authorization.Role.get_mitglied_role() do
{:ok, %Mv.Authorization.Role{id: id}} -> id
_ -> nil
end
# Only cache non-nil values to allow retry if role is created later
if role_id, do: Process.put({__MODULE__, :default_role_id}, role_id)
role_id
cached_role_id ->
cached_role_id
end
end
end