From 54e419ed4c0197d02ebd1a68b40fa3c36b390713 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Feb 2026 12:54:15 +0100 Subject: [PATCH] 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