From 6aadf4f93b12954526d38623ecd486318dd54d3e Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 4 Feb 2026 00:11:52 +0000 Subject: [PATCH 01/14] Update Mix dependencies --- mix.lock | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/mix.lock b/mix.lock index 26aa555..453ed8f 100644 --- a/mix.lock +++ b/mix.lock @@ -1,22 +1,22 @@ %{ - "ash": {:hex, :ash, "3.12.0", "5b78000df650d86b446d88977ef8aa5c9d9f7ffa1193fa3c4b901c60bff2d130", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7cf45b4eb83aa0ab5e6707d6e4ea4a10c29ab20613c87f06344f7953b2ca5e18"}, + "ash": {:hex, :ash, "3.14.1", "22e0ac5dfd4c7d502bd103f0b4380defd66d7c6c83b3a4f54af7045f13da00d7", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "776a5963790d5af79855ddca1718a037d06b49063a6b97fae9110050b3d5127d"}, "ash_admin": {:hex, :ash_admin, "0.13.24", "4fafddc7b4450a92878b58630688c55cab20b0c27e35cad68f29811f73815816", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "8f298cf6cb6635744ed75dd401295ed52513ea4df169f0f89d6a9a75dc4de4dc"}, - "ash_authentication": {:hex, :ash_authentication, "4.13.6", "95b17f0bfc00bd6e229145b90c7026f784ae81639e832de4b5c96a738de5ed46", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "27ded84bdc61fd267794dee17a6cbe6e52d0f365d3e8ea0460d95977b82ac6f1"}, - "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.14.1", "60d127a73c2144b39fa3dab045cc3f7fce0c3ccd2dd3e8534288f5da65f0c1db", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "3cd57aee855be3ccf2960ce0b005ad209c97fbfc81faa71212bcfbd6a4a90cae"}, - "ash_phoenix": {:hex, :ash_phoenix, "2.3.18", "fad1b8af1405758888086de568f08650c2911ee97074cfe2e325b14854bc43dd", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7ec28f9216221e83b90d9c3605e9d1cdd228984e09a1a86c9b9d393cebf25222"}, - "ash_postgres": {:hex, :ash_postgres, "2.6.27", "7aa119cc420909573a51802f414a49a9fb21a06ee78769efd7a4db040e748f5c", [:mix], [{:ash, ">= 3.11.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.16 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "f5e71dc3f77bc0c52374869df4b66493e13c0e27507c3d10ff13158ef7ea506f"}, - "ash_sql": {:hex, :ash_sql, "0.3.16", "a4e62d2cf9b2f4a451067e5e3de28349a8d0e69cf50fc1861bad85f478ded046", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "f3d5a810b23e12e3e102799c68b1e934fa7f909ccaa4bd530f10c7317cfcfe56"}, + "ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"}, + "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.15.0", "89e71e96a3d954aed7ed0c1f511d42cbfd19009b813f580b12749b01bbea5148", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "d2da66dcf62bc1054ce8f5d9c2829b1dff1dbc3f1d03f9ef0cbe89123d7df107"}, + "ash_phoenix": {:hex, :ash_phoenix, "2.3.19", "244b24256a7d730e5223f36f371a95971542a547a12f0fb73406f67977e86c97", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "754a7d869a3961a927abb7ff700af9895d2e69dd3b8f9471b0aa8e859cc4b135"}, + "ash_postgres": {:hex, :ash_postgres, "2.6.29", "93c7d39890930548acc704613b7f83e65c0880940be1b2048ee86dfb44918529", [:mix], [{:ash, "~> 3.14", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.4.3 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "0aed7ac3d8407ff094218b1dc86b88ea7e39249fb9e94360c7dac1711e206d8b"}, + "ash_sql": {:hex, :ash_sql, "0.4.3", "2c74e0a19646e3d31a384a2712fc48a82d04ceea74467771ce496fd64dbb55db", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "b0ecc00502178407e607ae4bcfd2f264f36f6a884218024b5e4d5b3dcfa5e027"}, "assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"}, - "bandit": {:hex, :bandit, "1.10.1", "6b1f8609d947ae2a74da5bba8aee938c94348634e54e5625eef622ca0bbbb062", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b4c35f273030e44268ace53bf3d5991dfc385c77374244e2f960876547671aa"}, + "bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, - "credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"}, + "credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"}, "crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"}, - "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"}, + "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, @@ -28,21 +28,21 @@ "ex_phone_number": {:hex, :ex_phone_number, "0.4.8", "c1c5e6f0673822a2a7b439b43af7d3eb1a5c19ae582b772b8b8d12625dd51ec1", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "43e2357c6b8cfe556bcd417f4ce9aaef267a786e31a2938902daaa0d36f69757"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, + "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "igniter": {:hex, :igniter, "0.7.0", "6848714fa5afa14258c82924a57af9364745316241a409435cf39cbe11e3ae80", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "1e7254780dbf4b44c9eccd6d86d47aa961efc298d7f520c24acb0258c8e90ba9"}, + "igniter": {:hex, :igniter, "0.7.2", "81c132c0df95963c7a228f74a32d3348773743ed9651f24183bfce0fe6ff16d1", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "f4cab73ec31f4fb452de1a17037f8a08826105265aa2d76486fcb848189bef9b"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, "lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, - "live_debugger": {:hex, :live_debugger, "0.5.0", "95e0f7727d61010f7e9053923fb2a9416904a7533c2dfb36120e7684cba4c0af", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "73ebe95118d22aa402675f677abd731cb16b136d1b6ae5f4010441fb50753b14"}, + "live_debugger": {:hex, :live_debugger, "0.5.1", "7302a4fda1920ba541b456c2d7a97acc3c7f9d7b938b5435927883b709c968a2", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "797fdca7cc60d7588c6e285b0d7ea73f2dce8b123bac43eae70271fa519bb907"}, "luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, @@ -57,26 +57,26 @@ "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.19", "c95e9acbc374fb796ee3e24bfecc8213123c74d9f9e45667ca40bb0a4d242953", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d5ad357d6b21562a5b431f0ad09dfe76db9ce5648c6949f1aac334c8c4455d32"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.22", "9b3c985bfe38e82668594a8ce90008548f30b9f23b718ebaea4701710ce9006f", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e1395d5622d8bf02113cb58183589b3da6f1751af235768816e90cc3ec5f1188"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, "picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"}, "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, - "postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"}, - "reactor": {:hex, :reactor, "0.17.0", "eb8bdb530dbae824e2d36a8538f8ec4f3aa7c2d1b61b04959fa787c634f88b49", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3c3bf71693adbad9117b11ec83cfed7d5851b916ade508ed9718de7ae165bf25"}, + "postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"}, + "reactor": {:hex, :reactor, "1.0.0", "024bd13df910bcb8c01cebed4f10bd778269a141a1c8a234e4f67796ac4883cf", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "ae8eb507fffc517f5aa5947db9d2ede2db8bae63b66c94ccb5a2027d30f830a0"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"}, "slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"}, "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, - "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, - "spark": {:hex, :spark, "2.3.14", "a08420d08e6e0e49d740aed3e160f1cb894ba8f6b3f5e6c63253e9df1995265c", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "af50c4ea5dd67eba822247f1c98e1d4e598cb7f6c28ccf5d002f0e0718096f4f"}, - "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, - "splode": {:hex, :splode, "0.2.10", "f755ebc8e5dc1556869c0513cf5f3450be602a41e01196249306483c4badbec0", [:mix], [], "hexpm", "906b6dc17b7ebc9b9fd9a31360bf0bd691d20e934fb28795c0ddb0c19d3198f1"}, + "sourceror": {:hex, :sourceror, "1.10.1", "325753ed460fe9fa34ebb4deda76d57b2e1507dcd78a5eb9e1c41bfb78b7cdfe", [:mix], [], "hexpm", "288f3079d93865cd1e3e20df5b884ef2cb440e0e03e8ae393624ee8a770ba588"}, + "spark": {:hex, :spark, "2.4.0", "f93d3ae6b5f3004e956d52f359fa40670366685447631bc7c058f4fbf250ebf3", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "4e5185f5737cd987bb9ef377ae3462a55b8312f5007c2bc4ad6e850d14ac0111"}, + "spitfire": {:hex, :spitfire, "0.3.1", "409b5ed3a2677df8790ed8b0542ca7e36c607d744fef4cb8cb8872fc80dd1803", [:mix], [], "hexpm", "72ff34d8f0096313a4b1a6505513c5ef4bbc0919bd8c181c07fc8d8dea8c9056"}, + "splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"}, "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, - "swoosh": {:hex, :swoosh, "1.20.0", "b04134c2b302da74c3a95ca4ddde191e4854d2847d6687783fecb023a9647598", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13e610f709bae54851d68afb6862882aa646e5c974bf49e3bf5edd84a73cf213"}, + "swoosh": {:hex, :swoosh, "1.21.0", "9f4fa629447774cfc9ad684d8a87a85384e8fce828b6390dd535dfbd43c9ee2a", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9127157bfb33b7e154d0f1ba4e888e14b08ede84e81dedcb318a2f33dbc6db51"}, "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, From e47547e065ad2bf57c6975dce5f67419d8b5ec62 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 12:37:48 +0100 Subject: [PATCH 02/14] Add Role resource policies (defense-in-depth) - PermissionSets: Role read :all for own_data, read_only, normal_user; admin keeps full CRUD - Role resource: authorizers and policies with HasPermission - Tests: role_policies_test.exs (read all, create/update/destroy admin only) - Fix existing tests to pass actor or authorize?: false for Role operations --- lib/mv/authorization/permission_sets.ex | 10 +- lib/mv/authorization/role.ex | 10 +- test/mv/authorization/role_policies_test.exs | 226 +++++++++++++++++++ test/mv/authorization/role_test.exs | 36 +-- test/mv/helpers/system_actor_test.exs | 45 ++-- test/mv_web/authorization_test.exs | 4 +- test/mv_web/live/role_live/show_test.exs | 6 +- test/mv_web/live/role_live_test.exs | 18 +- 8 files changed, 304 insertions(+), 51 deletions(-) create mode 100644 test/mv/authorization/role_policies_test.exs diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex index 9a5f7a7..b0e7015 100644 --- a/lib/mv/authorization/permission_sets.ex +++ b/lib/mv/authorization/permission_sets.ex @@ -78,6 +78,7 @@ defmodule Mv.Authorization.PermissionSets do defp custom_field_read_all, do: [perm("CustomField", :read, :all)] defp membership_fee_type_read_all, do: [perm("MembershipFeeType", :read, :all)] defp membership_fee_cycle_read_all, do: [perm("MembershipFeeCycle", :read, :all)] + defp role_read_all, do: [perm("Role", :read, :all)] @doc """ Returns the list of all valid permission set names. @@ -129,7 +130,8 @@ defmodule Mv.Authorization.PermissionSets do group_read_all() ++ [perm("MemberGroup", :read, :linked)] ++ membership_fee_type_read_all() ++ - [perm("MembershipFeeCycle", :read, :linked)], + [perm("MembershipFeeCycle", :read, :linked)] ++ + role_read_all(), pages: [ # No "/" - Mitglied must not see member index at root (same content as /members). # Own profile (sidebar links to /users/:id) and own user edit @@ -156,7 +158,8 @@ defmodule Mv.Authorization.PermissionSets do group_read_all() ++ [perm("MemberGroup", :read, :all)] ++ membership_fee_type_read_all() ++ - membership_fee_cycle_read_all(), + membership_fee_cycle_read_all() ++ + role_read_all(), pages: [ "/", # Own profile (sidebar links to /users/:id; redirect target must be allowed) @@ -211,7 +214,8 @@ defmodule Mv.Authorization.PermissionSets do perm("MembershipFeeCycle", :create, :all), perm("MembershipFeeCycle", :update, :all), perm("MembershipFeeCycle", :destroy, :all) - ], + ] ++ + role_read_all(), pages: [ "/", # Own profile (sidebar links to /users/:id; redirect target must be allowed) diff --git a/lib/mv/authorization/role.ex b/lib/mv/authorization/role.ex index 9c33e2d..59c0e51 100644 --- a/lib/mv/authorization/role.ex +++ b/lib/mv/authorization/role.ex @@ -37,7 +37,8 @@ defmodule Mv.Authorization.Role do """ use Ash.Resource, domain: Mv.Authorization, - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] postgres do table "roles" @@ -86,6 +87,13 @@ defmodule Mv.Authorization.Role do end end + policies do + policy action_type([:read, :create, :update, :destroy]) do + description "Role access: read for all permission sets, create/update/destroy for admin only (PermissionSets)" + authorize_if Mv.Authorization.Checks.HasPermission + end + end + validations do validate one_of( :permission_set_name, diff --git a/test/mv/authorization/role_policies_test.exs b/test/mv/authorization/role_policies_test.exs new file mode 100644 index 0000000..449f9d6 --- /dev/null +++ b/test/mv/authorization/role_policies_test.exs @@ -0,0 +1,226 @@ +defmodule Mv.Authorization.RolePoliciesTest do + @moduledoc """ + Tests for Role resource authorization policies. + + Rule: All permission sets (own_data, read_only, normal_user, admin) can **read** roles. + Only **admin** can create, update, or destroy roles. + """ + use Mv.DataCase, async: false + + alias Mv.Authorization + alias Mv.Authorization.Role + + describe "read access - all permission sets can read roles" do + setup do + # Create a role to read (via system_actor; once policies exist, system_actor is admin) + role = Mv.Fixtures.role_fixture("read_only") + %{role: role} + end + + @tag :permission_set_own_data + test "own_data can list roles", %{role: _role} do + user = Mv.Fixtures.user_with_role_fixture("own_data") + user = Mv.Authorization.Actor.ensure_loaded(user) + + {:ok, roles} = Authorization.list_roles(actor: user) + + assert is_list(roles) + assert roles != [] + end + + @tag :permission_set_own_data + test "own_data can get role by id", %{role: role} do + user = Mv.Fixtures.user_with_role_fixture("own_data") + user = Mv.Authorization.Actor.ensure_loaded(user) + + {:ok, loaded} = Ash.get(Role, role.id, actor: user, domain: Mv.Authorization) + + assert loaded.id == role.id + end + + @tag :permission_set_read_only + test "read_only can list roles", %{role: _role} do + user = Mv.Fixtures.user_with_role_fixture("read_only") + user = Mv.Authorization.Actor.ensure_loaded(user) + + {:ok, roles} = Authorization.list_roles(actor: user) + + assert is_list(roles) + assert roles != [] + end + + @tag :permission_set_read_only + test "read_only can get role by id", %{role: role} do + user = Mv.Fixtures.user_with_role_fixture("read_only") + user = Mv.Authorization.Actor.ensure_loaded(user) + + {:ok, loaded} = Ash.get(Role, role.id, actor: user, domain: Mv.Authorization) + + assert loaded.id == role.id + end + + @tag :permission_set_normal_user + test "normal_user can list roles", %{role: _role} do + user = Mv.Fixtures.user_with_role_fixture("normal_user") + user = Mv.Authorization.Actor.ensure_loaded(user) + + {:ok, roles} = Authorization.list_roles(actor: user) + + assert is_list(roles) + assert roles != [] + end + + @tag :permission_set_normal_user + test "normal_user can get role by id", %{role: role} do + user = Mv.Fixtures.user_with_role_fixture("normal_user") + user = Mv.Authorization.Actor.ensure_loaded(user) + + {:ok, loaded} = Ash.get(Role, role.id, actor: user, domain: Mv.Authorization) + + assert loaded.id == role.id + end + + @tag :permission_set_admin + test "admin can list roles", %{role: _role} do + admin = Mv.Fixtures.user_with_role_fixture("admin") + admin = Mv.Authorization.Actor.ensure_loaded(admin) + + {:ok, roles} = Authorization.list_roles(actor: admin) + + assert is_list(roles) + assert roles != [] + end + + @tag :permission_set_admin + test "admin can get role by id", %{role: role} do + admin = Mv.Fixtures.user_with_role_fixture("admin") + admin = Mv.Authorization.Actor.ensure_loaded(admin) + + {:ok, loaded} = Ash.get(Role, role.id, actor: admin, domain: Mv.Authorization) + + assert loaded.id == role.id + end + end + + describe "create/update/destroy - only admin allowed" do + setup do + # Non-system role for destroy test (role_fixture creates non-system roles) + role = Mv.Fixtures.role_fixture("normal_user") + %{role: role} + end + + test "admin can create_role", %{role: _role} do + admin = Mv.Fixtures.user_with_role_fixture("admin") + admin = Mv.Authorization.Actor.ensure_loaded(admin) + + attrs = %{ + name: "New Role #{System.unique_integer([:positive])}", + description: "Test", + permission_set_name: "read_only" + } + + assert {:ok, _created} = Authorization.create_role(attrs, actor: admin) + end + + test "admin can update_role", %{role: role} do + admin = Mv.Fixtures.user_with_role_fixture("admin") + admin = Mv.Authorization.Actor.ensure_loaded(admin) + + assert {:ok, updated} = + Authorization.update_role(role, %{description: "Updated by admin"}, actor: admin) + + assert updated.description == "Updated by admin" + end + + test "admin can destroy non-system role", %{role: role} do + admin = Mv.Fixtures.user_with_role_fixture("admin") + admin = Mv.Authorization.Actor.ensure_loaded(admin) + + assert :ok = Authorization.destroy_role(role, actor: admin) + end + + test "own_data cannot create_role (forbidden)", %{role: _role} do + user = Mv.Fixtures.user_with_role_fixture("own_data") + user = Mv.Authorization.Actor.ensure_loaded(user) + + attrs = %{ + name: "New Role #{System.unique_integer([:positive])}", + description: "Test", + permission_set_name: "read_only" + } + + assert {:error, %Ash.Error.Forbidden{}} = Authorization.create_role(attrs, actor: user) + end + + test "own_data cannot update_role (forbidden)", %{role: role} do + user = Mv.Fixtures.user_with_role_fixture("own_data") + user = Mv.Authorization.Actor.ensure_loaded(user) + + assert {:error, %Ash.Error.Forbidden{}} = + Authorization.update_role(role, %{description: "Updated"}, actor: user) + end + + test "own_data cannot destroy_role (forbidden)", %{role: role} do + user = Mv.Fixtures.user_with_role_fixture("own_data") + user = Mv.Authorization.Actor.ensure_loaded(user) + + assert {:error, %Ash.Error.Forbidden{}} = Authorization.destroy_role(role, actor: user) + end + + test "read_only cannot create_role (forbidden)", %{role: _role} do + user = Mv.Fixtures.user_with_role_fixture("read_only") + user = Mv.Authorization.Actor.ensure_loaded(user) + + attrs = %{ + name: "New Role #{System.unique_integer([:positive])}", + description: "Test", + permission_set_name: "read_only" + } + + assert {:error, %Ash.Error.Forbidden{}} = Authorization.create_role(attrs, actor: user) + end + + test "read_only cannot update_role (forbidden)", %{role: role} do + user = Mv.Fixtures.user_with_role_fixture("read_only") + user = Mv.Authorization.Actor.ensure_loaded(user) + + assert {:error, %Ash.Error.Forbidden{}} = + Authorization.update_role(role, %{description: "Updated"}, actor: user) + end + + test "read_only cannot destroy_role (forbidden)", %{role: role} do + user = Mv.Fixtures.user_with_role_fixture("read_only") + user = Mv.Authorization.Actor.ensure_loaded(user) + + assert {:error, %Ash.Error.Forbidden{}} = Authorization.destroy_role(role, actor: user) + end + + test "normal_user cannot create_role (forbidden)", %{role: _role} do + user = Mv.Fixtures.user_with_role_fixture("normal_user") + user = Mv.Authorization.Actor.ensure_loaded(user) + + attrs = %{ + name: "New Role #{System.unique_integer([:positive])}", + description: "Test", + permission_set_name: "normal_user" + } + + assert {:error, %Ash.Error.Forbidden{}} = Authorization.create_role(attrs, actor: user) + end + + test "normal_user cannot update_role (forbidden)", %{role: role} do + user = Mv.Fixtures.user_with_role_fixture("normal_user") + user = Mv.Authorization.Actor.ensure_loaded(user) + + assert {:error, %Ash.Error.Forbidden{}} = + Authorization.update_role(role, %{description: "Updated"}, actor: user) + end + + test "normal_user cannot destroy_role (forbidden)", %{role: role} do + user = Mv.Fixtures.user_with_role_fixture("normal_user") + user = Mv.Authorization.Actor.ensure_loaded(user) + + assert {:error, %Ash.Error.Forbidden{}} = Authorization.destroy_role(role, actor: user) + end + end +end diff --git a/test/mv/authorization/role_test.exs b/test/mv/authorization/role_test.exs index b7aa632..426719a 100644 --- a/test/mv/authorization/role_test.exs +++ b/test/mv/authorization/role_test.exs @@ -12,27 +12,29 @@ defmodule Mv.Authorization.RoleTest do end describe "permission_set_name validation" do - test "accepts valid permission set names" do + test "accepts valid permission set names", %{actor: actor} do attrs = %{ name: "Test Role", permission_set_name: "own_data" } - assert {:ok, role} = Authorization.create_role(attrs) + assert {:ok, role} = Authorization.create_role(attrs, actor: actor) assert role.permission_set_name == "own_data" end - test "rejects invalid permission set names" do + test "rejects invalid permission set names", %{actor: actor} do attrs = %{ name: "Test Role", permission_set_name: "invalid_set" } - assert {:error, %Ash.Error.Invalid{errors: errors}} = Authorization.create_role(attrs) + assert {:error, %Ash.Error.Invalid{errors: errors}} = + Authorization.create_role(attrs, actor: actor) + assert error_message(errors, :permission_set_name) =~ "must be one of" end - test "accepts all four valid permission sets" do + test "accepts all four valid permission sets", %{actor: actor} do valid_sets = ["own_data", "read_only", "normal_user", "admin"] for permission_set <- valid_sets do @@ -41,7 +43,7 @@ defmodule Mv.Authorization.RoleTest do permission_set_name: permission_set } - assert {:ok, _role} = Authorization.create_role(attrs) + assert {:ok, _role} = Authorization.create_role(attrs, actor: actor) end end end @@ -60,34 +62,36 @@ defmodule Mv.Authorization.RoleTest do {:ok, system_role} = Ash.create(changeset, actor: actor) assert {:error, %Ash.Error.Invalid{errors: errors}} = - Authorization.destroy_role(system_role) + Authorization.destroy_role(system_role, actor: actor) message = error_message(errors, :is_system_role) assert message =~ "Cannot delete system role" end - test "allows deletion of non-system roles" do + test "allows deletion of non-system roles", %{actor: actor} 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" - }) + Authorization.create_role( + %{name: "Regular Role", permission_set_name: "read_only"}, + actor: actor + ) - assert :ok = Authorization.destroy_role(regular_role) + assert :ok = Authorization.destroy_role(regular_role, actor: actor) end end describe "name uniqueness" do - test "enforces unique role names" do + test "enforces unique role names", %{actor: actor} do attrs = %{ name: "Unique Role", permission_set_name: "own_data" } - assert {:ok, _} = Authorization.create_role(attrs) + assert {:ok, _} = Authorization.create_role(attrs, actor: actor) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = + Authorization.create_role(attrs, actor: actor) - assert {:error, %Ash.Error.Invalid{errors: errors}} = Authorization.create_role(attrs) assert error_message(errors, :name) =~ "has already been taken" end end diff --git a/test/mv/helpers/system_actor_test.exs b/test/mv/helpers/system_actor_test.exs index c2715ae..add2ad5 100644 --- a/test/mv/helpers/system_actor_test.exs +++ b/test/mv/helpers/system_actor_test.exs @@ -18,18 +18,21 @@ defmodule Mv.Helpers.SystemActorTest do Ecto.Adapters.SQL.query!(Mv.Repo, "DELETE FROM users WHERE id = $1", [id]) end - # Helper function to ensure admin role exists + # Helper function to ensure admin role exists (bootstrap: no actor yet, use authorize?: false) defp ensure_admin_role do - case Authorization.list_roles() do + case Authorization.list_roles(authorize?: false) do {:ok, roles} -> case Enum.find(roles, &(&1.permission_set_name == "admin")) do nil -> {:ok, role} = - Authorization.create_role(%{ - name: "Admin", - description: "Administrator with full access", - permission_set_name: "admin" - }) + Authorization.create_role( + %{ + name: "Admin", + description: "Administrator with full access", + permission_set_name: "admin" + }, + authorize?: false + ) role @@ -39,11 +42,14 @@ defmodule Mv.Helpers.SystemActorTest do _ -> {:ok, role} = - Authorization.create_role(%{ - name: "Admin", - description: "Administrator with full access", - permission_set_name: "admin" - }) + Authorization.create_role( + %{ + name: "Admin", + description: "Administrator with full access", + permission_set_name: "admin" + }, + authorize?: false + ) role end @@ -364,12 +370,17 @@ defmodule Mv.Helpers.SystemActorTest do test "raises error if system user has wrong role", %{system_user: system_user} do # Create a non-admin role (using read_only as it's a valid permission set) + system_actor = SystemActor.get_system_actor() + {:ok, read_only_role} = - Authorization.create_role(%{ - name: "Read Only Role", - description: "Read-only access", - permission_set_name: "read_only" - }) + Authorization.create_role( + %{ + name: "Read Only Role", + description: "Read-only access", + permission_set_name: "read_only" + }, + actor: system_actor + ) system_actor = SystemActor.get_system_actor() diff --git a/test/mv_web/authorization_test.exs b/test/mv_web/authorization_test.exs index d07e482..7bb0b2a 100644 --- a/test/mv_web/authorization_test.exs +++ b/test/mv_web/authorization_test.exs @@ -50,14 +50,14 @@ defmodule MvWeb.AuthorizationTest do assert Authorization.can?(admin, :destroy, Mv.Authorization.Role) == true end - test "non-admin cannot manage roles" do + test "non-admin can read roles but cannot create/update/destroy" do normal_user = %{ id: "normal-123", role: %{permission_set_name: "normal_user"} } + assert Authorization.can?(normal_user, :read, Mv.Authorization.Role) == true assert Authorization.can?(normal_user, :create, Mv.Authorization.Role) == false - assert Authorization.can?(normal_user, :read, Mv.Authorization.Role) == false assert Authorization.can?(normal_user, :update, Mv.Authorization.Role) == false assert Authorization.can?(normal_user, :destroy, Mv.Authorization.Role) == false end diff --git a/test/mv_web/live/role_live/show_test.exs b/test/mv_web/live/role_live/show_test.exs index ed099ec..fe5c48d 100644 --- a/test/mv_web/live/role_live/show_test.exs +++ b/test/mv_web/live/role_live/show_test.exs @@ -18,7 +18,7 @@ defmodule MvWeb.RoleLive.ShowTest do alias Mv.Authorization alias Mv.Authorization.Role - # Helper to create a role + # Helper to create a role (authorize?: false for test data setup) defp create_role(attrs \\ %{}) do default_attrs = %{ name: "Test Role #{System.unique_integer([:positive])}", @@ -28,7 +28,7 @@ defmodule MvWeb.RoleLive.ShowTest do attrs = Map.merge(default_attrs, attrs) - case Authorization.create_role(attrs) do + case Authorization.create_role(attrs, authorize?: false) do {:ok, role} -> role {:error, error} -> raise "Failed to create role: #{inspect(error)}" end @@ -38,7 +38,7 @@ defmodule MvWeb.RoleLive.ShowTest do defp create_admin_user(conn, actor) do # Create admin role admin_role = - case Authorization.list_roles() do + case Authorization.list_roles(authorize?: false) do {:ok, roles} -> case Enum.find(roles, &(&1.name == "Admin")) do nil -> diff --git a/test/mv_web/live/role_live_test.exs b/test/mv_web/live/role_live_test.exs index 0edd2a4..cb112f2 100644 --- a/test/mv_web/live/role_live_test.exs +++ b/test/mv_web/live/role_live_test.exs @@ -9,7 +9,7 @@ defmodule MvWeb.RoleLiveTest do alias Mv.Authorization alias Mv.Authorization.Role - # Helper to create a role + # Helper to create a role (authorize?: false for test data setup) defp create_role(attrs \\ %{}) do default_attrs = %{ name: "Test Role #{System.unique_integer([:positive])}", @@ -19,7 +19,7 @@ defmodule MvWeb.RoleLiveTest do attrs = Map.merge(default_attrs, attrs) - case Authorization.create_role(attrs) do + case Authorization.create_role(attrs, authorize?: false) do {:ok, role} -> role {:error, error} -> raise "Failed to create role: #{inspect(error)}" end @@ -29,7 +29,7 @@ defmodule MvWeb.RoleLiveTest do defp create_admin_user(conn, actor) do # Create admin role admin_role = - case Authorization.list_roles() do + case Authorization.list_roles(authorize?: false) do {:ok, roles} -> case Enum.find(roles, &(&1.name == "Admin")) do nil -> @@ -332,7 +332,7 @@ defmodule MvWeb.RoleLiveTest do assert match?({:error, {:redirect, %{to: "/admin/roles"}}}, result) end - test "updates role name", %{conn: conn, role: role} do + test "updates role name", %{conn: conn, role: role, actor: actor} do {:ok, view, _html} = live(conn, "/admin/roles/#{role.id}/edit?return_to=show") attrs = %{ @@ -348,7 +348,7 @@ defmodule MvWeb.RoleLiveTest do assert_redirect(view, "/admin/roles/#{role.id}") # Verify update - {:ok, updated_role} = Authorization.get_role(role.id) + {:ok, updated_role} = Authorization.get_role(role.id, actor: actor) assert updated_role.name == "Updated Role Name" end @@ -377,7 +377,7 @@ defmodule MvWeb.RoleLiveTest do assert_redirect(view, "/admin/roles/#{system_role.id}") # Verify update - {:ok, updated_role} = Authorization.get_role(system_role.id) + {:ok, updated_role} = Authorization.get_role(system_role.id, actor: actor) assert updated_role.permission_set_name == "read_only" end end @@ -390,7 +390,7 @@ defmodule MvWeb.RoleLiveTest do end @tag :slow - test "deletes non-system role", %{conn: conn} do + test "deletes non-system role", %{conn: conn, actor: actor} do role = create_role() {:ok, view, html} = live(conn, "/admin/roles") @@ -404,7 +404,7 @@ defmodule MvWeb.RoleLiveTest do # Verify deletion by checking database assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} = - Authorization.get_role(role.id) + Authorization.get_role(role.id, actor: actor) end test "fails to delete system role with error message", %{conn: conn, actor: actor} do @@ -430,7 +430,7 @@ defmodule MvWeb.RoleLiveTest do assert render(view) =~ "System roles cannot be deleted" # Role should still exist - {:ok, _role} = Authorization.get_role(system_role.id) + {:ok, _role} = Authorization.get_role(system_role.id, actor: actor) end end From b70ece21297edb847fee530bf134fd90fe715594 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 12:50:10 +0100 Subject: [PATCH 03/14] Restrict member user link to admins (forbid policy) Add ForbidMemberUserLinkUnlessAdmin check; forbid_if on Member create/update. Fix member user-link tests: pass :user in params, assert via reload. --- lib/membership/member.ex | 12 +- .../forbid_member_user_link_unless_admin.ex | 65 ++++++++++ test/mv/membership/member_policies_test.exs | 116 ++++++++++++++++++ 3 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 lib/mv/authorization/checks/forbid_member_user_link_unless_admin.ex diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 8213ecb..8517634 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -312,14 +312,12 @@ defmodule Mv.Membership.Member do authorize_if expr(id == ^actor(:member_id)) end - # GENERAL: Check permissions from user's role - # HasPermission handles update permissions correctly: - # - :own_data → can update linked member (scope :linked) - # - :read_only → cannot update any member (no update permission) - # - :normal_user → can update all members (scope :all) - # - :admin → can update all members (scope :all) + # GENERAL: Check permissions from user's role; forbid member–user link unless admin + # ForbidMemberUserLinkUnlessAdmin: only admins may pass :user on create/update (no-op for read/destroy). + # HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all. policy action_type([:read, :create, :update, :destroy]) do - description "Check permissions from user's role and permission set" + description "Check permissions and forbid user link unless admin" + forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin authorize_if Mv.Authorization.Checks.HasPermission end diff --git a/lib/mv/authorization/checks/forbid_member_user_link_unless_admin.ex b/lib/mv/authorization/checks/forbid_member_user_link_unless_admin.ex new file mode 100644 index 0000000..facfdb2 --- /dev/null +++ b/lib/mv/authorization/checks/forbid_member_user_link_unless_admin.ex @@ -0,0 +1,65 @@ +defmodule Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin do + @moduledoc """ + Policy check: forbids setting or changing the member–user link unless the actor is admin. + + Used on Member create_member and update_member actions. When the `:user` argument + is present (linking a member to a user account), only admins may perform the action. + Non-admin users (e.g. normal_user / Kassenwart) can still create and update members + as long as they do not pass the `:user` argument. + + ## Usage + + In Member resource policies, add **before** the general HasPermission policy: + + policy action_type([:create, :update]) do + forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin + authorize_if Mv.Authorization.Checks.HasPermission + end + + ## Behaviour + + - If the action has no `:user` argument or it is nil/empty → does not forbid. + - If `:user` is set (e.g. `%{id: user_id}`) and actor is not admin → forbids (returns true). + - If actor is admin (or system actor) → does not forbid. + """ + use Ash.Policy.Check + + alias Mv.Authorization.Actor + + @impl true + def describe(_opts), do: "forbid setting member–user link unless actor is admin" + + @impl true + def strict_check(actor, authorizer, _opts) do + actor = Actor.ensure_loaded(actor) + + if user_argument_set?(authorizer) and not Actor.admin?(actor) do + {:ok, true} + else + {:ok, false} + end + end + + defp user_argument_set?(authorizer) do + user_arg = get_user_argument(authorizer) + not is_nil(user_arg) and not empty_user_arg?(user_arg) + end + + defp get_user_argument(authorizer) do + changeset = authorizer.changeset || authorizer.subject + + cond do + is_struct(changeset, Ash.Changeset) -> + Ash.Changeset.get_argument(changeset, :user) + + is_struct(changeset, Ash.ActionInput) -> + Map.get(changeset.arguments || %{}, :user) + + true -> + nil + end + end + + defp empty_user_arg?(%{} = m), do: map_size(m) == 0 + defp empty_user_arg?(_), do: false +end diff --git a/test/mv/membership/member_policies_test.exs b/test/mv/membership/member_policies_test.exs index a66941b..30936fe 100644 --- a/test/mv/membership/member_policies_test.exs +++ b/test/mv/membership/member_policies_test.exs @@ -403,4 +403,120 @@ defmodule Mv.Membership.MemberPoliciesTest do assert updated_member.first_name == "Updated" end end + + describe "member user link - only admin may set or change user link" do + test "normal_user can create member without :user argument", %{actor: _actor} do + normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") + normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user) + + {:ok, member} = + Membership.create_member( + %{ + first_name: "NoLink", + last_name: "Member", + email: "nolink#{System.unique_integer([:positive])}@example.com" + }, + actor: normal_user + ) + + assert member.first_name == "NoLink" + # Member has_one :user (FK on User side); ensure no user is linked + {:ok, member} = Ash.load(member, :user, domain: Mv.Membership) + assert is_nil(member.user) + end + + test "normal_user cannot create member with :user argument (forbidden)", %{actor: _actor} do + normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") + normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user) + # Another user to try to link to + other_user = Mv.Fixtures.user_with_role_fixture("read_only") + other_user = Mv.Authorization.Actor.ensure_loaded(other_user) + + attrs = %{ + first_name: "Linked", + last_name: "Member", + email: "linked#{System.unique_integer([:positive])}@example.com", + user: %{id: other_user.id} + } + + assert {:error, %Ash.Error.Forbidden{}} = + Membership.create_member(attrs, actor: normal_user) + end + + test "normal_user can update member without :user argument", %{actor: actor} do + normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") + normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user) + unlinked_member = create_unlinked_member(actor) + + {:ok, updated} = + Membership.update_member(unlinked_member, %{first_name: "UpdatedByNormal"}, + actor: normal_user + ) + + assert updated.first_name == "UpdatedByNormal" + end + + test "normal_user cannot update member with :user argument (forbidden)", %{actor: actor} do + normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") + normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user) + other_user = Mv.Fixtures.user_with_role_fixture("own_data") + other_user = Mv.Authorization.Actor.ensure_loaded(other_user) + unlinked_member = create_unlinked_member(actor) + + # Passing :user in params tries to link member to other_user - only admin may do that + params = %{first_name: unlinked_member.first_name, user: %{id: other_user.id}} + + assert {:error, %Ash.Error.Forbidden{}} = + Membership.update_member(unlinked_member, params, actor: normal_user) + end + + test "admin can create member with :user argument", %{actor: _actor} do + admin = Mv.Fixtures.user_with_role_fixture("admin") + admin = Mv.Authorization.Actor.ensure_loaded(admin) + link_target = Mv.Fixtures.user_with_role_fixture("own_data") + link_target = Mv.Authorization.Actor.ensure_loaded(link_target) + + attrs = %{ + first_name: "AdminLinked", + last_name: "Member", + email: "adminlinked#{System.unique_integer([:positive])}@example.com", + user: %{id: link_target.id} + } + + {:ok, member} = Membership.create_member(attrs, actor: admin) + + assert member.first_name == "AdminLinked" + # Reload link_target to see the new member_id set by manage_relationship + {:ok, link_target} = + Ash.get(Mv.Accounts.User, link_target.id, domain: Mv.Accounts, actor: admin) + + assert link_target.member_id == member.id + end + + test "admin can update member with :user argument (link)", %{actor: actor} do + admin = Mv.Fixtures.user_with_role_fixture("admin") + admin = Mv.Authorization.Actor.ensure_loaded(admin) + unlinked_member = create_unlinked_member(actor) + link_target = Mv.Fixtures.user_with_role_fixture("read_only") + link_target = Mv.Authorization.Actor.ensure_loaded(link_target) + + {:ok, updated} = + Membership.update_member( + unlinked_member, + %{user: %{id: link_target.id}}, + actor: admin + ) + + assert updated.id == unlinked_member.id + # Member should now be linked to link_target (user.member_id points to this member) + {:ok, reloaded_user} = + Ash.get(Mv.Accounts.User, link_target.id, + domain: Mv.Accounts, + load: [:member], + actor: admin + ) + + assert reloaded_user.member_id == updated.id + end + end end From 46dcb932d8a24f8808d3fe6f6ebc4346a362fffa Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 12:54:15 +0100 Subject: [PATCH 04/14] Docs: permission hardening Role and member user link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Role: Ash policies (HasPermission); read for all, create/update/destroy admin only. User–member link: only admins may set :user on Member create/update (ForbidMemberUserLinkUnlessAdmin). --- docs/roles-and-permissions-architecture.md | 37 +++++++++++----------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/docs/roles-and-permissions-architecture.md b/docs/roles-and-permissions-architecture.md index 92ad3c5..14a396d 100644 --- a/docs/roles-and-permissions-architecture.md +++ b/docs/roles-and-permissions-architecture.md @@ -1025,17 +1025,16 @@ defmodule Mv.Membership.Member do authorize_if expr(id == ^actor(:member_id)) end - # 2. GENERAL: Check permissions from role - # - :own_data → can UPDATE linked member (scope :linked via HasPermission) - # - :read_only → can READ all members (scope :all), no update permission - # - :normal_user → can CRUD all members (scope :all) - # - :admin → can CRUD all members (scope :all) + # 2. GENERAL: Forbid user link unless admin; then check permissions from role + # ForbidMemberUserLinkUnlessAdmin: only admins may pass :user on create/update (no-op for read/destroy) + # HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all policy action_type([:read, :create, :update, :destroy]) do - description "Check permissions from user's role" + description "Check permissions and forbid user link unless admin" + forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin authorize_if Mv.Authorization.Checks.HasPermission end - # 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed) + # 3. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed) end # Custom validation for email editing (see Special Cases section) @@ -1054,6 +1053,8 @@ end - **READ list queries**: No record at strict_check time → bypass with `expr(id == ^actor(:member_id))` needed for auto_filter ✅ - **UPDATE operations**: Changeset contains record → HasPermission evaluates `scope :linked` correctly ✅ +**User–member link:** Only admins may set or change the `:user` argument on create_member or update_member (see [User-Member Linking](#user-member-linking)). Non-admins can create/update members without passing `:user`. + **Permission Matrix:** | Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | @@ -1148,23 +1149,20 @@ end **Location:** `lib/mv/authorization/role.ex` -**Special Protection:** System roles cannot be deleted. +**Defense-in-depth:** The Role resource uses `authorizers: [Ash.Policy.Authorizer]` and policies with `Mv.Authorization.Checks.HasPermission`. **Read** is allowed for all permission sets (own_data, read_only, normal_user, admin) via `perm("Role", :read, :all)` in PermissionSets; reading roles is not a security concern. **Create, update, and destroy** are allowed only for admin (admin has full Role CRUD in PermissionSets). Seeds and bootstrap use `authorize?: false` where necessary. + +**Special Protection:** System roles cannot be deleted (validation on destroy). ```elixir defmodule Mv.Authorization.Role do - use Ash.Resource, ... + use Ash.Resource, + authorizers: [Ash.Policy.Authorizer] policies do - # Only admin can manage roles policy action_type([:read, :create, :update, :destroy]) do - description "Check permissions from user's role" + description "Check permissions from user's role (read all, create/update/destroy admin only)" authorize_if Mv.Authorization.Checks.HasPermission end - - # DEFAULT: Forbid - policy action_type([:read, :create, :update, :destroy]) do - forbid_if always() - end end # Prevent deletion of system roles @@ -1201,7 +1199,7 @@ end | Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | |--------|----------|----------|------------|-------------|-------| -| Read | ❌ | ❌ | ❌ | ❌ | ✅ | +| Read | ✅ | ✅ | ✅ | ✅ | ✅ | | Create | ❌ | ❌ | ❌ | ❌ | ✅ | | Update | ❌ | ❌ | ❌ | ❌ | ✅ | | Destroy* | ❌ | ❌ | ❌ | ❌ | ✅ | @@ -2045,7 +2043,10 @@ Users and Members are separate entities that can be linked. Special rules: - A user cannot link themselves to an existing member - A user CAN create a new member and be directly linked to it (self-service) -**Enforcement:** The User resource restricts the `update_user` action (which accepts the `member` argument for link/unlink) to admins only via `Mv.Authorization.Checks.ActorIsAdmin`. The UserLive.Form shows the Member-Linking UI and runs member link/unlink on save only when the current user is admin; non-admins use the `:update` action (email only) for profile edit. +**Enforcement:** + +- **User side:** The User resource restricts the `update_user` action (which accepts the `member` argument for link/unlink) to admins only via `Mv.Authorization.Checks.ActorIsAdmin`. The UserLive.Form shows the Member-Linking UI and runs member link/unlink on save only when the current user is admin; non-admins use the `:update` action (email only) for profile edit. +- **Member side:** Only admins may set or change the user–member link on **Member** create or update. When creating or updating a member, the `:user` argument (which links the member to a user account) is forbidden for non-admins. This is enforced by `Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin` in the Member resource policies (`forbid_if` before `authorize_if HasPermission`). Non-admins (e.g. normal_user / Kassenwart) can still create and update members as long as they do not pass the `:user` argument. ### Approach: Separate Ash Actions From f7ba98c36b84c7f908c26ac58d85935ce222258d Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 13:02:56 +0100 Subject: [PATCH 05/14] refactor: reduce nesting in SyncUserEmailToMember.sync_email Extract apply_sync/1 and sync_by_record_type/4 to satisfy Credo max depth 2. --- .../changes/sync_user_email_to_member.ex | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/lib/mv/email_sync/changes/sync_user_email_to_member.ex b/lib/mv/email_sync/changes/sync_user_email_to_member.ex index 624692b..26b26d4 100644 --- a/lib/mv/email_sync/changes/sync_user_email_to_member.ex +++ b/lib/mv/email_sync/changes/sync_user_email_to_member.ex @@ -44,26 +44,29 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do defp sync_email(changeset) do Ash.Changeset.around_transaction(changeset, fn cs, callback -> result = callback.(cs) - - with {:ok, record} <- Helpers.extract_record(result), - {:ok, user, member} <- get_user_and_member(record) do - # When called from Member-side, we need to update the member in the result - # When called from User-side, we update the linked member in DB only - case record do - %Mv.Membership.Member{} -> - # Member-side: Override member email in result with user email - Helpers.override_with_linked_email(result, user.email) - - %Mv.Accounts.User{} -> - # User-side: Sync user email to linked member in DB - Helpers.sync_email_to_linked_record(result, member, user.email) - end - else - _ -> result - end + apply_sync(result) end) end + defp apply_sync(result) do + with {:ok, record} <- Helpers.extract_record(result), + {:ok, user, member} <- get_user_and_member(record) do + sync_by_record_type(result, record, user, member) + else + _ -> result + end + end + + # When called from Member-side, we update the member in the result. + # When called from User-side, we sync user email to the linked member in DB. + defp sync_by_record_type(result, %Mv.Membership.Member{}, user, _member) do + Helpers.override_with_linked_email(result, user.email) + end + + defp sync_by_record_type(result, %Mv.Accounts.User{}, user, member) do + Helpers.sync_email_to_linked_record(result, member, user.email) + end + # Retrieves user and member - works for both resource types # Uses system actor via Loader functions defp get_user_and_member(%Mv.Accounts.User{} = user) do From 65ac6ca1d082355c184443adad061a36215f005f Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 13:24:14 +0100 Subject: [PATCH 06/14] Refactor member user-link tests: shared setup Use describe-level setup for normal_user, admin, unlinked_member. --- test/mv/membership/member_policies_test.exs | 78 ++++++++++++--------- 1 file changed, 44 insertions(+), 34 deletions(-) diff --git a/test/mv/membership/member_policies_test.exs b/test/mv/membership/member_policies_test.exs index 30936fe..287d0bb 100644 --- a/test/mv/membership/member_policies_test.exs +++ b/test/mv/membership/member_policies_test.exs @@ -405,10 +405,21 @@ defmodule Mv.Membership.MemberPoliciesTest do end describe "member user link - only admin may set or change user link" do - test "normal_user can create member without :user argument", %{actor: _actor} do - normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") - normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user) + setup %{actor: actor} do + normal_user = + Mv.Fixtures.user_with_role_fixture("normal_user") + |> Mv.Authorization.Actor.ensure_loaded() + admin = + Mv.Fixtures.user_with_role_fixture("admin") + |> Mv.Authorization.Actor.ensure_loaded() + + unlinked_member = create_unlinked_member(actor) + + %{normal_user: normal_user, admin: admin, unlinked_member: unlinked_member} + end + + test "normal_user can create member without :user argument", %{normal_user: normal_user} do {:ok, member} = Membership.create_member( %{ @@ -425,12 +436,12 @@ defmodule Mv.Membership.MemberPoliciesTest do assert is_nil(member.user) end - test "normal_user cannot create member with :user argument (forbidden)", %{actor: _actor} do - normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") - normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user) - # Another user to try to link to - other_user = Mv.Fixtures.user_with_role_fixture("read_only") - other_user = Mv.Authorization.Actor.ensure_loaded(other_user) + test "normal_user cannot create member with :user argument (forbidden)", %{ + normal_user: normal_user + } do + other_user = + Mv.Fixtures.user_with_role_fixture("read_only") + |> Mv.Authorization.Actor.ensure_loaded() attrs = %{ first_name: "Linked", @@ -443,11 +454,10 @@ defmodule Mv.Membership.MemberPoliciesTest do Membership.create_member(attrs, actor: normal_user) end - test "normal_user can update member without :user argument", %{actor: actor} do - normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") - normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user) - unlinked_member = create_unlinked_member(actor) - + test "normal_user can update member without :user argument", %{ + normal_user: normal_user, + unlinked_member: unlinked_member + } do {:ok, updated} = Membership.update_member(unlinked_member, %{first_name: "UpdatedByNormal"}, actor: normal_user @@ -456,25 +466,24 @@ defmodule Mv.Membership.MemberPoliciesTest do assert updated.first_name == "UpdatedByNormal" end - test "normal_user cannot update member with :user argument (forbidden)", %{actor: actor} do - normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") - normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user) - other_user = Mv.Fixtures.user_with_role_fixture("own_data") - other_user = Mv.Authorization.Actor.ensure_loaded(other_user) - unlinked_member = create_unlinked_member(actor) + test "normal_user cannot update member with :user argument (forbidden)", %{ + normal_user: normal_user, + unlinked_member: unlinked_member + } do + other_user = + Mv.Fixtures.user_with_role_fixture("own_data") + |> Mv.Authorization.Actor.ensure_loaded() - # Passing :user in params tries to link member to other_user - only admin may do that params = %{first_name: unlinked_member.first_name, user: %{id: other_user.id}} assert {:error, %Ash.Error.Forbidden{}} = Membership.update_member(unlinked_member, params, actor: normal_user) end - test "admin can create member with :user argument", %{actor: _actor} do - admin = Mv.Fixtures.user_with_role_fixture("admin") - admin = Mv.Authorization.Actor.ensure_loaded(admin) - link_target = Mv.Fixtures.user_with_role_fixture("own_data") - link_target = Mv.Authorization.Actor.ensure_loaded(link_target) + test "admin can create member with :user argument", %{admin: admin} do + link_target = + Mv.Fixtures.user_with_role_fixture("own_data") + |> Mv.Authorization.Actor.ensure_loaded() attrs = %{ first_name: "AdminLinked", @@ -486,19 +495,20 @@ defmodule Mv.Membership.MemberPoliciesTest do {:ok, member} = Membership.create_member(attrs, actor: admin) assert member.first_name == "AdminLinked" - # Reload link_target to see the new member_id set by manage_relationship + {:ok, link_target} = Ash.get(Mv.Accounts.User, link_target.id, domain: Mv.Accounts, actor: admin) assert link_target.member_id == member.id end - test "admin can update member with :user argument (link)", %{actor: actor} do - admin = Mv.Fixtures.user_with_role_fixture("admin") - admin = Mv.Authorization.Actor.ensure_loaded(admin) - unlinked_member = create_unlinked_member(actor) - link_target = Mv.Fixtures.user_with_role_fixture("read_only") - link_target = Mv.Authorization.Actor.ensure_loaded(link_target) + test "admin can update member with :user argument (link)", %{ + admin: admin, + unlinked_member: unlinked_member + } do + link_target = + Mv.Fixtures.user_with_role_fixture("read_only") + |> Mv.Authorization.Actor.ensure_loaded() {:ok, updated} = Membership.update_member( @@ -508,7 +518,7 @@ defmodule Mv.Membership.MemberPoliciesTest do ) assert updated.id == unlinked_member.id - # Member should now be linked to link_target (user.member_id points to this member) + {:ok, reloaded_user} = Ash.get(Mv.Accounts.User, link_target.id, domain: Mv.Accounts, From 40e75f40660932c2aa37ead6230e3771b4be74d1 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 13:29:41 +0100 Subject: [PATCH 07/14] refactor: reduce nesting in HasPermission.strict_check_with_permissions Extract strict_check_filter_scope/4 to satisfy Credo max depth 2. --- lib/mv/authorization/checks/has_permission.ex | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/lib/mv/authorization/checks/has_permission.ex b/lib/mv/authorization/checks/has_permission.ex index 1139c3c..721cee7 100644 --- a/lib/mv/authorization/checks/has_permission.ex +++ b/lib/mv/authorization/checks/has_permission.ex @@ -132,26 +132,10 @@ defmodule Mv.Authorization.Checks.HasPermission do resource_name ) do :authorized -> - # For :all scope, authorize directly {:ok, true} {:filter, filter_expr} -> - # For :own/:linked scope: - # - With a record, evaluate filter against record for strict authorization - # - Without a record (queries/lists), return false - # - # NOTE: Returning false here forces the use of expr-based bypass policies. - # This is necessary because Ash's policy evaluation doesn't reliably call auto_filter - # when strict_check returns :unknown. Instead, resources should use bypass policies - # with expr() directly for filter-based authorization (see User resource). - if record do - evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name) - else - # No record yet (e.g., read/list queries) - deny at strict_check level - # Resources must use expr-based bypass policies for list filtering - # Create: use a dedicated check that does not return a filter (e.g. CustomFieldValueCreateScope) - {:ok, false} - end + strict_check_filter_scope(record, filter_expr, actor, resource_name) false -> {:ok, false} @@ -175,6 +159,15 @@ defmodule Mv.Authorization.Checks.HasPermission do end end + # For :own/:linked scope: with record evaluate filter; without record deny (resources use bypass + expr). + defp strict_check_filter_scope(record, filter_expr, actor, resource_name) do + if record do + evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name) + else + {:ok, false} + end + end + @impl true def auto_filter(actor, authorizer, _opts) do resource = authorizer.resource From f342350537bc8ca2fc12dd049e89daf5bee9673d Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 13:46:49 +0100 Subject: [PATCH 08/14] Harden member user-link check: argument presence, nil actor, policy scope - Forbid on :user argument presence (not value) to block unlink via nil/empty - Defensive nil actor handling; policy restricted to create/update only - Test: Ash.load with actor; test non-admin cannot unlink via user: nil - Docs: unlink behaviour and policy split --- docs/roles-and-permissions-architecture.md | 20 ++++--- lib/membership/member.ex | 14 +++-- .../forbid_member_user_link_unless_admin.ex | 54 ++++++++++--------- test/mv/membership/member_policies_test.exs | 27 +++++++++- 4 files changed, 79 insertions(+), 36 deletions(-) diff --git a/docs/roles-and-permissions-architecture.md b/docs/roles-and-permissions-architecture.md index 14a396d..0035a1e 100644 --- a/docs/roles-and-permissions-architecture.md +++ b/docs/roles-and-permissions-architecture.md @@ -1025,16 +1025,22 @@ defmodule Mv.Membership.Member do authorize_if expr(id == ^actor(:member_id)) end - # 2. GENERAL: Forbid user link unless admin; then check permissions from role - # ForbidMemberUserLinkUnlessAdmin: only admins may pass :user on create/update (no-op for read/destroy) + # 2. READ/DESTROY: Check permissions only (no :user argument on these actions) + policy action_type([:read, :destroy]) do + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission + end + + # 3. CREATE/UPDATE: Forbid user link unless admin; then check permissions + # ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty). # HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all - policy action_type([:read, :create, :update, :destroy]) do - description "Check permissions and forbid user link unless admin" + policy action_type([:create, :update]) do + description "Forbid user link unless admin; then check permissions" forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin authorize_if Mv.Authorization.Checks.HasPermission end - - # 3. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed) + + # 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed) end # Custom validation for email editing (see Special Cases section) @@ -1053,7 +1059,7 @@ end - **READ list queries**: No record at strict_check time → bypass with `expr(id == ^actor(:member_id))` needed for auto_filter ✅ - **UPDATE operations**: Changeset contains record → HasPermission evaluates `scope :linked` correctly ✅ -**User–member link:** Only admins may set or change the `:user` argument on create_member or update_member (see [User-Member Linking](#user-member-linking)). Non-admins can create/update members without passing `:user`. +**User–member link:** Only admins may pass the `:user` argument on create_member or update_member (link or unlink via `user: nil`/`user: %{}`). The check uses **argument presence** (key in arguments), not value, to avoid bypass (see [User-Member Linking](#user-member-linking)). **Permission Matrix:** diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 8517634..fc007ac 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -312,11 +312,17 @@ defmodule Mv.Membership.Member do authorize_if expr(id == ^actor(:member_id)) end - # GENERAL: Check permissions from user's role; forbid member–user link unless admin - # ForbidMemberUserLinkUnlessAdmin: only admins may pass :user on create/update (no-op for read/destroy). + # READ/DESTROY: Check permissions only (no :user argument on these actions) + policy action_type([:read, :destroy]) do + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission + end + + # CREATE/UPDATE: Forbid member–user link unless admin, then check permissions + # ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty). # HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all. - policy action_type([:read, :create, :update, :destroy]) do - description "Check permissions and forbid user link unless admin" + policy action_type([:create, :update]) do + description "Forbid user link unless admin; then check permissions" forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin authorize_if Mv.Authorization.Checks.HasPermission end diff --git a/lib/mv/authorization/checks/forbid_member_user_link_unless_admin.ex b/lib/mv/authorization/checks/forbid_member_user_link_unless_admin.ex index facfdb2..ab4af9d 100644 --- a/lib/mv/authorization/checks/forbid_member_user_link_unless_admin.ex +++ b/lib/mv/authorization/checks/forbid_member_user_link_unless_admin.ex @@ -3,13 +3,23 @@ defmodule Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin do Policy check: forbids setting or changing the member–user link unless the actor is admin. Used on Member create_member and update_member actions. When the `:user` argument - is present (linking a member to a user account), only admins may perform the action. - Non-admin users (e.g. normal_user / Kassenwart) can still create and update members - as long as they do not pass the `:user` argument. + **is present** (key in arguments, regardless of value), only admins may perform the action. + This covers: + - **Linking:** `user: %{id: user_id}` → only admin + - **Unlinking:** `user: nil` or `user: %{}` on update_member triggers `on_missing: :unrelate` → only admin + Non-admin users (e.g. normal_user / Kassenwart) can create and update members only when + they do **not** pass the `:user` argument at all. + + ## Unlink via Member actions + + Unlink is intended via Member update_member: when `:user` is not provided in params, + manage_relationship uses `on_missing: :unrelate` and removes the link. Passing `user: nil` + or `user: %{}` explicitly is still "changing the link" and is forbidden for non-admins + (argument presence is checked, not value). ## Usage - In Member resource policies, add **before** the general HasPermission policy: + In Member resource policies, restrict to create/update only: policy action_type([:create, :update]) do forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin @@ -18,8 +28,9 @@ defmodule Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin do ## Behaviour - - If the action has no `:user` argument or it is nil/empty → does not forbid. - - If `:user` is set (e.g. `%{id: user_id}`) and actor is not admin → forbids (returns true). + - If the `:user` argument **key is not present** → does not forbid. + - If `:user` is present (any value, including nil or %{}) and actor is not admin → forbids. + - If actor is nil → treated as non-admin (forbid when :user present); no crash. - If actor is admin (or system actor) → does not forbid. """ use Ash.Policy.Check @@ -31,35 +42,30 @@ defmodule Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin do @impl true def strict_check(actor, authorizer, _opts) do - actor = Actor.ensure_loaded(actor) + # Defensive: nil actor → treat as non-admin (Actor.ensure_loaded(nil) and admin?(nil) are safe) + actor = if is_nil(actor), do: nil, else: Actor.ensure_loaded(actor) - if user_argument_set?(authorizer) and not Actor.admin?(actor) do + if user_argument_present?(authorizer) and not Actor.admin?(actor) do {:ok, true} else {:ok, false} end end - defp user_argument_set?(authorizer) do - user_arg = get_user_argument(authorizer) - not is_nil(user_arg) and not empty_user_arg?(user_arg) + # Forbid when :user was passed at all (link, unlink via nil/empty, or invalid value). + # Check argument key presence, not value, to avoid bypass via user: nil or user: %{}. + defp user_argument_present?(authorizer) do + args = get_arguments(authorizer) + Map.has_key?(args || %{}, :user) end - defp get_user_argument(authorizer) do - changeset = authorizer.changeset || authorizer.subject + defp get_arguments(authorizer) do + subject = authorizer.changeset || authorizer.subject cond do - is_struct(changeset, Ash.Changeset) -> - Ash.Changeset.get_argument(changeset, :user) - - is_struct(changeset, Ash.ActionInput) -> - Map.get(changeset.arguments || %{}, :user) - - true -> - nil + is_struct(subject, Ash.Changeset) -> subject.arguments + is_struct(subject, Ash.ActionInput) -> subject.arguments + true -> %{} end end - - defp empty_user_arg?(%{} = m), do: map_size(m) == 0 - defp empty_user_arg?(_), do: false end diff --git a/test/mv/membership/member_policies_test.exs b/test/mv/membership/member_policies_test.exs index 287d0bb..d9ab95c 100644 --- a/test/mv/membership/member_policies_test.exs +++ b/test/mv/membership/member_policies_test.exs @@ -432,7 +432,9 @@ defmodule Mv.Membership.MemberPoliciesTest do assert member.first_name == "NoLink" # Member has_one :user (FK on User side); ensure no user is linked - {:ok, member} = Ash.load(member, :user, domain: Mv.Membership) + {:ok, member} = + Ash.load(member, :user, domain: Mv.Membership, actor: normal_user) + assert is_nil(member.user) end @@ -480,6 +482,29 @@ defmodule Mv.Membership.MemberPoliciesTest do Membership.update_member(unlinked_member, params, actor: normal_user) end + test "normal_user cannot update member with user: nil (unlink forbidden)", %{ + normal_user: normal_user, + unlinked_member: unlinked_member + } do + # Link member first (via admin), then normal_user tries to unlink via user: nil + admin = + Mv.Fixtures.user_with_role_fixture("admin") |> Mv.Authorization.Actor.ensure_loaded() + + link_target = + Mv.Fixtures.user_with_role_fixture("own_data") |> Mv.Authorization.Actor.ensure_loaded() + + {:ok, linked_member} = + Membership.update_member( + unlinked_member, + %{user: %{id: link_target.id}}, + actor: admin + ) + + # Passing user: nil explicitly tries to unlink; only admin may do that + assert {:error, %Ash.Error.Forbidden{}} = + Membership.update_member(linked_member, %{user: nil}, actor: normal_user) + end + test "admin can create member with :user argument", %{admin: admin} do link_target = Mv.Fixtures.user_with_role_fixture("own_data") From 4d3a64c177c65536218772cf75323e8a69e5d950 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 12:37:48 +0100 Subject: [PATCH 09/14] Add Role resource policies (defense-in-depth) - PermissionSets: Role read :all for own_data, read_only, normal_user; admin keeps full CRUD - Role resource: authorizers and policies with HasPermission - Tests: role_policies_test.exs (read all, create/update/destroy admin only) - Fix existing tests to pass actor or authorize?: false for Role operations --- lib/mv/authorization/permission_sets.ex | 10 +- lib/mv/authorization/role.ex | 10 +- test/mv/authorization/role_policies_test.exs | 226 +++++++++++++++++++ test/mv/authorization/role_test.exs | 36 +-- test/mv/helpers/system_actor_test.exs | 45 ++-- test/mv_web/authorization_test.exs | 4 +- test/mv_web/live/role_live/show_test.exs | 6 +- test/mv_web/live/role_live_test.exs | 18 +- 8 files changed, 304 insertions(+), 51 deletions(-) create mode 100644 test/mv/authorization/role_policies_test.exs diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex index 9a5f7a7..b0e7015 100644 --- a/lib/mv/authorization/permission_sets.ex +++ b/lib/mv/authorization/permission_sets.ex @@ -78,6 +78,7 @@ defmodule Mv.Authorization.PermissionSets do defp custom_field_read_all, do: [perm("CustomField", :read, :all)] defp membership_fee_type_read_all, do: [perm("MembershipFeeType", :read, :all)] defp membership_fee_cycle_read_all, do: [perm("MembershipFeeCycle", :read, :all)] + defp role_read_all, do: [perm("Role", :read, :all)] @doc """ Returns the list of all valid permission set names. @@ -129,7 +130,8 @@ defmodule Mv.Authorization.PermissionSets do group_read_all() ++ [perm("MemberGroup", :read, :linked)] ++ membership_fee_type_read_all() ++ - [perm("MembershipFeeCycle", :read, :linked)], + [perm("MembershipFeeCycle", :read, :linked)] ++ + role_read_all(), pages: [ # No "/" - Mitglied must not see member index at root (same content as /members). # Own profile (sidebar links to /users/:id) and own user edit @@ -156,7 +158,8 @@ defmodule Mv.Authorization.PermissionSets do group_read_all() ++ [perm("MemberGroup", :read, :all)] ++ membership_fee_type_read_all() ++ - membership_fee_cycle_read_all(), + membership_fee_cycle_read_all() ++ + role_read_all(), pages: [ "/", # Own profile (sidebar links to /users/:id; redirect target must be allowed) @@ -211,7 +214,8 @@ defmodule Mv.Authorization.PermissionSets do perm("MembershipFeeCycle", :create, :all), perm("MembershipFeeCycle", :update, :all), perm("MembershipFeeCycle", :destroy, :all) - ], + ] ++ + role_read_all(), pages: [ "/", # Own profile (sidebar links to /users/:id; redirect target must be allowed) diff --git a/lib/mv/authorization/role.ex b/lib/mv/authorization/role.ex index 9c33e2d..59c0e51 100644 --- a/lib/mv/authorization/role.ex +++ b/lib/mv/authorization/role.ex @@ -37,7 +37,8 @@ defmodule Mv.Authorization.Role do """ use Ash.Resource, domain: Mv.Authorization, - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] postgres do table "roles" @@ -86,6 +87,13 @@ defmodule Mv.Authorization.Role do end end + policies do + policy action_type([:read, :create, :update, :destroy]) do + description "Role access: read for all permission sets, create/update/destroy for admin only (PermissionSets)" + authorize_if Mv.Authorization.Checks.HasPermission + end + end + validations do validate one_of( :permission_set_name, diff --git a/test/mv/authorization/role_policies_test.exs b/test/mv/authorization/role_policies_test.exs new file mode 100644 index 0000000..449f9d6 --- /dev/null +++ b/test/mv/authorization/role_policies_test.exs @@ -0,0 +1,226 @@ +defmodule Mv.Authorization.RolePoliciesTest do + @moduledoc """ + Tests for Role resource authorization policies. + + Rule: All permission sets (own_data, read_only, normal_user, admin) can **read** roles. + Only **admin** can create, update, or destroy roles. + """ + use Mv.DataCase, async: false + + alias Mv.Authorization + alias Mv.Authorization.Role + + describe "read access - all permission sets can read roles" do + setup do + # Create a role to read (via system_actor; once policies exist, system_actor is admin) + role = Mv.Fixtures.role_fixture("read_only") + %{role: role} + end + + @tag :permission_set_own_data + test "own_data can list roles", %{role: _role} do + user = Mv.Fixtures.user_with_role_fixture("own_data") + user = Mv.Authorization.Actor.ensure_loaded(user) + + {:ok, roles} = Authorization.list_roles(actor: user) + + assert is_list(roles) + assert roles != [] + end + + @tag :permission_set_own_data + test "own_data can get role by id", %{role: role} do + user = Mv.Fixtures.user_with_role_fixture("own_data") + user = Mv.Authorization.Actor.ensure_loaded(user) + + {:ok, loaded} = Ash.get(Role, role.id, actor: user, domain: Mv.Authorization) + + assert loaded.id == role.id + end + + @tag :permission_set_read_only + test "read_only can list roles", %{role: _role} do + user = Mv.Fixtures.user_with_role_fixture("read_only") + user = Mv.Authorization.Actor.ensure_loaded(user) + + {:ok, roles} = Authorization.list_roles(actor: user) + + assert is_list(roles) + assert roles != [] + end + + @tag :permission_set_read_only + test "read_only can get role by id", %{role: role} do + user = Mv.Fixtures.user_with_role_fixture("read_only") + user = Mv.Authorization.Actor.ensure_loaded(user) + + {:ok, loaded} = Ash.get(Role, role.id, actor: user, domain: Mv.Authorization) + + assert loaded.id == role.id + end + + @tag :permission_set_normal_user + test "normal_user can list roles", %{role: _role} do + user = Mv.Fixtures.user_with_role_fixture("normal_user") + user = Mv.Authorization.Actor.ensure_loaded(user) + + {:ok, roles} = Authorization.list_roles(actor: user) + + assert is_list(roles) + assert roles != [] + end + + @tag :permission_set_normal_user + test "normal_user can get role by id", %{role: role} do + user = Mv.Fixtures.user_with_role_fixture("normal_user") + user = Mv.Authorization.Actor.ensure_loaded(user) + + {:ok, loaded} = Ash.get(Role, role.id, actor: user, domain: Mv.Authorization) + + assert loaded.id == role.id + end + + @tag :permission_set_admin + test "admin can list roles", %{role: _role} do + admin = Mv.Fixtures.user_with_role_fixture("admin") + admin = Mv.Authorization.Actor.ensure_loaded(admin) + + {:ok, roles} = Authorization.list_roles(actor: admin) + + assert is_list(roles) + assert roles != [] + end + + @tag :permission_set_admin + test "admin can get role by id", %{role: role} do + admin = Mv.Fixtures.user_with_role_fixture("admin") + admin = Mv.Authorization.Actor.ensure_loaded(admin) + + {:ok, loaded} = Ash.get(Role, role.id, actor: admin, domain: Mv.Authorization) + + assert loaded.id == role.id + end + end + + describe "create/update/destroy - only admin allowed" do + setup do + # Non-system role for destroy test (role_fixture creates non-system roles) + role = Mv.Fixtures.role_fixture("normal_user") + %{role: role} + end + + test "admin can create_role", %{role: _role} do + admin = Mv.Fixtures.user_with_role_fixture("admin") + admin = Mv.Authorization.Actor.ensure_loaded(admin) + + attrs = %{ + name: "New Role #{System.unique_integer([:positive])}", + description: "Test", + permission_set_name: "read_only" + } + + assert {:ok, _created} = Authorization.create_role(attrs, actor: admin) + end + + test "admin can update_role", %{role: role} do + admin = Mv.Fixtures.user_with_role_fixture("admin") + admin = Mv.Authorization.Actor.ensure_loaded(admin) + + assert {:ok, updated} = + Authorization.update_role(role, %{description: "Updated by admin"}, actor: admin) + + assert updated.description == "Updated by admin" + end + + test "admin can destroy non-system role", %{role: role} do + admin = Mv.Fixtures.user_with_role_fixture("admin") + admin = Mv.Authorization.Actor.ensure_loaded(admin) + + assert :ok = Authorization.destroy_role(role, actor: admin) + end + + test "own_data cannot create_role (forbidden)", %{role: _role} do + user = Mv.Fixtures.user_with_role_fixture("own_data") + user = Mv.Authorization.Actor.ensure_loaded(user) + + attrs = %{ + name: "New Role #{System.unique_integer([:positive])}", + description: "Test", + permission_set_name: "read_only" + } + + assert {:error, %Ash.Error.Forbidden{}} = Authorization.create_role(attrs, actor: user) + end + + test "own_data cannot update_role (forbidden)", %{role: role} do + user = Mv.Fixtures.user_with_role_fixture("own_data") + user = Mv.Authorization.Actor.ensure_loaded(user) + + assert {:error, %Ash.Error.Forbidden{}} = + Authorization.update_role(role, %{description: "Updated"}, actor: user) + end + + test "own_data cannot destroy_role (forbidden)", %{role: role} do + user = Mv.Fixtures.user_with_role_fixture("own_data") + user = Mv.Authorization.Actor.ensure_loaded(user) + + assert {:error, %Ash.Error.Forbidden{}} = Authorization.destroy_role(role, actor: user) + end + + test "read_only cannot create_role (forbidden)", %{role: _role} do + user = Mv.Fixtures.user_with_role_fixture("read_only") + user = Mv.Authorization.Actor.ensure_loaded(user) + + attrs = %{ + name: "New Role #{System.unique_integer([:positive])}", + description: "Test", + permission_set_name: "read_only" + } + + assert {:error, %Ash.Error.Forbidden{}} = Authorization.create_role(attrs, actor: user) + end + + test "read_only cannot update_role (forbidden)", %{role: role} do + user = Mv.Fixtures.user_with_role_fixture("read_only") + user = Mv.Authorization.Actor.ensure_loaded(user) + + assert {:error, %Ash.Error.Forbidden{}} = + Authorization.update_role(role, %{description: "Updated"}, actor: user) + end + + test "read_only cannot destroy_role (forbidden)", %{role: role} do + user = Mv.Fixtures.user_with_role_fixture("read_only") + user = Mv.Authorization.Actor.ensure_loaded(user) + + assert {:error, %Ash.Error.Forbidden{}} = Authorization.destroy_role(role, actor: user) + end + + test "normal_user cannot create_role (forbidden)", %{role: _role} do + user = Mv.Fixtures.user_with_role_fixture("normal_user") + user = Mv.Authorization.Actor.ensure_loaded(user) + + attrs = %{ + name: "New Role #{System.unique_integer([:positive])}", + description: "Test", + permission_set_name: "normal_user" + } + + assert {:error, %Ash.Error.Forbidden{}} = Authorization.create_role(attrs, actor: user) + end + + test "normal_user cannot update_role (forbidden)", %{role: role} do + user = Mv.Fixtures.user_with_role_fixture("normal_user") + user = Mv.Authorization.Actor.ensure_loaded(user) + + assert {:error, %Ash.Error.Forbidden{}} = + Authorization.update_role(role, %{description: "Updated"}, actor: user) + end + + test "normal_user cannot destroy_role (forbidden)", %{role: role} do + user = Mv.Fixtures.user_with_role_fixture("normal_user") + user = Mv.Authorization.Actor.ensure_loaded(user) + + assert {:error, %Ash.Error.Forbidden{}} = Authorization.destroy_role(role, actor: user) + end + end +end diff --git a/test/mv/authorization/role_test.exs b/test/mv/authorization/role_test.exs index b7aa632..426719a 100644 --- a/test/mv/authorization/role_test.exs +++ b/test/mv/authorization/role_test.exs @@ -12,27 +12,29 @@ defmodule Mv.Authorization.RoleTest do end describe "permission_set_name validation" do - test "accepts valid permission set names" do + test "accepts valid permission set names", %{actor: actor} do attrs = %{ name: "Test Role", permission_set_name: "own_data" } - assert {:ok, role} = Authorization.create_role(attrs) + assert {:ok, role} = Authorization.create_role(attrs, actor: actor) assert role.permission_set_name == "own_data" end - test "rejects invalid permission set names" do + test "rejects invalid permission set names", %{actor: actor} do attrs = %{ name: "Test Role", permission_set_name: "invalid_set" } - assert {:error, %Ash.Error.Invalid{errors: errors}} = Authorization.create_role(attrs) + assert {:error, %Ash.Error.Invalid{errors: errors}} = + Authorization.create_role(attrs, actor: actor) + assert error_message(errors, :permission_set_name) =~ "must be one of" end - test "accepts all four valid permission sets" do + test "accepts all four valid permission sets", %{actor: actor} do valid_sets = ["own_data", "read_only", "normal_user", "admin"] for permission_set <- valid_sets do @@ -41,7 +43,7 @@ defmodule Mv.Authorization.RoleTest do permission_set_name: permission_set } - assert {:ok, _role} = Authorization.create_role(attrs) + assert {:ok, _role} = Authorization.create_role(attrs, actor: actor) end end end @@ -60,34 +62,36 @@ defmodule Mv.Authorization.RoleTest do {:ok, system_role} = Ash.create(changeset, actor: actor) assert {:error, %Ash.Error.Invalid{errors: errors}} = - Authorization.destroy_role(system_role) + Authorization.destroy_role(system_role, actor: actor) message = error_message(errors, :is_system_role) assert message =~ "Cannot delete system role" end - test "allows deletion of non-system roles" do + test "allows deletion of non-system roles", %{actor: actor} 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" - }) + Authorization.create_role( + %{name: "Regular Role", permission_set_name: "read_only"}, + actor: actor + ) - assert :ok = Authorization.destroy_role(regular_role) + assert :ok = Authorization.destroy_role(regular_role, actor: actor) end end describe "name uniqueness" do - test "enforces unique role names" do + test "enforces unique role names", %{actor: actor} do attrs = %{ name: "Unique Role", permission_set_name: "own_data" } - assert {:ok, _} = Authorization.create_role(attrs) + assert {:ok, _} = Authorization.create_role(attrs, actor: actor) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = + Authorization.create_role(attrs, actor: actor) - assert {:error, %Ash.Error.Invalid{errors: errors}} = Authorization.create_role(attrs) assert error_message(errors, :name) =~ "has already been taken" end end diff --git a/test/mv/helpers/system_actor_test.exs b/test/mv/helpers/system_actor_test.exs index c2715ae..add2ad5 100644 --- a/test/mv/helpers/system_actor_test.exs +++ b/test/mv/helpers/system_actor_test.exs @@ -18,18 +18,21 @@ defmodule Mv.Helpers.SystemActorTest do Ecto.Adapters.SQL.query!(Mv.Repo, "DELETE FROM users WHERE id = $1", [id]) end - # Helper function to ensure admin role exists + # Helper function to ensure admin role exists (bootstrap: no actor yet, use authorize?: false) defp ensure_admin_role do - case Authorization.list_roles() do + case Authorization.list_roles(authorize?: false) do {:ok, roles} -> case Enum.find(roles, &(&1.permission_set_name == "admin")) do nil -> {:ok, role} = - Authorization.create_role(%{ - name: "Admin", - description: "Administrator with full access", - permission_set_name: "admin" - }) + Authorization.create_role( + %{ + name: "Admin", + description: "Administrator with full access", + permission_set_name: "admin" + }, + authorize?: false + ) role @@ -39,11 +42,14 @@ defmodule Mv.Helpers.SystemActorTest do _ -> {:ok, role} = - Authorization.create_role(%{ - name: "Admin", - description: "Administrator with full access", - permission_set_name: "admin" - }) + Authorization.create_role( + %{ + name: "Admin", + description: "Administrator with full access", + permission_set_name: "admin" + }, + authorize?: false + ) role end @@ -364,12 +370,17 @@ defmodule Mv.Helpers.SystemActorTest do test "raises error if system user has wrong role", %{system_user: system_user} do # Create a non-admin role (using read_only as it's a valid permission set) + system_actor = SystemActor.get_system_actor() + {:ok, read_only_role} = - Authorization.create_role(%{ - name: "Read Only Role", - description: "Read-only access", - permission_set_name: "read_only" - }) + Authorization.create_role( + %{ + name: "Read Only Role", + description: "Read-only access", + permission_set_name: "read_only" + }, + actor: system_actor + ) system_actor = SystemActor.get_system_actor() diff --git a/test/mv_web/authorization_test.exs b/test/mv_web/authorization_test.exs index d07e482..7bb0b2a 100644 --- a/test/mv_web/authorization_test.exs +++ b/test/mv_web/authorization_test.exs @@ -50,14 +50,14 @@ defmodule MvWeb.AuthorizationTest do assert Authorization.can?(admin, :destroy, Mv.Authorization.Role) == true end - test "non-admin cannot manage roles" do + test "non-admin can read roles but cannot create/update/destroy" do normal_user = %{ id: "normal-123", role: %{permission_set_name: "normal_user"} } + assert Authorization.can?(normal_user, :read, Mv.Authorization.Role) == true assert Authorization.can?(normal_user, :create, Mv.Authorization.Role) == false - assert Authorization.can?(normal_user, :read, Mv.Authorization.Role) == false assert Authorization.can?(normal_user, :update, Mv.Authorization.Role) == false assert Authorization.can?(normal_user, :destroy, Mv.Authorization.Role) == false end diff --git a/test/mv_web/live/role_live/show_test.exs b/test/mv_web/live/role_live/show_test.exs index ed099ec..fe5c48d 100644 --- a/test/mv_web/live/role_live/show_test.exs +++ b/test/mv_web/live/role_live/show_test.exs @@ -18,7 +18,7 @@ defmodule MvWeb.RoleLive.ShowTest do alias Mv.Authorization alias Mv.Authorization.Role - # Helper to create a role + # Helper to create a role (authorize?: false for test data setup) defp create_role(attrs \\ %{}) do default_attrs = %{ name: "Test Role #{System.unique_integer([:positive])}", @@ -28,7 +28,7 @@ defmodule MvWeb.RoleLive.ShowTest do attrs = Map.merge(default_attrs, attrs) - case Authorization.create_role(attrs) do + case Authorization.create_role(attrs, authorize?: false) do {:ok, role} -> role {:error, error} -> raise "Failed to create role: #{inspect(error)}" end @@ -38,7 +38,7 @@ defmodule MvWeb.RoleLive.ShowTest do defp create_admin_user(conn, actor) do # Create admin role admin_role = - case Authorization.list_roles() do + case Authorization.list_roles(authorize?: false) do {:ok, roles} -> case Enum.find(roles, &(&1.name == "Admin")) do nil -> diff --git a/test/mv_web/live/role_live_test.exs b/test/mv_web/live/role_live_test.exs index 0edd2a4..cb112f2 100644 --- a/test/mv_web/live/role_live_test.exs +++ b/test/mv_web/live/role_live_test.exs @@ -9,7 +9,7 @@ defmodule MvWeb.RoleLiveTest do alias Mv.Authorization alias Mv.Authorization.Role - # Helper to create a role + # Helper to create a role (authorize?: false for test data setup) defp create_role(attrs \\ %{}) do default_attrs = %{ name: "Test Role #{System.unique_integer([:positive])}", @@ -19,7 +19,7 @@ defmodule MvWeb.RoleLiveTest do attrs = Map.merge(default_attrs, attrs) - case Authorization.create_role(attrs) do + case Authorization.create_role(attrs, authorize?: false) do {:ok, role} -> role {:error, error} -> raise "Failed to create role: #{inspect(error)}" end @@ -29,7 +29,7 @@ defmodule MvWeb.RoleLiveTest do defp create_admin_user(conn, actor) do # Create admin role admin_role = - case Authorization.list_roles() do + case Authorization.list_roles(authorize?: false) do {:ok, roles} -> case Enum.find(roles, &(&1.name == "Admin")) do nil -> @@ -332,7 +332,7 @@ defmodule MvWeb.RoleLiveTest do assert match?({:error, {:redirect, %{to: "/admin/roles"}}}, result) end - test "updates role name", %{conn: conn, role: role} do + test "updates role name", %{conn: conn, role: role, actor: actor} do {:ok, view, _html} = live(conn, "/admin/roles/#{role.id}/edit?return_to=show") attrs = %{ @@ -348,7 +348,7 @@ defmodule MvWeb.RoleLiveTest do assert_redirect(view, "/admin/roles/#{role.id}") # Verify update - {:ok, updated_role} = Authorization.get_role(role.id) + {:ok, updated_role} = Authorization.get_role(role.id, actor: actor) assert updated_role.name == "Updated Role Name" end @@ -377,7 +377,7 @@ defmodule MvWeb.RoleLiveTest do assert_redirect(view, "/admin/roles/#{system_role.id}") # Verify update - {:ok, updated_role} = Authorization.get_role(system_role.id) + {:ok, updated_role} = Authorization.get_role(system_role.id, actor: actor) assert updated_role.permission_set_name == "read_only" end end @@ -390,7 +390,7 @@ defmodule MvWeb.RoleLiveTest do end @tag :slow - test "deletes non-system role", %{conn: conn} do + test "deletes non-system role", %{conn: conn, actor: actor} do role = create_role() {:ok, view, html} = live(conn, "/admin/roles") @@ -404,7 +404,7 @@ defmodule MvWeb.RoleLiveTest do # Verify deletion by checking database assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} = - Authorization.get_role(role.id) + Authorization.get_role(role.id, actor: actor) end test "fails to delete system role with error message", %{conn: conn, actor: actor} do @@ -430,7 +430,7 @@ defmodule MvWeb.RoleLiveTest do assert render(view) =~ "System roles cannot be deleted" # Role should still exist - {:ok, _role} = Authorization.get_role(system_role.id) + {:ok, _role} = Authorization.get_role(system_role.id, actor: actor) end end From 26fbafdd9d8974f7d9ae004f925f2fb22e646584 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 12:50:10 +0100 Subject: [PATCH 10/14] Restrict member user link to admins (forbid policy) Add ForbidMemberUserLinkUnlessAdmin check; forbid_if on Member create/update. Fix member user-link tests: pass :user in params, assert via reload. --- lib/membership/member.ex | 12 +- .../forbid_member_user_link_unless_admin.ex | 65 ++++++++++ test/mv/membership/member_policies_test.exs | 116 ++++++++++++++++++ 3 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 lib/mv/authorization/checks/forbid_member_user_link_unless_admin.ex diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 8213ecb..8517634 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -312,14 +312,12 @@ defmodule Mv.Membership.Member do authorize_if expr(id == ^actor(:member_id)) end - # GENERAL: Check permissions from user's role - # HasPermission handles update permissions correctly: - # - :own_data → can update linked member (scope :linked) - # - :read_only → cannot update any member (no update permission) - # - :normal_user → can update all members (scope :all) - # - :admin → can update all members (scope :all) + # GENERAL: Check permissions from user's role; forbid member–user link unless admin + # ForbidMemberUserLinkUnlessAdmin: only admins may pass :user on create/update (no-op for read/destroy). + # HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all. policy action_type([:read, :create, :update, :destroy]) do - description "Check permissions from user's role and permission set" + description "Check permissions and forbid user link unless admin" + forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin authorize_if Mv.Authorization.Checks.HasPermission end diff --git a/lib/mv/authorization/checks/forbid_member_user_link_unless_admin.ex b/lib/mv/authorization/checks/forbid_member_user_link_unless_admin.ex new file mode 100644 index 0000000..facfdb2 --- /dev/null +++ b/lib/mv/authorization/checks/forbid_member_user_link_unless_admin.ex @@ -0,0 +1,65 @@ +defmodule Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin do + @moduledoc """ + Policy check: forbids setting or changing the member–user link unless the actor is admin. + + Used on Member create_member and update_member actions. When the `:user` argument + is present (linking a member to a user account), only admins may perform the action. + Non-admin users (e.g. normal_user / Kassenwart) can still create and update members + as long as they do not pass the `:user` argument. + + ## Usage + + In Member resource policies, add **before** the general HasPermission policy: + + policy action_type([:create, :update]) do + forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin + authorize_if Mv.Authorization.Checks.HasPermission + end + + ## Behaviour + + - If the action has no `:user` argument or it is nil/empty → does not forbid. + - If `:user` is set (e.g. `%{id: user_id}`) and actor is not admin → forbids (returns true). + - If actor is admin (or system actor) → does not forbid. + """ + use Ash.Policy.Check + + alias Mv.Authorization.Actor + + @impl true + def describe(_opts), do: "forbid setting member–user link unless actor is admin" + + @impl true + def strict_check(actor, authorizer, _opts) do + actor = Actor.ensure_loaded(actor) + + if user_argument_set?(authorizer) and not Actor.admin?(actor) do + {:ok, true} + else + {:ok, false} + end + end + + defp user_argument_set?(authorizer) do + user_arg = get_user_argument(authorizer) + not is_nil(user_arg) and not empty_user_arg?(user_arg) + end + + defp get_user_argument(authorizer) do + changeset = authorizer.changeset || authorizer.subject + + cond do + is_struct(changeset, Ash.Changeset) -> + Ash.Changeset.get_argument(changeset, :user) + + is_struct(changeset, Ash.ActionInput) -> + Map.get(changeset.arguments || %{}, :user) + + true -> + nil + end + end + + defp empty_user_arg?(%{} = m), do: map_size(m) == 0 + defp empty_user_arg?(_), do: false +end diff --git a/test/mv/membership/member_policies_test.exs b/test/mv/membership/member_policies_test.exs index a66941b..30936fe 100644 --- a/test/mv/membership/member_policies_test.exs +++ b/test/mv/membership/member_policies_test.exs @@ -403,4 +403,120 @@ defmodule Mv.Membership.MemberPoliciesTest do assert updated_member.first_name == "Updated" end end + + describe "member user link - only admin may set or change user link" do + test "normal_user can create member without :user argument", %{actor: _actor} do + normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") + normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user) + + {:ok, member} = + Membership.create_member( + %{ + first_name: "NoLink", + last_name: "Member", + email: "nolink#{System.unique_integer([:positive])}@example.com" + }, + actor: normal_user + ) + + assert member.first_name == "NoLink" + # Member has_one :user (FK on User side); ensure no user is linked + {:ok, member} = Ash.load(member, :user, domain: Mv.Membership) + assert is_nil(member.user) + end + + test "normal_user cannot create member with :user argument (forbidden)", %{actor: _actor} do + normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") + normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user) + # Another user to try to link to + other_user = Mv.Fixtures.user_with_role_fixture("read_only") + other_user = Mv.Authorization.Actor.ensure_loaded(other_user) + + attrs = %{ + first_name: "Linked", + last_name: "Member", + email: "linked#{System.unique_integer([:positive])}@example.com", + user: %{id: other_user.id} + } + + assert {:error, %Ash.Error.Forbidden{}} = + Membership.create_member(attrs, actor: normal_user) + end + + test "normal_user can update member without :user argument", %{actor: actor} do + normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") + normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user) + unlinked_member = create_unlinked_member(actor) + + {:ok, updated} = + Membership.update_member(unlinked_member, %{first_name: "UpdatedByNormal"}, + actor: normal_user + ) + + assert updated.first_name == "UpdatedByNormal" + end + + test "normal_user cannot update member with :user argument (forbidden)", %{actor: actor} do + normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") + normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user) + other_user = Mv.Fixtures.user_with_role_fixture("own_data") + other_user = Mv.Authorization.Actor.ensure_loaded(other_user) + unlinked_member = create_unlinked_member(actor) + + # Passing :user in params tries to link member to other_user - only admin may do that + params = %{first_name: unlinked_member.first_name, user: %{id: other_user.id}} + + assert {:error, %Ash.Error.Forbidden{}} = + Membership.update_member(unlinked_member, params, actor: normal_user) + end + + test "admin can create member with :user argument", %{actor: _actor} do + admin = Mv.Fixtures.user_with_role_fixture("admin") + admin = Mv.Authorization.Actor.ensure_loaded(admin) + link_target = Mv.Fixtures.user_with_role_fixture("own_data") + link_target = Mv.Authorization.Actor.ensure_loaded(link_target) + + attrs = %{ + first_name: "AdminLinked", + last_name: "Member", + email: "adminlinked#{System.unique_integer([:positive])}@example.com", + user: %{id: link_target.id} + } + + {:ok, member} = Membership.create_member(attrs, actor: admin) + + assert member.first_name == "AdminLinked" + # Reload link_target to see the new member_id set by manage_relationship + {:ok, link_target} = + Ash.get(Mv.Accounts.User, link_target.id, domain: Mv.Accounts, actor: admin) + + assert link_target.member_id == member.id + end + + test "admin can update member with :user argument (link)", %{actor: actor} do + admin = Mv.Fixtures.user_with_role_fixture("admin") + admin = Mv.Authorization.Actor.ensure_loaded(admin) + unlinked_member = create_unlinked_member(actor) + link_target = Mv.Fixtures.user_with_role_fixture("read_only") + link_target = Mv.Authorization.Actor.ensure_loaded(link_target) + + {:ok, updated} = + Membership.update_member( + unlinked_member, + %{user: %{id: link_target.id}}, + actor: admin + ) + + assert updated.id == unlinked_member.id + # Member should now be linked to link_target (user.member_id points to this member) + {:ok, reloaded_user} = + Ash.get(Mv.Accounts.User, link_target.id, + domain: Mv.Accounts, + load: [:member], + actor: admin + ) + + assert reloaded_user.member_id == updated.id + end + end end From 54e419ed4c0197d02ebd1a68b40fa3c36b390713 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 12:54:15 +0100 Subject: [PATCH 11/14] Docs: permission hardening Role and member user link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Role: Ash policies (HasPermission); read for all, create/update/destroy admin only. User–member link: only admins may set :user on Member create/update (ForbidMemberUserLinkUnlessAdmin). --- docs/roles-and-permissions-architecture.md | 37 +++++++++++----------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/docs/roles-and-permissions-architecture.md b/docs/roles-and-permissions-architecture.md index 92ad3c5..14a396d 100644 --- a/docs/roles-and-permissions-architecture.md +++ b/docs/roles-and-permissions-architecture.md @@ -1025,17 +1025,16 @@ defmodule Mv.Membership.Member do authorize_if expr(id == ^actor(:member_id)) end - # 2. GENERAL: Check permissions from role - # - :own_data → can UPDATE linked member (scope :linked via HasPermission) - # - :read_only → can READ all members (scope :all), no update permission - # - :normal_user → can CRUD all members (scope :all) - # - :admin → can CRUD all members (scope :all) + # 2. GENERAL: Forbid user link unless admin; then check permissions from role + # ForbidMemberUserLinkUnlessAdmin: only admins may pass :user on create/update (no-op for read/destroy) + # HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all policy action_type([:read, :create, :update, :destroy]) do - description "Check permissions from user's role" + description "Check permissions and forbid user link unless admin" + forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin authorize_if Mv.Authorization.Checks.HasPermission end - # 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed) + # 3. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed) end # Custom validation for email editing (see Special Cases section) @@ -1054,6 +1053,8 @@ end - **READ list queries**: No record at strict_check time → bypass with `expr(id == ^actor(:member_id))` needed for auto_filter ✅ - **UPDATE operations**: Changeset contains record → HasPermission evaluates `scope :linked` correctly ✅ +**User–member link:** Only admins may set or change the `:user` argument on create_member or update_member (see [User-Member Linking](#user-member-linking)). Non-admins can create/update members without passing `:user`. + **Permission Matrix:** | Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | @@ -1148,23 +1149,20 @@ end **Location:** `lib/mv/authorization/role.ex` -**Special Protection:** System roles cannot be deleted. +**Defense-in-depth:** The Role resource uses `authorizers: [Ash.Policy.Authorizer]` and policies with `Mv.Authorization.Checks.HasPermission`. **Read** is allowed for all permission sets (own_data, read_only, normal_user, admin) via `perm("Role", :read, :all)` in PermissionSets; reading roles is not a security concern. **Create, update, and destroy** are allowed only for admin (admin has full Role CRUD in PermissionSets). Seeds and bootstrap use `authorize?: false` where necessary. + +**Special Protection:** System roles cannot be deleted (validation on destroy). ```elixir defmodule Mv.Authorization.Role do - use Ash.Resource, ... + use Ash.Resource, + authorizers: [Ash.Policy.Authorizer] policies do - # Only admin can manage roles policy action_type([:read, :create, :update, :destroy]) do - description "Check permissions from user's role" + description "Check permissions from user's role (read all, create/update/destroy admin only)" authorize_if Mv.Authorization.Checks.HasPermission end - - # DEFAULT: Forbid - policy action_type([:read, :create, :update, :destroy]) do - forbid_if always() - end end # Prevent deletion of system roles @@ -1201,7 +1199,7 @@ end | Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | |--------|----------|----------|------------|-------------|-------| -| Read | ❌ | ❌ | ❌ | ❌ | ✅ | +| Read | ✅ | ✅ | ✅ | ✅ | ✅ | | Create | ❌ | ❌ | ❌ | ❌ | ✅ | | Update | ❌ | ❌ | ❌ | ❌ | ✅ | | Destroy* | ❌ | ❌ | ❌ | ❌ | ✅ | @@ -2045,7 +2043,10 @@ Users and Members are separate entities that can be linked. Special rules: - A user cannot link themselves to an existing member - A user CAN create a new member and be directly linked to it (self-service) -**Enforcement:** The User resource restricts the `update_user` action (which accepts the `member` argument for link/unlink) to admins only via `Mv.Authorization.Checks.ActorIsAdmin`. The UserLive.Form shows the Member-Linking UI and runs member link/unlink on save only when the current user is admin; non-admins use the `:update` action (email only) for profile edit. +**Enforcement:** + +- **User side:** The User resource restricts the `update_user` action (which accepts the `member` argument for link/unlink) to admins only via `Mv.Authorization.Checks.ActorIsAdmin`. The UserLive.Form shows the Member-Linking UI and runs member link/unlink on save only when the current user is admin; non-admins use the `:update` action (email only) for profile edit. +- **Member side:** Only admins may set or change the user–member link on **Member** create or update. When creating or updating a member, the `:user` argument (which links the member to a user account) is forbidden for non-admins. This is enforced by `Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin` in the Member resource policies (`forbid_if` before `authorize_if HasPermission`). Non-admins (e.g. normal_user / Kassenwart) can still create and update members as long as they do not pass the `:user` argument. ### Approach: Separate Ash Actions From 34e049ef32b7a6d9245676271420c7b3d269a58c Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 13:24:14 +0100 Subject: [PATCH 12/14] Refactor member user-link tests: shared setup Use describe-level setup for normal_user, admin, unlinked_member. --- test/mv/membership/member_policies_test.exs | 78 ++++++++++++--------- 1 file changed, 44 insertions(+), 34 deletions(-) diff --git a/test/mv/membership/member_policies_test.exs b/test/mv/membership/member_policies_test.exs index 30936fe..287d0bb 100644 --- a/test/mv/membership/member_policies_test.exs +++ b/test/mv/membership/member_policies_test.exs @@ -405,10 +405,21 @@ defmodule Mv.Membership.MemberPoliciesTest do end describe "member user link - only admin may set or change user link" do - test "normal_user can create member without :user argument", %{actor: _actor} do - normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") - normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user) + setup %{actor: actor} do + normal_user = + Mv.Fixtures.user_with_role_fixture("normal_user") + |> Mv.Authorization.Actor.ensure_loaded() + admin = + Mv.Fixtures.user_with_role_fixture("admin") + |> Mv.Authorization.Actor.ensure_loaded() + + unlinked_member = create_unlinked_member(actor) + + %{normal_user: normal_user, admin: admin, unlinked_member: unlinked_member} + end + + test "normal_user can create member without :user argument", %{normal_user: normal_user} do {:ok, member} = Membership.create_member( %{ @@ -425,12 +436,12 @@ defmodule Mv.Membership.MemberPoliciesTest do assert is_nil(member.user) end - test "normal_user cannot create member with :user argument (forbidden)", %{actor: _actor} do - normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") - normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user) - # Another user to try to link to - other_user = Mv.Fixtures.user_with_role_fixture("read_only") - other_user = Mv.Authorization.Actor.ensure_loaded(other_user) + test "normal_user cannot create member with :user argument (forbidden)", %{ + normal_user: normal_user + } do + other_user = + Mv.Fixtures.user_with_role_fixture("read_only") + |> Mv.Authorization.Actor.ensure_loaded() attrs = %{ first_name: "Linked", @@ -443,11 +454,10 @@ defmodule Mv.Membership.MemberPoliciesTest do Membership.create_member(attrs, actor: normal_user) end - test "normal_user can update member without :user argument", %{actor: actor} do - normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") - normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user) - unlinked_member = create_unlinked_member(actor) - + test "normal_user can update member without :user argument", %{ + normal_user: normal_user, + unlinked_member: unlinked_member + } do {:ok, updated} = Membership.update_member(unlinked_member, %{first_name: "UpdatedByNormal"}, actor: normal_user @@ -456,25 +466,24 @@ defmodule Mv.Membership.MemberPoliciesTest do assert updated.first_name == "UpdatedByNormal" end - test "normal_user cannot update member with :user argument (forbidden)", %{actor: actor} do - normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") - normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user) - other_user = Mv.Fixtures.user_with_role_fixture("own_data") - other_user = Mv.Authorization.Actor.ensure_loaded(other_user) - unlinked_member = create_unlinked_member(actor) + test "normal_user cannot update member with :user argument (forbidden)", %{ + normal_user: normal_user, + unlinked_member: unlinked_member + } do + other_user = + Mv.Fixtures.user_with_role_fixture("own_data") + |> Mv.Authorization.Actor.ensure_loaded() - # Passing :user in params tries to link member to other_user - only admin may do that params = %{first_name: unlinked_member.first_name, user: %{id: other_user.id}} assert {:error, %Ash.Error.Forbidden{}} = Membership.update_member(unlinked_member, params, actor: normal_user) end - test "admin can create member with :user argument", %{actor: _actor} do - admin = Mv.Fixtures.user_with_role_fixture("admin") - admin = Mv.Authorization.Actor.ensure_loaded(admin) - link_target = Mv.Fixtures.user_with_role_fixture("own_data") - link_target = Mv.Authorization.Actor.ensure_loaded(link_target) + test "admin can create member with :user argument", %{admin: admin} do + link_target = + Mv.Fixtures.user_with_role_fixture("own_data") + |> Mv.Authorization.Actor.ensure_loaded() attrs = %{ first_name: "AdminLinked", @@ -486,19 +495,20 @@ defmodule Mv.Membership.MemberPoliciesTest do {:ok, member} = Membership.create_member(attrs, actor: admin) assert member.first_name == "AdminLinked" - # Reload link_target to see the new member_id set by manage_relationship + {:ok, link_target} = Ash.get(Mv.Accounts.User, link_target.id, domain: Mv.Accounts, actor: admin) assert link_target.member_id == member.id end - test "admin can update member with :user argument (link)", %{actor: actor} do - admin = Mv.Fixtures.user_with_role_fixture("admin") - admin = Mv.Authorization.Actor.ensure_loaded(admin) - unlinked_member = create_unlinked_member(actor) - link_target = Mv.Fixtures.user_with_role_fixture("read_only") - link_target = Mv.Authorization.Actor.ensure_loaded(link_target) + test "admin can update member with :user argument (link)", %{ + admin: admin, + unlinked_member: unlinked_member + } do + link_target = + Mv.Fixtures.user_with_role_fixture("read_only") + |> Mv.Authorization.Actor.ensure_loaded() {:ok, updated} = Membership.update_member( @@ -508,7 +518,7 @@ defmodule Mv.Membership.MemberPoliciesTest do ) assert updated.id == unlinked_member.id - # Member should now be linked to link_target (user.member_id points to this member) + {:ok, reloaded_user} = Ash.get(Mv.Accounts.User, link_target.id, domain: Mv.Accounts, From 543fded1022979436b0a4e18843d6870e694e136 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 13:46:49 +0100 Subject: [PATCH 13/14] Harden member user-link check: argument presence, nil actor, policy scope - Forbid on :user argument presence (not value) to block unlink via nil/empty - Defensive nil actor handling; policy restricted to create/update only - Test: Ash.load with actor; test non-admin cannot unlink via user: nil - Docs: unlink behaviour and policy split --- docs/roles-and-permissions-architecture.md | 20 ++++--- lib/membership/member.ex | 14 +++-- .../forbid_member_user_link_unless_admin.ex | 54 ++++++++++--------- test/mv/membership/member_policies_test.exs | 27 +++++++++- 4 files changed, 79 insertions(+), 36 deletions(-) diff --git a/docs/roles-and-permissions-architecture.md b/docs/roles-and-permissions-architecture.md index 14a396d..0035a1e 100644 --- a/docs/roles-and-permissions-architecture.md +++ b/docs/roles-and-permissions-architecture.md @@ -1025,16 +1025,22 @@ defmodule Mv.Membership.Member do authorize_if expr(id == ^actor(:member_id)) end - # 2. GENERAL: Forbid user link unless admin; then check permissions from role - # ForbidMemberUserLinkUnlessAdmin: only admins may pass :user on create/update (no-op for read/destroy) + # 2. READ/DESTROY: Check permissions only (no :user argument on these actions) + policy action_type([:read, :destroy]) do + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission + end + + # 3. CREATE/UPDATE: Forbid user link unless admin; then check permissions + # ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty). # HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all - policy action_type([:read, :create, :update, :destroy]) do - description "Check permissions and forbid user link unless admin" + policy action_type([:create, :update]) do + description "Forbid user link unless admin; then check permissions" forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin authorize_if Mv.Authorization.Checks.HasPermission end - - # 3. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed) + + # 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed) end # Custom validation for email editing (see Special Cases section) @@ -1053,7 +1059,7 @@ end - **READ list queries**: No record at strict_check time → bypass with `expr(id == ^actor(:member_id))` needed for auto_filter ✅ - **UPDATE operations**: Changeset contains record → HasPermission evaluates `scope :linked` correctly ✅ -**User–member link:** Only admins may set or change the `:user` argument on create_member or update_member (see [User-Member Linking](#user-member-linking)). Non-admins can create/update members without passing `:user`. +**User–member link:** Only admins may pass the `:user` argument on create_member or update_member (link or unlink via `user: nil`/`user: %{}`). The check uses **argument presence** (key in arguments), not value, to avoid bypass (see [User-Member Linking](#user-member-linking)). **Permission Matrix:** diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 8517634..fc007ac 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -312,11 +312,17 @@ defmodule Mv.Membership.Member do authorize_if expr(id == ^actor(:member_id)) end - # GENERAL: Check permissions from user's role; forbid member–user link unless admin - # ForbidMemberUserLinkUnlessAdmin: only admins may pass :user on create/update (no-op for read/destroy). + # READ/DESTROY: Check permissions only (no :user argument on these actions) + policy action_type([:read, :destroy]) do + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission + end + + # CREATE/UPDATE: Forbid member–user link unless admin, then check permissions + # ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty). # HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all. - policy action_type([:read, :create, :update, :destroy]) do - description "Check permissions and forbid user link unless admin" + policy action_type([:create, :update]) do + description "Forbid user link unless admin; then check permissions" forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin authorize_if Mv.Authorization.Checks.HasPermission end diff --git a/lib/mv/authorization/checks/forbid_member_user_link_unless_admin.ex b/lib/mv/authorization/checks/forbid_member_user_link_unless_admin.ex index facfdb2..ab4af9d 100644 --- a/lib/mv/authorization/checks/forbid_member_user_link_unless_admin.ex +++ b/lib/mv/authorization/checks/forbid_member_user_link_unless_admin.ex @@ -3,13 +3,23 @@ defmodule Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin do Policy check: forbids setting or changing the member–user link unless the actor is admin. Used on Member create_member and update_member actions. When the `:user` argument - is present (linking a member to a user account), only admins may perform the action. - Non-admin users (e.g. normal_user / Kassenwart) can still create and update members - as long as they do not pass the `:user` argument. + **is present** (key in arguments, regardless of value), only admins may perform the action. + This covers: + - **Linking:** `user: %{id: user_id}` → only admin + - **Unlinking:** `user: nil` or `user: %{}` on update_member triggers `on_missing: :unrelate` → only admin + Non-admin users (e.g. normal_user / Kassenwart) can create and update members only when + they do **not** pass the `:user` argument at all. + + ## Unlink via Member actions + + Unlink is intended via Member update_member: when `:user` is not provided in params, + manage_relationship uses `on_missing: :unrelate` and removes the link. Passing `user: nil` + or `user: %{}` explicitly is still "changing the link" and is forbidden for non-admins + (argument presence is checked, not value). ## Usage - In Member resource policies, add **before** the general HasPermission policy: + In Member resource policies, restrict to create/update only: policy action_type([:create, :update]) do forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin @@ -18,8 +28,9 @@ defmodule Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin do ## Behaviour - - If the action has no `:user` argument or it is nil/empty → does not forbid. - - If `:user` is set (e.g. `%{id: user_id}`) and actor is not admin → forbids (returns true). + - If the `:user` argument **key is not present** → does not forbid. + - If `:user` is present (any value, including nil or %{}) and actor is not admin → forbids. + - If actor is nil → treated as non-admin (forbid when :user present); no crash. - If actor is admin (or system actor) → does not forbid. """ use Ash.Policy.Check @@ -31,35 +42,30 @@ defmodule Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin do @impl true def strict_check(actor, authorizer, _opts) do - actor = Actor.ensure_loaded(actor) + # Defensive: nil actor → treat as non-admin (Actor.ensure_loaded(nil) and admin?(nil) are safe) + actor = if is_nil(actor), do: nil, else: Actor.ensure_loaded(actor) - if user_argument_set?(authorizer) and not Actor.admin?(actor) do + if user_argument_present?(authorizer) and not Actor.admin?(actor) do {:ok, true} else {:ok, false} end end - defp user_argument_set?(authorizer) do - user_arg = get_user_argument(authorizer) - not is_nil(user_arg) and not empty_user_arg?(user_arg) + # Forbid when :user was passed at all (link, unlink via nil/empty, or invalid value). + # Check argument key presence, not value, to avoid bypass via user: nil or user: %{}. + defp user_argument_present?(authorizer) do + args = get_arguments(authorizer) + Map.has_key?(args || %{}, :user) end - defp get_user_argument(authorizer) do - changeset = authorizer.changeset || authorizer.subject + defp get_arguments(authorizer) do + subject = authorizer.changeset || authorizer.subject cond do - is_struct(changeset, Ash.Changeset) -> - Ash.Changeset.get_argument(changeset, :user) - - is_struct(changeset, Ash.ActionInput) -> - Map.get(changeset.arguments || %{}, :user) - - true -> - nil + is_struct(subject, Ash.Changeset) -> subject.arguments + is_struct(subject, Ash.ActionInput) -> subject.arguments + true -> %{} end end - - defp empty_user_arg?(%{} = m), do: map_size(m) == 0 - defp empty_user_arg?(_), do: false end diff --git a/test/mv/membership/member_policies_test.exs b/test/mv/membership/member_policies_test.exs index 287d0bb..d9ab95c 100644 --- a/test/mv/membership/member_policies_test.exs +++ b/test/mv/membership/member_policies_test.exs @@ -432,7 +432,9 @@ defmodule Mv.Membership.MemberPoliciesTest do assert member.first_name == "NoLink" # Member has_one :user (FK on User side); ensure no user is linked - {:ok, member} = Ash.load(member, :user, domain: Mv.Membership) + {:ok, member} = + Ash.load(member, :user, domain: Mv.Membership, actor: normal_user) + assert is_nil(member.user) end @@ -480,6 +482,29 @@ defmodule Mv.Membership.MemberPoliciesTest do Membership.update_member(unlinked_member, params, actor: normal_user) end + test "normal_user cannot update member with user: nil (unlink forbidden)", %{ + normal_user: normal_user, + unlinked_member: unlinked_member + } do + # Link member first (via admin), then normal_user tries to unlink via user: nil + admin = + Mv.Fixtures.user_with_role_fixture("admin") |> Mv.Authorization.Actor.ensure_loaded() + + link_target = + Mv.Fixtures.user_with_role_fixture("own_data") |> Mv.Authorization.Actor.ensure_loaded() + + {:ok, linked_member} = + Membership.update_member( + unlinked_member, + %{user: %{id: link_target.id}}, + actor: admin + ) + + # Passing user: nil explicitly tries to unlink; only admin may do that + assert {:error, %Ash.Error.Forbidden{}} = + Membership.update_member(linked_member, %{user: nil}, actor: normal_user) + end + test "admin can create member with :user argument", %{admin: admin} do link_target = Mv.Fixtures.user_with_role_fixture("own_data") From 5194b20b5c2d54f8b6114f0edead9bd5f9e6f11f Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 14:06:36 +0100 Subject: [PATCH 14/14] Fix unlink-by-omission: on_missing :ignore, test, doc, string-key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Member update_member: on_missing :unrelate → :ignore (no unlink when :user omitted) - Test: normal_user update linked member without :user keeps link - Doc: unlink only explicit (user: nil), admin-only; Actor.admin?(nil) note - Check: defense-in-depth for "user" string key --- docs/roles-and-permissions-architecture.md | 2 +- lib/membership/member.ex | 8 ++--- .../forbid_member_user_link_unless_admin.ex | 26 ++++++++--------- test/mv/membership/member_policies_test.exs | 29 +++++++++++++++++++ 4 files changed, 46 insertions(+), 19 deletions(-) diff --git a/docs/roles-and-permissions-architecture.md b/docs/roles-and-permissions-architecture.md index 0035a1e..216c6c9 100644 --- a/docs/roles-and-permissions-architecture.md +++ b/docs/roles-and-permissions-architecture.md @@ -2052,7 +2052,7 @@ Users and Members are separate entities that can be linked. Special rules: **Enforcement:** - **User side:** The User resource restricts the `update_user` action (which accepts the `member` argument for link/unlink) to admins only via `Mv.Authorization.Checks.ActorIsAdmin`. The UserLive.Form shows the Member-Linking UI and runs member link/unlink on save only when the current user is admin; non-admins use the `:update` action (email only) for profile edit. -- **Member side:** Only admins may set or change the user–member link on **Member** create or update. When creating or updating a member, the `:user` argument (which links the member to a user account) is forbidden for non-admins. This is enforced by `Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin` in the Member resource policies (`forbid_if` before `authorize_if HasPermission`). Non-admins (e.g. normal_user / Kassenwart) can still create and update members as long as they do not pass the `:user` argument. +- **Member side:** Only admins may set or change the user–member link on **Member** create or update. When creating or updating a member, the `:user` argument (which links the member to a user account) is forbidden for non-admins. This is enforced by `Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin` in the Member resource policies (`forbid_if` before `authorize_if HasPermission`). Non-admins can still create and update members as long as they do **not** pass the `:user` argument. The Member resource uses **`on_missing: :ignore`** for the `:user` relationship on update_member, so **omitting** `:user` from params does **not** change the link (no "unlink by omission"); unlink is only possible by explicitly passing `:user` (e.g. `user: nil`), which is admin-only. ### Approach: Separate Ash Actions diff --git a/lib/membership/member.ex b/lib/membership/member.ex index fc007ac..06dbf57 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -154,15 +154,13 @@ defmodule Mv.Membership.Member do change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create) # Manage the user relationship during member update + # on_missing: :ignore so that omitting :user does NOT unlink (security: only admins may + # change the link; unlink is explicit via user: nil, forbidden for non-admins by policy). change manage_relationship(:user, :user, - # Look up existing user and relate to it on_lookup: :relate, - # Error if user doesn't exist in database on_no_match: :error, - # Error if user is already linked to another member (prevents "stealing") on_match: :error, - # If no user provided, remove existing relationship (allows user removal) - on_missing: :unrelate + on_missing: :ignore ) # Sync member email to user when email changes (Member → User) diff --git a/lib/mv/authorization/checks/forbid_member_user_link_unless_admin.ex b/lib/mv/authorization/checks/forbid_member_user_link_unless_admin.ex index ab4af9d..1e7cb77 100644 --- a/lib/mv/authorization/checks/forbid_member_user_link_unless_admin.ex +++ b/lib/mv/authorization/checks/forbid_member_user_link_unless_admin.ex @@ -6,16 +6,16 @@ defmodule Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin do **is present** (key in arguments, regardless of value), only admins may perform the action. This covers: - **Linking:** `user: %{id: user_id}` → only admin - - **Unlinking:** `user: nil` or `user: %{}` on update_member triggers `on_missing: :unrelate` → only admin - Non-admin users (e.g. normal_user / Kassenwart) can create and update members only when - they do **not** pass the `:user` argument at all. + - **Unlinking:** explicit `user: nil` or `user: %{}` on update_member → only admin + Non-admin users can create and update members only when they do **not** pass the + `:user` argument; omitting `:user` leaves the relationship unchanged. - ## Unlink via Member actions + ## Unlink semantics (update_member) - Unlink is intended via Member update_member: when `:user` is not provided in params, - manage_relationship uses `on_missing: :unrelate` and removes the link. Passing `user: nil` - or `user: %{}` explicitly is still "changing the link" and is forbidden for non-admins - (argument presence is checked, not value). + The Member resource uses `on_missing: :ignore` for the `:user` relationship on update. + So **omitting** `:user` from params does **not** change the link (no "unlink by omission"). + Unlink is only possible by **explicitly** passing `:user` (e.g. `user: nil`), which this + check forbids for non-admins. Admins may link or unlink via the `:user` argument. ## Usage @@ -30,7 +30,7 @@ defmodule Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin do - If the `:user` argument **key is not present** → does not forbid. - If `:user` is present (any value, including nil or %{}) and actor is not admin → forbids. - - If actor is nil → treated as non-admin (forbid when :user present); no crash. + - If actor is nil → treated as non-admin (forbid when :user present). `Actor.admin?(nil)` is defined and returns false. - If actor is admin (or system actor) → does not forbid. """ use Ash.Policy.Check @@ -42,7 +42,7 @@ defmodule Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin do @impl true def strict_check(actor, authorizer, _opts) do - # Defensive: nil actor → treat as non-admin (Actor.ensure_loaded(nil) and admin?(nil) are safe) + # Nil actor: treat as non-admin (Actor.admin?(nil) returns false; no crash) actor = if is_nil(actor), do: nil, else: Actor.ensure_loaded(actor) if user_argument_present?(authorizer) and not Actor.admin?(actor) do @@ -53,10 +53,10 @@ defmodule Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin do end # Forbid when :user was passed at all (link, unlink via nil/empty, or invalid value). - # Check argument key presence, not value, to avoid bypass via user: nil or user: %{}. + # Check argument key presence (atom or string) for defense-in-depth. defp user_argument_present?(authorizer) do - args = get_arguments(authorizer) - Map.has_key?(args || %{}, :user) + args = get_arguments(authorizer) || %{} + Map.has_key?(args, :user) or Map.has_key?(args, "user") end defp get_arguments(authorizer) do diff --git a/test/mv/membership/member_policies_test.exs b/test/mv/membership/member_policies_test.exs index d9ab95c..f2d3084 100644 --- a/test/mv/membership/member_policies_test.exs +++ b/test/mv/membership/member_policies_test.exs @@ -505,6 +505,35 @@ defmodule Mv.Membership.MemberPoliciesTest do Membership.update_member(linked_member, %{user: nil}, actor: normal_user) end + test "normal_user update linked member without :user keeps link", %{ + normal_user: normal_user, + admin: admin, + unlinked_member: unlinked_member + } do + # Admin links member to a user + link_target = + Mv.Fixtures.user_with_role_fixture("own_data") + |> Mv.Authorization.Actor.ensure_loaded() + + {:ok, linked_member} = + Membership.update_member( + unlinked_member, + %{user: %{id: link_target.id}}, + actor: admin + ) + + # normal_user updates only first_name (no :user) – link must remain (on_missing: :ignore) + {:ok, updated} = + Membership.update_member(linked_member, %{first_name: "Updated"}, actor: normal_user) + + assert updated.first_name == "Updated" + + {:ok, user} = + Ash.get(Mv.Accounts.User, link_target.id, domain: Mv.Accounts, actor: admin) + + assert user.member_id == updated.id + end + test "admin can create member with :user argument", %{admin: admin} do link_target = Mv.Fixtures.user_with_role_fixture("own_data")