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] require Ash.Query import Ash.Expr 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 accept [:email] # 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." # Accept email and role_id (role_id only used by admins; policy restricts update_user to admins). # member_id is NOT in accept list - use argument :member for relationship management. accept [:email, :role_id] # 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 # Internal update used only by SystemActor/bootstrap and tests to assign role to system user. # Not protected by system-user validation so bootstrap can run. update :update_internal do accept [] 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 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 # Sync email changes to linked member when email is changed (e.g. form changes both) change Mv.EmailSync.Changes.SyncUserEmailToMember do where [changing(:email)] end 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 # Single record expected; required for AshAuthentication OAuth2 strategy (returns list of 0 or 1). get? true 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])) # Sync role from OIDC groups after sign-in (e.g. admin group → Admin role) # get? true can return nil, a single %User{}, or a list; normalize to list for Enum.each prepare Ash.Resource.Preparation.Builtins.after_action(fn query, result, _context -> user_info = Ash.Query.get_argument(query, :user_info) || %{} oauth_tokens = Ash.Query.get_argument(query, :oauth_tokens) || %{} users = case result do nil -> [] u when is_struct(u, User) -> [u] list when is_list(list) -> list _ -> [] end Enum.each(users, fn user -> Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens) end) {:ok, result} end) 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 # 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) oauth_tokens = Ash.Changeset.get_argument(changeset, :oauth_tokens) || %{} Ash.Changeset.after_action(changeset, fn _cs, record -> Mv.OidcRoleSync.apply_admin_role_from_user_info(record, user_info, oauth_tokens) # Return original record so __metadata__.token (from GenerateTokenChange) is preserved {:ok, record} end) end 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_user allows :member argument (link/unlink). Only admins may use it to prevent # privilege escalation (own_data could otherwise link to any member and get :linked scope). policy action(:update_user) do description "Only admins can update user with member link/unlink" forbid_unless Mv.Authorization.Checks.ActorIsAdmin 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; only allowed when context.private.oidc_role_sync is set. bypass action(:set_role_from_oidc_sync) do description "Internal: OIDC role sync (server-side only)" authorize_if Mv.Authorization.Checks.OidcRoleSyncContext 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 # Last-admin: prevent the only admin from leaving the admin role (at least one admin required). # Only block when the user is leaving admin (target role is not admin). Switching between # two admin roles (e.g. "Admin" and "Superadmin" both with permission_set_name "admin") is allowed. validate fn changeset, _context -> if Ash.Changeset.changing_attribute?(changeset, :role_id) do new_role_id = Ash.Changeset.get_attribute(changeset, :role_id) if is_nil(new_role_id) do :ok else current_role_id = changeset.data.role_id current_role = Mv.Authorization.Role |> Ash.get!(current_role_id, authorize?: false) new_role = Mv.Authorization.Role |> Ash.get!(new_role_id, authorize?: false) # Only block when current user is admin and target role is not admin (leaving admin) if current_role.permission_set_name == "admin" and new_role.permission_set_name != "admin" do admin_role_ids = Mv.Authorization.Role |> Ash.Query.for_read(:read) |> Ash.Query.filter(expr(permission_set_name == "admin")) |> Ash.read!(authorize?: false) |> Enum.map(& &1.id) # Count only non-system users with admin role (system user is for internal ops) system_email = Mv.Helpers.SystemActor.system_user_email() count = Mv.Accounts.User |> Ash.Query.for_read(:read) |> Ash.Query.filter(expr(role_id in ^admin_role_ids)) |> Ash.Query.filter(expr(email != ^system_email)) |> Ash.count!(authorize?: false) if count <= 1 do {:error, field: :role_id, message: "At least one user must keep the Admin role."} else :ok end else :ok end end else :ok end end, on: [:update], 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. validate fn changeset, _context -> if Mv.Helpers.SystemActor.system_user?(changeset.data) do {:error, field: :email, message: "Cannot modify system actor user. This user is required for internal operations."} else :ok end end, on: [:update, :destroy], where: [action_is([:update, :update_user, :admin_set_password, :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