Merge branch 'main' into feature/337_polish_import
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
carla 2026-02-04 16:28:55 +01:00
commit 3415faeb21
87 changed files with 4381 additions and 1171 deletions

View file

@ -4,6 +4,7 @@
2. **DB constraints** - Prevent duplicates within same table (users.email, members.email)
3. **Custom validations** - Prevent cross-table conflicts only for linked entities
4. **Sync is bidirectional**: User ↔ Member (but User always wins on link)
5. **Linked member email change** - When a member is linked, only administrators or the linked user may change that member's email (Member resource validation `EmailChangePermission`). Because User.email wins on link and changes sync Member → User, allowing anyone to change a linked member's email would overwrite that user's account email; this rule keeps sync under control.
---

View file

@ -4,7 +4,7 @@
**Feature:** Groups Management
**Version:** 1.0
**Last Updated:** 2025-01-XX
**Status:** Architecture Design - Ready for Implementation
**Status:** ✅ Implemented (authorization: see [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md))
---
@ -412,15 +412,17 @@ lib/
## Authorization
**Status:** ✅ Implemented. Group and MemberGroup resource policies and PermissionSets are in place. See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) for the full permission matrix and policy patterns.
### Permission Model (MVP)
**Resource:** `groups`
**Resource:** `Group` (and `MemberGroup`)
**Actions:**
- `read` - View groups (all users with member read permission)
- `create` - Create groups (admin only)
- `update` - Edit groups (admin only)
- `destroy` - Delete groups (admin only)
- `read` - View groups (all permission sets)
- `create` - Create groups (normal_user and admin)
- `update` - Edit groups (normal_user and admin)
- `destroy` - Delete groups (normal_user and admin)
**Scopes:**
- `:all` - All groups (for all permission sets that have read access)
@ -442,7 +444,7 @@ lib/
**Own Data Permission Set:**
- `read` action on `Group` resource with `:all` scope - granted
**Note:** All permission sets use `:all` scope for groups. Groups are considered public information that all users with member read permission can view. Only admins can manage (create/update/destroy) groups.
**Note:** All permission sets use `:all` scope for groups. Groups are considered public information that all users with member read permission can view. normal_user and admin can manage (create/update/destroy) groups.
### Member-Group Association Permissions

View file

@ -334,20 +334,18 @@ lib/
### Permission System Integration
**See:** [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)
**Status:** ✅ Implemented. See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) for the full permission matrix and policy patterns.
**Required Permissions:**
**PermissionSets (lib/mv/authorization/permission_sets.ex):**
- `MembershipFeeType.create/update/destroy` - Admin only
- `MembershipFeeType.read` - Admin, Treasurer, Board
- `MembershipFeeCycle.update` (status changes) - Admin, Treasurer
- `MembershipFeeCycle.read` - Admin, Treasurer, Board, Own member
- **MembershipFeeType:** All permission sets can read (:all); only admin has create/update/destroy (:all).
- **MembershipFeeCycle:** All can read (:all); read_only has read only; normal_user and admin have read + create + update + destroy (:all).
- **Manual "Regenerate Cycles" (UI + server):** The "Regenerate Cycles" button in the member detail view is shown to users who have MembershipFeeCycle create permission (normal_user and admin). UI access is gated by `can_create_cycle`. The LiveView handler also enforces `can?(:create, MembershipFeeCycle)` server-side before running regeneration (so e.g. a read_only user cannot trigger it via DevTools). Regeneration runs with system actor.
**Policy Patterns:**
**Resource Policies:**
- Use existing HasPermission check
- Leverage existing roles (Admin, Kassenwart)
- Member can read own cycles (linked via member_id)
- **MembershipFeeType** (`lib/membership_fees/membership_fee_type.ex`): `authorizers: [Ash.Policy.Authorizer]`, single policy with `HasPermission` for read/create/update/destroy.
- **MembershipFeeCycle** (`lib/membership_fees/membership_fee_cycle.ex`): Same pattern; update includes mark_as_paid, mark_as_suspended, mark_as_unpaid.
### LiveView Integration
@ -357,7 +355,7 @@ lib/
2. MembershipFeeCycle table component (member detail view)
- Implemented as `MvWeb.MemberLive.Show.MembershipFeesComponent`
- Displays all cycles in a table with status management
- Allows changing cycle status, editing amounts, and regenerating cycles
- Allows changing cycle status, editing amounts, and manually regenerating cycles (normal_user and admin)
3. Settings form section (admin)
4. Member list column (membership fee status)

View file

@ -97,6 +97,10 @@ Control CRUD operations on:
- CustomFieldValue (custom field values)
- CustomField (custom field definitions)
- Role (role management)
- Group (group definitions; read all, create/update/destroy normal_user and admin)
- MemberGroup (membergroup associations; own_data read :linked, read_only read :all, normal_user/admin create/destroy)
- MembershipFeeType (fee type definitions; all read, admin-only create/update/destroy)
- MembershipFeeCycle (fee cycles; own_data read :linked, read_only read :all, normal_user/admin read+create+update+destroy; manual "Regenerate Cycles" for normal_user and admin)
**4. Page-Level Permissions**
@ -105,6 +109,7 @@ Control access to LiveView pages:
- Show pages (detail views)
- Form pages (create/edit)
- Admin pages
- Settings pages: `/settings` and `/membership_fee_settings` are admin-only (explicit in PermissionSets)
**5. Granular Scopes**
@ -121,6 +126,8 @@ Three scope levels for permissions:
- **Linked Member Email:** Only admins can edit email of member linked to user
- **System Roles:** "Mitglied" role cannot be deleted (is_system_role flag)
- **User-Member Linking:** Only admins can link/unlink users and members
- **User Role Assignment:** Only admins can change a user's role (via `update_user` with `role_id`). Last-admin validation ensures at least one user keeps the Admin role.
- **Settings Pages:** `/settings` and `/membership_fee_settings` are admin-only (explicit in PermissionSets pages).
**7. UI Consistency**
@ -684,6 +691,12 @@ Quick reference table showing what each permission set allows:
| **CustomFieldValue** (all) | - | R | R, C, U, D | R, C, U, D |
| **CustomField** (all) | R | R | R | R, C, U, D |
| **Role** (all) | - | - | - | R, C, U, D |
| **Group** (all) | R | R | R, C, U, D | R, C, U, D |
| **MemberGroup** (linked) | R | - | - | - |
| **MemberGroup** (all) | - | R | R, C, D | R, C, D |
| **MembershipFeeType** (all) | R | R | R | R, C, U, D |
| **MembershipFeeCycle** (linked) | R | - | - | - |
| **MembershipFeeCycle** (all) | - | R | R, C, U, D | R, C, U, D |
**Legend:** R=Read, C=Create, U=Update, D=Destroy
@ -1012,16 +1025,21 @@ 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)
policy action_type([:read, :create, :update, :destroy]) do
# 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([: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
# 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed)
end
@ -1041,6 +1059,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 ✅
**Usermember 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:**
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
@ -1135,23 +1155,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
@ -1188,13 +1205,43 @@ end
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|--------|----------|----------|------------|-------------|-------|
| Read | ❌ | ❌ | ❌ | ❌ | ✅ |
| Read | ✅ | ✅ | ✅ | ✅ | ✅ |
| Create | ❌ | ❌ | ❌ | ❌ | ✅ |
| Update | ❌ | ❌ | ❌ | ❌ | ✅ |
| Destroy* | ❌ | ❌ | ❌ | ❌ | ✅ |
*Cannot destroy if `is_system_role=true`
### User Role Assignment (Admin-Only)
**Location:** `lib/accounts/user.ex` (update_user action), `lib/mv_web/live/user_live/form.ex`
Only admins can change a user's role. The `update_user` action accepts `role_id`; the User form shows a role dropdown when `can?(actor, :update, Mv.Authorization.Role)`. **Last-admin validation:** If the only non-system admin tries to change their role, the change is rejected with "At least one user must keep the Admin role." (System user is excluded from the admin count.) See [User-Member Linking](#user-member-linking) for the same admin-only pattern.
### Group Resource Policies
**Location:** `lib/membership/group.ex`
Policies use `HasPermission` for read/create/update/destroy. All permission sets can read; normal_user and admin can create, update, destroy. No bypass (scope :all only in PermissionSets).
### MemberGroup Resource Policies
**Location:** `lib/membership/member_group.ex`
Bypass for read restricted to own_data (MemberGroupReadLinkedForOwnData check: own_data only, filter `member_id == actor.member_id`); HasPermission for read (read_only/normal_user/admin :all) and create/destroy (normal_user + admin only). Admin with member_id set still gets :all from HasPermission (bypass does not apply).
### MembershipFeeType Resource Policies
**Location:** `lib/membership_fees/membership_fee_type.ex`
Policies use `HasPermission` for read/create/update/destroy. All permission sets can read; only admin can create, update, destroy.
### MembershipFeeCycle Resource Policies
**Location:** `lib/membership_fees/membership_fee_cycle.ex`
Bypass for read restricted to own_data (MembershipFeeCycleReadLinkedForOwnData: own_data only, filter `member_id == actor.member_id`); HasPermission for read (read_only/normal_user/admin :all) and create/update/destroy. own_data can only read cycles of the linked member; read_only can read all; normal_user and admin can read, create, update, and destroy (including mark_as_paid and manual "Regenerate Cycles"; UI button when `can_create_cycle`). Regenerate-cycles handler enforces `can?(:create, MembershipFeeCycle)` server-side.
---
## Page Permission System
@ -2002,7 +2049,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 usermember 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

View file

@ -78,10 +78,11 @@ Stored in database `roles` table, each referencing a `permission_set_name`:
- ✅ Hardcoded PermissionSets module with 4 permission sets
- ✅ Role database table and CRUD interface
- ✅ Custom Ash Policy Check (`HasPermission`) that reads from PermissionSets
- ✅ Policies on all resources (Member, User, CustomFieldValue, CustomField, Role)
- ✅ Page-level permissions via Phoenix Plug
- ✅ Policies on all resources (Member, User, CustomFieldValue, CustomField, Role, Group, MemberGroup, MembershipFeeType, MembershipFeeCycle)
- ✅ Page-level permissions via Phoenix Plug (including admin-only `/settings` and `/membership_fee_settings`)
- ✅ UI authorization helpers for conditional rendering
- ✅ Special case: Member email validation for linked users
- ✅ User role assignment: admin-only `role_id` in update_user; Last-Admin validation; role dropdown in User form when `can?(actor, :update, Role)`
- ✅ Seed data for 5 roles
**Benefits of Hardcoded Approach:**