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 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 # 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 defaults [:read, :create, :destroy] update :update do primary? true require_atomic? false end create :create_user do # 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 ) end update :update_user do # 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 function cannot be done atomically 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 ) 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 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 filter expr(email == get_path(^arg(:user_info), [:preferred_username])) end create :register_with_rauthy do argument :user_info, :map, allow_nil?: false argument :oauth_tokens, :map, allow_nil?: false upsert? true 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) changeset |> Ash.Changeset.change_attribute(:email, user_info["preferred_username"]) |> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"]) end 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) do where action_is([:register_with_password, :admin_set_password]) 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 attribute :email, :ci_string, allow_nil?: false, public?: true 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 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