diff --git a/config/config.exs b/config/config.exs index 5fcfcf5..cc338b2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -49,7 +49,7 @@ config :spark, config :mv, ecto_repos: [Mv.Repo], generators: [timestamp_type: :utc_datetime], - ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees] + ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees, Mv.Authorization] # Configures the endpoint config :mv, MvWeb.Endpoint, diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index dbc62b2..ceedeae 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -17,6 +17,10 @@ defmodule Mv.Accounts.User 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 @@ -357,6 +361,12 @@ defmodule Mv.Accounts.User do # 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 diff --git a/lib/mv/authorization/authorization.ex b/lib/mv/authorization/authorization.ex new file mode 100644 index 0000000..aac07a9 --- /dev/null +++ b/lib/mv/authorization/authorization.ex @@ -0,0 +1,31 @@ +defmodule Mv.Authorization do + @moduledoc """ + Ash Domain for authorization and role management. + + ## Resources + - `Role` - User roles that reference permission sets + + ## Public API + The domain exposes these main actions: + - Role CRUD: `create_role/1`, `list_roles/0`, `update_role/2`, `destroy_role/1` + + ## Admin Interface + The domain is configured with AshAdmin for management UI. + """ + use Ash.Domain, + extensions: [AshAdmin.Domain, AshPhoenix] + + admin do + show? true + end + + resources do + resource Mv.Authorization.Role do + define :create_role, action: :create_role + define :list_roles, action: :read + define :get_role, action: :read, get_by: [:id] + define :update_role, action: :update_role + define :destroy_role, action: :destroy + end + end +end diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex new file mode 100644 index 0000000..d01e285 --- /dev/null +++ b/lib/mv/authorization/permission_sets.ex @@ -0,0 +1,34 @@ +defmodule Mv.Authorization.PermissionSets do + @moduledoc """ + Defines the four hardcoded permission sets for the application. + + This is a minimal stub implementation. The full implementation + with all permission details will be added in a subsequent issue. + + ## Permission Sets + + 1. **own_data** - Default for "Mitglied" role + 2. **read_only** - For "Vorstand" and "Buchhaltung" roles + 3. **normal_user** - For "Kassenwart" role + 4. **admin** - For "Admin" role + + ## Usage + + # Get list of all valid permission set names + PermissionSets.all_permission_sets() + # => [:own_data, :read_only, :normal_user, :admin] + """ + + @doc """ + Returns the list of all valid permission set names. + + ## Examples + + iex> PermissionSets.all_permission_sets() + [:own_data, :read_only, :normal_user, :admin] + """ + @spec all_permission_sets() :: [atom()] + def all_permission_sets do + [:own_data, :read_only, :normal_user, :admin] + end +end diff --git a/lib/mv/authorization/role.ex b/lib/mv/authorization/role.ex new file mode 100644 index 0000000..da43510 --- /dev/null +++ b/lib/mv/authorization/role.ex @@ -0,0 +1,142 @@ +defmodule Mv.Authorization.Role do + @moduledoc """ + Represents a user role that references a permission set. + + Roles are stored in the database and link users to permission sets. + Each role has a `permission_set_name` that references one of the four + hardcoded permission sets defined in `Mv.Authorization.PermissionSets`. + + ## Fields + + - `name` - Unique role name (e.g., "Vorstand", "Admin") + - `description` - Human-readable description of the role + - `permission_set_name` - Must be one of: "own_data", "read_only", "normal_user", "admin" + - `is_system_role` - If true, role cannot be deleted (protects critical roles like "Mitglied") + + ## Relationships + + - `has_many :users` - Users assigned to this role + + ## Validations + + - `permission_set_name` must be a valid permission set (checked against PermissionSets.all_permission_sets/0) + - `name` must be unique + - System roles cannot be deleted (enforced via validation) + + ## Examples + + # Create a new role + {:ok, role} = Mv.Authorization.create_role(%{ + name: "Vorstand", + description: "Board member with read access", + permission_set_name: "read_only" + }) + + # List all roles + {:ok, roles} = Mv.Authorization.list_roles() + """ + use Ash.Resource, + domain: Mv.Authorization, + data_layer: AshPostgres.DataLayer + + postgres do + table "roles" + repo Mv.Repo + + references do + # Prevent deletion of roles that are assigned to users + reference :users, on_delete: :restrict + end + end + + code_interface do + define :create_role + define :list_roles, action: :read + define :update_role + define :destroy_role, action: :destroy + end + + actions do + defaults [:read] + + create :create_role do + primary? true + # is_system_role is intentionally excluded - should only be set via seeds/internal actions + accept [:name, :description, :permission_set_name] + # Note: In Ash 3.0, require_atomic? is not available for create actions + # Custom validations will still work + end + + update :update_role do + primary? true + # is_system_role is intentionally excluded - should only be set via seeds/internal actions + accept [:name, :description, :permission_set_name] + # Required because custom validation functions cannot be executed atomically + require_atomic? false + end + + destroy :destroy do + # Required because custom validation functions cannot be executed atomically + require_atomic? false + end + end + + validations do + validate one_of( + :permission_set_name, + Mv.Authorization.PermissionSets.all_permission_sets() + |> Enum.map(&Atom.to_string/1) + ), + message: + "must be one of: #{Mv.Authorization.PermissionSets.all_permission_sets() |> Enum.map_join(", ", &Atom.to_string/1)}" + + validate fn changeset, _context -> + if changeset.data.is_system_role do + {:error, + field: :is_system_role, + message: + "Cannot delete system role. System roles are required for the application to function."} + else + :ok + end + end, + on: [:destroy] + end + + attributes do + uuid_v7_primary_key :id + + attribute :name, :string do + allow_nil? false + public? true + end + + attribute :description, :string do + allow_nil? true + public? true + end + + attribute :permission_set_name, :string do + allow_nil? false + public? true + end + + attribute :is_system_role, :boolean do + allow_nil? false + default false + public? true + end + + timestamps() + end + + relationships do + has_many :users, Mv.Accounts.User do + destination_attribute :role_id + end + end + + identities do + identity :unique_name, [:name] + end +end diff --git a/priv/repo/migrations/20260106161215_add_authorization_domain.exs b/priv/repo/migrations/20260106161215_add_authorization_domain.exs new file mode 100644 index 0000000..7631043 --- /dev/null +++ b/priv/repo/migrations/20260106161215_add_authorization_domain.exs @@ -0,0 +1,80 @@ +defmodule Mv.Repo.Migrations.AddAuthorizationDomain do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:users) do + add :role_id, :uuid + end + + create table(:roles, primary_key: false) do + add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true + end + + alter table(:users) do + modify :role_id, + references(:roles, + column: :id, + name: "users_role_id_fkey", + type: :uuid, + on_delete: :restrict, + prefix: "public" + ) + end + + alter table(:roles) do + add :name, :text, null: false + add :description, :text + add :permission_set_name, :text, null: false + add :is_system_role, :boolean, null: false, default: false + + add :inserted_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :updated_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + end + + create unique_index(:roles, [:name], name: "roles_unique_name_index") + + create index(:roles, [:permission_set_name], name: "roles_permission_set_name_index") + + create index(:users, [:role_id], name: "users_role_id_index") + end + + def down do + drop_if_exists index(:users, [:role_id], name: "users_role_id_index") + + drop_if_exists index(:roles, [:permission_set_name], name: "roles_permission_set_name_index") + + drop_if_exists unique_index(:roles, [:name], name: "roles_unique_name_index") + + alter table(:roles) do + remove :updated_at + remove :inserted_at + remove :is_system_role + remove :permission_set_name + remove :description + remove :name + end + + drop constraint(:users, "users_role_id_fkey") + + alter table(:users) do + modify :role_id, :uuid + end + + drop table(:roles) + + alter table(:users) do + remove :role_id + end + end +end diff --git a/priv/resource_snapshots/repo/roles/20260106165250.json b/priv/resource_snapshots/repo/roles/20260106165250.json new file mode 100644 index 0000000..56fedf5 --- /dev/null +++ b/priv/resource_snapshots/repo/roles/20260106165250.json @@ -0,0 +1,118 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "permission_set_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "is_system_role", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "8822483B2830DB45988E3B673F36EAE43311B336EE34FBDA1FA24BF9867D7494", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "roles_unique_name_index", + "keys": [ + { + "type": "atom", + "value": "name" + } + ], + "name": "unique_name", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "roles" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/users/20260106161215.json b/priv/resource_snapshots/repo/users/20260106161215.json new file mode 100644 index 0000000..3fcf712 --- /dev/null +++ b/priv/resource_snapshots/repo/users/20260106161215.json @@ -0,0 +1,172 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "citext" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "hashed_password", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "oidc_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "users_member_id_fkey", + "on_delete": "nilify", + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "members" + }, + "scale": null, + "size": null, + "source": "member_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "users_role_id_fkey", + "on_delete": "restrict", + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "roles" + }, + "scale": null, + "size": null, + "source": "role_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "E381FA10CFC1D8D4CCD09AC1AD4B0CC9F8931436F22139CCF3A4558E84C422D3", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "users_unique_email_index", + "keys": [ + { + "type": "atom", + "value": "email" + } + ], + "name": "unique_email", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "users_unique_member_index", + "keys": [ + { + "type": "atom", + "value": "member_id" + } + ], + "name": "unique_member", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "users_unique_oidc_id_index", + "keys": [ + { + "type": "atom", + "value": "oidc_id" + } + ], + "name": "unique_oidc_id", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "users" +} \ No newline at end of file diff --git a/test/mv/authorization/role_test.exs b/test/mv/authorization/role_test.exs new file mode 100644 index 0000000..b263455 --- /dev/null +++ b/test/mv/authorization/role_test.exs @@ -0,0 +1,97 @@ +defmodule Mv.Authorization.RoleTest do + @moduledoc """ + Unit tests for Role resource validations and constraints. + """ + use Mv.DataCase, async: true + + alias Mv.Authorization + + describe "permission_set_name validation" do + test "accepts valid permission set names" do + attrs = %{ + name: "Test Role", + permission_set_name: "own_data" + } + + assert {:ok, role} = Authorization.create_role(attrs) + assert role.permission_set_name == "own_data" + end + + test "rejects invalid permission set names" do + attrs = %{ + name: "Test Role", + permission_set_name: "invalid_set" + } + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Authorization.create_role(attrs) + assert error_message(errors, :permission_set_name) =~ "must be one of" + end + + test "accepts all four valid permission sets" do + valid_sets = ["own_data", "read_only", "normal_user", "admin"] + + for permission_set <- valid_sets do + attrs = %{ + name: "Role #{permission_set}", + permission_set_name: permission_set + } + + assert {:ok, _role} = Authorization.create_role(attrs) + end + end + end + + describe "system role deletion protection" do + test "prevents deletion of system roles" do + # is_system_role is not settable via public API, so we use Ash.Changeset directly + changeset = + Mv.Authorization.Role + |> Ash.Changeset.for_create(:create_role, %{ + name: "System Role", + permission_set_name: "own_data" + }) + |> Ash.Changeset.force_change_attribute(:is_system_role, true) + + {:ok, system_role} = Ash.create(changeset) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = + Authorization.destroy_role(system_role) + + message = error_message(errors, :is_system_role) + assert message =~ "Cannot delete system role" + end + + test "allows deletion of non-system roles" do + # is_system_role defaults to false, so regular create works + {:ok, regular_role} = + Authorization.create_role(%{ + name: "Regular Role", + permission_set_name: "read_only" + }) + + assert :ok = Authorization.destroy_role(regular_role) + end + end + + describe "name uniqueness" do + test "enforces unique role names" do + attrs = %{ + name: "Unique Role", + permission_set_name: "own_data" + } + + assert {:ok, _} = Authorization.create_role(attrs) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Authorization.create_role(attrs) + assert error_message(errors, :name) =~ "has already been taken" + end + end + + # Helper function for error evaluation + defp error_message(errors, field) when is_atom(field) do + errors + |> Enum.filter(fn err -> Map.get(err, :field) == field end) + |> Enum.map(&Map.get(&1, :message, "")) + |> List.first() || "" + end +end