From 37d165522776acc12418531f33c8a9be2cc4ad84 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 6 Jan 2026 17:18:29 +0100 Subject: [PATCH 01/18] feat: add PermissionSets stub module for role validation Add minimal PermissionSets module with all_permission_sets/0 function to support permission_set_name validation in Role resource. --- lib/mv/authorization/permission_sets.ex | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 lib/mv/authorization/permission_sets.ex diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex new file mode 100644 index 0000000..fb9d249 --- /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 for Issue #1. The full implementation + with all permission details will be added in Issue #2. + + ## 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 From 1b2927ce40a5722da6f223874fae143f51d04c35 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 6 Jan 2026 17:18:30 +0100 Subject: [PATCH 02/18] feat: create Authorization domain Add Mv.Authorization domain with AshAdmin and AshPhoenix extensions. Register domain in config for role management. --- lib/mv/authorization/authorization.ex | 30 +++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 lib/mv/authorization/authorization.ex diff --git a/lib/mv/authorization/authorization.ex b/lib/mv/authorization/authorization.ex new file mode 100644 index 0000000..23672f1 --- /dev/null +++ b/lib/mv/authorization/authorization.ex @@ -0,0 +1,30 @@ +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 :update_role, action: :update_role + define :destroy_role, action: :destroy + end + end +end From 4535551b8d83e8e52e11ef9fbeda5aed3a2fb529 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 6 Jan 2026 17:18:32 +0100 Subject: [PATCH 03/18] feat: add Role resource with validations Create Role resource with name, description, permission_set_name, and is_system_role fields. Add validations for permission_set_name and system role deletion protection. --- lib/mv/authorization/role.ex | 152 +++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 lib/mv/authorization/role.ex diff --git a/lib/mv/authorization/role.ex b/lib/mv/authorization/role.ex new file mode 100644 index 0000000..e5b9795 --- /dev/null +++ b/lib/mv/authorization/role.ex @@ -0,0 +1,152 @@ +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 + 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 + accept [:name, :description, :permission_set_name, :is_system_role] + # 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 + accept [:name, :description, :permission_set_name, :is_system_role] + # 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 fn changeset, _context -> + permission_set_name = Ash.Changeset.get_attribute(changeset, :permission_set_name) + + if permission_set_name do + valid_sets = + Mv.Authorization.PermissionSets.all_permission_sets() + |> Enum.map(&Atom.to_string/1) + + if permission_set_name in valid_sets do + :ok + else + valid_sets_string = Enum.join(valid_sets, ", ") + + {:error, + field: :permission_set_name, + message: "Invalid permission set name. Must be one of: #{valid_sets_string}"} + end + else + :ok + end + end + + validate fn changeset, _context -> + if changeset.action_type == :destroy do + if changeset.data.is_system_role do + {:error, + message: + "Cannot delete system role. System roles are required for the application to function."} + else + :ok + end + else + :ok + end + end, + on: [:destroy] + end + + attributes do + uuid_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 From 90c32c2afdc8e21431ff4356326ea1e4e135aa71 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 6 Jan 2026 17:18:33 +0100 Subject: [PATCH 04/18] feat: add role relationship to User resource Add belongs_to :role relationship to User resource and register Authorization domain in config. --- config/config.exs | 2 +- lib/accounts/user.ex | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) 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..b0d919b 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -357,6 +357,11 @@ 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) + belongs_to :role, Mv.Authorization.Role end identities do From 851d63f626162bc599af6651a0ab230221ea731b Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 6 Jan 2026 17:18:34 +0100 Subject: [PATCH 05/18] feat: add authorization domain migration Create roles table and add role_id to users table with indexes and foreign key constraints. --- ...0260106161215_add_authorization_domain.exs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 priv/repo/migrations/20260106161215_add_authorization_domain.exs 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..02edcd3 --- /dev/null +++ b/priv/repo/migrations/20260106161215_add_authorization_domain.exs @@ -0,0 +1,79 @@ +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("gen_random_uuid()"), primary_key: true + end + + alter table(:users) do + modify :role_id, + references(:roles, + column: :id, + name: "users_role_id_fkey", + type: :uuid, + 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 From b569612a63687b9a1ffbf16f061771e336998f3d Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 6 Jan 2026 17:18:45 +0100 Subject: [PATCH 06/18] feat: add resource snapshots for roles and users Add Ash resource snapshots generated during migration creation. --- .../repo/roles/20260106161215.json | 118 ++++++++++++ .../repo/users/20260106161215.json | 172 ++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 priv/resource_snapshots/repo/roles/20260106161215.json create mode 100644 priv/resource_snapshots/repo/users/20260106161215.json diff --git a/priv/resource_snapshots/repo/roles/20260106161215.json b/priv/resource_snapshots/repo/roles/20260106161215.json new file mode 100644 index 0000000..78c5636 --- /dev/null +++ b/priv/resource_snapshots/repo/roles/20260106161215.json @@ -0,0 +1,118 @@ +{ + "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": "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": "FFDA74F44B5F11381D4C1F4DACA54901A1E02C3D181A88484AEED4E1ADA21B87", + "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..886e1a1 --- /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": null, + "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 From 82ec4e565a209e6a66974898342d08f1a332715e Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 6 Jan 2026 18:14:16 +0100 Subject: [PATCH 07/18] refactor: use UUIDv7 and improve Role validations - Change id from uuid_primary_key to uuid_v7_primary_key - Replace custom validation with built-in one_of validation - Add explicit on_delete: :restrict for users foreign key - Update postgres references configuration --- lib/mv/authorization/role.ex | 34 ++++++++++++---------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/lib/mv/authorization/role.ex b/lib/mv/authorization/role.ex index e5b9795..3397172 100644 --- a/lib/mv/authorization/role.ex +++ b/lib/mv/authorization/role.ex @@ -42,6 +42,11 @@ defmodule Mv.Authorization.Role do 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 @@ -75,27 +80,12 @@ defmodule Mv.Authorization.Role do end validations do - validate fn changeset, _context -> - permission_set_name = Ash.Changeset.get_attribute(changeset, :permission_set_name) - - if permission_set_name do - valid_sets = - Mv.Authorization.PermissionSets.all_permission_sets() - |> Enum.map(&Atom.to_string/1) - - if permission_set_name in valid_sets do - :ok - else - valid_sets_string = Enum.join(valid_sets, ", ") - - {:error, - field: :permission_set_name, - message: "Invalid permission set name. Must be one of: #{valid_sets_string}"} - end - else - :ok - end - end + validate one_of( + :permission_set_name, + Mv.Authorization.PermissionSets.all_permission_sets() + |> Enum.map(&Atom.to_string/1) + ), + message: "must be one of: own_data, read_only, normal_user, admin" validate fn changeset, _context -> if changeset.action_type == :destroy do @@ -114,7 +104,7 @@ defmodule Mv.Authorization.Role do end attributes do - uuid_primary_key :id + uuid_v7_primary_key :id attribute :name, :string do allow_nil? false From 402a78dd0a363b9b7db5870e0b079b54bdc4ccbe Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 6 Jan 2026 18:14:18 +0100 Subject: [PATCH 08/18] refactor: update migration for UUIDv7 and explicit FK constraint - Add on_delete: :restrict to users.role_id foreign key - Update roles.id to use uuid_generate_v7() default - Regenerate resource snapshots --- ...0260106161215_add_authorization_domain.exs | 1 + .../20260106165250_update_role_to_uuidv7.exs | 21 ++++ .../repo/roles/20260106165250.json | 118 ++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 priv/repo/migrations/20260106165250_update_role_to_uuidv7.exs create mode 100644 priv/resource_snapshots/repo/roles/20260106165250.json diff --git a/priv/repo/migrations/20260106161215_add_authorization_domain.exs b/priv/repo/migrations/20260106161215_add_authorization_domain.exs index 02edcd3..445fd19 100644 --- a/priv/repo/migrations/20260106161215_add_authorization_domain.exs +++ b/priv/repo/migrations/20260106161215_add_authorization_domain.exs @@ -22,6 +22,7 @@ defmodule Mv.Repo.Migrations.AddAuthorizationDomain do column: :id, name: "users_role_id_fkey", type: :uuid, + on_delete: :restrict, prefix: "public" ) end diff --git a/priv/repo/migrations/20260106165250_update_role_to_uuidv7.exs b/priv/repo/migrations/20260106165250_update_role_to_uuidv7.exs new file mode 100644 index 0000000..9be7534 --- /dev/null +++ b/priv/repo/migrations/20260106165250_update_role_to_uuidv7.exs @@ -0,0 +1,21 @@ +defmodule Mv.Repo.Migrations.UpdateRoleToUuidv7 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(:roles) do + modify :id, :uuid, default: fragment("uuid_generate_v7()") + end + end + + def down do + alter table(:roles) do + modify :id, :uuid, default: fragment("gen_random_uuid()") + 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 From 12c08cabee79538f3737325f11e95c71de720924 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 6 Jan 2026 18:14:19 +0100 Subject: [PATCH 09/18] docs: clean up PermissionSets documentation Remove issue number references from moduledoc --- lib/mv/authorization/permission_sets.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex index fb9d249..d01e285 100644 --- a/lib/mv/authorization/permission_sets.ex +++ b/lib/mv/authorization/permission_sets.ex @@ -2,8 +2,8 @@ defmodule Mv.Authorization.PermissionSets do @moduledoc """ Defines the four hardcoded permission sets for the application. - This is a minimal stub implementation for Issue #1. The full implementation - with all permission details will be added in Issue #2. + This is a minimal stub implementation. The full implementation + with all permission details will be added in a subsequent issue. ## Permission Sets From 9bb0fe5e375cb8e0a9d985af9a548b622c1be694 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 6 Jan 2026 18:14:20 +0100 Subject: [PATCH 10/18] test: add unit tests for Role validations Add tests for permission_set_name validation, system role deletion protection, and name uniqueness constraints. --- test/mv/authorization/role_test.exs | 93 +++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 test/mv/authorization/role_test.exs diff --git a/test/mv/authorization/role_test.exs b/test/mv/authorization/role_test.exs new file mode 100644 index 0000000..ab1ebeb --- /dev/null +++ b/test/mv/authorization/role_test.exs @@ -0,0 +1,93 @@ +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 + {:ok, system_role} = + Authorization.create_role(%{ + name: "System Role", + permission_set_name: "own_data", + is_system_role: true + }) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = + Authorization.destroy_role(system_role) + + message = error_message(errors, nil) + assert message =~ "Cannot delete system role" + end + + test "allows deletion of non-system roles" do + {:ok, regular_role} = + Authorization.create_role(%{ + name: "Regular Role", + permission_set_name: "read_only", + is_system_role: false + }) + + 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) do + errors + |> Enum.filter(fn err -> Map.get(err, :field) == field end) + |> Enum.map(&Map.get(&1, :message, "")) + |> List.first() || "" + end +end From 557eb4d27d5865feb15663156c7c70cb30268596 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 6 Jan 2026 18:37:34 +0100 Subject: [PATCH 11/18] refactor: simplify system role deletion validation Remove redundant action_type check since validation already runs only on destroy actions. Add field to error for better error handling. --- lib/mv/authorization/role.ex | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/mv/authorization/role.ex b/lib/mv/authorization/role.ex index 3397172..ff1f2c1 100644 --- a/lib/mv/authorization/role.ex +++ b/lib/mv/authorization/role.ex @@ -88,14 +88,11 @@ defmodule Mv.Authorization.Role do message: "must be one of: own_data, read_only, normal_user, admin" validate fn changeset, _context -> - if changeset.action_type == :destroy do - if changeset.data.is_system_role do - {:error, - message: - "Cannot delete system role. System roles are required for the application to function."} - else - :ok - end + 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 From f63405052fb1ceba40aaeae16f970b93197dbfb6 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 6 Jan 2026 18:37:35 +0100 Subject: [PATCH 12/18] feat: add get_role action to Authorization domain Add get_role action for retrieving single role by ID through code interface. --- lib/mv/authorization/authorization.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/mv/authorization/authorization.ex b/lib/mv/authorization/authorization.ex index 23672f1..aac07a9 100644 --- a/lib/mv/authorization/authorization.ex +++ b/lib/mv/authorization/authorization.ex @@ -23,6 +23,7 @@ defmodule Mv.Authorization 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 From deacc43030290b4d737a11f48f136f8201512d22 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 6 Jan 2026 18:37:37 +0100 Subject: [PATCH 13/18] docs: document FK constraint behavior for role relationship Add comment explaining on_delete: :restrict behavior for users.role_id foreign key constraint. --- lib/accounts/user.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index b0d919b..655dcc6 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -361,6 +361,7 @@ defmodule Mv.Accounts.User do # 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 From c6a766377a55473cf597e326e7777cb937acd99c Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 6 Jan 2026 18:37:38 +0100 Subject: [PATCH 14/18] refactor: improve error_message test helper Add pattern matching for nil field case to handle errors without specific field (e.g., system role deletion). --- test/mv/authorization/role_test.exs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/mv/authorization/role_test.exs b/test/mv/authorization/role_test.exs index ab1ebeb..effa000 100644 --- a/test/mv/authorization/role_test.exs +++ b/test/mv/authorization/role_test.exs @@ -84,6 +84,13 @@ defmodule Mv.Authorization.RoleTest do end # Helper function for error evaluation + # When field is nil, returns first error message (for errors without specific field) + defp error_message(errors, field) when is_nil(field) do + errors + |> Enum.map(&Map.get(&1, :message, "")) + |> List.first() || "" + end + defp error_message(errors, field) do errors |> Enum.filter(fn err -> Map.get(err, :field) == field end) From ce1d5790a37ef2bd678283eb8097ce84611996aa Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 6 Jan 2026 18:37:39 +0100 Subject: [PATCH 15/18] refactor: squash migrations into single authorization domain migration Combine initial authorization migration with UUIDv7 update into one migration. Migration now creates roles table with UUIDv7 default and explicit on_delete: :restrict FK constraint. --- ...0260106161215_add_authorization_domain.exs | 2 +- .../20260106165250_update_role_to_uuidv7.exs | 21 ------------------- 2 files changed, 1 insertion(+), 22 deletions(-) delete mode 100644 priv/repo/migrations/20260106165250_update_role_to_uuidv7.exs diff --git a/priv/repo/migrations/20260106161215_add_authorization_domain.exs b/priv/repo/migrations/20260106161215_add_authorization_domain.exs index 445fd19..7631043 100644 --- a/priv/repo/migrations/20260106161215_add_authorization_domain.exs +++ b/priv/repo/migrations/20260106161215_add_authorization_domain.exs @@ -13,7 +13,7 @@ defmodule Mv.Repo.Migrations.AddAuthorizationDomain do end create table(:roles, primary_key: false) do - add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true end alter table(:users) do diff --git a/priv/repo/migrations/20260106165250_update_role_to_uuidv7.exs b/priv/repo/migrations/20260106165250_update_role_to_uuidv7.exs deleted file mode 100644 index 9be7534..0000000 --- a/priv/repo/migrations/20260106165250_update_role_to_uuidv7.exs +++ /dev/null @@ -1,21 +0,0 @@ -defmodule Mv.Repo.Migrations.UpdateRoleToUuidv7 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(:roles) do - modify :id, :uuid, default: fragment("uuid_generate_v7()") - end - end - - def down do - alter table(:roles) do - modify :id, :uuid, default: fragment("gen_random_uuid()") - end - end -end From 73763b1f584d976c8a42b13001d2d9e2b308b514 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 6 Jan 2026 18:44:04 +0100 Subject: [PATCH 16/18] refactor: improve error_message test helper robustness Use Enum.reject for nil field case to explicitly filter errors without field. Update test to use :is_system_role field since validation error includes field. --- test/mv/authorization/role_test.exs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/mv/authorization/role_test.exs b/test/mv/authorization/role_test.exs index effa000..be297f2 100644 --- a/test/mv/authorization/role_test.exs +++ b/test/mv/authorization/role_test.exs @@ -53,7 +53,7 @@ defmodule Mv.Authorization.RoleTest do assert {:error, %Ash.Error.Invalid{errors: errors}} = Authorization.destroy_role(system_role) - message = error_message(errors, nil) + message = error_message(errors, :is_system_role) assert message =~ "Cannot delete system role" end @@ -84,14 +84,15 @@ defmodule Mv.Authorization.RoleTest do end # Helper function for error evaluation - # When field is nil, returns first error message (for errors without specific field) - defp error_message(errors, field) when is_nil(field) do + # When field is nil, returns first error message for errors without specific field + defp error_message(errors, nil) do errors + |> Enum.reject(fn err -> Map.has_key?(err, :field) end) |> Enum.map(&Map.get(&1, :message, "")) |> List.first() || "" end - defp error_message(errors, field) do + 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, "")) From 5f13901ca59835e557694c344fde7eaf405f24dc Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 6 Jan 2026 19:04:03 +0100 Subject: [PATCH 17/18] security: remove is_system_role from public API 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. --- lib/accounts/user.ex | 4 + lib/mv/authorization/role.ex | 9 +- .../repo/roles/20260106161215.json | 118 ------------------ .../repo/users/20260106161215.json | 2 +- 4 files changed, 11 insertions(+), 122 deletions(-) delete mode 100644 priv/resource_snapshots/repo/roles/20260106161215.json diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 655dcc6..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 diff --git a/lib/mv/authorization/role.ex b/lib/mv/authorization/role.ex index ff1f2c1..da43510 100644 --- a/lib/mv/authorization/role.ex +++ b/lib/mv/authorization/role.ex @@ -61,14 +61,16 @@ defmodule Mv.Authorization.Role do create :create_role do primary? true - accept [:name, :description, :permission_set_name, :is_system_role] + # 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 - accept [:name, :description, :permission_set_name, :is_system_role] + # 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 @@ -85,7 +87,8 @@ defmodule Mv.Authorization.Role do Mv.Authorization.PermissionSets.all_permission_sets() |> Enum.map(&Atom.to_string/1) ), - message: "must be one of: own_data, read_only, normal_user, admin" + 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 diff --git a/priv/resource_snapshots/repo/roles/20260106161215.json b/priv/resource_snapshots/repo/roles/20260106161215.json deleted file mode 100644 index 78c5636..0000000 --- a/priv/resource_snapshots/repo/roles/20260106161215.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "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": "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": "FFDA74F44B5F11381D4C1F4DACA54901A1E02C3D181A88484AEED4E1ADA21B87", - "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 index 886e1a1..3fcf712 100644 --- a/priv/resource_snapshots/repo/users/20260106161215.json +++ b/priv/resource_snapshots/repo/users/20260106161215.json @@ -99,7 +99,7 @@ "strategy": null }, "name": "users_role_id_fkey", - "on_delete": null, + "on_delete": "restrict", "on_update": null, "primary_key?": true, "schema": "public", From 3265468bd61e459dd3569fbee36bc5f8ea853fdf Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 6 Jan 2026 19:04:05 +0100 Subject: [PATCH 18/18] test: update role tests for is_system_role API change Use Ash.Changeset.force_change_attribute to set is_system_role in tests since it's no longer settable via public API. Remove unused nil clause from error_message helper. --- test/mv/authorization/role_test.exs | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/test/mv/authorization/role_test.exs b/test/mv/authorization/role_test.exs index be297f2..b263455 100644 --- a/test/mv/authorization/role_test.exs +++ b/test/mv/authorization/role_test.exs @@ -43,12 +43,16 @@ defmodule Mv.Authorization.RoleTest do describe "system role deletion protection" do test "prevents deletion of system roles" do - {:ok, system_role} = - Authorization.create_role(%{ + # 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", - is_system_role: true + 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) @@ -58,11 +62,11 @@ defmodule Mv.Authorization.RoleTest do 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", - is_system_role: false + permission_set_name: "read_only" }) assert :ok = Authorization.destroy_role(regular_role) @@ -84,14 +88,6 @@ defmodule Mv.Authorization.RoleTest do end # Helper function for error evaluation - # When field is nil, returns first error message for errors without specific field - defp error_message(errors, nil) do - errors - |> Enum.reject(fn err -> Map.has_key?(err, :field) end) - |> Enum.map(&Map.get(&1, :message, "")) - |> List.first() || "" - end - defp error_message(errors, field) when is_atom(field) do errors |> Enum.filter(fn err -> Map.get(err, :field) == field end)