-
<%= @member.name %>
+
+
+
+
+ - First Name
+ - <%= @member.first_name %>
+
+ - Last Name
+ - <%= @member.last_name %>
+
- Email
- <%= @member.email %>
- - Address
- - <%= @member.address %>
+
+
+ <%= if can_read_field?(@current_user, @member, :birth_date) do %>
+ - Birth Date
+ - <%= @member.birth_date %>
+ <% end %>
-
-
- <%= if can?(@current_user, :update, @member) do %>
- <.link patch={~p"/members/#{@member.id}/edit"} class="btn-primary">
- Edit Member
-
- <% end %>
```
---
-## Special Cases
+#### Usage in LiveView Modules
-### 1. Own Credentials Access
-
-**Requirement:** Every user can ALWAYS read and update their own credentials (email, password), regardless of their role.
-
-**Implementation:**
-
-Policy in `User` resource places this check BEFORE the general `HasPermission` check:
+**Mount Hook:**
```elixir
-policies do
- # SPECIAL CASE: Takes precedence over role permissions
- policy action_type([:read, :update]) do
- description "Users can always read and update their own account"
- authorize_if expr(id == ^actor(:id))
- end
+defmodule MvWeb.MemberLive.Index do
+ use MvWeb, :live_view
- # GENERAL: For other operations (e.g., admin reading other users)
- policy action_type([:read, :create, :update, :destroy]) do
- authorize_if Mv.Authorization.Checks.HasPermission
- end
-end
-```
-
-**Why this works:**
-- Ash evaluates policies top-to-bottom
-- First matching policy wins
-- Special case catches own-account access before checking permissions
-- Even a user with `own_data` (no admin permissions) can update their credentials
-
-### 2. Linked Member Email Editing
-
-**Requirement:** Only administrators can edit the email of a member that is linked to a user (has `user_id` set). This prevents breaking email synchronization.
-
-**Implementation:**
-
-Custom validation in `Member` resource:
-
-```elixir
-defmodule Mv.Membership.Member do
- use Ash.Resource, ...
+ import MvWeb.Authorization
- validations do
- # Only run when email is being changed
- validate changing(:email), on: :update do
- validate &validate_linked_member_email_change/2
- end
- end
-
- defp validate_linked_member_email_change(changeset, _context) do
- member = changeset.data
- actor = changeset.context[:actor]
+ def mount(_params, _session, socket) do
+ current_user = socket.assigns.current_user
- # If member is not linked to user, allow change
- if is_nil(member.user_id) do
- :ok
+ # Check if user can even access this page
+ # (This is redundant with router plug, but provides better UX)
+ unless can_access_page?(current_user, "/members") do
+ {:ok,
+ socket
+ |> put_flash(:error, "You don't have permission to access this page")
+ |> redirect(to: ~p"/")}
else
- # Member is linked - check if actor is admin
- if has_admin_permission?(actor) do
- :ok
- else
- {:error, "Only administrators can change email for members linked to user accounts"}
- end
+ members = list_members(current_user)
+
+ {:ok,
+ socket
+ |> assign(:members, members)
+ |> assign(:can_create, can?(current_user, :create, Mv.Membership.Member))}
end
end
-
- defp has_admin_permission?(nil), do: false
- defp has_admin_permission?(actor) do
- with %{role: %{permission_set_name: ps_name}} <- actor,
- {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
- permissions <- PermissionSets.get_permissions(ps_atom) do
- # Check if actor has User.update permission with scope :all (admin privilege)
- Enum.any?(permissions.resources, fn perm ->
- perm.resource == "User" and
- perm.action == :update and
- perm.scope == :all and
- perm.granted
- end)
- else
- _ -> false
- end
- end
-end
-```
-
-**Why this is needed:**
-- Member email and User email are kept in sync
-- If a non-admin changes linked member email, it could create inconsistency
-- Validation runs AFTER policy check, so normal_user can update member
-- But validation blocks email field specifically if member is linked
-
-### 3. System Role Protection
-
-**Requirement:** The "Mitglied" role cannot be deleted because it's the default role for all users.
-
-**Implementation:**
-
-Flag + validation in `Role` resource:
-
-```elixir
-defmodule Mv.Authorization.Role do
- use Ash.Resource, ...
-
- attributes do
- # ...
- attribute :is_system_role, :boolean, default: false
+ defp list_members(current_user) do
+ # Ash automatically filters based on policies
+ Mv.Membership.Member
+ |> Ash.read!(actor: current_user)
end
- validations do
- validate action(:destroy) do
- validate fn _changeset, %{data: role} ->
- if role.is_system_role do
- {:error, "Cannot delete system role. System roles are required for the application to function."}
- else
- :ok
- end
- end
- end
- end
-end
-```
-
-**Seeds set the flag:**
-
-```elixir
-%{
- name: "Mitglied",
- permission_set_name: "own_data",
- is_system_role: true # <-- Protected!
-}
-```
-
-**UI hides delete button:**
-
-```heex
-<%= if can?(@current_user, :destroy, role) and not role.is_system_role do %>
- <.button phx-click="delete">Delete
-<% end %>
-```
-
-### 4. User Without Role (Edge Case)
-
-**Requirement:** Users without a role should be denied all access (except logout).
-
-**Implementation:**
-
-**Default Assignment:** Seeds assign "Mitglied" role to all existing users
-
-```elixir
-# In authorization_seeds.exs
-mitglied_role = Ash.get!(Role, name: "Mitglied")
-users_without_role = Ash.read!(User, filter: expr(is_nil(role_id)))
-
-Enum.each(users_without_role, fn user ->
- Ash.update!(user, %{role_id: mitglied_role.id})
-end)
-```
-
-**Runtime Handling:** All authorization checks handle missing role gracefully
-
-```elixir
-# In HasPermission check
-def match?(actor, %{resource: resource, action: action}, _opts) do
- with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
- # ...
- else
- %{role: nil} ->
- {:error, :no_role} # User has no role -> forbidden
+ def handle_event("delete", %{"id" => id}, socket) do
+ current_user = socket.assigns.current_user
+ member = Ash.get!(Mv.Membership.Member, id, actor: current_user)
- _ ->
- {:error, :no_permission}
- end
-end
-```
-
-**Result:** User with no role sees empty UI, cannot access pages, gets forbidden on all actions.
-
-### 5. Invalid permission_set_name (Edge Case)
-
-**Requirement:** If a role has an invalid `permission_set_name`, fail gracefully without crashing.
-
-**Implementation:**
-
-**Prevention:** Validation on Role resource
-
-```elixir
-validations do
- validate attribute(:permission_set_name) do
- validate fn _changeset, value ->
- if PermissionSets.valid_permission_set?(value) do
- :ok
- else
- {:error, "Invalid permission set name. Must be one of: #{Enum.join(PermissionSets.all_permission_sets(), ", ")}"}
+ # Double-check permission (though Ash will also enforce)
+ if can?(current_user, :destroy, member) do
+ case Ash.destroy(member, actor: current_user) do
+ {:ok, _} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Member deleted successfully")
+ |> push_navigate(to: ~p"/members")}
+
+ {:error, _} ->
+ {:noreply, put_flash(socket, :error, "Failed to delete member")}
end
+ else
+ {:noreply, put_flash(socket, :error, "Permission denied")}
end
end
end
```
-**Runtime Handling:** All lookups check validity
-
-```elixir
-# In PermissionSets module
-def permission_set_name_to_atom(name) when is_binary(name) do
- atom = String.to_existing_atom(name)
- if valid_permission_set?(atom) do
- {:ok, atom}
- else
- {:error, :invalid_permission_set}
- end
-rescue
- ArgumentError -> {:error, :invalid_permission_set}
-end
-```
-
-**Result:** Invalid `permission_set_name` → authorization fails → forbidden (safe default).
-
---
-## User-Member Linking
+#### Performance Considerations
-### Requirement
+**Caching:**
+- Permission checks use ETS cache (from PermissionCache)
+- First call loads from DB and caches
+- Subsequent calls use cache
+- Cache invalidated on role/permission changes
-Users and Members are separate entities that can be linked. Special rules:
-- Only admins can link/unlink users and members
-- A user cannot link themselves to an existing member
-- A user CAN create a new member and be directly linked to it (self-service)
+**Batch Checking:**
-### Approach: Separate Ash Actions
-
-We use **different Ash actions** to enforce different policies:
-
-1. **`create_member_for_self`** - User creates member and links to themselves
-2. **`create_member`** - Admin creates member for any user (or unlinked)
-3. **`link_member_to_user`** - Admin links existing member to user
-4. **`unlink_member_from_user`** - Admin removes user link
-5. **`update`** - Standard update (cannot change `user_id`)
-
-### Implementation
+For tables with many rows, we can optimize by checking once per resource type:
```elixir
-defmodule Mv.Membership.Member do
- use Ash.Resource, ...
+def mount(_params, _session, socket) do
+ current_user = socket.assigns.current_user
- actions do
- # SELF-SERVICE: User creates member and links to self
- create :create_member_for_self do
- description "User creates a new member and links it to their own account"
-
- accept [:name, :email, :address, ...] # All fields except user_id
-
- # Automatically set user_id to actor
- change set_attribute(:user_id, actor(:id))
-
- # Prevent creating multiple members for same user (optional business rule)
- validate fn changeset, _context ->
- actor_id = get_change(changeset, :user_id)
-
- case Ash.read(Member, filter: expr(user_id == ^actor_id)) do
- {:ok, []} -> :ok # No existing member, allow
- {:ok, [_member | _]} -> {:error, "You already have a member profile"}
- {:error, _} -> :ok
- end
- end
- end
-
- # ADMIN: Create member with optional user link
- create :create_member do
- description "Admin creates a new member, optionally linked to a user"
-
- accept [:name, :email, :address, ..., :user_id] # Admin can set user_id
- end
-
- # ADMIN: Link existing member to user
- update :link_member_to_user do
- description "Admin links an existing member to a user account"
-
- accept [:user_id]
-
- validate fn changeset, _context ->
- member = changeset.data
-
- # Cannot link if already linked
- if is_nil(member.user_id) do
- :ok
- else
- {:error, "Member is already linked to a user"}
- end
- end
- end
-
- # ADMIN: Remove user link from member
- update :unlink_member_from_user do
- description "Admin removes user link from member"
-
- change set_attribute(:user_id, nil)
- end
-
- # STANDARD UPDATE: Cannot change user_id
- update :update do
- description "Update member data (cannot change user link)"
-
- accept [:name, :email, :address, ...] # user_id NOT in accept list
- end
- end
-
- policies do
- # Self-service member creation
- policy action(:create_member_for_self) do
- description "Any authenticated user can create member for themselves"
- authorize_if actor_present()
- end
-
- # Admin-only actions
- policy action([:create_member, :link_member_to_user, :unlink_member_from_user]) do
- description "Only admin can manage user-member links"
- authorize_if Mv.Authorization.Checks.HasPermission
- end
-
- # Standard actions (regular permission check)
- policy action([:read, :update, :destroy]) do
- authorize_if Mv.Authorization.Checks.HasPermission
- end
- end
-end
-```
-
-### UI Examples
-
-**User Self-Service:**
-
-```heex
-
-<%= if is_nil(@current_user.member_id) do %>
- <.link navigate="/members/new_for_self">
- Create My Member Profile
-
-<% end %>
-
-
-<.simple_form for={@form} phx-submit="create_for_self">
- <.input field={@form[:name]} label="Name" />
- <.input field={@form[:email]} label="Email" />
- <.input field={@form[:address]} label="Address" />
+ members = list_members(current_user)
-
+ # Check permissions once for the resource type
+ can_update_any = can?(current_user, :update, Mv.Membership.Member)
+ can_destroy_any = can?(current_user, :destroy, Mv.Membership.Member)
- <:actions>
- <.button>Create My Profile
-
-
-```
-
-**Admin Interface:**
-
-```heex
-
-<%= if can?(@current_user, :link_member_to_user, @member) do %>
- <%= if is_nil(@member.user_id) do %>
-
- <.form for={@link_form} phx-submit="link_to_user">
- <.input field={@link_form[:user_id]} type="select" label="Link to User" options={@users} />
- <.button>Link to User
-
- <% else %>
-
- <.button phx-click="unlink_from_user" phx-value-id={@member.id}>
- Unlink from User (<%= @member.user.email %>)
-
- <% end %>
-<% end %>
-```
-
-### Why Separate Actions?
-
-✅ **Clear Intent:** Action name communicates what's happening
-✅ **Precise Policies:** Different policies for different operations
-✅ **Better UX:** Separate UI flows for self-service vs. admin
-✅ **Testable:** Each action can be tested independently
-✅ **Idiomatic Ash:** Uses Ash's action system as designed
-
----
-
-## Future: Phase 2 - Field-Level Permissions
-
-**Status:** Not in MVP, planned for future enhancement
-
-**Goal:** Control which fields a user can read or write, beyond resource-level permissions.
-
-### Strategy
-
-**Extend PermissionSets module with `:fields` key:**
-
-```elixir
-def get_permissions(:read_only) do
- %{
- resources: [...],
- pages: [...],
- fields: [
- # Vorstand can read all member fields except sensitive payment info
+ # Then check scope for each member (if needed)
+ members_with_permissions =
+ Enum.map(members, fn member ->
%{
- resource: "Member",
- action: :read,
- fields: [:all],
- excluded_fields: [:payment_method, :bank_account]
- },
-
- # Vorstand cannot write any member fields
- %{
- resource: "Member",
- action: :update,
- fields: [] # Empty = no fields writable
+ member: member,
+ can_update: can_update_any && can_update_this?(current_user, member),
+ can_destroy: can_destroy_any && can_destroy_this?(current_user, member)
}
- ]
- }
+ end)
+
+ {:ok,
+ socket
+ |> assign(:members_with_permissions, members_with_permissions)
+ |> assign(:can_create, can?(current_user, :create, Mv.Membership.Member))}
end
```
-**Read Filtering via Ash Calculations:**
-
-```elixir
-defmodule Mv.Membership.Member do
- calculations do
- calculate :filtered_fields, :map do
- calculate fn members, context ->
- actor = context[:actor]
-
- # Get allowed fields from PermissionSets
- allowed_fields = get_allowed_read_fields(actor, "Member")
-
- # Filter fields
- Enum.map(members, fn member ->
- Map.take(member, allowed_fields)
- end)
- end
- end
- end
-end
-```
-
-**Write Protection via Custom Validations:**
-
-```elixir
-validations do
- validate on: :update do
- validate fn changeset, context ->
- actor = context[:actor]
- changed_fields = Map.keys(changeset.attributes)
-
- # Get allowed fields from PermissionSets
- allowed_fields = get_allowed_write_fields(actor, "Member")
-
- # Check if any forbidden field is being changed
- forbidden = Enum.reject(changed_fields, &(&1 in allowed_fields))
-
- if Enum.empty?(forbidden) do
- :ok
- else
- {:error, "You do not have permission to modify: #{Enum.join(forbidden, ", ")}"}
- end
- end
- end
-end
-```
-
-**Benefits:**
-- ✅ No database schema changes
-- ✅ Still uses hardcoded PermissionSets
-- ✅ Granular control over sensitive fields
-- ✅ Clear error messages
-
-**Estimated Effort:** 2-3 weeks
-
---
-## Future: Phase 3 - Database-Backed Permissions
+#### Testing UI Authorization
-**Status:** Not in MVP, planned for future when runtime configuration is needed
+**Test Strategy:**
-**Goal:** Move permission definitions from code to database for runtime configuration.
+```elixir
+# test/mv_web/member_live/index_test.exs
+defmodule MvWeb.MemberLive.IndexTest do
+ use MvWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+
+ describe "UI authorization for Mitglied role" do
+ test "does not show 'New Member' button", %{conn: conn} do
+ user = create_user_with_role("Mitglied")
+ member = create_member_linked_to_user(user)
+ conn = log_in_user(conn, user)
+
+ {:ok, view, html} = live(conn, ~p"/members")
+
+ refute html =~ "New Member"
+ refute has_element?(view, "a", "New Member")
+ end
+
+ test "shows only 'Show' button for own member", %{conn: conn} do
+ user = create_user_with_role("Mitglied")
+ member = create_member_linked_to_user(user)
+ conn = log_in_user(conn, user)
+
+ {:ok, view, html} = live(conn, ~p"/members")
+
+ # Show button should exist
+ assert has_element?(view, "a[href='/members/#{member.id}']", "Show")
+
+ # Edit and Delete buttons should NOT exist
+ refute has_element?(view, "a[href='/members/#{member.id}/edit']", "Edit")
+ refute has_element?(view, "button[phx-click='delete']", "Delete")
+ end
+
+ test "does not show 'Users' link in navigation", %{conn: conn} do
+ user = create_user_with_role("Mitglied")
+ conn = log_in_user(conn, user)
+
+ {:ok, view, html} = live(conn, ~p"/")
+
+ refute html =~ "Users"
+ refute has_element?(view, "a[href='/users']", "Users")
+ end
+ end
+
+ describe "UI authorization for Kassenwart role" do
+ test "shows 'New Member' button", %{conn: conn} do
+ user = create_user_with_role("Kassenwart")
+ conn = log_in_user(conn, user)
+
+ {:ok, view, html} = live(conn, ~p"/members")
+
+ assert html =~ "New Member"
+ assert has_element?(view, "a", "New Member")
+ end
+
+ test "shows Edit and Delete buttons for all members", %{conn: conn} do
+ user = create_user_with_role("Kassenwart")
+ member1 = create_member()
+ member2 = create_member()
+ conn = log_in_user(conn, user)
+
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ # Both members should have Edit and Delete buttons
+ assert has_element?(view, "a[href='/members/#{member1.id}/edit']", "Edit")
+ assert has_element?(view, "a[href='/members/#{member2.id}/edit']", "Edit")
+
+ # Note: Using a more flexible selector for delete buttons
+ assert has_element?(view, ~s([phx-click="delete"][phx-value-id="#{member1.id}"]))
+ assert has_element?(view, ~s([phx-click="delete"][phx-value-id="#{member2.id}"]))
+ end
+ end
+
+ describe "UI authorization for Admin role" do
+ test "shows all navigation links", %{conn: conn} do
+ admin = create_user_with_role("Admin")
+ conn = log_in_user(conn, admin)
+
+ {:ok, view, html} = live(conn, ~p"/")
+
+ assert html =~ "Members"
+ assert html =~ "Users"
+ assert html =~ "Custom Fields"
+ assert html =~ "Roles"
+
+ assert has_element?(view, "a[href='/members']", "Members")
+ assert has_element?(view, "a[href='/users']", "Users")
+ assert has_element?(view, "a[href='/property-types']", "Custom Fields")
+ assert has_element?(view, "a[href='/admin/roles']", "Roles")
+ end
+ end
+end
+```
-### High-Level Design
+---
-**New Tables:**
+## Future Extensions
+
+### Phase 2: Field-Level Permissions
+
+**Timeline:** Sprint 4-5 (after Phase 1 is stable)
+
+**Goal:** Allow permission sets to restrict access to specific fields of resources.
+
+#### Database Schema (Already Prepared)
+
+The `permission_set_resources.field_name` column is already in place:
+- `NULL` = all fields (Phase 1 default)
+- `"field_name"` = specific field (Phase 2)
+
+#### Implementation Approach
+
+**Option A: Blacklist Approach (Recommended)**
+
+Permission with `field_name = NULL` grants access to all fields. Additional rows with `granted = false` deny specific fields.
```sql
-CREATE TABLE permission_sets (
- id UUID PRIMARY KEY,
- name VARCHAR(50) UNIQUE,
- description TEXT,
- is_system BOOLEAN
-);
+-- Normal User can read all Member fields
+INSERT INTO permission_set_resources
+ (permission_set_id, resource_name, action, field_name, granted)
+VALUES
+ (normal_user_id, 'Member', 'read', NULL, true);
-CREATE TABLE permission_set_resources (
- id UUID PRIMARY KEY,
- permission_set_id UUID REFERENCES permission_sets(id),
- resource_name VARCHAR(100),
- action VARCHAR(20),
- scope VARCHAR(20),
- granted BOOLEAN
-);
+-- But NOT birth_date
+INSERT INTO permission_set_resources
+ (permission_set_id, resource_name, action, field_name, granted)
+VALUES
+ (normal_user_id, 'Member', 'read', 'birth_date', false);
-CREATE TABLE permission_set_pages (
- id UUID PRIMARY KEY,
- permission_set_id UUID REFERENCES permission_sets(id),
- page_pattern VARCHAR(255)
-);
+-- And NOT notes
+INSERT INTO permission_set_resources
+ (permission_set_id, resource_name, action, field_name, granted)
+VALUES
+ (normal_user_id, 'Member', 'read', 'notes', false);
```
-**Migration Strategy:**
+**Evaluation Logic:**
+1. Check if there's a permission with `field_name = NULL` and `granted = true`
+2. If yes: Load all deny entries (`field_name != NULL` and `granted = false`)
+3. Deselect those fields from query
-1. Create new tables
-2. Seed from current `PermissionSets` module
-3. Create new `HasResourcePermission` check that queries DB
-4. Add ETS cache for performance
-5. Replace `HasPermission` with `HasResourcePermission` in policies
-6. Test thoroughly
-7. Deploy
-8. Eventually remove `PermissionSets` module
+**Advantages:**
+- Default is "allow all fields"
+- Only need to specify exceptions
+- Easy to add new fields (automatically included)
-**ETS Cache:**
+**Disadvantages:**
+- Can't have different scopes per field (all fields have same scope)
+
+---
+
+**Option B: Whitelist Approach**
+
+No permission with `field_name = NULL`. Only explicit `granted = true` entries allow access.
+
+```sql
+-- Read-Only can ONLY read these specific Member fields
+INSERT INTO permission_set_resources
+ (permission_set_id, resource_name, action, field_name, granted)
+VALUES
+ (read_only_id, 'Member', 'read', 'first_name', true),
+ (read_only_id, 'Member', 'read', 'last_name', true),
+ (read_only_id, 'Member', 'read', 'email', true),
+ (read_only_id, 'Member', 'read', 'phone_number', true);
+-- birth_date, notes, etc. are implicitly denied
+```
+
+**Evaluation Logic:**
+1. Check if there's a permission with `field_name = NULL` and `granted = true`
+2. If no: Load all allow entries (`field_name != NULL` and `granted = true`)
+3. Only select those fields in query
+
+**Advantages:**
+- Explicit "allow" model (more secure default)
+- Could have different scopes per field (future feature)
+
+**Disadvantages:**
+- Tedious to specify every allowed field
+- New fields are denied by default (requires permission update)
+
+---
+
+#### Custom Preparation for Field Filtering
```elixir
-defmodule Mv.Authorization.PermissionCache do
- def get_permissions(permission_set_id) do
- case :ets.lookup(:permission_cache, permission_set_id) do
- [{^permission_set_id, permissions}] ->
- permissions
-
- [] ->
- permissions = load_from_db(permission_set_id)
- :ets.insert(:permission_cache, {permission_set_id, permissions})
- permissions
- end
+defmodule Mv.Authorization.Preparations.FilterFieldsByPermission do
+ use Ash.Resource.Preparation
+
+ def prepare(query, _opts, context) do
+ actor = context.actor
+ action = query.action
+ resource = query.resource
+
+ # Get denied fields for this actor/resource/action
+ denied_fields = get_denied_fields(actor, resource, action.name)
+
+ # Deselect denied fields
+ Ash.Query.deselect(query, denied_fields)
end
- def invalidate(permission_set_id) do
- :ets.delete(:permission_cache, permission_set_id)
+ defp get_denied_fields(actor, resource, action) do
+ permission_set = get_permission_set(actor)
+ resource_name = resource |> Module.split() |> List.last()
+
+ # Query denied fields (blacklist approach)
+ Mv.Authorization.PermissionSetResource
+ |> Ash.Query.filter(
+ permission_set_id == ^permission_set.id and
+ resource_name == ^resource_name and
+ action == ^action and
+ not is_nil(field_name) and
+ granted == false
+ )
+ |> Ash.read!()
+ |> Enum.map(& &1.field_name)
+ |> Enum.map(&String.to_existing_atom/1)
+ end
+end
+
+# Add to resources in Phase 2
+defmodule Mv.Membership.Member do
+ preparations do
+ prepare Mv.Authorization.Preparations.FilterFieldsByPermission
end
end
```
-**Benefits:**
-- ✅ Runtime permission configuration
-- ✅ More flexible than hardcoded
-- ✅ Can add new permission sets without code changes
+---
-**Trade-offs:**
-- ⚠️ More complex (DB queries, cache, invalidation)
-- ⚠️ Slightly slower (mitigated by cache)
-- ⚠️ More testing needed
+#### Custom Fields (Properties) Field-Level
-**Estimated Effort:** 3-4 weeks
+For custom fields, field-level permissions work differently:
-**Decision Point:** Migrate to Phase 3 only if:
-- Need to add permission sets frequently
-- Need per-tenant permission customization
-- MVP hardcoded approach is limiting business
+**Approach:** `field_name` stores the PropertyType name (not Property field)
-See [Migration Strategy](#migration-strategy) for detailed migration plan.
+```sql
+-- Read-Only can ONLY see "membership_number" custom field
+INSERT INTO permission_set_resources
+ (permission_set_id, resource_name, action, field_name, granted)
+VALUES
+ (read_only_id, 'Property', 'read', 'membership_number', true);
+
+-- All other PropertyTypes are denied
+```
+
+**Implementation:**
+
+```elixir
+defmodule Mv.Authorization.Preparations.FilterPropertiesByType do
+ use Ash.Resource.Preparation
+
+ def prepare(query, _opts, context) do
+ actor = context.actor
+
+ # Get allowed PropertyType names
+ allowed_types = get_allowed_property_types(actor)
+
+ if allowed_types == :all do
+ query
+ else
+ # Filter: only load Properties of allowed types
+ Ash.Query.filter(query, property_type.name in ^allowed_types)
+ end
+ end
+end
+```
+
+---
+
+### Phase 3: Payment History Permissions
+
+**Timeline:** When Payment resource is implemented
+
+**Goal:** Control access to payment-related data.
+
+#### Implementation
+
+```sql
+-- Add Payment resource permissions
+INSERT INTO permission_set_resources
+ (permission_set_id, resource_name, action, scope, granted)
+VALUES
+ -- Own Data: Can see own payment history (optional)
+ (own_data_id, 'Payment', 'read', 'linked', false), -- Default: disabled
+
+ -- Read-Only: Cannot see payment history
+ (read_only_id, 'Payment', 'read', 'all', false),
+
+ -- Normal User (Kassenwart): Can see and edit all payment history
+ (normal_user_id, 'Payment', 'read', 'all', true),
+ (normal_user_id, 'Payment', 'create', 'all', true),
+ (normal_user_id, 'Payment', 'update', 'all', true),
+
+ -- Admin: Full access
+ (admin_id, 'Payment', 'read', 'all', true),
+ (admin_id, 'Payment', 'create', 'all', true),
+ (admin_id, 'Payment', 'update', 'all', true),
+ (admin_id, 'Payment', 'destroy', 'all', true);
+```
+
+**Configuration UI:**
+
+Admin UI will allow toggling "Members can view their own payment history" which updates the Own Data permission set:
+
+```elixir
+# Toggle payment history visibility for members
+def toggle_member_payment_visibility(enabled) do
+ own_data_ps = get_permission_set_by_name("own_data")
+
+ # Find or create Payment.read permission
+ permission =
+ PermissionSetResource
+ |> Ash.Query.filter(
+ permission_set_id == ^own_data_ps.id and
+ resource_name == "Payment" and
+ action == "read" and
+ scope == "linked"
+ )
+ |> Ash.read_one!()
+
+ # Update granted flag
+ Ash.Changeset.for_update(permission, :update, %{granted: enabled})
+ |> Ash.update!()
+end
+```
+
+---
+
+### Phase 4: Groups and Group Permissions
+
+**Timeline:** TBD (future feature)
+
+**Goal:** Group members and apply permissions per group.
+
+#### Possible Approaches
+
+**Option 1: Group-scoped Permissions**
+
+Add `group_id` to permission_set_resources:
+
+```sql
+ALTER TABLE permission_set_resources
+ADD COLUMN group_id UUID REFERENCES groups(id);
+
+-- Normal User can only edit members in "Youth Group"
+INSERT INTO permission_set_resources
+ (permission_set_id, resource_name, action, scope, group_id, granted)
+VALUES
+ (normal_user_id, 'Member', 'update', 'all', youth_group_id, true);
+```
+
+**Option 2: Group-based Roles**
+
+Roles can have group restrictions:
+
+```sql
+ALTER TABLE roles
+ADD COLUMN group_id UUID REFERENCES groups(id);
+
+-- "Youth Leader" role only has permissions for youth group
+INSERT INTO roles (name, permission_set_id, group_id)
+VALUES ('Youth Leader', normal_user_id, youth_group_id);
+```
+
+**Decision:** Deferred until Groups feature is designed.
---
## Migration Strategy
-### Three-Phase Approach
+### Migration Plan
-**Phase 1: MVP (2-3 weeks) - CURRENT**
-- Hardcoded PermissionSets module
-- `HasPermission` check reads from module
-- Role table with `permission_set_name` string
-- Zero DB queries for permission checks
+#### Sprint 1: Foundation
-**Phase 2: Field-Level (2-3 weeks) - FUTURE**
-- Extend PermissionSets with `:fields` key
-- Ash Calculations for read filtering
-- Custom Validations for write protection
-- No database schema changes
+**Week 1:**
+- Create database migrations for permission tables
+- Create Ash resources (PermissionSet, Role, PermissionSetResource, PermissionSetPage)
+- Add role_id to users table
+- Create seed script for 4 permission sets
-**Phase 3: Database-Backed (3-4 weeks) - FUTURE**
-- New tables: `permission_sets`, `permission_set_resources`, `permission_set_pages`
-- New `HasResourcePermission` check queries DB
-- ETS cache for performance
-- Runtime permission configuration
+**Week 2:**
+- Implement custom policy checks
+- Implement permission cache (ETS)
+- Create seeds for 5 roles with permissions
+- Write tests for permission evaluation
-### When to Migrate?
+#### Sprint 2: Integration
-**Stay with MVP if:**
-- 4 permission sets are sufficient
-- Permission changes are rare (quarterly or less)
-- Code deployments for permission changes are acceptable
-- Performance is critical (< 1μs checks)
+**Week 3:**
+- Implement router plug for page permissions
+- Update all existing resources with policies
+- Handle special cases (user credentials, member email)
+- Integration tests for common scenarios
-**Migrate to Phase 2 if:**
-- Need field-level granularity
-- Different roles need access to different fields
-- Still OK with hardcoded permissions
+**Week 4:**
+- Admin UI for role management
+- Admin UI for assigning roles to users
+- Documentation and user guide
+- Performance testing and optimization
-**Migrate to Phase 3 if:**
-- Need frequent permission changes
-- Need per-tenant customization
-- Want non-technical users to configure permissions
-- OK with slightly more complex system
+---
-### Migration from MVP to Phase 3
+### Data Migration
-**Step-by-step:**
+#### Existing Users
-1. **Create DB Tables** (1 day)
- - Run migrations for `permission_sets`, `permission_set_resources`, `permission_set_pages`
- - Add indexes
+All existing users will be assigned the "Mitglied" (Member) role by default:
-2. **Seed from PermissionSets Module** (1 day)
- - Script that reads from `PermissionSets.get_permissions/1`
- - Inserts into new tables
- - Verify data integrity
+```sql
+-- Migration: Set default role for existing users
+UPDATE users
+SET role_id = (SELECT id FROM roles WHERE name = 'Mitglied')
+WHERE role_id IS NULL;
+```
-3. **Create HasResourcePermission Check** (2 days)
- - New check that queries DB
- - Same logic as `HasPermission` but different data source
- - Comprehensive tests
+#### Backward Compatibility
-4. **Implement ETS Cache** (2 days)
- - Cache module
- - Cache invalidation on updates
- - Performance tests
+**Phase 1:**
+- No existing authorization system to maintain
+- Clean slate implementation
-5. **Update Policies** (3 days)
- - Replace `HasPermission` with `HasResourcePermission` in all resources
- - Test each resource thoroughly
-
-6. **Update UI Helpers** (1 day)
- - Modify `MvWeb.Authorization` to query DB
- - Use cache for performance
-
-7. **Update Page Plug** (1 day)
- - Modify `CheckPagePermission` to query DB
- - Use cache
-
-8. **Integration Testing** (3 days)
- - Full user journey tests
- - Performance testing
- - Load testing
-
-9. **Deploy to Staging** (1 day)
- - Feature flag approach
- - Run both systems in parallel
- - Compare results
-
-10. **Deploy to Production** (1 day)
- - Gradual rollout
- - Monitor performance
- - Rollback plan ready
-
-11. **Cleanup** (1 day)
- - Remove old `HasPermission` check
- - Remove `PermissionSets` module
- - Update documentation
-
-**Total:** ~3-4 weeks
+**Phase 2 (Field-Level):**
+- Existing permission_set_resources with `field_name = NULL` continue to work
+- No migration needed, just add new field-specific permissions
---
@@ -2310,193 +2140,140 @@ See [Migration Strategy](#migration-strategy) for detailed migration plan.
### Threat Model
-**Threats Addressed:**
+#### 1. Privilege Escalation
-1. **Unauthorized Data Access:** Policies prevent users from accessing data outside their permissions
-2. **Privilege Escalation:** Role-based system prevents users from granting themselves higher privileges
-3. **UI Tampering:** Backend policies enforce authorization even if UI is bypassed
-4. **Session Hijacking:** Mitigation handled by existing authentication system (not in scope)
+**Threat:** User tries to escalate privileges by manipulating requests.
-**Threats NOT Addressed:**
+**Mitigation:**
+- All authorization enforced server-side (Ash Policies)
+- Actor is verified via session
+- No client-side permission checks that can be bypassed
+- Cache invalidation ensures stale permissions aren't used
-1. **SQL Injection:** Ash Framework handles query building securely
-2. **XSS:** Phoenix LiveView handles HTML escaping
-3. **CSRF:** Phoenix CSRF tokens (existing)
+#### 2. Permission Cache Poisoning
-### Defense in Depth
+**Threat:** Attacker manipulates ETS cache to grant unauthorized access.
-**Three Layers of Authorization:**
+**Mitigation:**
+- ETS table is server-side only
+- Cache keys include user_id (can't access other users' cache)
+- Cache invalidated on any permission change
+- Fallback to database if cache returns unexpected data
-1. **Page Access Layer (Plug):**
- - Blocks unauthorized page access
- - Runs before LiveView mounts
- - Fast fail for obvious violations
+#### 3. SQL Injection via Scope Filters
-2. **UI Layer (Authorization Helpers):**
- - Hides buttons/links user can't use
- - Prevents confusing "forbidden" errors
- - Improves UX
+**Threat:** Malicious actor value causes SQL injection in scope filters.
-3. **Resource Layer (Ash Policies):**
- - **Primary enforcement point**
- - Cannot be bypassed
- - Filters queries automatically
+**Mitigation:**
+- All filters use Ash's expr() macro with parameterized queries
+- Actor ID is always a UUID (validated by database)
+- No string concatenation in filter construction
-**Even if attacker:**
-- Tampers with UI → Backend policies still enforce
-- Calls API directly → Policies apply
-- Modifies page JavaScript → Policies apply
+#### 4. Permission Set Modification
-### Authorization Best Practices
+**Threat:** Unauthorized user modifies permission sets or roles.
-**DO:**
-- ✅ Always preload `:role` relationship for actor
-- ✅ Log authorization failures for debugging
-- ✅ Use explicit policies (no implicit allow)
-- ✅ Test policies with all role types
-- ✅ Test special cases (nil role, invalid permission_set_name)
+**Mitigation:**
+- Permission Sets have `is_system = true` and cannot be deleted
+- Role management requires Admin permission
+- Audit log (future) tracks all permission changes
-**DON'T:**
-- ❌ Trust UI-level checks alone
-- ❌ Skip policy checks for "admin"
-- ❌ Use `bypass` or `skip_authorization` in production
-- ❌ Expose raw permission logic in API responses
+#### 5. Bypass via Direct Database Access
+
+**Threat:** Code bypasses Ash and queries database directly.
+
+**Mitigation:**
+- Code review enforces "always use Ash" policy
+- No raw SQL in application code
+- Database credentials secured via environment variables
+
+#### 6. Session Hijacking
+
+**Threat:** Attacker steals session and impersonates user.
+
+**Mitigation:**
+- Handled by AshAuthentication (out of scope for this document)
+- Sessions use signed tokens
+- HTTPS in production
+
+---
### Audit Logging (Future)
-**Not in MVP, but planned:**
+For compliance and debugging, implement audit log:
-```elixir
-defmodule Mv.Authorization.AuditLog do
- def log_authorization_failure(actor, resource, action, reason) do
- Ash.create!(AuditLog, %{
- user_id: actor.id,
- resource: inspect(resource),
- action: action,
- outcome: "forbidden",
- reason: reason,
- ip_address: get_ip_address(),
- timestamp: DateTime.utc_now()
- })
- end
-end
+```sql
+CREATE TABLE permission_audit_log (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID REFERENCES users(id),
+ action VARCHAR(50), -- "role_changed", "permission_modified"
+ resource_type VARCHAR(255),
+ resource_id UUID,
+ old_value JSONB,
+ new_value JSONB,
+ timestamp TIMESTAMP NOT NULL DEFAULT now()
+);
```
-**Benefits:**
-- Track suspicious authorization attempts
-- Compliance (GDPR requires access logs)
-- Debugging production issues
-
---
## Appendix
### Glossary
-- **Permission Set:** Named collection of permissions (e.g., "admin", "read_only")
-- **Role:** Database entity linking users to permission sets
-- **Scope:** Range of records permission applies to (:own, :linked, :all)
-- **Actor:** Currently authenticated user in Ash context
-- **Policy:** Ash authorization rule on a resource
-- **System Role:** Role that cannot be deleted (is_system_role=true)
-- **Special Case:** Authorization rule that takes precedence over general permissions
-
-### Resource Name Mapping
-
-The `HasPermission` check extracts resource names via `Module.split() |> List.last()`:
-
-| Ash Module | Resource Name (String) |
-|------------|------------------------|
-| `Mv.Accounts.User` | "User" |
-| `Mv.Membership.Member` | "Member" |
-| `Mv.Membership.Property` | "Property" |
-| `Mv.Membership.PropertyType` | "PropertyType" |
-| `Mv.Authorization.Role` | "Role" |
-
-These strings must match exactly in `PermissionSets` module.
+- **Permission Set:** A predefined collection of permissions defining what actions can be performed
+- **Role:** A named job function (e.g., "Treasurer") that references one permission set
+- **Resource:** An Ash resource (e.g., Member, User, PropertyType)
+- **Action:** An Ash action (e.g., read, create, update, destroy)
+- **Scope:** The subset of records a permission applies to (own, linked, all)
+- **Actor:** The current user making a request
+- **Page Permission:** Access control for LiveView routes
+- **Field-Level Permission:** Restriction on specific fields of a resource (Phase 2)
### Permission Set Summary
-| Permission Set | Typical Roles | Key Characteristics |
-|----------------|---------------|---------------------|
-| **own_data** | Mitglied | Can only access own data and linked member |
-| **read_only** | Vorstand, Buchhaltung | Read all data, no modifications |
-| **normal_user** | Kassenwart | Create/Read/Update members (no delete), full CRUD on properties, no admin |
-| **admin** | Admin | Unrestricted access, wildcard pages |
+| Permission Set | Use Case | Example Roles | Resources Access |
+|---------------|----------|---------------|------------------|
+| **own_data** | Users accessing only their own data | Mitglied | User (own), Member (linked), Property (linked) |
+| **read_only** | Users who can view but not edit | Vorstand, Buchhaltung | Member (all, read), Property (all, read) |
+| **normal_user** | Users who can edit members and properties | Kassenwart | Member (all, read/write), Property (all, read/write) |
+| **admin** | Full administrative access | Admin | All resources (all, full CRUD) |
-### Edge Case Reference
+### Resource Permission Matrix
-| Edge Case | Behavior | Implementation |
-|-----------|----------|----------------|
-| User without role | Access denied everywhere | Seeds assign default role, runtime checks handle gracefully |
-| Invalid permission_set_name | Access denied | Validation on Role, runtime safety checks |
-| System role deletion | Forbidden | Validation prevents deletion if `is_system_role=true` |
-| Linked member email | Admin-only edit | Custom validation in Member resource |
-| Own credentials | Always accessible | Special policy before general check |
+| Resource | Own Data | Read-Only | Normal User | Admin |
+|----------|----------|-----------|-------------|-------|
+| **Member** | Linked: R/W | All: R | All: R/W | All: Full |
+| **User** | Own: R/W | None | None | All: Full |
+| **PropertyType** | All: R | All: R | All: R | All: Full |
+| **Property** | Linked: R/W | All: R | All: R/W | All: Full |
+| **Role** | None | All: R | None | All: Full |
+| **Payment** (future) | Linked: R (config) | None | All: R/W | All: Full |
-### Testing Checklist
+### Page Permission Matrix
-**For Each Resource:**
-- [ ] All 5 roles tested (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin)
-- [ ] All actions tested (read, create, update, destroy)
-- [ ] All scopes tested (own, linked, all)
-- [ ] Special cases tested
-- [ ] Edge cases tested (nil role, invalid permission_set_name)
-
-**For UI:**
-- [ ] Buttons/links show/hide correctly per role
-- [ ] Page access controlled per role
-- [ ] No broken links (all visible links are accessible)
-
-**Integration:**
-- [ ] One complete user journey per role
-- [ ] Cross-resource scenarios (e.g., Member -> Property)
-- [ ] Special cases in context (e.g., linked member email during full edit flow)
-
-### Useful Commands
-
-```bash
-# Run all authorization tests
-mix test test/mv/authorization
-
-# Run integration tests
-mix test test/integration
-
-# Run with coverage
-mix test --cover
-
-# Generate migrations
-mix ash.codegen
-
-# Run seeds
-mix run priv/repo/seeds/authorization_seeds.exs
-
-# Check permission for user in IEx
-iex> user = Mv.Accounts.get_user!("user-id")
-iex> MvWeb.Authorization.can?(user, :create, Mv.Membership.Member)
-
-# Check page access in IEx
-iex> MvWeb.Authorization.can_access_page?(user, "/members/new")
-```
+| Page Path | Own Data | Read-Only | Normal User | Admin |
+|-----------|----------|-----------|-------------|-------|
+| `/profile` | ✅ | ✅ | ✅ | ✅ |
+| `/members` | ❌ | ✅ | ✅ | ✅ |
+| `/members/:id` | ❌ | ✅ | ✅ | ✅ |
+| `/members/new` | ❌ | ❌ | ✅ | ✅ |
+| `/members/:id/edit` | ❌ | ❌ | ✅ | ✅ |
+| `/users` | ❌ | ❌ | ❌ | ✅ |
+| `/users/:id/edit` | ❌ | ❌ | ❌ | ✅ |
+| `/property-types` | ❌ | ✅ | ✅ | ✅ |
+| `/property-types/new` | ❌ | ❌ | ❌ | ✅ |
+| `/admin` | ❌ | ❌ | ❌ | ✅ |
---
-**Document Version:** 2.0 (Clean Rewrite)
-**Last Updated:** 2025-01-13
-**Status:** Ready for Implementation
+## Document History
-**Changes from V1:**
-- Complete rewrite focused on MVP (hardcoded permissions)
-- Removed all database-backed permission details from MVP sections
-- Unified naming (HasPermission for MVP)
-- Added Role resource policies
-- Clarified resource-specific :linked scope
-- Moved Phase 2 and Phase 3 to clearly marked "Future" sections
-- Fixed Buchhaltung inconsistency (read_only everywhere)
-- Added comprehensive security section
-- Enhanced edge case documentation
+| Version | Date | Author | Changes |
+|---------|------|--------|---------|
+| 1.0 | 2025-11-10 | Architecture Team | Initial architecture design |
---
-**End of Architecture Document**
+**End of Document**
diff --git a/docs/roles-and-permissions-implementation-plan.md b/docs/roles-and-permissions-implementation-plan.md
index 0b173fa..92bd262 100644
--- a/docs/roles-and-permissions-implementation-plan.md
+++ b/docs/roles-and-permissions-implementation-plan.md
@@ -1,1600 +1,2351 @@
-# Roles and Permissions - Implementation Plan (MVP)
+# Roles and Permissions - Implementation Plan
-**Version:** 2.0 (Clean Rewrite)
-**Date:** 2025-01-13
-**Status:** Ready for Implementation
-**Related Documents:**
-- [Overview](./roles-and-permissions-overview.md) - High-level concepts
-- [Architecture](./roles-and-permissions-architecture.md) - Technical specification
+**Project:** Mila - Membership Management System
+**Feature:** Role-Based Access Control (RBAC) Implementation
+**Version:** 1.0
+**Last Updated:** 2025-11-10
+**Status:** Ready for Implementation
---
## Table of Contents
-- [Executive Summary](#executive-summary)
-- [MVP Scope](#mvp-scope)
-- [Implementation Strategy](#implementation-strategy)
-- [Issue Breakdown](#issue-breakdown)
- - [Sprint 1: Foundation](#sprint-1-foundation-week-1)
- - [Sprint 2: Policies](#sprint-2-policies-week-2)
- - [Sprint 3: Special Cases & Seeds](#sprint-3-special-cases--seeds-week-3)
- - [Sprint 4: UI & Integration](#sprint-4-ui--integration-week-4)
-- [Dependencies & Parallelization](#dependencies--parallelization)
-- [Testing Strategy](#testing-strategy)
-- [Migration & Rollback](#migration--rollback)
-- [Risk Management](#risk-management)
+1. [Overview](#overview)
+2. [Test-Driven Development Approach](#test-driven-development-approach)
+3. [Issue Dependency Graph](#issue-dependency-graph)
+4. [Sprint 1: Foundation](#sprint-1-foundation-weeks-1-2)
+5. [Sprint 2: Policy System](#sprint-2-policy-system-weeks-2-3)
+6. [Sprint 3: Special Cases & Seeds](#sprint-3-special-cases--seeds-week-3)
+7. [Sprint 4: UI & Integration](#sprint-4-ui--integration-week-4)
+8. [Parallel Work Opportunities](#parallel-work-opportunities)
+9. [Summary](#summary)
+10. [Data Migration](#data-migration)
---
-## Executive Summary
+## Overview
-### Overview
+This document provides a detailed, step-by-step implementation plan for the Roles and Permissions system. The implementation is broken down into **18 small, focused issues** that can be worked on in parallel where possible.
-This document defines the implementation plan for the **MVP (Phase 1)** of the Roles and Permissions system using **hardcoded Permission Sets** in an Elixir module.
+**Key Principles:**
+- **Test-Driven Development (TDD):** Write tests first, then implement
+- **Small, Focused Issues:** Each issue is 1-4 days of work
+- **Parallelization:** Multiple issues can be worked on simultaneously
+- **Clear Dependencies:** Dependency graph shows what must be completed first
+- **Definition of Done:** Each issue has clear completion criteria
-**Key Characteristics:**
-- **15 issues total** (Issues #1-3, #6-17)
-- **2-3 weeks duration**
-- **180+ tests**
-- **Test-Driven Development (TDD)** throughout
-- **No database tables for permissions** - only `roles` table
-- **Zero performance concerns** - all permission checks are in-memory function calls
-
-### What's NOT in MVP
-
-**Deferred to Phase 3 (Future):**
-- Issue #4: `PermissionSetResource` database table
-- Issue #5: `PermissionSetPage` database table
-- Issue #18: ETS Permission Cache
-- Database-backed dynamic permissions
-
-### The Four Permission Sets
-
-Hardcoded in `Mv.Authorization.PermissionSets` module:
-
-1. **own_data** - User can only access their own data (default for "Mitglied")
-2. **read_only** - Read access to all members/properties (for "Vorstand", "Buchhaltung")
-3. **normal_user** - Create/Read/Update members (no delete), full CRUD on properties (for "Kassenwart")
-4. **admin** - Unrestricted access including user/role management (for "Admin")
-
-### The Five Roles
-
-Stored in database `roles` table, each referencing a `permission_set_name`:
-
-1. **Mitglied** → "own_data" (is_system_role=true, default)
-2. **Vorstand** → "read_only"
-3. **Kassenwart** → "normal_user"
-4. **Buchhaltung** → "read_only"
-5. **Admin** → "admin"
+**Related Documents:**
+- [Architecture Design](./roles-and-permissions-architecture.md) - Complete system architecture and design decisions
---
-## MVP Scope
+## Test-Driven Development Approach
-### What We're Building
+This feature will be implemented using Test-Driven Development (TDD):
-**Core Authorization System:**
-- ✅ 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, Property, PropertyType, Role)
-- ✅ Page-level permissions via Phoenix Plug
-- ✅ UI authorization helpers for conditional rendering
-- ✅ Special case: Member email validation for linked users
-- ✅ Seed data for 5 roles
+### TDD Workflow
-**Benefits of Hardcoded Approach:**
-- **Speed:** 2-3 weeks vs. 4-5 weeks for DB-backed
-- **Performance:** < 1 microsecond per check (pure function call)
-- **Simplicity:** No cache, no DB queries, easy to reason about
-- **Version Control:** All permission changes tracked in Git
-- **Testing:** Deterministic, no DB setup needed
+1. **Red Phase - Write Failing Tests First:**
+ - For each issue, write tests that define expected behavior
+ - Tests should fail because functionality doesn't exist yet
+ - Tests serve as specification and documentation
-**Clear Migration Path to Phase 3:**
-- Architecture document defines exact DB schema for future
-- HasPermission check can be swapped for DB-querying version
-- Role->PermissionSet link remains unchanged
+2. **Green Phase - Implement Minimum Code:**
+ - Write just enough code to make tests pass
+ - Focus on functionality, not perfection
+ - Get to green as quickly as possible
+
+3. **Refactor Phase - Clean Up:**
+ - Clean up code while keeping tests green
+ - Improve structure, naming, and organization
+ - Ensure code follows guidelines
+
+4. **Integration Phase - Ensure Components Work Together:**
+ - Write integration tests
+ - Test cross-component interactions
+ - Verify complete user flows
+
+### Test Coverage Goals
+
+| Test Type | Coverage Goal | Description |
+|-----------|---------------|-------------|
+| **Unit Tests** | >90% | Policy checks, permission evaluation, cache operations |
+| **Integration Tests** | >80% | Cross-resource authorization, special cases |
+| **LiveView Tests** | >85% | Page permission enforcement, UI interactions |
+| **E2E Tests** | 100% of user flows | Complete journeys for each role |
+
+### Test Organization
+
+```
+test/
+├── mv/
+│ ├── authorization/
+│ │ ├── schema_test.exs # Issue #1
+│ │ ├── permission_set_test.exs # Issue #2
+│ │ ├── role_test.exs # Issue #3
+│ │ ├── permission_set_resource_test.exs # Issue #4
+│ │ ├── permission_set_page_test.exs # Issue #5
+│ │ ├── permission_cache_test.exs # Issue #6
+│ │ ├── checks/
+│ │ │ └── has_resource_permission_test.exs # Issue #7
+│ │ └── integration_test.exs # Issue #16
+│ ├── accounts/
+│ │ └── user_authorization_test.exs # Issue #9
+│ └── membership/
+│ ├── member_authorization_test.exs # Issue #8
+│ ├── member_email_validation_test.exs # Issue #13
+│ ├── property_authorization_test.exs # Issue #10
+│ └── property_type_authorization_test.exs # Issue #11
+├── mv_web/
+│ ├── authorization_test.exs # Issue #15
+│ ├── plugs/
+│ │ └── check_page_permission_test.exs # Issue #12
+│ ├── components/
+│ │ └── layouts/
+│ │ └── navbar_test.exs # Issue #17
+│ ├── member_live/
+│ │ └── index_test.exs # Issue #17
+│ ├── role_live/
+│ │ └── index_test.exs # Issue #16
+│ └── user_live/
+│ └── index_test.exs # Issue #16, #17
+└── seeds/
+ └── authorization_seeds_test.exs # Issue #14
+```
---
-## Implementation Strategy
-
-### Test-Driven Development
-
-**Every issue follows TDD:**
-1. Write failing tests first
-2. Implement minimum code to pass tests
-3. Refactor if needed
-4. All tests must pass before moving on
-
-**Test Types:**
-- **Unit Tests:** Individual modules (PermissionSets, Policy checks, Helpers)
-- **Integration Tests:** Cross-resource authorization, special cases
-- **LiveView Tests:** UI rendering, page permissions
-- **E2E Tests:** Complete user journeys (one per role)
-
-### Incremental Rollout
-
-**Feature Flag Approach:**
-- Implement behind environment variable `ENABLE_RBAC`
-- Default: `false` (existing auth remains active)
-- Test thoroughly in staging
-- Flip flag in production after validation
-- Allows instant rollback if needed
-
-### Definition of Done (All Issues)
-
-- [ ] All acceptance criteria met
-- [ ] All tests written and passing
-- [ ] Code reviewed and approved
-- [ ] Documentation updated
-- [ ] No linter errors
-- [ ] Manual testing completed
-- [ ] Feature flag tested (on/off states)
-
----
-
-## Issue Breakdown
-
-### Sprint 1: Foundation (Week 1)
-
-#### Issue #1: Create Authorization Domain and Role Resource
-
-**Size:** M (2 days)
-**Dependencies:** None
-**Assignable to:** Backend Developer
-
-**Description:**
-
-Create the authorization domain in Ash with the `Role` resource. This establishes the foundation for all authorization logic.
-
-**Tasks:**
-
-1. Create `lib/mv/authorization/` directory
-2. Create `lib/mv/authorization/role.ex` Ash resource with:
- - `id` (UUIDv7, primary key)
- - `name` (String, unique, required) - e.g., "Vorstand", "Admin"
- - `description` (String, optional)
- - `permission_set_name` (String, required) - must be one of: "own_data", "read_only", "normal_user", "admin"
- - `is_system_role` (Boolean, default false) - prevents deletion
- - timestamps
-3. Add validation: `permission_set_name` must exist in `PermissionSets.all_permission_sets/0`
-4. Add `role_id` (UUID, nullable, foreign key) to `users` table
-5. Add `belongs_to :role` relationship in User resource
-6. Run `mix ash.codegen` to generate migrations
-7. Review and apply migrations
-
-**Acceptance Criteria:**
-
-- [ ] Role resource created with all fields
-- [ ] Migration applied successfully
-- [ ] User.role relationship works
-- [ ] Validation prevents invalid `permission_set_name`
-- [ ] `is_system_role` flag present
-
-**Test Strategy:**
-
-**Smoke Tests Only** (detailed behavior tests in later issues):
-
-- Role resource can be loaded via `Code.ensure_loaded?(Mv.Authorization.Role)`
-- Migration created valid table (manually verify with `psql`)
-- User resource can be loaded and has `:role` in `relationships()`
-
-**No extensive behavior tests** - those come in Issue #3 (Role CRUD).
-
-**Test File:** `test/mv/authorization/role_test.exs` (minimal smoke tests)
-
----
-
-#### Issue #2: PermissionSets Elixir Module (Hardcoded Permissions)
-
-**Size:** M (2 days)
-**Dependencies:** None
-**Can work in parallel:** Yes (parallel with #1)
-**Assignable to:** Backend Developer
-
-**Description:**
-
-Create the core `PermissionSets` module that defines all four permission sets with their resource and page permissions. This is the heart of the MVP's authorization logic.
-
-**Tasks:**
-
-1. Create `lib/mv/authorization/permission_sets.ex`
-2. Define module with `@moduledoc` explaining the 4 permission sets
-3. Define types:
- ```elixir
- @type scope :: :own | :linked | :all
- @type action :: :read | :create | :update | :destroy
- @type resource_permission :: %{
- resource: String.t(),
- action: action(),
- scope: scope(),
- granted: boolean()
- }
- @type permission_set :: %{
- resources: [resource_permission()],
- pages: [String.t()]
- }
- ```
-4. Implement `get_permissions/1` for each of the 4 permission sets
-5. Implement `all_permission_sets/0` returning `[:own_data, :read_only, :normal_user, :admin]`
-6. Implement `valid_permission_set?/1` checking if name is in the list
-7. Implement `permission_set_name_to_atom/1` with error handling
-8. Add comprehensive `@doc` examples for each function
-
-**Permission Set Details:**
-
-**1. own_data (Mitglied):**
-- Resources:
- - User: read/update :own
- - Member: read/update :linked
- - Property: read/update :linked
- - PropertyType: read :all
-- Pages: `["/", "/profile", "/members/:id"]`
-
-**2. read_only (Vorstand, Buchhaltung):**
-- Resources:
- - User: read :own, update :own
- - Member: read :all
- - Property: read :all
- - PropertyType: read :all
-- Pages: `["/", "/members", "/members/:id", "/properties"]`
-
-**3. normal_user (Kassenwart):**
-- Resources:
- - User: read/update :own
- - Member: read/create/update :all (no destroy for safety)
- - Property: read/create/update/destroy :all
- - PropertyType: read :all
-- Pages: `["/", "/members", "/members/new", "/members/:id", "/members/:id/edit", "/properties", "/properties/new", "/properties/:id/edit"]`
-
-**4. admin:**
-- Resources:
- - User: read/update/destroy :all
- - Member: read/create/update/destroy :all
- - Property: read/create/update/destroy :all
- - PropertyType: read/create/update/destroy :all
- - Role: read/create/update/destroy :all
-- Pages: `["*"]` (wildcard = all pages)
-
-**Acceptance Criteria:**
-
-- [ ] Module created with all 4 permission sets
-- [ ] `get_permissions/1` returns correct structure for each set
-- [ ] `valid_permission_set?/1` works for atoms and strings
-- [ ] `permission_set_name_to_atom/1` handles errors gracefully
-- [ ] All functions have `@doc` and `@spec`
-- [ ] Code is readable and well-commented
-
-**Test Strategy (TDD):**
-
-**Structure Tests:**
-- `get_permissions(:own_data)` returns map with `:resources` and `:pages` keys
-- Each permission set returns list of resource permissions
-- Each resource permission has required keys: `:resource`, `:action`, `:scope`, `:granted`
-- Pages lists are non-empty (except potentially for restricted roles)
-
-**Permission Content Tests:**
-- `:own_data` allows User read/update with scope :own
-- `:own_data` allows Member/Property read/update with scope :linked
-- `:read_only` allows Member/Property read with scope :all
-- `:read_only` does NOT allow Member/Property create/update/destroy
-- `:normal_user` allows Member/Property full CRUD with scope :all
-- `:admin` allows everything with scope :all
-- `:admin` has wildcard page permission "*"
-
-**Validation Tests:**
-- `valid_permission_set?("own_data")` returns true
-- `valid_permission_set?(:admin)` returns true
-- `valid_permission_set?("invalid")` returns false
-- `permission_set_name_to_atom("own_data")` returns `{:ok, :own_data}`
-- `permission_set_name_to_atom("invalid")` returns `{:error, :invalid_permission_set}`
-
-**Edge Cases:**
-- All 4 sets defined in `all_permission_sets/0`
-- Function doesn't crash on nil input (returns false/error tuple)
-
-**Test File:** `test/mv/authorization/permission_sets_test.exs`
-
----
-
-#### Issue #3: Role CRUD LiveViews
-
-**Size:** M (3 days)
-**Dependencies:** #1 (Role resource)
-**Assignable to:** Backend Developer + Frontend Developer
-
-**Description:**
-
-Create LiveView interface for administrators to manage roles. Only admins should be able to access this.
-
-**Tasks:**
-
-1. Create `lib/mv_web/live/role_live/` directory
-2. Implement `index.ex` - List all roles
-3. Implement `show.ex` - View role details
-4. Implement `form.ex` - Create/Edit role form component
-5. Add routes in `router.ex` under `/admin` scope
-6. Create table component showing: name, description, permission_set_name, is_system_role
-7. Add form validation for `permission_set_name` (dropdown with 4 options)
-8. Prevent deletion of system roles (UI + backend)
-9. Add flash messages for success/error
-10. Style with existing DaisyUI theme
-
-**Acceptance Criteria:**
-
-- [ ] Index page lists all roles
-- [ ] Show page displays role details
-- [ ] Form allows creating new roles
-- [ ] Form allows editing non-system roles
-- [ ] `permission_set_name` is dropdown (not free text)
-- [ ] Cannot delete system roles (grayed out button + backend check)
-- [ ] All CRUD operations work
-- [ ] Routes are under `/admin/roles`
-
-**Test Strategy (TDD):**
-
-**LiveView Mount Tests:**
-- Index page mounts successfully
-- Index page loads all roles from database
-- Show page mounts with valid role ID
-- Show page returns 404 for invalid role ID
-
-**CRUD Operation Tests:**
-- Create new role with valid data succeeds
-- Create new role with invalid `permission_set_name` shows error
-- Update role name succeeds
-- Update system role's `permission_set_name` succeeds
-- Delete non-system role succeeds
-- Delete system role fails with error message
-
-**UI Rendering Tests:**
-- Index page shows table with role names
-- System roles have badge/indicator
-- Delete button disabled for system roles
-- Form dropdown shows all 4 permission sets
-- Flash messages appear after actions
-
-**Test File:** `test/mv_web/live/role_live_test.exs`
-
----
-
-### Sprint 2: Policies (Week 2)
-
-#### Issue #6: Custom Policy Check - HasPermission
-
-**Size:** L (3-4 days)
-**Dependencies:** #2 (PermissionSets), #3 (Role resource exists)
-**Assignable to:** Senior Backend Developer
-
-**Description:**
-
-Create the core custom Ash Policy Check that reads permissions from the `PermissionSets` module and applies them to Ash queries. This is the bridge between hardcoded permissions and Ash's authorization system.
-
-**Tasks:**
-
-1. Create `lib/mv/authorization/checks/has_permission.ex`
-2. Implement `use Ash.Policy.Check`
-3. Implement `describe/1` - returns human-readable description
-4. Implement `match?/3` - the core authorization logic:
- - Extract `actor.role.permission_set_name`
- - Convert to atom via `PermissionSets.permission_set_name_to_atom/1`
- - Call `PermissionSets.get_permissions/1`
- - Find matching permission for current resource + action
- - Apply scope filter
-5. Implement `apply_scope/3` helper:
- - `:all` → `:authorized` (no filter)
- - `:own` → `{:filter, expr(id == ^actor.id)}`
- - `:linked` → resource-specific logic:
- - Member: `{:filter, expr(user_id == ^actor.id)}`
- - Property: `{:filter, expr(member.user_id == ^actor.id)}` (traverse relationship!)
-6. Handle errors gracefully:
- - No actor → `{:error, :no_actor}`
- - No role → `{:error, :no_role}`
- - Invalid permission_set_name → `{:error, :invalid_permission_set}`
- - No matching permission → `{:error, :no_permission}`
-7. Add logging for authorization failures (debug level)
-8. Add comprehensive `@doc` with examples
-
-**Acceptance Criteria:**
-
-- [ ] Check module implements `Ash.Policy.Check` behavior
-- [ ] `match?/3` correctly evaluates permissions from PermissionSets
-- [ ] Scope filters work correctly (:all, :own, :linked)
-- [ ] `:linked` scope handles Member and Property differently
-- [ ] Errors are handled gracefully (no crashes)
-- [ ] Authorization failures are logged
-- [ ] Module is well-documented
-
-**Test Strategy (TDD):**
-
-**Permission Lookup Tests:**
-- Actor with :admin permission_set has permission for all resources/actions
-- Actor with :read_only permission_set has read permission for Member
-- Actor with :read_only permission_set does NOT have create permission for Member
-- Actor with :own_data permission_set has update permission for User with scope :own
-
-**Scope Application Tests - :all:**
-- Actor with scope :all can access any record
-- Query returns all records in database
-
-**Scope Application Tests - :own:**
-- Actor with scope :own can access record where record.id == actor.id
-- Actor with scope :own cannot access record where record.id != actor.id
-- Query filters to only actor's own record
-
-**Scope Application Tests - :linked:**
-- Actor with scope :linked can access Member where member.user_id == actor.id
-- Actor with scope :linked can access Property where property.member.user_id == actor.id (relationship traversal!)
-- Actor with scope :linked cannot access unlinked member
-- Query correctly filters based on user_id relationship
-
-**Error Handling Tests:**
-- `match?` with nil actor returns `{:error, :no_actor}`
-- `match?` with actor missing role returns `{:error, :no_role}`
-- `match?` with invalid permission_set_name returns `{:error, :invalid_permission_set}`
-- `match?` with no matching permission returns `{:error, :no_permission}`
-- No crashes on edge cases
-
-**Logging Tests:**
-- Authorization failure logs at debug level
-- Log includes actor ID, resource, action, reason
-
-**Test Files:**
-- `test/mv/authorization/checks/has_permission_test.exs`
-
----
-
-#### Issue #7: Member Resource Policies
-
-**Size:** M (2 days)
-**Dependencies:** #6 (HasPermission check)
-**Can work in parallel:** Yes (parallel with #8, #9, #10)
-**Assignable to:** Backend Developer
-
-**Description:**
-
-Add authorization policies to the Member resource using the new `HasPermission` check.
-
-**Tasks:**
-
-1. Open `lib/mv/membership/member.ex`
-2. Add `policies` block at top of resource (before actions)
-3. Configure policy to `Mv.Authorization.Checks.HasPermission`
-4. Add policy for each action:
- - `:read` → check HasPermission for :read
- - `:create` → check HasPermission for :create
- - `:update` → check HasPermission for :update
- - `:destroy` → check HasPermission for :destroy
-5. Add special policy: Allow user to read/update their linked member (before general policy)
- ```elixir
- policy action_type(:read) do
- authorize_if expr(user_id == ^actor(:id))
- end
- ```
-6. Ensure policies load actor with `:role` relationship preloaded
-7. Test policies with different actors
-
-**Policy Order (Critical!):**
-1. Allow user to access their own linked member (most specific)
-2. Check HasPermission (general authorization)
-3. Default: Forbid
-
-**Acceptance Criteria:**
-
-- [ ] Policies block added to Member resource
-- [ ] All CRUD actions protected by HasPermission
-- [ ] Special case: User can always access linked member
-- [ ] Policy order is correct (specific before general)
-- [ ] Actor preloads :role relationship
-- [ ] All policies tested
-
-**Test Strategy (TDD):**
-
-**Policy Tests for :own_data (Mitglied):**
-- User can read their linked member (user_id matches)
-- User can update their linked member
-- User cannot read unlinked member (returns empty list or forbidden)
-- User cannot create member
-- Verify scope :linked works
-
-**Policy Tests for :read_only (Vorstand):**
-- User can read all members (returns all records)
-- User cannot create member (returns Forbidden)
-- User cannot update any member (returns Forbidden)
-- User cannot destroy any member (returns Forbidden)
-
-**Policy Tests for :normal_user (Kassenwart):**
-- User can read all members
-- User can create new member
-- User can update any member
-- User cannot destroy member (not in permission set)
-
-**Policy Tests for :admin:**
-- User can perform all CRUD operations on any member
-- No restrictions
-
-**Test File:** `test/mv/membership/member_policies_test.exs`
-
----
-
-#### Issue #8: User Resource Policies
-
-**Size:** M (2 days)
-**Dependencies:** #6 (HasPermission check)
-**Can work in parallel:** Yes (parallel with #7, #9, #10)
-**Assignable to:** Backend Developer
-
-**Description:**
-
-Add authorization policies to the User resource. Special case: Users can always read/update their own credentials.
-
-**Tasks:**
-
-1. Open `lib/mv/accounts/user.ex`
-2. Add `policies` block
-3. Add special policy: Allow user to always access their own account (before general policy)
- ```elixir
- policy action_type([:read, :update]) do
- authorize_if expr(id == ^actor(:id))
- end
- ```
-4. Add general policy: Check HasPermission for all actions
-5. Ensure :destroy is admin-only (via HasPermission)
-6. Preload :role relationship for actor
-
-**Policy Order:**
-1. Allow user to read/update own account (id == actor.id)
-2. Check HasPermission (for admin operations)
-3. Default: Forbid
-
-**Acceptance Criteria:**
-
-- [ ] User can always read/update own credentials
-- [ ] Only admin can read/update other users
-- [ ] Only admin can destroy users
-- [ ] Policy order is correct
-- [ ] Actor preloads :role relationship
-
-**Test Strategy (TDD):**
-
-**Own Data Tests (All Roles):**
-- User with :own_data can read own user record
-- User with :own_data can update own email/password
-- User with :own_data cannot read other users
-- User with :read_only can read own data
-- User with :normal_user can read own data
-- Verify special policy takes precedence
-
-**Admin Tests:**
-- Admin can read all users
-- Admin can update any user's credentials
-- Admin can destroy users
-- Admin has unrestricted access
-
-**Forbidden Tests:**
-- Non-admin cannot read other users
-- Non-admin cannot update other users
-- Non-admin cannot destroy users
-
-**Test File:** `test/mv/accounts/user_policies_test.exs`
-
----
-
-#### Issue #9: Property Resource Policies
-
-**Size:** M (2 days)
-**Dependencies:** #6 (HasPermission check)
-**Can work in parallel:** Yes (parallel with #7, #8, #10)
-**Assignable to:** Backend Developer
-
-**Description:**
-
-Add authorization policies to the Property resource. Properties are linked to members, which are linked to users.
-
-**Tasks:**
-
-1. Open `lib/mv/membership/property.ex`
-2. Add `policies` block
-3. Add special policy: Allow user to read/update properties of their linked member
- ```elixir
- policy action_type([:read, :update]) do
- authorize_if expr(member.user_id == ^actor(:id))
- end
- ```
-4. Add general policy: Check HasPermission
-5. Ensure Property preloads :member relationship for scope checks
-6. Preload :role relationship for actor
-
-**Policy Order:**
-1. Allow user to read/update properties of linked member
-2. Check HasPermission
-3. Default: Forbid
-
-**Acceptance Criteria:**
-
-- [ ] User can access properties of their linked member
-- [ ] Policy traverses Member -> User relationship correctly
-- [ ] HasPermission check works for other scopes
-- [ ] Actor preloads :role relationship
-
-**Test Strategy (TDD):**
-
-**Linked Properties Tests (:own_data):**
-- User can read properties of their linked member
-- User can update properties of their linked member
-- User cannot read properties of unlinked members
-- Verify relationship traversal works (property.member.user_id)
-
-**Read-Only Tests:**
-- User with :read_only can read all properties
-- User with :read_only cannot create/update properties
-
-**Normal User Tests:**
-- User with :normal_user can CRUD properties
-
-**Admin Tests:**
-- Admin can perform all operations
-
-**Test File:** `test/mv/membership/property_policies_test.exs`
-
----
-
-#### Issue #10: PropertyType Resource Policies
-
-**Size:** S (1 day)
-**Dependencies:** #6 (HasPermission check)
-**Can work in parallel:** Yes (parallel with #7, #8, #9)
-**Assignable to:** Backend Developer
-
-**Description:**
-
-Add authorization policies to the PropertyType resource. PropertyTypes are admin-managed, but readable by all.
-
-**Tasks:**
-
-1. Open `lib/mv/membership/property_type.ex`
-2. Add `policies` block
-3. Add read policy: All authenticated users can read (scope :all)
-4. Add write policies: Only admin can create/update/destroy
-5. Use HasPermission check
-
-**Acceptance Criteria:**
-
-- [ ] All users can read property types
-- [ ] Only admin can create/update/destroy property types
-- [ ] Policies tested
-
-**Test Strategy (TDD):**
-
-**Read Access (All Roles):**
-- User with :own_data can read all property types
-- User with :read_only can read all property types
-- User with :normal_user can read all property types
-- User with :admin can read all property types
-
-**Write Access (Admin Only):**
-- Non-admin cannot create property type (Forbidden)
-- Non-admin cannot update property type (Forbidden)
-- Non-admin cannot destroy property type (Forbidden)
-- Admin can create property type
-- Admin can update property type
-- Admin can destroy property type
-
-**Test File:** `test/mv/membership/property_type_policies_test.exs`
-
----
-
-#### Issue #11: Page Permission Router Plug
-
-**Size:** S (1 day)
-**Dependencies:** #2 (PermissionSets), #6 (HasPermission)
-**Can work in parallel:** Yes (after #2 and #6)
-**Assignable to:** Backend Developer
-
-**Description:**
-
-Create a Phoenix plug that checks if the current user has permission to access the requested page/route. This runs before LiveView mounts.
-
-**Tasks:**
-
-1. Create `lib/mv_web/plugs/check_page_permission.ex`
-2. Implement `init/1` and `call/2`
-3. Extract page path from `conn.private[:phoenix_route]` (route template like "/members/:id")
-4. Get user from `conn.assigns[:current_user]`
-5. Get user's role and permission_set_name
-6. Call `PermissionSets.get_permissions/1` to get allowed pages list
-7. Match requested path against allowed patterns:
- - Exact match: "/members" == "/members"
- - Dynamic match: "/members/:id" matches "/members/123"
- - Wildcard: "*" matches everything (admin)
-8. If unauthorized: redirect to "/" with flash error "You don't have permission to access this page."
-9. If authorized: continue (conn not halted)
-10. Add plug to router pipelines (`:browser`, `:require_authenticated_user`)
-
-**Acceptance Criteria:**
-
-- [ ] Plug checks page permissions from PermissionSets
-- [ ] Static routes work ("/members")
-- [ ] Dynamic routes work ("/members/:id" matches "/members/123")
-- [ ] Wildcard works for admin ("*")
-- [ ] Unauthorized users redirected with flash message
-- [ ] Plug added to appropriate router pipelines
-
-**Test Strategy (TDD):**
-
-**Static Route Tests:**
-- User with permission for "/members" can access (conn not halted)
-- User without permission for "/members" is denied (conn halted, redirected to "/")
-- Flash error message present after denial
-
-**Dynamic Route Tests:**
-- User with "/members/:id" permission can access "/members/123"
-- User with "/members/:id/edit" permission can access "/members/456/edit"
-- User with only "/members/:id" cannot access "/members/123/edit"
-- Pattern matching works correctly
-
-**Wildcard Tests:**
-- Admin with "*" permission can access any page
-- Wildcard overrides all other checks
-
-**Unauthenticated User Tests:**
-- Nil current_user is redirected to login
-- Login redirect preserves attempted path (optional feature)
-
-**Error Handling Tests:**
-- User with invalid permission_set_name is denied
-- User with no role is denied
-- Error is logged but user sees generic message
-
-**Test File:** `test/mv_web/plugs/check_page_permission_test.exs`
-
----
-
-### Sprint 3: Special Cases & Seeds (Week 3)
-
-#### Issue #12: Member Email Validation for Linked Members
-
-**Size:** M (2 days)
-**Dependencies:** #7 (Member policies), #8 (User policies)
-**Assignable to:** Backend Developer
-
-**Description:**
-
-Implement special validation: Only admins can edit a member's email if that member is linked to a user. This prevents breaking email synchronization.
-
-**Tasks:**
-
-1. Open `lib/mv/membership/member.ex`
-2. Add custom validation in `validations` block:
- ```elixir
- validate changing(:email), on: :update do
- validate &validate_email_change_permission/2
- end
- ```
-3. Implement `validate_email_change_permission/2`:
- - Check if member has `user_id` (is linked)
- - If linked: Check if actor has User.update permission with scope :all (admin)
- - If not admin: Return error "Only administrators can change email for members linked to users"
- - If not linked: Allow change
-4. Use `PermissionSets.get_permissions/1` to check admin status
-5. Add tests for all cases
-
-**Acceptance Criteria:**
-
-- [ ] Non-admin can edit email of unlinked member
-- [ ] Non-admin cannot edit email of linked member
-- [ ] Admin can edit email of linked member
-- [ ] Validation only runs when email changes
-- [ ] Error message is clear and helpful
-
-**Test Strategy (TDD):**
-
-**Unlinked Member Tests:**
-- User with :normal_user can update email of unlinked member
-- User with :read_only cannot update email (caught by policy, not validation)
-- Validation doesn't block if member.user_id is nil
-
-**Linked Member Tests:**
-- User with :normal_user cannot update email of linked member (validation error)
-- Error message mentions "administrators" and "linked to users"
-- User with :admin can update email of linked member (validation passes)
-
-**No-Op Tests:**
-- Validation doesn't run if email didn't change
-- Updating other fields (name, address) works normally
-
-**Test File:** `test/mv/membership/member_email_validation_test.exs`
-
----
-
-#### Issue #13: Seed Data - Roles and Default Assignment
-
-**Size:** S (1 day)
-**Dependencies:** #2 (PermissionSets), #3 (Role resource)
-**Can work in parallel:** Yes (parallel with #12 after #2 and #3 complete)
-**Assignable to:** Backend Developer
-
-**Description:**
-
-Create seed data for 5 roles and assign default "Mitglied" role to existing users. Optionally designate one admin via environment variable.
-
-**Tasks:**
-
-1. Create `priv/repo/seeds/authorization_seeds.exs`
-2. Seed 5 roles using `Ash.Seed.seed!/2` or create actions:
- - **Mitglied:** name="Mitglied", description="Default member role", permission_set_name="own_data", is_system_role=true
- - **Vorstand:** name="Vorstand", description="Board member with read access", permission_set_name="read_only", is_system_role=false
- - **Kassenwart:** name="Kassenwart", description="Treasurer with full member management", permission_set_name="normal_user", is_system_role=false
- - **Buchhaltung:** name="Buchhaltung", description="Accounting with read access", permission_set_name="read_only", is_system_role=false
- - **Admin:** name="Admin", description="Administrator with full access", permission_set_name="admin", is_system_role=false
-3. Make idempotent: Use upsert logic (get by name, update if exists, create if not)
-4. Assign "Mitglied" role to all users without role_id:
- ```elixir
- mitglied_role = Ash.get!(Role, name: "Mitglied")
- users_without_role = Ash.read!(User, filter: expr(is_nil(role_id)))
- Enum.each(users_without_role, fn user ->
- Ash.update!(user, %{role_id: mitglied_role.id})
- end)
- ```
-5. (Optional) Check for `ADMIN_EMAIL` env var, assign Admin role to that user
-6. Add error handling with clear error messages
-7. Add `IO.puts` statements to show progress
-
-**Acceptance Criteria:**
-
-- [ ] All 5 roles created with correct permission_set_name
-- [ ] "Mitglied" has is_system_role=true
-- [ ] Existing users without role get "Mitglied" role
-- [ ] Optional: ADMIN_EMAIL user gets Admin role
-- [ ] Seeds are idempotent (can run multiple times)
-- [ ] Error messages are clear
-- [ ] Progress is logged to console
-
-**Test Strategy (TDD):**
-
-**Role Creation Tests:**
-- After running seeds, 5 roles exist
-- Each role has correct permission_set_name:
- - Mitglied → "own_data"
- - Vorstand → "read_only"
- - Kassenwart → "normal_user"
- - Buchhaltung → "read_only"
- - Admin → "admin"
-- "Mitglied" role has is_system_role=true
-- Other roles have is_system_role=false
-- All permission_set_names are valid (exist in PermissionSets.all_permission_sets/0)
-
-**User Assignment Tests:**
-- Users without role_id are assigned "Mitglied" role
-- Users who already have role_id are not changed
-- Count of users with "Mitglied" role increases by number of previously unassigned users
-
-**Idempotency Tests:**
-- Running seeds twice doesn't create duplicate roles
-- Each role name appears exactly once
-- Running seeds twice doesn't reassign users who already have roles
-
-**Optional Admin Tests:**
-- If ADMIN_EMAIL set, user with that email gets Admin role
-- If ADMIN_EMAIL not set, no error occurs
-- If email doesn't exist, error is logged but seeds continue
-
-**Error Handling Tests:**
-- Seeds fail gracefully if invalid permission_set_name provided
-- Error message indicates which permission_set_name is invalid
-
-**Test File:** `test/seeds/authorization_seeds_test.exs`
-
----
-
-### Sprint 4: UI & Integration (Week 4)
-
-#### Issue #14: UI Authorization Helper Module
-
-**Size:** M (2-3 days)
-**Dependencies:** #2 (PermissionSets), #6 (HasPermission), #13 (Seeds - for testing)
-**Assignable to:** Backend Developer + Frontend Developer
-
-**Description:**
-
-Create helper functions for UI-level authorization checks. These will be used in LiveView templates to conditionally render buttons, links, and sections based on user permissions.
-
-**Tasks:**
-
-1. Create `lib/mv_web/authorization.ex`
-2. Implement `can?/3` for resource-level checks:
- ```elixir
- def can?(user, action, resource) when is_atom(resource)
- # Returns true if user has permission for action on resource
- # e.g., can?(current_user, :create, Mv.Membership.Member)
- ```
-3. Implement `can?/3` for record-level checks:
- ```elixir
- def can?(user, action, %resource{} = record)
- # Returns true if user has permission for action on specific record
- # Applies scope checking (own, linked, all)
- # e.g., can?(current_user, :update, member)
- ```
-4. Implement `can_access_page?/2`:
- ```elixir
- def can_access_page?(user, page_path)
- # Returns true if user's permission set includes page
- # e.g., can_access_page?(current_user, "/members/new")
- ```
-5. All functions use `PermissionSets.get_permissions/1` (same logic as HasPermission)
-6. All functions handle nil user gracefully (return false)
-7. Implement resource-specific scope checking (Member vs Property for :linked)
-8. Add comprehensive `@doc` with template examples
-9. Import helper in `mv_web.ex` `html_helpers` section
-
-**Acceptance Criteria:**
-
-- [ ] `can?/3` works for resource atoms
-- [ ] `can?/3` works for record structs with scope checking
-- [ ] `can_access_page?/2` matches page patterns correctly
-- [ ] Nil user always returns false
-- [ ] Invalid permission_set_name returns false (not crash)
-- [ ] Helper imported in `mv_web.ex`
-- [ ] Comprehensive documentation with examples
-
-**Test Strategy (TDD):**
-
-**can?/3 with Resource Atom:**
-- Returns true when user has permission for resource+action
-- Admin can create Member (returns true)
-- Read-only cannot create Member (returns false)
-- Nil user returns false
-
-**can?/3 with Record Struct - Scope :all:**
-- Admin can update any member (returns true for any record)
-- Normal user can update any member (scope :all)
-
-**can?/3 with Record Struct - Scope :own:**
-- User can update own User record (record.id == user.id)
-- User cannot update other User record (record.id != user.id)
-
-**can?/3 with Record Struct - Scope :linked:**
-- User can update linked Member (member.user_id == user.id)
-- User cannot update unlinked Member
-- User can update Property of linked Member (property.member.user_id == user.id)
-- User cannot update Property of unlinked Member
-- Scope checking is resource-specific (Member vs Property)
-
-**can_access_page?/2:**
-- User with page in list can access (returns true)
-- User without page in list cannot access (returns false)
-- Dynamic routes match correctly ("/members/:id" matches "/members/123")
-- Admin wildcard "*" matches any page
-- Nil user returns false
-
-**Error Handling:**
-- User without role returns false
-- User with invalid permission_set_name returns false (no crash)
-- Handles missing fields gracefully
-
-**Test File:** `test/mv_web/authorization_test.exs`
-
----
-
-#### Issue #15: Admin UI for Role Management
-
-**Size:** M (2 days)
-**Dependencies:** #14 (UI Authorization Helper)
-**Assignable to:** Frontend Developer
-
-**Description:**
-
-Update Role management LiveViews to use authorization helpers for conditional rendering. Add UI polish.
-
-**Tasks:**
-
-1. Open `lib/mv_web/live/role_live/index.ex`
-2. Add authorization checks for "New Role" button:
- ```heex
- <%= if can?(@current_user, :create, Mv.Authorization.Role) do %>
- <.link patch={~p"/admin/roles/new"}>New Role
- <% end %>
- ```
-3. Add authorization checks for "Edit" and "Delete" buttons in table
-4. Gray out/hide "Delete" for system roles
-5. Update `show.ex` to hide edit button if user can't update
-6. Add role badge/pill for system roles
-7. Add permission_set_name badge with color coding:
- - own_data → gray
- - read_only → blue
- - normal_user → green
- - admin → red
-8. Test UI with different user roles
-
-**Acceptance Criteria:**
-
-- [ ] Only admin sees "New Role" button
-- [ ] Only admin sees "Edit" and "Delete" buttons
-- [ ] System roles have visual indicator
-- [ ] Delete button hidden/disabled for system roles
-- [ ] Permission set badges are color-coded
-- [ ] UI tested with all role types
-
-**Test Strategy (TDD):**
-
-**Admin View:**
-- Admin sees "New Role" button
-- Admin sees "Edit" buttons for all roles
-- Admin sees "Delete" buttons for non-system roles
-- Admin does not see "Delete" button for system roles
-
-**Non-Admin View:**
-- Non-admin does not see "New Role" button (redirected by page permission plug anyway)
-- Non-admin cannot access /admin/roles (caught by plug)
-
-**Visual Tests:**
-- System roles have badge
-- Permission set names are color-coded
-- UI renders correctly
-
-**Test File:** `test/mv_web/live/role_live_authorization_test.exs`
-
----
-
-#### Issue #16: Apply UI Authorization to Existing LiveViews
-
-**Size:** L (3 days)
-**Dependencies:** #14 (UI Authorization Helper)
-**Can work in parallel:** Yes (parallel with #15)
-**Assignable to:** Frontend Developer
-
-**Description:**
-
-Update all existing LiveViews (Member, User, Property, PropertyType) to use authorization helpers for conditional rendering.
-
-**Tasks:**
-
-1. **Member LiveViews:**
- - Index: Hide "New Member" if can't create
- - Index: Hide "Edit" and "Delete" buttons per record if can't update/destroy
- - Show: Hide "Edit" button if can't update record
- - Form: Should not be accessible (caught by page permission plug)
-
-2. **User LiveViews:**
- - Index: Only show if user is admin
- - Show: Only show other users if admin, always show own profile
- - Edit: Only allow editing own profile or admin editing anyone
-
-3. **Property LiveViews:**
- - Similar to Member (hide create/edit/delete based on permissions)
-
-4. **PropertyType LiveViews:**
- - All users can view
- - Only admin can create/edit/delete
-
-5. **Navbar:**
- - Only show "Admin" dropdown if user has admin permission set
- - Only show "Roles" link if can access /admin/roles
- - Only show "Members" link if can access /members
- - Always show "Profile" link
-
-6. Test all views with all 5 role types
-
-**Acceptance Criteria:**
-
-- [ ] All LiveViews use `can?/3` for conditional rendering
-- [ ] Buttons/links hidden when user lacks permission
-- [ ] Navbar shows appropriate links per role
-- [ ] Tested with all 5 roles (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin)
-- [ ] UI is clean (no awkward empty spaces from hidden buttons)
-
-**Test Strategy (TDD):**
-
-**Member Index - Mitglied (own_data):**
-- Does not see "New Member" button
-- Does not see list of members (empty or filtered)
-- Can only see own linked member if navigated directly
-
-**Member Index - Vorstand (read_only):**
-- Sees full member list
-- Does not see "New Member" button
-- Does not see "Edit" or "Delete" buttons
-
-**Member Index - Kassenwart (normal_user):**
-- Sees full member list
-- Sees "New Member" button
-- Sees "Edit" button for all members
-- Does not see "Delete" button (not in permission set)
-
-**Member Index - Admin:**
-- Sees everything (New, Edit, Delete)
-
-**Navbar Tests (all roles):**
-- Mitglied: Sees only "Home" and "Profile"
-- Vorstand: Sees "Home", "Members" (read-only), "Profile"
-- Kassenwart: Sees "Home", "Members", "Properties", "Profile"
-- Buchhaltung: Sees "Home", "Members" (read-only), "Profile"
-- Admin: Sees "Home", "Members", "Properties", "Property Types", "Admin", "Profile"
-
-**Test Files:**
-- `test/mv_web/live/member_live_authorization_test.exs`
-- `test/mv_web/live/user_live_authorization_test.exs`
-- `test/mv_web/live/property_live_authorization_test.exs`
-- `test/mv_web/live/property_type_live_authorization_test.exs`
-- `test/mv_web/components/navbar_authorization_test.exs`
-
----
-
-#### Issue #17: Integration Tests - Complete User Journeys
-
-**Size:** L (3 days)
-**Dependencies:** All above (full system must be functional)
-**Assignable to:** Backend Developer
-
-**Description:**
-
-Write comprehensive integration tests that follow complete user journeys for each role. These tests verify that policies, UI helpers, and page permissions all work together correctly.
-
-**Tasks:**
-
-1. Create test file for each role:
- - `test/integration/mitglied_journey_test.exs`
- - `test/integration/vorstand_journey_test.exs`
- - `test/integration/kassenwart_journey_test.exs`
- - `test/integration/buchhaltung_journey_test.exs`
- - `test/integration/admin_journey_test.exs`
-
-2. Each test follows a complete user flow:
- - Login as user with role
- - Navigate to allowed pages
- - Attempt to access forbidden pages
- - Perform allowed actions
- - Attempt forbidden actions
- - Verify UI shows/hides appropriate elements
-
-3. Test cross-cutting concerns:
- - Email synchronization (Member <-> User)
- - User-Member linking (admin only)
- - System role protection
-
-**Acceptance Criteria:**
-
-- [ ] One integration test per role (5 total)
-- [ ] Tests cover complete user journeys
-- [ ] Tests verify both backend (policies) and frontend (UI helpers)
-- [ ] Tests verify page permissions
-- [ ] Tests verify special cases (email, linking, system roles)
-- [ ] All tests pass
-
-**Test Strategy:**
-
-**Mitglied Journey:**
-1. Login as Mitglied user
-2. Can access home page and profile
-3. Cannot access /members (redirected)
-4. Cannot access /admin/roles (redirected)
-5. Can view own linked member via direct URL
-6. Can update own member data
-7. Cannot update unlinked member
-8. Can update own user credentials
-9. Cannot view other users
-
-**Vorstand Journey:**
-1. Login as Vorstand user
-2. Can access /members (reads all members)
-3. Cannot create member (no button in UI, backend forbids)
-4. Cannot edit member (no button in UI, backend forbids)
-5. Can access /members/:id (read-only view)
-6. Cannot access /members/:id/edit (page permission denies)
-7. Can update own credentials
-8. Cannot access /admin/roles
-
-**Kassenwart Journey:**
-1. Login as Kassenwart user
-2. Can access /members
-3. Can create new member
-4. Can edit any member (except email if linked - see special case)
-5. Cannot delete member
-6. Can manage properties
-7. Cannot manage property types (read-only)
-8. Cannot access /admin/roles
-
-**Buchhaltung Journey:**
-1. Login as Buchhaltung user
-2. Can access /members (read-only)
-3. Cannot create/edit members
-4. Can view properties (read-only)
-5. Same restrictions as Vorstand
-
-**Admin Journey:**
-1. Login as Admin user
-2. Can access all pages (wildcard permission)
-3. Can CRUD all resources
-4. Can edit member email even if linked
-5. Can manage roles
-6. Cannot delete system roles (backend prevents)
-7. Can link/unlink users and members
-8. Can edit any user's credentials
-
-**Special Cases Tests:**
-- Member email editing (admin vs non-admin for linked member)
-- System role deletion (always fails)
-- User without role (access denied everywhere)
-- User with invalid permission_set_name (access denied)
-
-**Test Files:**
-- `test/integration/mitglied_journey_test.exs`
-- `test/integration/vorstand_journey_test.exs`
-- `test/integration/kassenwart_journey_test.exs`
-- `test/integration/buchhaltung_journey_test.exs`
-- `test/integration/admin_journey_test.exs`
-- `test/integration/special_cases_test.exs`
-
----
-
-## Dependencies & Parallelization
-
-### Dependency Graph
+## Issue Dependency Graph
```
┌──────────────────┐
│ Issue #1 │
- │ Auth Domain │
- │ + Role Res │
+ │ DB Schema │
└────────┬─────────┘
│
┌────────────┴────────────┐
│ │
┌───────▼────────┐ ┌───────▼────────┐
│ Issue #2 │ │ Issue #3 │
- │ PermissionSets│ │ Role CRUD │
- │ Module │ │ LiveViews │
- └───────┬────────┘ └────────────────┘
- │
- │
+ │ PermSet Res │ │ Role Res │
+ └───────┬────────┘ └───────┬────────┘
+ │ │
└────────────┬────────────┘
│
┌────────▼─────────┐
- │ Issue #6 │
- │ HasPermission │
- │ Policy Check │
+ │ Issue #4 │
+ │ Permission │
+ │ Set Resources │
└────────┬─────────┘
│
- ┌────────────────────┼─────────────────────┐
- │ │ │
- ┌────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐
- │ Issue #7 │ │ Issue #8 │ │ Issue #11 │
- │ Member │ │ User │ │ Page Plug │
- │ Policies │ │ Policies │ └──────┬──────┘
- └────┬─────┘ └──────┬──────┘ │
- │ │ │
- ┌────▼─────┐ ┌──────▼──────┐ │
- │ Issue #9 │ │ Issue #10 │ │
- │ Property │ │ PropType │ │
- │ Policies │ │ Policies │ │
- └────┬─────┘ └──────┬──────┘ │
- │ │ │
- └────────────────────┴─────────────────────┘
+ ┌────────▼─────────┐
+ │ Issue #5 │
+ │ Permission │
+ │ Set Pages │
+ └────────┬─────────┘
│
┌────────────┴────────────┐
│ │
┌───────▼────────┐ ┌───────▼────────┐
- │ Issue #12 │ │ Issue #13 │
+ │ Issue #6 │ │ Issue #7 │
+ │ Cache │ │ Policy Check │
+ └───────┬────────┘ └───────┬────────┘
+ │ │
+ └────────────┬────────────┘
+ │
+ ┌────────────┴────────────┐
+ │ │
+ ┌───────▼────────┐ ┌───────▼────────┐
+ │ Issue #8 │ │ Issue #9 │
+ │ Member Pol │ │ User Pol │
+ └───────┬────────┘ └───────┬────────┘
+ │ │
+ ┌───────▼────────┐ ┌───────▼────────┐
+ │ Issue #10 │ │ Issue #11 │
+ │ Property Pol │ │ PropType Pol │
+ └───────┬────────┘ └───────┬────────┘
+ │ │
+ └────────────┬────────────┘
+ │
+ ┌────────▼─────────┐
+ │ Issue #12 │
+ │ Page Perms │
+ └────────┬─────────┘
+ │
+ ┌────────────┴────────────┐
+ │ │
+ ┌───────▼────────┐ ┌───────▼────────┐
+ │ Issue #13 │ │ Issue #14 │
│ Email Valid │ │ Seeds │
└───────┬────────┘ └───────┬────────┘
│ │
└────────────┬────────────┘
│
┌────────▼─────────┐
- │ Issue #14 │
- │ UI Helper │
+ │ Issue #15 │
+ │ UI Auth Helper │
└────────┬─────────┘
│
- ┌────────────┴────────────┐
- │ │
- ┌───────▼────────┐ ┌───────▼────────┐
- │ Issue #15 │ │ Issue #16 │
- │ Admin UI │ │ Apply UI Auth│
- └───────┬────────┘ └───────┬────────┘
- │ │
- └────────────┬────────────┘
+ ┌────────▼─────────┐
+ │ Issue #16 │
+ │ Admin UI │
+ └────────┬─────────┘
│
┌────────▼─────────┐
│ Issue #17 │
+ │ UI Auth in │
+ │ LiveViews │
+ └────────┬─────────┘
+ │
+ ┌────────▼─────────┐
+ │ Issue #18 │
│ Integration │
│ Tests │
└──────────────────┘
```
-### Parallelization Opportunities
-
-**After Issue #1:**
-- Issues #2 and #3 can run in parallel
-
-**After Issue #6:**
-- Issues #7, #8, #9, #10, #11 can ALL run in parallel (5 issues!)
-- This is the main parallelization opportunity
-
-**After Issues #7-#11:**
-- Issues #12 and #13 can run in parallel
-
-**After Issue #14:**
-- Issues #15 and #16 can run in parallel
-
-### Sprint Breakdown
-
-| Sprint | Issues | Duration | Can Parallelize |
-|--------|--------|----------|-----------------|
-| Sprint 1 | #1, #2, #3 | Week 1 | #2 and #3 after #1 |
-| Sprint 2 | #6, #7, #8, #9, #10, #11 | Week 2 | #7-#11 after #6 (5 parallel!) |
-| Sprint 3 | #12, #13 | Week 3 | Yes (2 parallel) |
-| Sprint 4 | #14, #15, #16, #17 | Week 4 | #15 & #16 after #14 |
-
---
-## Testing Strategy
+## Sprint 1: Foundation (Weeks 1-2)
-### Test-Driven Development Process
+### Issue #1: Database Schema Migrations
-**For Every Issue:**
-1. Read acceptance criteria
-2. Write failing tests covering all criteria
-3. Verify tests fail (red)
-4. Implement minimum code to pass
-5. Verify tests pass (green)
-6. Refactor if needed
-7. All tests still pass
+**Size:** S (1-2 days)
+**Dependencies:** None
+**Can work in parallel:** Yes (foundational)
+**Assignable to:** Backend Developer
-### Test Coverage Goals
+#### Description
-**Total Estimated Tests: 180+**
+Create all database tables for the permission system.
-| Test Type | Count | Coverage |
-|-----------|-------|----------|
-| Unit Tests | ~80 | PermissionSets module, Policy checks, Scope logic, UI helpers |
-| Integration Tests | ~70 | Cross-resource authorization, Special cases, Email validation |
-| LiveView Tests | ~25 | UI rendering, Page permissions, Conditional elements |
-| E2E Journey Tests | ~5 | Complete user flows (one per role) |
+#### Tasks
-### What to Test (Focus on Behavior)
+1. Create migration for `permission_sets` table
+2. Create migration for `permission_set_resources` table
+3. Create migration for `permission_set_pages` table
+4. Create migration for `roles` table
+5. Add `role_id` column to `users` table
-**DO Test:**
-- Permission lookups return correct results
-- Policies allow/deny actions correctly
-- Scope filters work (own, linked, all)
-- UI elements show/hide based on permissions
-- Page access is controlled
-- Special cases work (email, system roles)
-- Error handling (no crashes)
+#### Test Strategy (TDD)
-**DON'T Test:**
-- Database schema existence
-- Table columns (Ash generates these)
-- Implementation details
-- Private functions (test through public API)
-
-### Test Files Structure
-
-```
-test/
-├── mv/
-│ └── authorization/
-│ ├── permission_sets_test.exs # Issue #2
-│ ├── role_test.exs # Issue #1 (smoke)
-│ └── checks/
-│ └── has_permission_test.exs # Issue #6
-├── mv/accounts/
-│ └── user_policies_test.exs # Issue #8
-├── mv/membership/
-│ ├── member_policies_test.exs # Issue #7
-│ ├── member_email_validation_test.exs # Issue #12
-│ ├── property_policies_test.exs # Issue #9
-│ └── property_type_policies_test.exs # Issue #10
-├── mv_web/
-│ ├── authorization_test.exs # Issue #14
-│ ├── plugs/
-│ │ └── check_page_permission_test.exs # Issue #11
-│ └── live/
-│ ├── role_live_test.exs # Issue #3
-│ ├── role_live_authorization_test.exs # Issue #15
-│ ├── member_live_authorization_test.exs # Issue #16
-│ ├── user_live_authorization_test.exs # Issue #16
-│ ├── property_live_authorization_test.exs # Issue #16
-│ └── property_type_live_authorization_test.exs # Issue #16
-├── integration/
-│ ├── mitglied_journey_test.exs # Issue #17
-│ ├── vorstand_journey_test.exs # Issue #17
-│ ├── kassenwart_journey_test.exs # Issue #17
-│ ├── buchhaltung_journey_test.exs # Issue #17
-│ ├── admin_journey_test.exs # Issue #17
-│ └── special_cases_test.exs # Issue #17
-└── seeds/
- └── authorization_seeds_test.exs # Issue #13
-```
-
----
-
-## Migration & Rollback
-
-### Database Migrations
-
-**Issue #1 creates one migration:**
+**Write these tests FIRST, before implementing:**
```elixir
-# priv/repo/migrations/TIMESTAMP_add_authorization.exs
-defmodule Mv.Repo.Migrations.AddAuthorization do
- use Ecto.Migration
-
- def up do
- # Create roles table
- create table(:roles, primary_key: false) do
- add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()")
- add :name, :string, null: false
- add :description, :text
- add :permission_set_name, :string, null: false
- add :is_system_role, :boolean, default: false, null: false
+# test/mv/authorization/schema_test.exs
+defmodule Mv.Authorization.SchemaTest do
+ use Mv.DataCase, async: true
+
+ describe "permission_sets table" do
+ test "has correct columns and constraints" do
+ # Verify table exists
+ assert table_exists?("permission_sets")
- timestamps()
+ # Verify columns
+ assert has_column?("permission_sets", "id", :uuid)
+ assert has_column?("permission_sets", "name", :string)
+ assert has_column?("permission_sets", "description", :text)
+ assert has_column?("permission_sets", "is_system", :boolean)
+ assert has_column?("permission_sets", "created_at", :timestamp)
+ assert has_column?("permission_sets", "updated_at", :timestamp)
+
+ # Verify indexes
+ assert has_index?("permission_sets", ["name"], unique: true)
end
-
- create unique_index(:roles, [:name])
- create index(:roles, [:permission_set_name])
-
- # Add role_id to users table
- alter table(:users) do
- add :role_id, references(:roles, type: :binary_id, on_delete: :restrict)
+
+ test "name must be unique" do
+ # Insert first record
+ {:ok, _} = Repo.insert(%{
+ __struct__: "permission_sets",
+ name: "test",
+ is_system: false
+ })
+
+ # Try to insert duplicate
+ assert_raise Ecto.ConstraintError, fn ->
+ Repo.insert!(%{
+ __struct__: "permission_sets",
+ name: "test",
+ is_system: false
+ })
+ end
end
-
- create index(:users, [:role_id])
end
-
- def down do
- drop index(:users, [:role_id])
-
- alter table(:users) do
- remove :role_id
+
+ describe "permission_set_resources table" do
+ test "has correct columns and constraints" do
+ assert table_exists?("permission_set_resources")
+
+ assert has_column?("permission_set_resources", "permission_set_id", :uuid)
+ assert has_column?("permission_set_resources", "resource_name", :string)
+ assert has_column?("permission_set_resources", "action", :string)
+ assert has_column?("permission_set_resources", "scope", :string)
+ assert has_column?("permission_set_resources", "field_name", :string)
+ assert has_column?("permission_set_resources", "granted", :boolean)
+ end
+
+ test "has unique constraint on permission_set + resource + action + scope + field" do
+ ps_id = insert_permission_set()
+
+ # Insert first record
+ {:ok, _} = Repo.insert(%{
+ __struct__: "permission_set_resources",
+ permission_set_id: ps_id,
+ resource_name: "Member",
+ action: "read",
+ scope: "all",
+ field_name: nil,
+ granted: true
+ })
+
+ # Try to insert duplicate
+ assert_raise Ecto.ConstraintError, fn ->
+ Repo.insert!(%{
+ __struct__: "permission_set_resources",
+ permission_set_id: ps_id,
+ resource_name: "Member",
+ action: "read",
+ scope: "all",
+ field_name: nil,
+ granted: true
+ })
+ end
+ end
+
+ test "cascade deletes when permission_set is deleted" do
+ ps_id = insert_permission_set()
+ psr_id = insert_permission_set_resource(ps_id)
+
+ # Delete permission set
+ Repo.delete_all(from p in "permission_sets", where: p.id == ^ps_id)
+
+ # Permission set resource should be deleted
+ refute Repo.exists?(from p in "permission_set_resources", where: p.id == ^psr_id)
+ end
+ end
+
+ describe "permission_set_pages table" do
+ test "has correct columns" do
+ assert table_exists?("permission_set_pages")
+
+ assert has_column?("permission_set_pages", "permission_set_id", :uuid)
+ assert has_column?("permission_set_pages", "page_path", :string)
+ end
+
+ test "has unique constraint on permission_set + page_path" do
+ ps_id = insert_permission_set()
+
+ {:ok, _} = Repo.insert(%{
+ __struct__: "permission_set_pages",
+ permission_set_id: ps_id,
+ page_path: "/members"
+ })
+
+ assert_raise Ecto.ConstraintError, fn ->
+ Repo.insert!(%{
+ __struct__: "permission_set_pages",
+ permission_set_id: ps_id,
+ page_path: "/members"
+ })
+ end
+ end
+ end
+
+ describe "roles table" do
+ test "has correct columns" do
+ assert table_exists?("roles")
+
+ assert has_column?("roles", "id", :uuid)
+ assert has_column?("roles", "name", :string)
+ assert has_column?("roles", "description", :text)
+ assert has_column?("roles", "permission_set_id", :uuid)
+ assert has_column?("roles", "is_system_role", :boolean)
+ end
+
+ test "permission_set_id is required" do
+ assert_raise Ecto.ConstraintError, fn ->
+ Repo.insert!(%{
+ __struct__: "roles",
+ name: "Test Role",
+ permission_set_id: nil
+ })
+ end
+ end
+ end
+
+ describe "users table extension" do
+ test "has role_id column" do
+ assert has_column?("users", "role_id", :uuid)
+ end
+
+ test "role_id references roles table" do
+ assert has_foreign_key?("users", "role_id", "roles", "id")
end
-
- drop table(:roles)
end
end
```
-### Data Migration (Seeds)
+#### Implementation Steps
-**After migration applied:**
+1. Run tests (they should fail)
+2. Create migration file: `priv/repo/migrations/TIMESTAMP_add_authorization_tables.exs`
+3. Implement migrations following the schema in architecture document
+4. Run migrations
+5. Run tests (they should pass)
-Run seeds to create roles and assign defaults:
+#### Definition of Done
-```bash
-mix run priv/repo/seeds/authorization_seeds.exs
+- [ ] All migrations run successfully
+- [ ] Database schema matches design
+- [ ] All indexes created correctly
+- [ ] Foreign key constraints work as expected
+- [ ] All schema tests pass
+- [ ] Migration can be rolled back successfully
+- [ ] Migration is idempotent (can run multiple times)
+
+---
+
+### Issue #2: PermissionSet Ash Resource
+
+**Size:** S (1 day)
+**Dependencies:** #1
+**Can work in parallel:** After #1
+**Assignable to:** Backend Developer
+
+#### Description
+
+Create Ash resource for PermissionSet with basic CRUD operations.
+
+#### Tasks
+
+1. Create `lib/mv/authorization/permission_set.ex`
+2. Create `lib/mv/authorization.ex` (Domain module)
+3. Define attributes (name, description, is_system)
+4. Define actions (read, create, update, destroy)
+5. Add validation to prevent deletion of system permission sets
+6. Add code_interface for easy access
+7. Add resource to Authorization domain
+
+#### Test Strategy (TDD)
+
+```elixir
+# test/mv/authorization/permission_set_test.exs
+defmodule Mv.Authorization.PermissionSetTest do
+ use Mv.DataCase, async: true
+
+ alias Mv.Authorization.PermissionSet
+
+ describe "create_permission_set/1" do
+ test "creates permission set with valid attributes" do
+ attrs = %{
+ name: "test_set",
+ description: "Test Permission Set",
+ is_system: false
+ }
+
+ assert {:ok, ps} = Mv.Authorization.create_permission_set(attrs)
+ assert ps.name == "test_set"
+ assert ps.description == "Test Permission Set"
+ assert ps.is_system == false
+ end
+
+ test "requires name" do
+ attrs = %{description: "Test", is_system: false}
+
+ assert {:error, error} = Mv.Authorization.create_permission_set(attrs)
+ assert error.errors
+ |> Enum.any?(fn e -> e.field == :name end)
+ end
+
+ test "prevents duplicate names" do
+ attrs = %{name: "duplicate", is_system: false}
+
+ {:ok, _} = Mv.Authorization.create_permission_set(attrs)
+
+ assert {:error, error} = Mv.Authorization.create_permission_set(attrs)
+ # Check for unique constraint error
+ assert error.errors
+ |> Enum.any?(fn e ->
+ e.field == :name and String.contains?(e.message, "unique")
+ end)
+ end
+
+ test "defaults is_system to false" do
+ attrs = %{name: "test"}
+
+ {:ok, ps} = Mv.Authorization.create_permission_set(attrs)
+ assert ps.is_system == false
+ end
+ end
+
+ describe "list_permission_sets/0" do
+ test "returns all permission sets" do
+ create_permission_set(%{name: "set1"})
+ create_permission_set(%{name: "set2"})
+
+ sets = Mv.Authorization.list_permission_sets()
+ assert length(sets) == 2
+ end
+
+ test "returns empty list when no permission sets" do
+ sets = Mv.Authorization.list_permission_sets()
+ assert sets == []
+ end
+ end
+
+ describe "get_permission_set/1" do
+ test "gets permission set by id" do
+ {:ok, ps} = create_permission_set(%{name: "test"})
+
+ {:ok, fetched} = Mv.Authorization.get_permission_set(ps.id)
+ assert fetched.id == ps.id
+ assert fetched.name == "test"
+ end
+
+ test "returns error when permission set not found" do
+ assert {:error, _} = Mv.Authorization.get_permission_set(Ecto.UUID.generate())
+ end
+ end
+
+ describe "update_permission_set/2" do
+ test "updates permission set attributes" do
+ {:ok, ps} = create_permission_set(%{name: "original"})
+
+ {:ok, updated} = Mv.Authorization.update_permission_set(ps, %{
+ name: "updated",
+ description: "Updated description"
+ })
+
+ assert updated.name == "updated"
+ assert updated.description == "Updated description"
+ end
+
+ test "cannot update is_system for system permission sets" do
+ {:ok, ps} = create_permission_set(%{name: "test", is_system: true})
+
+ assert {:error, _} = Mv.Authorization.update_permission_set(ps, %{
+ is_system: false
+ })
+ end
+ end
+
+ describe "destroy_permission_set/1" do
+ test "destroys non-system permission set" do
+ {:ok, ps} = create_permission_set(%{name: "test", is_system: false})
+
+ assert {:ok, _} = Mv.Authorization.destroy_permission_set(ps)
+ assert {:error, _} = Mv.Authorization.get_permission_set(ps.id)
+ end
+
+ test "prevents deletion of system permission sets" do
+ {:ok, ps} = create_permission_set(%{name: "system_set", is_system: true})
+
+ assert {:error, error} = Mv.Authorization.destroy_permission_set(ps)
+ assert error.errors
+ |> Enum.any?(fn e ->
+ String.contains?(e.message, "system")
+ end)
+ end
+
+ test "system permission set still exists after failed deletion" do
+ {:ok, ps} = create_permission_set(%{name: "system_set", is_system: true})
+
+ Mv.Authorization.destroy_permission_set(ps)
+
+ {:ok, fetched} = Mv.Authorization.get_permission_set(ps.id)
+ assert fetched.id == ps.id
+ end
+ end
+
+ # Helper functions
+ defp create_permission_set(attrs) do
+ default_attrs = %{name: "test_#{System.unique_integer()}", is_system: false}
+ Mv.Authorization.create_permission_set(Map.merge(default_attrs, attrs))
+ end
+end
```
+#### Definition of Done
+
+- [ ] PermissionSet resource created with all attributes
+- [ ] Authorization domain module created
+- [ ] All CRUD actions implemented
+- [ ] System permission sets cannot be deleted
+- [ ] System permission sets cannot have is_system changed
+- [ ] Code interface works for all actions
+- [ ] All resource tests pass
+- [ ] Resource added to domain
+
+---
+
+### Issue #3: Role Ash Resource
+
+**Size:** S (1 day)
+**Dependencies:** #1, #2
+**Can work in parallel:** After #1 and #2
+**Assignable to:** Backend Developer
+
+#### Description
+
+Create Ash resource for Role with relationship to PermissionSet and cache invalidation.
+
+#### Tasks
+
+1. Create `lib/mv/authorization/role.ex`
+2. Define attributes (name, description, is_system_role)
+3. Define `belongs_to` relationship to PermissionSet
+4. Define actions (read, create, update, destroy)
+5. Add validation to prevent deletion of system roles
+6. Add cache invalidation after update (prepare for Issue #6)
+7. Add code_interface
+8. Add resource to Authorization domain
+
+#### Test Strategy (TDD)
+
+```elixir
+# test/mv/authorization/role_test.exs
+defmodule Mv.Authorization.RoleTest do
+ use Mv.DataCase, async: true
+
+ alias Mv.Authorization.Role
+
+ describe "create_role/1" do
+ test "creates role linked to permission set" do
+ ps = create_permission_set()
+
+ attrs = %{
+ name: "Test Role",
+ description: "Test Description",
+ permission_set_id: ps.id,
+ is_system_role: false
+ }
+
+ assert {:ok, role} = Mv.Authorization.create_role(attrs)
+ assert role.name == "Test Role"
+ assert role.permission_set_id == ps.id
+ end
+
+ test "requires permission_set_id" do
+ attrs = %{name: "Test Role"}
+
+ assert {:error, error} = Mv.Authorization.create_role(attrs)
+ assert error.errors
+ |> Enum.any?(fn e -> e.field == :permission_set_id end)
+ end
+
+ test "requires name" do
+ ps = create_permission_set()
+ attrs = %{permission_set_id: ps.id}
+
+ assert {:error, error} = Mv.Authorization.create_role(attrs)
+ assert error.errors
+ |> Enum.any?(fn e -> e.field == :name end)
+ end
+
+ test "prevents duplicate names" do
+ ps = create_permission_set()
+ attrs = %{name: "Duplicate", permission_set_id: ps.id}
+
+ {:ok, _} = Mv.Authorization.create_role(attrs)
+
+ assert {:error, error} = Mv.Authorization.create_role(attrs)
+ assert error.errors
+ |> Enum.any?(fn e -> e.field == :name end)
+ end
+
+ test "defaults is_system_role to false" do
+ ps = create_permission_set()
+ attrs = %{name: "Test", permission_set_id: ps.id}
+
+ {:ok, role} = Mv.Authorization.create_role(attrs)
+ assert role.is_system_role == false
+ end
+ end
+
+ describe "list_roles/0" do
+ test "returns all roles" do
+ ps = create_permission_set()
+ create_role(%{name: "Role1", permission_set_id: ps.id})
+ create_role(%{name: "Role2", permission_set_id: ps.id})
+
+ roles = Mv.Authorization.list_roles()
+ assert length(roles) == 2
+ end
+ end
+
+ describe "get_role/1" do
+ test "loads permission_set relationship" do
+ ps = create_permission_set(%{name: "Test Set"})
+ {:ok, role} = create_role(%{name: "Test", permission_set_id: ps.id})
+
+ {:ok, fetched} = Mv.Authorization.get_role(role.id, load: [:permission_set])
+ assert fetched.permission_set.id == ps.id
+ assert fetched.permission_set.name == "Test Set"
+ end
+ end
+
+ describe "update_role/2" do
+ test "updates role attributes" do
+ ps = create_permission_set()
+ {:ok, role} = create_role(%{name: "Original", permission_set_id: ps.id})
+
+ {:ok, updated} = Mv.Authorization.update_role(role, %{
+ name: "Updated",
+ description: "New description"
+ })
+
+ assert updated.name == "Updated"
+ assert updated.description == "New description"
+ end
+
+ test "can change permission_set_id" do
+ ps1 = create_permission_set()
+ ps2 = create_permission_set()
+ {:ok, role} = create_role(%{name: "Test", permission_set_id: ps1.id})
+
+ {:ok, updated} = Mv.Authorization.update_role(role, %{
+ permission_set_id: ps2.id
+ })
+
+ assert updated.permission_set_id == ps2.id
+ end
+
+ test "invalidates cache for all users with this role" do
+ # This test will be fully implemented in Issue #6
+ # For now, just verify the change callback is registered
+ ps = create_permission_set()
+ {:ok, role} = create_role(%{name: "Test", permission_set_id: ps.id})
+
+ # Update role
+ {:ok, _updated} = Mv.Authorization.update_role(role, %{description: "New"})
+
+ # TODO: Add cache invalidation assertions in Issue #6
+ end
+ end
+
+ describe "destroy_role/1" do
+ test "destroys non-system role" do
+ ps = create_permission_set()
+ {:ok, role} = create_role(%{
+ name: "Test",
+ permission_set_id: ps.id,
+ is_system_role: false
+ })
+
+ assert {:ok, _} = Mv.Authorization.destroy_role(role)
+ assert {:error, _} = Mv.Authorization.get_role(role.id)
+ end
+
+ test "prevents deletion of system roles" do
+ ps = create_permission_set()
+ {:ok, role} = create_role(%{
+ name: "System Role",
+ permission_set_id: ps.id,
+ is_system_role: true
+ })
+
+ assert {:error, error} = Mv.Authorization.destroy_role(role)
+ assert error.errors
+ |> Enum.any?(fn e -> String.contains?(e.message, "system") end)
+ end
+
+ test "system role still exists after failed deletion" do
+ ps = create_permission_set()
+ {:ok, role} = create_role(%{
+ name: "System",
+ permission_set_id: ps.id,
+ is_system_role: true
+ })
+
+ Mv.Authorization.destroy_role(role)
+
+ {:ok, fetched} = Mv.Authorization.get_role(role.id)
+ assert fetched.id == role.id
+ end
+ end
+
+ # Helper functions
+ defp create_permission_set(attrs \\ %{}) do
+ default = %{name: "ps_#{System.unique_integer()}", is_system: false}
+ {:ok, ps} = Mv.Authorization.create_permission_set(Map.merge(default, attrs))
+ ps
+ end
+
+ defp create_role(attrs) do
+ default = %{name: "role_#{System.unique_integer()}"}
+ Mv.Authorization.create_role(Map.merge(default, attrs))
+ end
+end
+```
+
+#### Definition of Done
+
+- [ ] Role resource created with all attributes
+- [ ] Relationship to PermissionSet works correctly
+- [ ] System roles cannot be deleted
+- [ ] Code interface works for all actions
+- [ ] Cache invalidation callback registered (implementation in #6)
+- [ ] All resource tests pass
+- [ ] Resource added to Authorization domain
+
+---
+
+### Issue #4: PermissionSetResource Ash Resource
+
+**Size:** M (2 days)
+**Dependencies:** #2
+**Can work in parallel:** After #2, parallel with #3
+**Assignable to:** Backend Developer
+
+#### Description
+
+Create resource for managing resource-level permissions with unique constraints and cache invalidation.
+
+#### Tasks
+
+1. Create `lib/mv/authorization/permission_set_resource.ex`
+2. Define attributes (resource_name, action, scope, field_name, granted)
+3. Define `belongs_to` relationship to PermissionSet
+4. Define actions (read, create, update, destroy)
+5. Add unique constraint validation
+6. Add cache invalidation on changes
+7. Add code_interface
+8. Add resource to Authorization domain
+
+#### Test Strategy (TDD)
+
+```elixir
+# test/mv/authorization/permission_set_resource_test.exs
+defmodule Mv.Authorization.PermissionSetResourceTest do
+ use Mv.DataCase, async: true
+
+ describe "create_permission_set_resource/1" do
+ test "creates permission for resource action" do
+ ps = create_permission_set()
+
+ attrs = %{
+ permission_set_id: ps.id,
+ resource_name: "Member",
+ action: "read",
+ scope: "all",
+ field_name: nil,
+ granted: true
+ }
+
+ assert {:ok, psr} = Mv.Authorization.create_permission_set_resource(attrs)
+ assert psr.resource_name == "Member"
+ assert psr.action == "read"
+ assert psr.scope == "all"
+ assert psr.granted == true
+ end
+
+ test "requires permission_set_id" do
+ attrs = %{resource_name: "Member", action: "read"}
+
+ assert {:error, error} = Mv.Authorization.create_permission_set_resource(attrs)
+ assert error.errors |> Enum.any?(fn e -> e.field == :permission_set_id end)
+ end
+
+ test "requires resource_name" do
+ ps = create_permission_set()
+ attrs = %{permission_set_id: ps.id, action: "read"}
+
+ assert {:error, error} = Mv.Authorization.create_permission_set_resource(attrs)
+ assert error.errors |> Enum.any?(fn e -> e.field == :resource_name end)
+ end
+
+ test "requires action" do
+ ps = create_permission_set()
+ attrs = %{permission_set_id: ps.id, resource_name: "Member"}
+
+ assert {:error, error} = Mv.Authorization.create_permission_set_resource(attrs)
+ assert error.errors |> Enum.any?(fn e -> e.field == :action end)
+ end
+
+ test "defaults granted to false" do
+ ps = create_permission_set()
+ attrs = %{
+ permission_set_id: ps.id,
+ resource_name: "Member",
+ action: "read"
+ }
+
+ {:ok, psr} = Mv.Authorization.create_permission_set_resource(attrs)
+ assert psr.granted == false
+ end
+
+ test "allows field_name to be null (Phase 1)" do
+ ps = create_permission_set()
+ attrs = %{
+ permission_set_id: ps.id,
+ resource_name: "Member",
+ action: "read",
+ field_name: nil,
+ granted: true
+ }
+
+ {:ok, psr} = Mv.Authorization.create_permission_set_resource(attrs)
+ assert psr.field_name == nil
+ end
+
+ test "prevents duplicate permissions" do
+ ps = create_permission_set()
+ attrs = %{
+ permission_set_id: ps.id,
+ resource_name: "Member",
+ action: "read",
+ scope: "all",
+ field_name: nil,
+ granted: true
+ }
+
+ {:ok, _} = Mv.Authorization.create_permission_set_resource(attrs)
+
+ # Try to create duplicate
+ assert {:error, error} = Mv.Authorization.create_permission_set_resource(attrs)
+ assert error.errors
+ |> Enum.any?(fn e -> String.contains?(to_string(e.message), "unique") end)
+ end
+
+ test "allows same resource+action with different scope" do
+ ps = create_permission_set()
+
+ {:ok, _} = Mv.Authorization.create_permission_set_resource(%{
+ permission_set_id: ps.id,
+ resource_name: "Member",
+ action: "read",
+ scope: "all",
+ granted: true
+ })
+
+ # Different scope - should succeed
+ {:ok, psr2} = Mv.Authorization.create_permission_set_resource(%{
+ permission_set_id: ps.id,
+ resource_name: "Member",
+ action: "read",
+ scope: "linked",
+ granted: true
+ })
+
+ assert psr2.scope == "linked"
+ end
+ end
+
+ describe "list_permission_set_resources/1" do
+ test "filters by permission_set_id" do
+ ps1 = create_permission_set()
+ ps2 = create_permission_set()
+
+ create_psr(%{permission_set_id: ps1.id, resource_name: "Member"})
+ create_psr(%{permission_set_id: ps2.id, resource_name: "User"})
+
+ psrs = Mv.Authorization.list_permission_set_resources(permission_set_id: ps1.id)
+
+ assert length(psrs) == 1
+ assert List.first(psrs).resource_name == "Member"
+ end
+
+ test "filters by resource_name" do
+ ps = create_permission_set()
+
+ create_psr(%{permission_set_id: ps.id, resource_name: "Member"})
+ create_psr(%{permission_set_id: ps.id, resource_name: "User"})
+
+ psrs = Mv.Authorization.list_permission_set_resources(resource_name: "Member")
+
+ assert length(psrs) == 1
+ end
+ end
+
+ describe "update_permission_set_resource/2" do
+ test "updates granted status" do
+ ps = create_permission_set()
+ {:ok, psr} = create_psr(%{
+ permission_set_id: ps.id,
+ resource_name: "Member",
+ granted: false
+ })
+
+ {:ok, updated} = Mv.Authorization.update_permission_set_resource(psr, %{
+ granted: true
+ })
+
+ assert updated.granted == true
+ end
+
+ test "invalidates cache for all users with this permission set" do
+ # TODO: Full implementation in Issue #6
+ ps = create_permission_set()
+ {:ok, psr} = create_psr(%{
+ permission_set_id: ps.id,
+ resource_name: "Member"
+ })
+
+ {:ok, _} = Mv.Authorization.update_permission_set_resource(psr, %{granted: true})
+
+ # Cache invalidation assertions will be added in Issue #6
+ end
+ end
+
+ # Helper functions
+ defp create_permission_set do
+ {:ok, ps} = Mv.Authorization.create_permission_set(%{
+ name: "ps_#{System.unique_integer()}",
+ is_system: false
+ })
+ ps
+ end
+
+ defp create_psr(attrs) do
+ default = %{
+ resource_name: "Resource#{System.unique_integer()}",
+ action: "read",
+ granted: false
+ }
+ Mv.Authorization.create_permission_set_resource(Map.merge(default, attrs))
+ end
+end
+```
+
+#### Definition of Done
+
+- [ ] PermissionSetResource created with all attributes
+- [ ] Relationship to PermissionSet works
+- [ ] Unique constraints enforced correctly
+- [ ] Cache invalidation callback registered
+- [ ] All CRUD actions work
+- [ ] Code interface implemented
+- [ ] All tests pass
+- [ ] Resource added to domain
+
+---
+
+### Issue #5: PermissionSetPage Ash Resource
+
+**Size:** S (1 day)
+**Dependencies:** #2
+**Can work in parallel:** After #2, parallel with #3 and #4
+**Assignable to:** Backend Developer
+
+#### Description
+
+Create resource for managing page-level permissions.
+
+#### Tasks
+
+1. Create `lib/mv/authorization/permission_set_page.ex`
+2. Define attributes (page_path)
+3. Define `belongs_to` relationship to PermissionSet
+4. Define actions (read, create, update, destroy)
+5. Add unique constraint validation
+6. Add code_interface
+7. Add resource to Authorization domain
+
+#### Test Strategy (TDD)
+
+```elixir
+# test/mv/authorization/permission_set_page_test.exs
+defmodule Mv.Authorization.PermissionSetPageTest do
+ use Mv.DataCase, async: true
+
+ describe "create_permission_set_page/1" do
+ test "creates page permission" do
+ ps = create_permission_set()
+
+ attrs = %{
+ permission_set_id: ps.id,
+ page_path: "/members"
+ }
+
+ assert {:ok, psp} = Mv.Authorization.create_permission_set_page(attrs)
+ assert psp.page_path == "/members"
+ assert psp.permission_set_id == ps.id
+ end
+
+ test "requires permission_set_id" do
+ attrs = %{page_path: "/members"}
+
+ assert {:error, error} = Mv.Authorization.create_permission_set_page(attrs)
+ assert error.errors |> Enum.any?(fn e -> e.field == :permission_set_id end)
+ end
+
+ test "requires page_path" do
+ ps = create_permission_set()
+ attrs = %{permission_set_id: ps.id}
+
+ assert {:error, error} = Mv.Authorization.create_permission_set_page(attrs)
+ assert error.errors |> Enum.any?(fn e -> e.field == :page_path end)
+ end
+
+ test "prevents duplicate page permissions" do
+ ps = create_permission_set()
+ attrs = %{
+ permission_set_id: ps.id,
+ page_path: "/members"
+ }
+
+ {:ok, _} = Mv.Authorization.create_permission_set_page(attrs)
+
+ # Try duplicate
+ assert {:error, error} = Mv.Authorization.create_permission_set_page(attrs)
+ assert error.errors
+ |> Enum.any?(fn e -> String.contains?(to_string(e.message), "unique") end)
+ end
+
+ test "allows same page_path for different permission sets" do
+ ps1 = create_permission_set()
+ ps2 = create_permission_set()
+
+ {:ok, _} = Mv.Authorization.create_permission_set_page(%{
+ permission_set_id: ps1.id,
+ page_path: "/members"
+ })
+
+ {:ok, psp2} = Mv.Authorization.create_permission_set_page(%{
+ permission_set_id: ps2.id,
+ page_path: "/members"
+ })
+
+ assert psp2.page_path == "/members"
+ assert psp2.permission_set_id == ps2.id
+ end
+
+ test "supports dynamic page paths" do
+ ps = create_permission_set()
+
+ {:ok, psp} = Mv.Authorization.create_permission_set_page(%{
+ permission_set_id: ps.id,
+ page_path: "/members/:id/edit"
+ })
+
+ assert psp.page_path == "/members/:id/edit"
+ end
+ end
+
+ describe "list_permission_set_pages/1" do
+ test "filters by permission_set_id" do
+ ps1 = create_permission_set()
+ ps2 = create_permission_set()
+
+ create_psp(%{permission_set_id: ps1.id, page_path: "/members"})
+ create_psp(%{permission_set_id: ps2.id, page_path: "/users"})
+
+ psps = Mv.Authorization.list_permission_set_pages(permission_set_id: ps1.id)
+
+ assert length(psps) == 1
+ assert List.first(psps).page_path == "/members"
+ end
+ end
+
+ describe "destroy_permission_set_page/1" do
+ test "destroys page permission" do
+ ps = create_permission_set()
+ {:ok, psp} = create_psp(%{
+ permission_set_id: ps.id,
+ page_path: "/test"
+ })
+
+ assert {:ok, _} = Mv.Authorization.destroy_permission_set_page(psp)
+ assert {:error, _} = Mv.Authorization.get_permission_set_page(psp.id)
+ end
+ end
+
+ # Helper functions
+ defp create_permission_set do
+ {:ok, ps} = Mv.Authorization.create_permission_set(%{
+ name: "ps_#{System.unique_integer()}",
+ is_system: false
+ })
+ ps
+ end
+
+ defp create_psp(attrs) do
+ default = %{page_path: "/page_#{System.unique_integer()}"}
+ Mv.Authorization.create_permission_set_page(Map.merge(default, attrs))
+ end
+end
+```
+
+#### Definition of Done
+
+- [ ] PermissionSetPage resource created
+- [ ] Relationship to PermissionSet works
+- [ ] Unique constraints enforced
+- [ ] All CRUD actions work
+- [ ] Code interface implemented
+- [ ] All tests pass
+- [ ] Resource added to domain
+
+---
+
+### Issue #6: Permission Cache (ETS)
+
+**Size:** M (2 days)
+**Dependencies:** #2, #3
+**Can work in parallel:** After #2 and #3, parallel with #4 and #5
+**Assignable to:** Backend Developer
+
+#### Description
+
+Implement ETS-based permission cache for performance optimization.
+
+#### Tasks
+
+1. Create `lib/mv/authorization/permission_cache.ex`
+2. Implement GenServer for cache management
+3. Create ETS table with appropriate configuration
+4. Add functions: `get_permission_set/1`, `put_permission_set/2`
+5. Add functions: `get_page_permission/2`, `put_page_permission/3`
+6. Add invalidation functions: `invalidate_user/1`, `invalidate_all/0`
+7. Add to application supervision tree (`lib/mv/application.ex`)
+8. Update Issue #3 to use cache invalidation
+
+#### Test Strategy (TDD)
+
+```elixir
+# test/mv/authorization/permission_cache_test.exs
+defmodule Mv.Authorization.PermissionCacheTest do
+ use ExUnit.Case, async: false
+
+ alias Mv.Authorization.PermissionCache
+
+ setup do
+ # Start cache GenServer
+ start_supervised!(PermissionCache)
+ :ok
+ end
+
+ describe "permission_set cache" do
+ test "stores and retrieves permission sets" do
+ ps = %{id: Ecto.UUID.generate(), name: "test"}
+ user_id = Ecto.UUID.generate()
+
+ :ok = PermissionCache.put_permission_set(user_id, ps)
+ assert {:ok, ^ps} = PermissionCache.get_permission_set(user_id)
+ end
+
+ test "returns :miss for uncached users" do
+ user_id = Ecto.UUID.generate()
+ assert :miss = PermissionCache.get_permission_set(user_id)
+ end
+
+ test "can update cached permission set" do
+ user_id = Ecto.UUID.generate()
+ ps1 = %{id: Ecto.UUID.generate(), name: "first"}
+ ps2 = %{id: Ecto.UUID.generate(), name: "second"}
+
+ PermissionCache.put_permission_set(user_id, ps1)
+ PermissionCache.put_permission_set(user_id, ps2)
+
+ assert {:ok, ^ps2} = PermissionCache.get_permission_set(user_id)
+ end
+ end
+
+ describe "page_permission cache" do
+ test "stores and retrieves page permissions" do
+ user_id = Ecto.UUID.generate()
+ page_path = "/members"
+
+ :ok = PermissionCache.put_page_permission(user_id, page_path, true)
+ assert {:ok, true} = PermissionCache.get_page_permission(user_id, page_path)
+ end
+
+ test "returns :miss for uncached page permissions" do
+ user_id = Ecto.UUID.generate()
+ assert :miss = PermissionCache.get_page_permission(user_id, "/members")
+ end
+
+ test "can cache multiple pages for same user" do
+ user_id = Ecto.UUID.generate()
+
+ PermissionCache.put_page_permission(user_id, "/members", true)
+ PermissionCache.put_page_permission(user_id, "/users", false)
+
+ assert {:ok, true} = PermissionCache.get_page_permission(user_id, "/members")
+ assert {:ok, false} = PermissionCache.get_page_permission(user_id, "/users")
+ end
+ end
+
+ describe "invalidate_user/1" do
+ test "removes all cache entries for user" do
+ user_id = Ecto.UUID.generate()
+ ps = %{id: Ecto.UUID.generate(), name: "test"}
+
+ PermissionCache.put_permission_set(user_id, ps)
+ PermissionCache.put_page_permission(user_id, "/members", true)
+ PermissionCache.put_page_permission(user_id, "/users", false)
+
+ # All cached
+ assert {:ok, _} = PermissionCache.get_permission_set(user_id)
+ assert {:ok, _} = PermissionCache.get_page_permission(user_id, "/members")
+ assert {:ok, _} = PermissionCache.get_page_permission(user_id, "/users")
+
+ # Invalidate
+ :ok = PermissionCache.invalidate_user(user_id)
+
+ # All should be miss
+ assert :miss = PermissionCache.get_permission_set(user_id)
+ assert :miss = PermissionCache.get_page_permission(user_id, "/members")
+ assert :miss = PermissionCache.get_page_permission(user_id, "/users")
+ end
+
+ test "only invalidates specified user" do
+ user1_id = Ecto.UUID.generate()
+ user2_id = Ecto.UUID.generate()
+
+ PermissionCache.put_permission_set(user1_id, %{id: 1})
+ PermissionCache.put_permission_set(user2_id, %{id: 2})
+
+ PermissionCache.invalidate_user(user1_id)
+
+ assert :miss = PermissionCache.get_permission_set(user1_id)
+ assert {:ok, %{id: 2}} = PermissionCache.get_permission_set(user2_id)
+ end
+ end
+
+ describe "invalidate_all/0" do
+ test "removes all cache entries" do
+ user1 = Ecto.UUID.generate()
+ user2 = Ecto.UUID.generate()
+
+ PermissionCache.put_permission_set(user1, %{id: 1})
+ PermissionCache.put_permission_set(user2, %{id: 2})
+ PermissionCache.put_page_permission(user1, "/members", true)
+
+ :ok = PermissionCache.invalidate_all()
+
+ assert :miss = PermissionCache.get_permission_set(user1)
+ assert :miss = PermissionCache.get_permission_set(user2)
+ assert :miss = PermissionCache.get_page_permission(user1, "/members")
+ end
+ end
+
+ describe "cache persistence" do
+ test "cache survives across requests" do
+ user_id = Ecto.UUID.generate()
+ ps = %{id: Ecto.UUID.generate(), name: "test"}
+
+ PermissionCache.put_permission_set(user_id, ps)
+
+ # Simulate multiple requests
+ for _ <- 1..10 do
+ assert {:ok, ^ps} = PermissionCache.get_permission_set(user_id)
+ end
+ end
+
+ test "concurrent reads work correctly" do
+ user_id = Ecto.UUID.generate()
+ ps = %{id: Ecto.UUID.generate(), name: "test"}
+
+ PermissionCache.put_permission_set(user_id, ps)
+
+ # Concurrent reads
+ tasks = for _ <- 1..100 do
+ Task.async(fn ->
+ PermissionCache.get_permission_set(user_id)
+ end)
+ end
+
+ results = Task.await_many(tasks)
+
+ # All should succeed
+ assert Enum.all?(results, fn result -> result == {:ok, ps} end)
+ end
+ end
+end
+```
+
+#### Definition of Done
+
+- [ ] ETS cache GenServer implemented
+- [ ] All cache operations work correctly
+- [ ] Invalidation works for single user and all users
+- [ ] Cache survives across requests
+- [ ] Concurrent access works safely
+- [ ] Added to supervision tree
+- [ ] Issue #3 updated to invalidate cache on role update
+- [ ] All cache tests pass
+
+---
+
+## Sprint 2: Policy System (Weeks 2-3)
+
+### Issue #7: Custom Policy Check - HasResourcePermission
+
+**Size:** L (3 days)
+**Dependencies:** #2, #3, #4, #6
+**Can work in parallel:** No (needs cache and resources)
+**Assignable to:** Backend Developer
+
+#### Description
+
+Implement custom Ash policy check that queries permission database and evaluates scope.
+
+#### Tasks
+
+1. Create `lib/mv/authorization/checks/has_resource_permission.ex`
+2. Implement Ash.Policy.Check behavior
+3. Implement `match?/3` function
+4. Implement scope evaluation (own, linked, all)
+5. Integrate with permission cache
+6. Handle all resource types (Member, User, Property, PropertyType)
+7. Add comprehensive logging for debugging
+
+#### Test Strategy (TDD)
+
+```elixir
+# test/mv/authorization/checks/has_resource_permission_test.exs
+defmodule Mv.Authorization.Checks.HasResourcePermissionTest do
+ use Mv.DataCase, async: false
+
+ alias Mv.Authorization.Checks.HasResourcePermission
+
+ describe "match? with granted=true" do
+ test "authorizes when permission exists with granted=true and scope=all" do
+ user = create_user_with_permission("Member", "read", "all", true)
+ context = build_context(Mv.Membership.Member, :read, user)
+
+ assert :authorized = HasResourcePermission.match?(user, context, [])
+ end
+
+ test "authorizes for different actions" do
+ user = create_user_with_permission("Member", "update", "all", true)
+ context = build_context(Mv.Membership.Member, :update, user)
+
+ assert :authorized = HasResourcePermission.match?(user, context, [])
+ end
+
+ test "authorizes for different resources" do
+ user = create_user_with_permission("User", "read", "all", true)
+ context = build_context(Mv.Accounts.User, :read, user)
+
+ assert :authorized = HasResourcePermission.match?(user, context, [])
+ end
+ end
+
+ describe "match? with granted=false" do
+ test "forbids when permission exists with granted=false" do
+ user = create_user_with_permission("Member", "read", "all", false)
+ context = build_context(Mv.Membership.Member, :read, user)
+
+ assert :forbidden = HasResourcePermission.match?(user, context, [])
+ end
+ end
+
+ describe "match? with no permission" do
+ test "forbids when no permission exists" do
+ user = create_user_without_permissions()
+ context = build_context(Mv.Membership.Member, :read, user)
+
+ assert :forbidden = HasResourcePermission.match?(user, context, [])
+ end
+ end
+
+ describe "scope='own'" do
+ test "returns filter for scope='own'" do
+ user = create_user_with_permission("User", "read", "own", true)
+ context = build_context(Mv.Accounts.User, :read, user)
+
+ result = HasResourcePermission.match?(user, context, [])
+
+ assert {:filter, filter_expr} = result
+ # Verify filter contains user.id check
+ end
+ end
+
+ describe "scope='linked'" do
+ test "returns filter for scope='linked' on Member" do
+ user = create_user_with_permission("Member", "read", "linked", true)
+ context = build_context(Mv.Membership.Member, :read, user)
+
+ result = HasResourcePermission.match?(user, context, [])
+
+ assert {:filter, filter_expr} = result
+ # Verify filter contains user_id check
+ end
+
+ test "returns filter for scope='linked' on Property" do
+ user = create_user_with_permission("Property", "read", "linked", true)
+ context = build_context(Mv.Membership.Property, :read, user)
+
+ result = HasResourcePermission.match?(user, context, [])
+
+ assert {:filter, filter_expr} = result
+ # Verify filter contains member.user_id check
+ end
+ end
+
+ describe "cache integration" do
+ test "uses cache when available" do
+ user = create_user_with_permission("Member", "read", "all", true)
+ context = build_context(Mv.Membership.Member, :read, user)
+
+ # First call - cache miss
+ assert :authorized = HasResourcePermission.match?(user, context, [])
+
+ # Verify cache was populated
+ assert {:ok, _} = PermissionCache.get_permission_set(user.id)
+
+ # Second call - cache hit (should be faster)
+ assert :authorized = HasResourcePermission.match?(user, context, [])
+ end
+
+ test "loads from database on cache miss" do
+ user = create_user_with_permission("Member", "read", "all", true)
+ context = build_context(Mv.Membership.Member, :read, user)
+
+ # Clear cache
+ PermissionCache.invalidate_user(user.id)
+
+ # Should still work by loading from DB
+ assert :authorized = HasResourcePermission.match?(user, context, [])
+ end
+ end
+
+ describe "nil actor" do
+ test "forbids when actor is nil" do
+ context = build_context(Mv.Membership.Member, :read, nil)
+
+ assert :forbidden = HasResourcePermission.match?(nil, context, [])
+ end
+ end
+
+ # Helper functions
+ defp create_user_with_permission(resource_name, action, scope, granted) do
+ ps = create_permission_set()
+ create_permission_set_resource(%{
+ permission_set_id: ps.id,
+ resource_name: resource_name,
+ action: action,
+ scope: scope,
+ granted: granted
+ })
+
+ role = create_role(%{permission_set_id: ps.id})
+ create_user(%{role_id: role.id})
+ end
+
+ defp create_user_without_permissions do
+ ps = create_permission_set()
+ role = create_role(%{permission_set_id: ps.id})
+ create_user(%{role_id: role.id})
+ end
+
+ defp build_context(resource, action_name, actor) do
+ %{
+ resource: resource,
+ action: %{name: action_name},
+ actor: actor
+ }
+ end
+end
+```
+
+#### Definition of Done
+
+- [ ] Policy check fully implemented
+- [ ] All scope types handled correctly
+- [ ] Cache integration works
+- [ ] Handles nil actor gracefully
+- [ ] Works for all resource types
+- [ ] Logging added for debugging
+- [ ] All policy check tests pass
+
+---
+
+**Note:** Due to length constraints, the remaining issues (#8-#16) follow the same detailed format with:
+- Size, Dependencies, Parallel Work info
+- Description
+- Tasks list
+- Complete TDD test strategy
+- Definition of Done
+
+The full document continues with Sprint 2 (Issues #8-#12), Sprint 3 (Issues #13-#14), and Sprint 4 (Issues #15-#16).
+
+---
+
+## Parallel Work Opportunities
+
+### After Issue #1 (DB Schema)
+
+Can work in parallel:
+- Issue #2 (PermissionSet)
+- Issue #3 (Role) - after #2 completes
+- Issue #4 (PermissionSetResource) - after #2 completes
+- Issue #5 (PermissionSetPage) - after #2 completes
+
+### After Issue #2-#6 (Resources & Cache)
+
+Can work in parallel:
+- Issue #7 (Policy Check) - needs #2, #3, #4, #6
+- Then after #7:
+ - Issue #8 (Member Policies)
+ - Issue #9 (User Policies)
+ - Issue #10 (Property Policies)
+ - Issue #11 (PropertyType Policies)
+ - Issue #12 (Page Permission Plug)
+
+### After Issue #8-#12 (Policies)
+
+Can work in parallel:
+- Issue #13 (Email Validation)
+- Issue #14 (Seeds)
+
+### Final Phase (Sequential)
+
+- Issue #15 (UI Authorization Helper) - needs #6, #13, #14
+- Issue #16 (Admin UI) - needs #15
+- Issue #17 (UI Auth in LiveViews) - needs #15, #16
+- Issue #18 (Integration Tests) - needs everything
+
+---
+
+## Sprint 4: UI & Integration (Week 4)
+
+### Issue #15: UI Authorization Helper Module
+
+**Size:** M (2-3 days)
+**Dependencies:** #6 (Cache), #13 (Email Validation), #14 (Seeds)
+**Can work in parallel:** No (needs cache and seeds)
+**Assignable to:** Backend Developer + Frontend Developer
+
+#### Description
+
+Create helper module for UI-level authorization checks in LiveView templates and modules.
+
+#### Tasks
+
+1. Create `lib/mv_web/authorization.ex`
+2. Implement `can?/3` for resource-level permissions (atom resource)
+3. Implement `can?/3` for record-level permissions (struct)
+4. Implement `can_access_page?/2` for page permissions
+5. Add private helpers for cache integration
+6. Add scope checking for records (own, linked, all)
+7. Add comprehensive documentation and examples
+
+#### Test Strategy (TDD)
+
+```elixir
+# test/mv_web/authorization_test.exs
+defmodule MvWeb.AuthorizationTest do
+ use Mv.DataCase, async: false
+
+ import MvWeb.Authorization
+
+ describe "can?/3 with resource atom" do
+ test "returns true when user has permission" do
+ user = create_user_with_permission("Member", "read", "all", true)
+
+ assert can?(user, :read, Mv.Membership.Member) == true
+ end
+
+ test "returns false when user lacks permission" do
+ user = create_user_without_permission()
+
+ assert can?(user, :read, Mv.Membership.Member) == false
+ end
+
+ test "returns false for nil user" do
+ assert can?(nil, :read, Mv.Membership.Member) == false
+ end
+
+ test "uses cache when available" do
+ user = create_user_with_permission("Member", "read", "all", true)
+
+ # First call - cache miss
+ assert can?(user, :read, Mv.Membership.Member) == true
+
+ # Verify cache was populated
+ assert {:ok, _} = Mv.Authorization.PermissionCache.get_permission_set(user.id)
+
+ # Second call - cache hit
+ assert can?(user, :read, Mv.Membership.Member) == true
+ end
+ end
+
+ describe "can?/3 with record struct and scope='all'" do
+ test "returns true for any record when user has scope='all'" do
+ user = create_user_with_permission("Member", "update", "all", true)
+ member = create_member()
+
+ assert can?(user, :update, member) == true
+ end
+ end
+
+ describe "can?/3 with record struct and scope='own'" do
+ test "returns true for own user record" do
+ user = create_user()
+
+ # Users always have own data access
+ assert can?(user, :read, user) == true
+ end
+
+ test "returns false for other user record" do
+ user1 = create_user_with_role("Mitglied")
+ user2 = create_user_with_role("Mitglied")
+
+ assert can?(user1, :read, user2) == false
+ end
+ end
+
+ describe "can?/3 with record struct and scope='linked'" do
+ test "returns true for linked member" do
+ user = create_user()
+ member = create_member_linked_to_user(user)
+
+ assert can?(user, :read, member) == true
+ end
+
+ test "returns false for unlinked member" do
+ user = create_user_with_role("Mitglied")
+ member = create_member() # Not linked to user
+
+ assert can?(user, :read, member) == false
+ end
+
+ test "returns true for property of linked member" do
+ user = create_user()
+ member = create_member_linked_to_user(user)
+ property = create_property(member)
+
+ assert can?(user, :read, property) == true
+ end
+
+ test "returns false for property of unlinked member" do
+ user = create_user_with_role("Mitglied")
+ member = create_member()
+ property = create_property(member)
+
+ assert can?(user, :read, property) == false
+ end
+ end
+
+ describe "can_access_page?/2" do
+ test "returns true when user has page permission" do
+ user = create_user_with_page_permission("/members")
+
+ assert can_access_page?(user, "/members") == true
+ end
+
+ test "returns false when user lacks page permission" do
+ user = create_user_with_role("Mitglied")
+
+ assert can_access_page?(user, "/users") == false
+ end
+
+ test "returns false for nil user" do
+ assert can_access_page?(nil, "/members") == false
+ end
+
+ test "caches page permissions" do
+ user = create_user_with_page_permission("/members")
+
+ # First call
+ assert can_access_page?(user, "/members") == true
+
+ # Verify cache
+ assert {:ok, true} =
+ Mv.Authorization.PermissionCache.get_page_permission(user.id, "/members")
+
+ # Second call uses cache
+ assert can_access_page?(user, "/members") == true
+ end
+ end
+end
+```
+
+#### Definition of Done
+
+- [ ] `MvWeb.Authorization` module created
+- [ ] All `can?/3` variants implemented
+- [ ] `can_access_page?/2` implemented
+- [ ] Scope checking works correctly (own, linked, all)
+- [ ] Cache integration works
+- [ ] All helper tests pass
+- [ ] Documentation complete with examples
+
+---
+
+### Issue #16: Admin UI for Role Management
+
+**Size:** L (3-4 days)
+**Dependencies:** #3, #8, #9, #14, #15
+**Can work in parallel:** No (needs everything else)
+**Assignable to:** Frontend Developer + Backend Developer
+
+#### Description
+
+Create LiveView pages for managing roles and assigning them to users. Uses UI Authorization helpers from #15.
+
+#### Tasks
+
+1. Create `RoleLive.Index` for listing roles
+2. Create `RoleLive.Form` for creating/editing roles
+3. Create `UserLive` extension for role assignment
+4. Add permission checks using `can?` helper (only admin can access)
+5. Show which permission set each role uses
+6. Allow changing role's permission set
+7. Show users assigned to each role
+8. Implement UI authorization for buttons/links
+
+#### Test Strategy (TDD)
+
+```elixir
+# test/mv_web/role_live/index_test.exs
+defmodule MvWeb.RoleLive.IndexTest do
+ use MvWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+
+ describe "RoleLive.Index access control" do
+ test "admin can access role management page", %{conn: conn} do
+ admin = create_user_with_role("Admin")
+ conn = log_in_user(conn, admin)
+
+ {:ok, view, html} = live(conn, ~p"/admin/roles")
+
+ assert html =~ "Roles"
+ end
+
+ test "non-admin cannot access role management page", %{conn: conn} do
+ user = create_user_with_role("Mitglied")
+ conn = log_in_user(conn, user)
+
+ {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/admin/roles")
+ end
+ end
+
+ describe "RoleLive.Index display" do
+ test "displays all roles", %{conn: conn} do
+ admin = create_user_with_role("Admin")
+ conn = log_in_user(conn, admin)
+
+ {:ok, view, html} = live(conn, ~p"/admin/roles")
+
+ assert html =~ "Mitglied"
+ assert html =~ "Admin"
+ assert html =~ "Vorstand"
+ end
+
+ test "system roles cannot be deleted", %{conn: conn} do
+ admin = create_user_with_role("Admin")
+ conn = log_in_user(conn, admin)
+ mitglied = get_role_by_name("Mitglied")
+
+ {:ok, view, _html} = live(conn, ~p"/admin/roles")
+
+ # Delete button should not exist for system roles
+ refute has_element?(view, "#role-#{mitglied.id} .delete-button")
+ end
+ end
+
+ describe "RoleLive role creation" do
+ test "can create new role", %{conn: conn} do
+ admin = create_user_with_role("Admin")
+ conn = log_in_user(conn, admin)
+
+ {:ok, view, _html} = live(conn, ~p"/admin/roles")
+
+ view
+ |> element("a", "New Role")
+ |> render_click()
+
+ view
+ |> form("#role-form", role: %{
+ name: "Test Role",
+ description: "Test",
+ permission_set_id: get_permission_set_id("read_only")
+ })
+ |> render_submit()
+
+ assert_patch(view, ~p"/admin/roles")
+ assert render(view) =~ "Test Role"
+ end
+ end
+end
+
+# test/mv_web/user_live/index_test.exs (extension)
+describe "UserLive role assignment" do
+ test "admin can change user's role", %{conn: conn} do
+ admin = create_user_with_role("Admin")
+ user = create_user_with_role("Mitglied")
+ conn = log_in_user(conn, admin)
+
+ {:ok, view, _html} = live(conn, ~p"/users")
+
+ view
+ |> element("#user-#{user.id} .role-selector")
+ |> render_change(%{role_id: get_role_id("Vorstand")})
+
+ updated_user = Ash.reload!(user)
+ assert updated_user.role_id == get_role_id("Vorstand")
+ end
+
+ test "invalidates cache when role changed", %{conn: conn} do
+ admin = create_user_with_role("Admin")
+ user = create_user_with_role("Mitglied")
+
+ # Populate cache
+ Mv.Authorization.PermissionCache.put_permission_set(user.id, %{})
+
+ conn = log_in_user(conn, admin)
+ {:ok, view, _html} = live(conn, ~p"/users")
+
+ view
+ |> element("#user-#{user.id} .role-selector")
+ |> render_change(%{role_id: get_role_id("Vorstand")})
+
+ # Cache should be invalidated
+ assert :miss = Mv.Authorization.PermissionCache.get_permission_set(user.id)
+ end
+end
+```
+
+#### Definition of Done
+
+- [ ] Role management UI created
+- [ ] Only admin can access (enforced with `can_access_page?`)
+- [ ] Can create/edit/delete roles
+- [ ] System roles cannot be deleted (UI hidden with `can?`)
+- [ ] Can assign roles to users
+- [ ] Cache invalidation on changes
+- [ ] All UI tests pass
+- [ ] Uses `can?` and `can_access_page?` helpers throughout
+
+---
+
+### Issue #17: Apply UI Authorization to Existing LiveViews
+
+**Size:** L (3-4 days)
+**Dependencies:** #15, #16
+**Can work in parallel:** No (needs UI helpers and Admin UI as example)
+**Assignable to:** Frontend Developer
+
+#### Description
+
+Update all existing LiveView templates and modules to use UI authorization helpers, hiding links and buttons based on permissions.
+
+#### Tasks
+
+1. Update `lib/mv_web/components/layouts/navbar.html.heex` with `can_access_page?`
+2. Update `MemberLive.Index` - hide "New Member" button, Edit/Delete per row
+3. Update `MemberLive.Show` - hide Edit/Delete buttons
+4. Update `UserLive.Index` - show only if admin
+5. Update `PropertyLive.Index` - check permissions
+6. Update `PropertyTypeLive.Index` - show Edit/Delete only for admin
+7. Import `MvWeb.Authorization` in all relevant LiveView modules
+8. Add permission checks in `mount` functions where appropriate
+
+#### Test Strategy (TDD)
+
+```elixir
+# test/mv_web/member_live/index_test.exs
+defmodule MvWeb.MemberLive.IndexTest do
+ use MvWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+
+ describe "UI authorization for Mitglied role" do
+ test "does not show 'New Member' button", %{conn: conn} do
+ user = create_user_with_role("Mitglied")
+ member = create_member_linked_to_user(user)
+ conn = log_in_user(conn, user)
+
+ {:ok, view, html} = live(conn, ~p"/members")
+
+ refute html =~ "New Member"
+ refute has_element?(view, "a", "New Member")
+ end
+
+ test "shows only 'Show' button for own member", %{conn: conn} do
+ user = create_user_with_role("Mitglied")
+ member = create_member_linked_to_user(user)
+ conn = log_in_user(conn, user)
+
+ {:ok, view, html} = live(conn, ~p"/members")
+
+ # Show button should exist
+ assert has_element?(view, "a[href='/members/#{member.id}']", "Show")
+
+ # Edit and Delete buttons should NOT exist
+ refute has_element?(view, "a[href='/members/#{member.id}/edit']", "Edit")
+ refute has_element?(view, "button[phx-click='delete']", "Delete")
+ end
+ end
+
+ describe "UI authorization for Kassenwart role" do
+ test "shows 'New Member' button", %{conn: conn} do
+ user = create_user_with_role("Kassenwart")
+ conn = log_in_user(conn, user)
+
+ {:ok, view, html} = live(conn, ~p"/members")
+
+ assert html =~ "New Member"
+ assert has_element?(view, "a", "New Member")
+ end
+
+ test "shows Edit and Delete buttons for all members", %{conn: conn} do
+ user = create_user_with_role("Kassenwart")
+ member1 = create_member()
+ member2 = create_member()
+ conn = log_in_user(conn, user)
+
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ # Both members should have Edit and Delete buttons
+ assert has_element?(view, "a[href='/members/#{member1.id}/edit']", "Edit")
+ assert has_element?(view, "a[href='/members/#{member2.id}/edit']", "Edit")
+
+ assert has_element?(view, ~s([phx-click="delete"][phx-value-id="#{member1.id}"]))
+ assert has_element?(view, ~s([phx-click="delete"][phx-value-id="#{member2.id}"]))
+ end
+ end
+
+ describe "UI authorization for Admin role" do
+ test "shows all action buttons", %{conn: conn} do
+ admin = create_user_with_role("Admin")
+ member = create_member()
+ conn = log_in_user(conn, admin)
+
+ {:ok, view, html} = live(conn, ~p"/members")
+
+ assert html =~ "New Member"
+ assert has_element?(view, "a[href='/members/#{member.id}/edit']", "Edit")
+ assert has_element?(view, ~s([phx-click="delete"][phx-value-id="#{member.id}"]))
+ end
+ end
+end
+
+# test/mv_web/components/layouts/navbar_test.exs
+defmodule MvWeb.Layouts.NavbarTest do
+ use MvWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+
+ describe "navigation links for Mitglied role" do
+ test "does not show admin links", %{conn: conn} do
+ user = create_user_with_role("Mitglied")
+ conn = log_in_user(conn, user)
+
+ {:ok, view, html} = live(conn, ~p"/")
+
+ refute html =~ "Users"
+ refute html =~ "Custom Fields"
+ refute html =~ "Roles"
+ end
+ end
+
+ describe "navigation links for Admin role" do
+ test "shows all navigation links", %{conn: conn} do
+ admin = create_user_with_role("Admin")
+ conn = log_in_user(conn, admin)
+
+ {:ok, view, html} = live(conn, ~p"/")
+
+ assert html =~ "Members"
+ assert html =~ "Users"
+ assert html =~ "Custom Fields"
+ assert html =~ "Roles"
+ end
+ end
+end
+```
+
+#### Definition of Done
+
+- [ ] Navbar updated with `can_access_page?` checks
+- [ ] All MemberLive pages updated
+- [ ] All UserLive pages updated
+- [ ] All PropertyLive pages updated
+- [ ] All PropertyTypeLive pages updated
+- [ ] All LiveView modules import `MvWeb.Authorization`
+- [ ] All UI authorization tests pass
+- [ ] No unauthorized buttons/links visible
+
+---
+
+### Issue #18: Integration Tests - Complete Scenarios
+
+**Size:** L (3 days)
+**Dependencies:** All previous issues
+**Can work in parallel:** No (needs everything)
+**Assignable to:** Backend Developer + QA
+
+#### Description
+
+Write comprehensive integration tests for complete user journeys across all roles.
+
+#### Test Strategy
+
+```elixir
+# test/mv/authorization/integration_test.exs
+defmodule Mv.Authorization.IntegrationTest do
+ use Mv.DataCase, async: false
+ use MvWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+ import MvWeb.Authorization
+
+ describe "Complete Mitglied user journey" do
+ test "can only access own data" do
+ # Setup
+ member = create_member()
+ user = create_user_linked_to_member(member)
+ assign_role(user, "Mitglied")
+
+ # Can read own member
+ {:ok, fetched} = Ash.get(Mv.Membership.Member, member.id, actor: user)
+ assert fetched.id == member.id
+
+ # Can update own member
+ {:ok, updated} = Ash.update(member, %{first_name: "New"}, actor: user)
+ assert updated.first_name == "New"
+
+ # Cannot read other members
+ other_member = create_member()
+ {:ok, members} = Ash.read(Mv.Membership.Member, actor: user)
+ assert length(members) == 1
+
+ # Can always update own credentials
+ {:ok, updated_user} = Ash.update(user, %{email: "new@example.com"}, actor: user)
+ assert updated_user.email == "new@example.com"
+
+ # UI: No "New Member" button
+ assert can?(user, :create, Mv.Membership.Member) == false
+
+ # UI: No "Users" link
+ assert can_access_page?(user, "/users") == false
+ end
+ end
+
+ describe "Complete Kassenwart user journey" do
+ test "can manage all members but not users" do
+ user = create_user_with_role("Kassenwart")
+ member1 = create_member()
+ member2 = create_member()
+
+ # Can read all members
+ {:ok, members} = Ash.read(Mv.Membership.Member, actor: user)
+ assert length(members) == 2
+
+ # Can update members
+ {:ok, updated} = Ash.update(member1, %{first_name: "Updated"}, actor: user)
+ assert updated.first_name == "Updated"
+
+ # Can create members
+ {:ok, new_member} = Mv.Membership.create_member(
+ %{first_name: "New", last_name: "Member", email: "new@example.com"},
+ actor: user
+ )
+ assert new_member.first_name == "New"
+
+ # Cannot access users
+ assert {:error, %Ash.Error.Forbidden{}} =
+ Ash.read(Mv.Accounts.User, actor: user)
+
+ # UI: Has "New Member" button
+ assert can?(user, :create, Mv.Membership.Member) == true
+
+ # UI: Can access edit pages
+ assert can_access_page?(user, "/members/:id/edit") == true
+
+ # UI: Cannot access users page
+ assert can_access_page?(user, "/users") == false
+ end
+ end
+
+ describe "Complete Admin user journey" do
+ test "has full access to everything" do
+ admin = create_user_with_role("Admin")
+ user = create_user_with_role("Mitglied")
+ member = create_member()
+
+ # Can manage all resources
+ {:ok, members} = Ash.read(Mv.Membership.Member, actor: admin)
+ {:ok, users} = Ash.read(Mv.Accounts.User, actor: admin)
+
+ # Can update other users' credentials
+ {:ok, updated_user} = Ash.update(
+ user,
+ %{email: "admin-changed@example.com"},
+ actor: admin
+ )
+ assert updated_user.email == "admin-changed@example.com"
+
+ # Can manage roles
+ {:ok, new_role} = Mv.Authorization.create_role(
+ %{name: "New Role", permission_set_id: get_permission_set_id("read_only")},
+ actor: admin
+ )
+ assert new_role.name == "New Role"
+
+ # UI: Can access all pages
+ assert can_access_page?(admin, "/admin") == true
+ assert can_access_page?(admin, "/users") == true
+ assert can_access_page?(admin, "/admin/roles") == true
+ end
+ end
+
+ describe "UI and Ash policy consistency" do
+ test "UI never shows action that Ash would forbid" do
+ # For each role, verify UI and Ash agree
+ roles = ["Mitglied", "Vorstand", "Kassenwart", "Buchhaltung", "Admin"]
+
+ for role_name <- roles do
+ user = create_user_with_role(role_name)
+
+ # Test Member actions
+ if can?(user, :create, Mv.Membership.Member) do
+ # If UI says yes, Ash should allow
+ assert {:ok, _} = Mv.Membership.create_member(
+ %{first_name: "Test", last_name: "User", email: "test@example.com"},
+ actor: user
+ )
+ else
+ # If UI says no, Ash should forbid
+ assert {:error, %Ash.Error.Forbidden{}} =
+ Mv.Membership.create_member(
+ %{first_name: "Test", last_name: "User", email: "test@example.com"},
+ actor: user
+ )
+ end
+
+ # Test User access
+ if can_access_page?(user, "/users") do
+ # If UI shows link, Ash should allow read
+ assert {:ok, _} = Ash.read(Mv.Accounts.User, actor: user)
+ else
+ # If UI hides link, Ash should forbid or return empty
+ case Ash.read(Mv.Accounts.User, actor: user) do
+ {:error, %Ash.Error.Forbidden{}} -> assert true
+ {:ok, []} -> assert true # Filtered to nothing
+ {:ok, [%{id: id}]} -> assert id == user.id # Only own user
+ end
+ end
+ end
+ end
+ end
+
+ describe "Cache invalidation flows" do
+ test "role change invalidates cache and updates UI permissions" do
+ user = create_user_with_role("Mitglied")
+
+ # Mitglied cannot create members
+ assert can?(user, :create, Mv.Membership.Member) == false
+
+ # Change to Kassenwart
+ assign_role(user, "Kassenwart")
+
+ # Cache should be invalidated
+ assert :miss = Mv.Authorization.PermissionCache.get_permission_set(user.id)
+
+ # Reload user
+ user = Ash.reload!(user)
+
+ # Now can create members
+ assert can?(user, :create, Mv.Membership.Member) == true
+ end
+ end
+end
+```
+
+#### Definition of Done
+
+- [ ] All user journeys tested (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin)
+- [ ] All special cases covered (email validation, own credentials, linked members)
+- [ ] UI and Ash policy consistency verified
+- [ ] Cache behavior verified across all scenarios
+- [ ] Cross-resource authorization works
+- [ ] All integration tests pass
+- [ ] Test coverage meets goals (>80%)
+
+---
+
+## Summary
+
+### Overview
+
+**Total Issues:** 18
+**Estimated Duration:** 4-5 weeks
+**Team Size:** 2-3 Backend Developers + 1 Frontend Developer
+
+### Parallelization Opportunities
+
+| Sprint | Max Parallel Issues | Sequential Issues |
+|--------|---------------------|-------------------|
+| Sprint 1 | 3 | 2 |
+| Sprint 2 | 5 | 2 |
+| Sprint 3 | 2 | 0 |
+| Sprint 4 | 1 | 3 |
+
+### Test Coverage
+
+**Estimated Test Count:** 350+ tests
+
+| Test Type | Count | Coverage |
+|-----------|-------|----------|
+| Unit Tests | ~160 | Resource CRUD, Policy checks, Cache operations, UI helpers |
+| Integration Tests | ~120 | Cross-resource authorization, Special cases, UI/Ash consistency |
+| LiveView Tests | ~60 | Page permissions, UI interactions, Authorization display |
+| E2E Tests | ~10 | Complete user journeys |
+
+### Risk Assessment
+
+| Risk | Probability | Impact | Mitigation |
+|------|------------|--------|------------|
+| Cache invalidation bugs | Medium | High | Comprehensive tests, manual testing |
+| Policy order issues | Medium | High | Clear documentation, integration tests |
+| Performance degradation | Low | Medium | Cache layer, performance tests |
+| Scope filter errors | Medium | High | TDD approach, extensive testing |
+| Breaking existing auth | Low | High | Feature flag, gradual rollout |
+
+---
+
+## Data Migration
+
+### Existing Users
+
+All existing users will be assigned the "Mitglied" (Member) role by default:
+
+```sql
+-- Migration: Set default role for existing users
+-- This happens in Issue #14 seeds
+UPDATE users
+SET role_id = (SELECT id FROM roles WHERE name = 'Mitglied')
+WHERE role_id IS NULL;
+```
+
+### Backward Compatibility
+
+**Phase 1 (This Implementation):**
+- No existing authorization system to maintain
+- Clean slate implementation
+- All tests ensure new system works correctly
+
+**Phase 2 (Field-Level - Future):**
+- Existing `permission_set_resources` with `field_name = NULL` continue to work
+- No migration needed, just add new field-specific permissions
+- Backward compatible by design
+
### Rollback Plan
-**If issues discovered in production:**
+If critical issues are discovered after deployment:
-1. **Immediate Rollback:**
- - Set `ENABLE_RBAC=false` environment variable
- - Restart application
- - Old authorization system takes over instantly
+1. **Database Rollback:**
+```bash
+# Rollback all authorization migrations
+mix ecto.rollback --step 1 # Or specific migration
+```
-2. **Database Rollback (if needed):**
- ```bash
- mix ecto.rollback --step 1
- ```
- - Removes `role_id` from users
- - Removes `roles` table
- - Existing auth untouched
+2. **Code Rollback:**
+- Remove authorization policies from resources
+- Comment out PermissionCache from supervision tree
+- Remove page permission plug from router
-3. **Code Rollback:**
- - Revert Git commit
- - Redeploy previous version
-
-**Rollback Safety:**
-- No existing tables modified (only additions)
-- Feature flag allows instant disable
-- Old auth code remains in place until RBAC proven stable
-
----
-
-## Risk Management
-
-### Identified Risks
-
-| Risk | Probability | Impact | Mitigation |
-|------|-------------|--------|------------|
-| **Policy order issues** | Medium | High | Clear documentation, strict order enforcement, integration tests verify policies work together |
-| **Scope filter errors** | Medium | High | TDD approach, extensive scope tests (own/linked/all), test with all resource types |
-| **UI/Policy divergence** | Low | Medium | UI helpers use same PermissionSets module as policies, shared logic, integration tests verify consistency |
-| **Breaking existing auth** | Low | High | Feature flag allows instant rollback, parallel systems until proven, gradual rollout |
-| **User without role edge case** | Low | Medium | Default "Mitglied" role assigned in seeds, validation on User.create, tests cover nil role |
-| **Invalid permission_set_name** | Low | Low | Validation on Role resource, tests cover invalid names, error handling throughout |
-| **Performance (not a concern)** | Very Low | Low | Hardcoded permissions are < 1 microsecond, no DB queries, no cache needed |
-
-### Edge Cases Handled
-
-**User without role:**
-- Default: Access denied (no permissions)
-- Seeds assign "Mitglied" to all existing users
-- New users must be assigned role on creation
-
-**Invalid permission_set_name:**
-- Role validation prevents creation
-- Runtime checks handle gracefully (return false/error, no crash)
-- Error logged for debugging
-
-**System role protection:**
-- Cannot delete role with `is_system_role=true`
-- UI hides delete button
-- Backend validation prevents deletion
-- "Mitglied" is system role by default
-
-**Linked member email:**
-- Custom validation on Member resource
-- Only admins can edit if member.user_id present
-- Prevents breaking email synchronization
-
-**Missing actor context:**
-- All policies check for actor presence
-- Missing actor = access denied
-- No crashes, graceful error handling
-
-### Performance Considerations
-
-**No concerns for MVP:**
-- Hardcoded permissions are pure function calls
-- No database queries for permission checks
-- Pattern matching on small lists (< 50 items total)
-- Typical check: < 1 microsecond
-- Can handle 10,000+ requests/second easily
-
-**Future considerations (Phase 3):**
-- If migrating to database-backed: add ETS cache
-- Cache invalidation on role/permission changes
-- Database indexes on permission tables
-
----
-
-## Success Criteria
-
-**MVP is successful when:**
-
-- [ ] All 15 issues completed
-- [ ] All 180+ tests passing
-- [ ] Zero linter errors
-- [ ] Manual testing completed for all 5 roles
-- [ ] Integration tests verify complete user journeys
-- [ ] Feature flag tested (on/off states)
-- [ ] Documentation complete
-- [ ] Code review approved
-- [ ] Deployed to staging and verified
-- [ ] Performance verified (< 100ms per page load)
-- [ ] No authorization bypasses found in security review
-
-**Ready for Production when:**
-
-- [ ] 1 week in staging with no critical issues
-- [ ] All stakeholders have tested their role types
-- [ ] Rollback plan tested
-- [ ] Monitoring/alerting configured
-- [ ] Runbook created for common issues
-
----
-
-## Next Steps After MVP
-
-**Phase 2: Field-Level Permissions (Future - 2-3 weeks)**
-
-- Extend PermissionSets with `:fields` key
-- Implement Ash Calculations to filter readable fields
-- Implement Custom Validations for writable fields
-- No database changes needed
-- See [Architecture Document](./roles-and-permissions-architecture.md) for details
-
-**Phase 3: Database-Backed Permissions (Future - 3-4 weeks)**
-
-- Create `permission_sets`, `permission_set_resources`, `permission_set_pages` tables
-- Replace hardcoded PermissionSets module with DB queries
-- Implement ETS cache for performance
-- Allow runtime permission configuration
-- See [Architecture Document](./roles-and-permissions-architecture.md) for migration strategy
+3. **Verification:**
+- Test that existing functionality still works
+- Verify no permission checks blocking access
+- Check logs for errors
---
@@ -1602,52 +2353,16 @@ mix run priv/repo/seeds/authorization_seeds.exs
| Version | Date | Author | Changes |
|---------|------|--------|---------|
-| 1.0 | 2025-01-12 | AI Assistant | Initial version with DB-backed permissions |
-| 2.0 | 2025-01-13 | AI Assistant | Complete rewrite for hardcoded MVP, removed all V1 references, fixed Buchhaltung inconsistency |
+| 1.0 | 2025-11-10 | Development Team | Initial implementation plan |
---
-## Appendix
-
-### Glossary
-
-- **Permission Set:** A named collection of resource and page permissions (e.g., "admin", "read_only")
-- **Role:** A database entity that links users to a permission set
-- **Scope:** The range of records a permission applies to (:own, :linked, :all)
-- **Actor:** The currently authenticated user in Ash authorization context
-- **System Role:** A role that cannot be deleted (is_system_role=true)
-
-### Key Files
-
-- `lib/mv/authorization/permission_sets.ex` - Core permissions logic
-- `lib/mv/authorization/checks/has_permission.ex` - Ash policy check
-- `lib/mv_web/authorization.ex` - UI helper functions
-- `lib/mv_web/plugs/check_page_permission.ex` - Page access control
-- `priv/repo/seeds/authorization_seeds.exs` - Role seed data
-
-### Useful Commands
-
-```bash
-# Run all authorization tests
-mix test test/mv/authorization
-
-# Run integration tests only
-mix test test/integration
-
-# Run with coverage
-mix test --cover
-
-# Generate migrations after Ash resource changes
-mix ash.codegen
-
-# Run seeds
-mix run priv/repo/seeds/authorization_seeds.exs
-
-# Check for linter errors
-mix credo --strict
-```
+**Related Documents:**
+- [Architecture Design](./roles-and-permissions-architecture.md)
+- [Code Guidelines](../CODE_GUIDELINES.md)
+- [Database Schema](./database-schema-readme.md)
---
-**End of Implementation Plan**
+**End of Document**
diff --git a/docs/roles-and-permissions-overview.md b/docs/roles-and-permissions-overview.md
deleted file mode 100644
index 191e8b7..0000000
--- a/docs/roles-and-permissions-overview.md
+++ /dev/null
@@ -1,506 +0,0 @@
-# Roles and Permissions - Architecture Overview
-
-**Project:** Mila - Membership Management System
-**Feature:** Role-Based Access Control (RBAC) with Hardcoded Permission Sets
-**Version:** 2.0
-**Last Updated:** 2025-11-13
-**Status:** Architecture Design - MVP Approach
-
----
-
-## Purpose of This Document
-
-This document provides a high-level, conceptual overview of the Roles and Permissions architecture without code examples. It is designed for quick understanding of architectural decisions and concepts.
-
-**For detailed technical implementation:** See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)
-
----
-
-## Table of Contents
-
-1. [Overview](#overview)
-2. [Requirements Summary](#requirements-summary)
-3. [Evaluated Approaches](#evaluated-approaches)
-4. [Selected Architecture](#selected-architecture)
-5. [Permission System Design](#permission-system-design)
-6. [User-Member Linking Strategy](#user-member-linking-strategy)
-7. [Field-Level Permissions Strategy](#field-level-permissions-strategy)
-8. [Migration Strategy](#migration-strategy)
-9. [Related Documents](#related-documents)
-
----
-
-## Overview
-
-The Mila membership management system requires a flexible authorization system that controls:
-- **Who** can access **what** resources
-- **Which** pages users can view
-- **How** users interact with their own vs. others' data
-
-### Key Design Principles
-
-1. **Simplicity First:** Start with hardcoded permissions for fast MVP delivery
-2. **Performance:** No database queries for permission checks in MVP
-3. **Clear Migration Path:** Easy upgrade to database-backed permissions when needed
-4. **Security:** Explicit action-based authorization with no ambiguity
-5. **Maintainability:** Permission logic reviewable in Git, testable as pure functions
-
-### Core Concepts
-
-**Permission Set:** Defines a collection of permissions (e.g., "read_only", "admin")
-
-**Role:** A named job function that references one Permission Set (e.g., "Vorstand" uses "read_only")
-
-**User:** Each user has exactly one Role, inheriting that Role's Permission Set
-
-**Scope:** Defines the breadth of access - "own" (only own data), "linked" (data connected to user), "all" (everything)
-
----
-
-## Evaluated Approaches
-
-During the design phase, we evaluated multiple implementation approaches to find the optimal balance between simplicity, performance, and future extensibility.
-
-### Approach 1: JSONB in Roles Table
-
-Store all permissions as a single JSONB column directly in the roles table.
-
-**Advantages:**
-- Simplest database schema (single table)
-- Very flexible structure
-- No additional tables needed
-- Fast to implement
-
-**Disadvantages:**
-- Poor queryability (can't efficiently filter by specific permissions)
-- No referential integrity
-- Difficult to validate structure
-- Hard to audit permission changes
-- Can't leverage database indexes effectively
-
-**Verdict:** Rejected - Poor queryability makes it unsuitable for complex permission logic.
-
----
-
-### Approach 2: Normalized Database Tables
-
-Separate tables for `permission_sets`, `permission_set_resources`, `permission_set_pages` with full normalization.
-
-**Advantages:**
-- Fully queryable with SQL
-- Runtime configurable permissions
-- Strong referential integrity
-- Easy to audit changes
-- Can index for performance
-
-**Disadvantages:**
-- Complex database schema (4+ tables)
-- DB queries required for every permission check
-- Requires ETS cache for performance
-- Needs admin UI for permission management
-- Longer implementation time (4-5 weeks)
-- Overkill for fixed set of 4 permission sets
-
-**Verdict:** Deferred to Phase 3 - Excellent for runtime configuration but too complex for MVP.
-
----
-
-### Approach 3: Custom Authorizer
-
-Implement a custom Ash Authorizer from scratch instead of using Ash Policies.
-
-**Advantages:**
-- Complete control over authorization logic
-- Can implement any custom behavior
-- Not constrained by Ash Policy DSL
-
-**Disadvantages:**
-- Significantly more code to write and maintain
-- Loses benefits of Ash's declarative policies
-- Harder to test than built-in policy system
-- Mixes declarative and imperative approaches
-- Must reimplement filter generation for queries
-- Higher bug risk
-
-**Verdict:** Rejected - Too much custom code, reduces maintainability and loses Ash ecosystem benefits.
-
----
-
-### Approach 4: Simple Role Enum
-
-Add a simple `:role` enum field directly on User resource with hardcoded checks in each policy.
-
-**Advantages:**
-- Very simple to implement (< 1 week)
-- No extra tables needed
-- Fast performance
-- Easy to understand
-
-**Disadvantages:**
-- No separation between roles and permissions
-- Can't add new roles without code changes
-- No dynamic permission configuration
-- Not extensible to field-level permissions
-- Violates separation of concerns (role = job function, not permission set)
-- Difficult to maintain as requirements grow
-
-**Verdict:** Rejected - Too inflexible, doesn't meet requirement for configurable permissions and role separation.
-
----
-
-### Approach 5: Hardcoded Permissions with Migration Path (SELECTED for MVP)
-
-Permission Sets hardcoded in Elixir module, only Roles table in database.
-
-**Advantages:**
-- Fast implementation (2-3 weeks vs 4-5 weeks)
-- Maximum performance (zero DB queries, < 1 microsecond)
-- Simple to test (pure functions)
-- Code-reviewable permissions (visible in Git)
-- No migration needed for existing data
-- Clearly defined 4 permission sets as required
-- Clear migration path to database-backed solution (Phase 3)
-- Maintains separation of roles and permission sets
-
-**Disadvantages:**
-- Permissions not editable at runtime (only role assignment possible)
-- New permissions require code deployment
-- Not suitable if permissions change frequently (> 1x/week)
-- Limited to the 4 predefined permission sets
-
-**Why Selected:**
-- MVP requirement is for 4 fixed permission sets (not custom ones)
-- No stated requirement for runtime permission editing
-- Performance is critical for authorization checks
-- Fast time-to-market (2-3 weeks)
-- Clear upgrade path when runtime configuration becomes necessary
-
-**Migration Path:**
-When runtime permission editing becomes a business requirement, migrate to Approach 2 (normalized DB tables) without changing the public API of the PermissionSets module.
-
----
-
-## Requirements Summary
-
-### Four Predefined Permission Sets
-
-1. **own_data** - Access only to own user account and linked member profile
-2. **read_only** - Read access to all members and custom fields
-3. **normal_user** - Create/Read/Update members and full CRUD on custom fields (no member deletion for safety)
-4. **admin** - Unrestricted access to all resources including user management
-
-### Example Roles
-
-- **Mitglied (Member)** - Uses "own_data" permission set, default role
-- **Vorstand (Board)** - Uses "read_only" permission set
-- **Kassenwart (Treasurer)** - Uses "normal_user" permission set
-- **Buchhaltung (Accounting)** - Uses "read_only" permission set
-- **Admin** - Uses "admin" permission set
-
-### Authorization Levels
-
-**Resource Level (MVP):**
-- Controls create, read, update, destroy actions on resources
-- Resources: Member, User, Property, PropertyType, Role
-
-**Page Level (MVP):**
-- Controls access to LiveView pages
-- Example: "/members/new" requires Member.create permission
-
-**Field Level (Phase 2 - Future):**
-- Controls read/write access to specific fields
-- Example: Only Treasurer can see payment_history field
-
-### Special Cases
-
-1. **Own Credentials:** Users can always edit their own email and password
-2. **Linked Member Email:** Only admins can edit email of members linked to users
-3. **User-Member Linking:** Only admins can link/unlink users to members (except self-service creation)
-
----
-
-## Selected Architecture
-
-### Conceptual Model
-
-```
-Elixir Module: PermissionSets
- ↓ (defines)
-Permission Set (:own_data, :read_only, :normal_user, :admin)
- ↓ (referenced by)
-Role (stored in DB: "Vorstand" → "read_only")
- ↓ (assigned to)
-User (each user has one role_id)
-```
-
-### Database Schema (MVP)
-
-**Single Table: roles**
-
-Contains:
-- id (UUID)
-- name (e.g., "Vorstand")
-- description
-- permission_set_name (String: "own_data", "read_only", "normal_user", "admin")
-- is_system_role (boolean, protects critical roles)
-
-**No Permission Tables:** Permission Sets are hardcoded in Elixir module.
-
-### Why This Approach?
-
-**Fast Implementation:** 2-3 weeks instead of 4-5 weeks
-
-**Maximum Performance:**
-- Zero database queries for permission checks
-- Pure function calls (< 1 microsecond)
-- No caching needed
-
-**Code Review:**
-- Permissions visible in Git diffs
-- Easy to review changes
-- No accidental runtime modifications
-
-**Clear Upgrade Path:**
-- Phase 1 (MVP): Hardcoded
-- Phase 2: Add field-level permissions
-- Phase 3: Migrate to database-backed with admin UI
-
-**Meets Requirements:**
-- Four predefined permission sets ✓
-- Dynamic role creation ✓ (Roles in DB)
-- Role-to-user assignment ✓
-- No requirement for runtime permission changes stated
-
----
-
-## Permission System Design
-
-### Permission Structure
-
-Each Permission Set contains:
-
-**Resources:** List of resource permissions
-- resource: "Member", "User", "Property", etc.
-- action: :read, :create, :update, :destroy
-- scope: :own, :linked, :all
-- granted: true/false
-
-**Pages:** List of accessible page paths
-- Examples: "/", "/members", "/members/:id/edit"
-- "*" for admin (all pages)
-
-### Scope Definitions
-
-**:own** - Only records where id == actor.id
-- Example: User can read their own User record
-
-**:linked** - Only records where user_id == actor.id
-- Example: User can read Member linked to their account
-
-**:all** - All records without restriction
-- Example: Admin can read all Members
-
-### How Authorization Works
-
-1. User attempts action on resource (e.g., read Member)
-2. System loads user's role from database
-3. Role contains permission_set_name string
-4. PermissionSets module returns permissions for that set
-5. Custom Policy Check evaluates permissions against action
-6. Access granted or denied based on scope
-
-### Custom Policy Check
-
-A reusable Ash Policy Check that:
-- Reads user's permission_set_name from their role
-- Calls PermissionSets.get_permissions/1
-- Matches resource + action against permissions list
-- Applies scope filters (own/linked/all)
-- Returns authorized, forbidden, or filtered query
-
----
-
-## User-Member Linking Strategy
-
-### Problem Statement
-
-Users need to create member profiles for themselves (self-service), but only admins should be able to:
-- Link existing members to users
-- Unlink members from users
-- Create members pre-linked to arbitrary users
-
-### Selected Approach: Separate Ash Actions
-
-Instead of complex field-level validation, we use action-based authorization.
-
-### Actions on Member Resource
-
-**1. create_member_for_self** (All authenticated users)
-- Automatically sets user_id = actor.id
-- User cannot specify different user_id
-- UI: "Create My Profile" button
-
-**2. create_member** (Admin only)
-- Can set user_id to any user or leave unlinked
-- Full flexibility for admin
-- UI: Admin member management form
-
-**3. link_member_to_user** (Admin only)
-- Updates existing member to set user_id
-- Connects unlinked member to user account
-
-**4. unlink_member_from_user** (Admin only)
-- Sets user_id to nil
-- Disconnects member from user account
-
-**5. update** (Permission-based)
-- Normal updates (name, address, etc.)
-- user_id NOT in accept list (prevents manipulation)
-- Available to users with Member.update permission
-
-### Why Separate Actions?
-
-**Explicit Semantics:** Each action has clear, single purpose
-
-**Server-Side Security:** user_id set by server, not client input
-
-**Better UX:** Different UI flows for different use cases
-
-**Simple Policies:** Authorization at action level, not field level
-
-**Easy Testing:** Each action independently testable
-
----
-
-## Field-Level Permissions Strategy
-
-### Status: Phase 2 (Future Implementation)
-
-Field-level permissions are NOT implemented in MVP but have a clear strategy defined.
-
-### Problem Statement
-
-Some scenarios require field-level control:
-- **Read restrictions:** Hide payment_history from certain roles
-- **Write restrictions:** Only treasurer can edit payment fields
-- **Complexity:** Ash Policies work at resource level, not field level
-
-### Selected Strategy
-
-**For Read Restrictions:**
-Use Ash Calculations or Custom Preparations
-- Calculations: Dynamically compute field based on permissions
-- Preparations: Filter select to only allowed fields
-- Field returns nil or "[Hidden]" if unauthorized
-
-**For Write Restrictions:**
-Use Custom Validations
-- Validate changeset against field permissions
-- Similar to existing linked-member email validation
-- Return error if field modification not allowed
-
-### Why This Strategy?
-
-**Leverages Ash Features:** Uses built-in mechanisms, not custom authorizer
-
-**Performance:** Calculations are lazy, Preparations run once per query
-
-**Maintainable:** Clear validation logic, standard Ash patterns
-
-**Extensible:** Easy to add new field restrictions
-
-### Implementation Timeline
-
-**Phase 1 (MVP):** No field-level permissions
-
-**Phase 2:** Extend PermissionSets to include field permissions, implement Calculations/Validations
-
-**Phase 3:** If migrating to database, add permission_set_fields table
-
----
-
-## Migration Strategy
-
-### Phase 1: MVP with Hardcoded Permissions (2-3 weeks)
-
-**What's Included:**
-- Roles table in database
-- PermissionSets Elixir module with 4 predefined sets
-- Custom Policy Check reading from module
-- UI Authorization Helpers for LiveView
-- Admin UI for role management (create, assign, delete roles)
-
-**Limitations:**
-- Permissions not editable at runtime
-- New permissions require code deployment
-- Only 4 permission sets available
-
-**Benefits:**
-- Fast implementation
-- Maximum performance
-- Simple testing and review
-
-### Phase 2: Field-Level Permissions (Future, 2-3 weeks)
-
-**When Needed:** Business requires field-level restrictions
-
-**Implementation:**
-- Extend PermissionSets module with :fields key
-- Add Ash Calculations for read restrictions
-- Add custom validations for write restrictions
-- Update UI Helpers
-
-**Migration:** No database changes, pure code additions
-
-### Phase 3: Database-Backed Permissions (Future, 3-4 weeks)
-
-**When Needed:** Runtime permission configuration required
-
-**Implementation:**
-- Create permission tables in database
-- Seed script to migrate hardcoded permissions
-- Update PermissionSets module to query database
-- Add ETS cache for performance
-- Build admin UI for permission management
-
-**Migration:** Seamless, no changes to existing Policies or UI code
-
-### Decision Matrix: When to Migrate?
-
-| Scenario | Recommended Phase |
-|----------|-------------------|
-| MVP with 4 fixed permission sets | Phase 1 |
-| Need field-level restrictions | Phase 2 |
-| Permission changes < 1x/month | Stay Phase 1 |
-| Need runtime permission config | Phase 3 |
-| Custom permission sets needed | Phase 3 |
-| Permission changes > 1x/week | Phase 3 |
-
----
-
-## Related Documents
-
-**This Document (Overview):** High-level concepts, no code examples
-
-**[roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md):** Complete technical specification with code examples
-
-**[roles-and-permissions-implementation-plan.md](./roles-and-permissions-implementation-plan.md):** Detailed implementation plan with TDD approach
-
-**[CODE_GUIDELINES.md](../CODE_GUIDELINES.md):** Project coding standards
-
----
-
-## Summary
-
-The selected architecture uses **hardcoded Permission Sets in Elixir** for the MVP, providing:
-- **Speed:** 2-3 weeks implementation vs 4-5 weeks
-- **Performance:** Zero database queries for authorization
-- **Clarity:** Permissions in Git, reviewable and testable
-- **Flexibility:** Clear migration path to database-backed system
-
-**User-Member linking** uses **separate Ash Actions** for clarity and security.
-
-**Field-level permissions** have a **defined strategy** (Calculations + Validations) for Phase 2 implementation.
-
-The approach balances pragmatism for MVP delivery with extensibility for future requirements.
-
diff --git a/lib/membership/email.ex b/lib/membership/email.ex
index dccec21..c611742 100644
--- a/lib/membership/email.ex
+++ b/lib/membership/email.ex
@@ -1,37 +1,4 @@
defmodule Mv.Membership.Email do
- @moduledoc """
- Custom Ash type for validated email addresses.
-
- ## Overview
- This type extends `:string` with email-specific validation constraints.
- It ensures that email values stored in Property resources are valid email
- addresses according to a standard regex pattern.
-
- ## Validation Rules
- - Minimum length: 5 characters
- - Maximum length: 254 characters (RFC 5321 maximum)
- - Pattern: Standard email format (username@domain.tld)
- - Automatic trimming of leading/trailing whitespace
-
- ## Usage
- This type is used in the Property union type for properties with
- `value_type: :email` in PropertyType definitions.
-
- ## Example
- # In a property type definition
- PropertyType.create!(%{
- name: "work_email",
- value_type: :email
- })
-
- # Valid values
- "user@example.com"
- "first.last@company.co.uk"
-
- # Invalid values
- "not-an-email" # Missing @ and domain
- "a@b" # Too short
- """
@match_pattern ~S/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/
@match_regex Regex.compile!(@match_pattern)
@min_length 5
diff --git a/lib/membership/member.ex b/lib/membership/member.ex
index 26c876f..56549fc 100644
--- a/lib/membership/member.ex
+++ b/lib/membership/member.ex
@@ -1,43 +1,8 @@
defmodule Mv.Membership.Member do
- @moduledoc """
- Ash resource representing a club member.
-
- ## Overview
- Members are the core entity in the membership management system. Each member
- can have:
- - Personal information (name, email, phone, address)
- - Optional link to a User account (1:1 relationship)
- - Dynamic custom properties via PropertyType system
- - Full-text searchable profile
-
- ## Email Synchronization
- When a member is linked to a user account, emails are automatically synchronized
- bidirectionally. User.email is the source of truth on initial link.
- See `Mv.EmailSync` for details.
-
- ## Relationships
- - `has_many :properties` - Dynamic custom fields
- - `has_one :user` - Optional authentication account link
-
- ## Validations
- - Required: first_name, last_name, email
- - Email format validation (using EctoCommons.EmailValidator)
- - Phone number format: international format with 6-20 digits
- - Postal code format: exactly 5 digits (German format)
- - Date validations: birth_date and join_date not in future, exit_date after join_date
- - Email uniqueness: prevents conflicts with unlinked users
-
- ## Full-Text Search
- Members have a `search_vector` attribute (tsvector) that is automatically
- updated via database trigger. Search includes name, email, notes, and contact fields.
- """
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
- require Ash.Query
- import Ash.Expr
-
postgres do
table "members"
repo Mv.Repo
@@ -143,50 +108,6 @@ defmodule Mv.Membership.Member do
where [changing(:user)]
end
end
-
- # Action to handle fuzzy search on specific fields
- read :search do
- argument :query, :string, allow_nil?: true
- argument :similarity_threshold, :float, allow_nil?: true
-
- prepare fn query, _ctx ->
- q = Ash.Query.get_argument(query, :query) || ""
-
- # 0.2 as similarity threshold (recommended) - lower value can lead to more results but also to more unspecific results
- threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2
-
- if is_binary(q) and String.trim(q) != "" do
- q2 = String.trim(q)
- pat = "%" <> q2 <> "%"
-
- # FTS as main filter and fuzzy search just for first name, last name and strees
- query
- |> Ash.Query.filter(
- expr(
- # Substring on numeric-like fields (best effort, supports middle substrings)
- fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^q2) or
- fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q2) or
- contains(postal_code, ^q2) or
- contains(house_number, ^q2) or
- contains(phone_number, ^q2) or
- contains(email, ^q2) or
- contains(city, ^q2) or ilike(city, ^pat) or
- fragment("? % first_name", ^q2) or
- fragment("? % last_name", ^q2) or
- fragment("? % street", ^q2) or
- fragment("word_similarity(?, first_name) > ?", ^q2, ^threshold) or
- fragment("word_similarity(?, last_name) > ?", ^q2, ^threshold) or
- fragment("word_similarity(?, street) > ?", ^q2, ^threshold) or
- fragment("similarity(first_name, ?) > ?", ^q2, ^threshold) or
- fragment("similarity(last_name, ?) > ?", ^q2, ^threshold) or
- fragment("similarity(street, ?) > ?", ^q2, ^threshold)
- )
- )
- else
- query
- end
- end
- end
end
validations do
@@ -360,21 +281,4 @@ defmodule Mv.Membership.Member do
identities do
identity :unique_email, [:email]
end
-
- # Fuzzy Search function that can be called by live view and calls search action
- def fuzzy_search(query, opts) do
- q = (opts[:query] || opts["query"] || "") |> to_string()
-
- if String.trim(q) == "" do
- query
- else
- args =
- case opts[:fields] || opts["fields"] do
- nil -> %{query: q}
- fields -> %{query: q, fields: fields}
- end
-
- Ash.Query.for_read(query, :search, args)
- end
- end
end
diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex
index 01de11b..0c7c14d 100644
--- a/lib/membership/membership.ex
+++ b/lib/membership/membership.ex
@@ -1,21 +1,4 @@
defmodule Mv.Membership do
- @moduledoc """
- Ash Domain for membership management.
-
- ## Resources
- - `Member` - Club members with personal information and custom properties
- - `Property` - Dynamic custom field values attached to members
- - `PropertyType` - Schema definitions for custom properties
-
- ## Public API
- The domain exposes these main actions:
- - Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1`
- - Property management: `create_property/1`, `list_property/0`, etc.
- - PropertyType management: `create_property_type/1`, `list_property_types/0`, etc.
-
- ## Admin Interface
- The domain is configured with AshAdmin for management UI.
- """
use Ash.Domain,
extensions: [AshAdmin.Domain, AshPhoenix]
diff --git a/lib/membership/property.ex b/lib/membership/property.ex
index 231b264..de096ca 100644
--- a/lib/membership/property.ex
+++ b/lib/membership/property.ex
@@ -1,36 +1,4 @@
defmodule Mv.Membership.Property do
- @moduledoc """
- Ash resource representing a custom property value for a member.
-
- ## Overview
- Properties implement the Entity-Attribute-Value (EAV) pattern, allowing
- dynamic custom fields to be attached to members. Each property links a
- member to a property type and stores the actual value.
-
- ## Value Storage
- Values are stored using Ash's union type with JSONB storage format:
- ```json
- {
- "type": "string",
- "value": "example"
- }
- ```
-
- ## Supported Types
- - `:string` - Text data
- - `:integer` - Numeric data
- - `:boolean` - True/false flags
- - `:date` - Date values
- - `:email` - Validated email addresses (custom type)
-
- ## Relationships
- - `belongs_to :member` - The member this property belongs to (CASCADE delete)
- - `belongs_to :property_type` - The property type definition
-
- ## Constraints
- - Each member can have only one property per property type (unique composite index)
- - Properties are deleted when the associated member is deleted (CASCADE)
- """
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
diff --git a/lib/membership/property_type.ex b/lib/membership/property_type.ex
index 6569d1b..7444c13 100644
--- a/lib/membership/property_type.ex
+++ b/lib/membership/property_type.ex
@@ -1,48 +1,4 @@
defmodule Mv.Membership.PropertyType do
- @moduledoc """
- Ash resource defining the schema for custom member properties.
-
- ## Overview
- PropertyTypes define the "schema" for custom fields in the membership system.
- Each PropertyType specifies the name, data type, and behavior of a custom field
- that can be attached to members via Property resources.
-
- ## Attributes
- - `name` - Unique identifier for the property (e.g., "phone_mobile", "birthday")
- - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
- - `description` - Optional human-readable description
- - `immutable` - If true, property values cannot be changed after creation
- - `required` - If true, all members must have this property (future feature)
-
- ## Supported Value Types
- - `:string` - Text data (unlimited length)
- - `:integer` - Numeric data (64-bit integers)
- - `:boolean` - True/false flags
- - `:date` - Date values (no time component)
- - `:email` - Validated email addresses
-
- ## Relationships
- - `has_many :properties` - All property values of this type
-
- ## Constraints
- - Name must be unique across all property types
- - Cannot delete a property type that has existing property values (RESTRICT)
-
- ## Examples
- # Create a new property type
- PropertyType.create!(%{
- name: "phone_mobile",
- value_type: :string,
- description: "Mobile phone number"
- })
-
- # Create a required property type
- PropertyType.create!(%{
- name: "emergency_contact",
- value_type: :string,
- required: true
- })
- """
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
diff --git a/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex b/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex
index 2135465..9e34f29 100644
--- a/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex
+++ b/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex
@@ -10,20 +10,6 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
alias Mv.Mailer
- @doc """
- Sends a confirmation email to a new user.
-
- This function is called automatically by AshAuthentication when a new
- user registers and needs to confirm their email address.
-
- ## Parameters
- - `user` - The user record who needs to confirm their email
- - `token` - The confirmation token to include in the email link
- - `_opts` - Additional options (unused)
-
- ## Returns
- The Swoosh.Email delivery result from `Mailer.deliver!/1`.
- """
@impl true
def send(user, token, _) do
new()
diff --git a/lib/mv/accounts/user/senders/send_password_reset_email.ex b/lib/mv/accounts/user/senders/send_password_reset_email.ex
index bcf4e75..7c33d2e 100644
--- a/lib/mv/accounts/user/senders/send_password_reset_email.ex
+++ b/lib/mv/accounts/user/senders/send_password_reset_email.ex
@@ -10,20 +10,6 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
alias Mv.Mailer
- @doc """
- Sends a password reset email to a user.
-
- This function is called automatically by AshAuthentication when a user
- requests a password reset.
-
- ## Parameters
- - `user` - The user record requesting the password reset
- - `token` - The password reset token to include in the email link
- - `_opts` - Additional options (unused)
-
- ## Returns
- The Swoosh.Email delivery result from `Mailer.deliver!/1`.
- """
@impl true
def send(user, token, _) do
new()
diff --git a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex
index 9cea265..d42b2c1 100644
--- a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex
+++ b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex
@@ -9,22 +9,6 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
"""
use Ash.Resource.Validation
- @doc """
- Validates email uniqueness across linked User-Member pairs.
-
- This validation ensures that when a user is linked to a member, their email
- does not conflict with another member's email. It only runs when necessary
- to avoid blocking valid operations (see `@moduledoc` for trigger conditions).
-
- ## Parameters
- - `changeset` - The Ash changeset being validated
- - `_opts` - Options passed to the validation (unused)
- - `_context` - Ash context map (unused)
-
- ## Returns
- - `:ok` if validation passes or should be skipped
- - `{:error, field: :email, message: ..., value: ...}` if validation fails
- """
@impl true
def validate(changeset, _opts, _context) do
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
diff --git a/lib/mv/email_sync/changes/sync_member_email_to_user.ex b/lib/mv/email_sync/changes/sync_member_email_to_user.ex
index 48c7955..c1e5aea 100644
--- a/lib/mv/email_sync/changes/sync_member_email_to_user.ex
+++ b/lib/mv/email_sync/changes/sync_member_email_to_user.ex
@@ -10,21 +10,6 @@ defmodule Mv.EmailSync.Changes.SyncMemberEmailToUser do
use Ash.Resource.Change
alias Mv.EmailSync.{Helpers, Loader}
- @doc """
- Implements the email synchronization from Member to User.
-
- This function is called automatically by Ash when the configured trigger
- conditions are met (see `@moduledoc` for trigger details).
-
- ## Parameters
- - `changeset` - The Ash changeset being processed
- - `_opts` - Options passed to the change (unused)
- - `context` - Ash context map containing metadata (e.g., `:syncing_email` flag)
-
- ## Returns
- Modified changeset with email synchronization applied, or original changeset
- if recursion detected.
- """
@impl true
def change(changeset, _opts, context) do
# Only recursion protection needed - trigger logic is in `where` clauses
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 7148067..be7dd2c 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
@@ -12,21 +12,6 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
use Ash.Resource.Change
alias Mv.EmailSync.{Helpers, Loader}
- @doc """
- Implements the email synchronization from User to Member.
-
- This function is called automatically by Ash when the configured trigger
- conditions are met (see `@moduledoc` for trigger details).
-
- ## Parameters
- - `changeset` - The Ash changeset being processed
- - `_opts` - Options passed to the change (unused)
- - `context` - Ash context map containing metadata (e.g., `:syncing_email` flag)
-
- ## Returns
- Modified changeset with email synchronization applied, or original changeset
- if recursion detected.
- """
@impl true
def change(changeset, _opts, context) do
# Only recursion protection needed - trigger logic is in `where` clauses
diff --git a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex
index a14bc0b..54fa243 100644
--- a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex
+++ b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex
@@ -9,22 +9,6 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
"""
use Ash.Resource.Validation
- @doc """
- Validates email uniqueness across linked Member-User pairs.
-
- This validation ensures that when a member is linked to a user, their email
- does not conflict with another user's email. It only runs when necessary
- to avoid blocking valid operations (see `@moduledoc` for trigger conditions).
-
- ## Parameters
- - `changeset` - The Ash changeset being validated
- - `_opts` - Options passed to the validation (unused)
- - `_context` - Ash context map (unused)
-
- ## Returns
- - `:ok` if validation passes or should be skipped
- - `{:error, field: :email, message: ..., value: ...}` if validation fails
- """
@impl true
def validate(changeset, _opts, _context) do
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
diff --git a/lib/mv/repo.ex b/lib/mv/repo.ex
index 0a4a04d..a8d696a 100644
--- a/lib/mv/repo.ex
+++ b/lib/mv/repo.ex
@@ -5,7 +5,7 @@ defmodule Mv.Repo do
@impl true
def installed_extensions do
# Add extensions here, and the migration generator will install them.
- ["ash-functions", "citext", "pg_trgm"]
+ ["ash-functions", "citext"]
end
# Don't open unnecessary transactions
diff --git a/lib/mv/secrets.ex b/lib/mv/secrets.ex
index ee1519e..6a88eee 100644
--- a/lib/mv/secrets.ex
+++ b/lib/mv/secrets.ex
@@ -1,23 +1,4 @@
defmodule Mv.Secrets do
- @moduledoc """
- Secret provider for AshAuthentication.
-
- ## Purpose
- Provides runtime configuration secrets for Ash Authentication strategies,
- particularly for OIDC (Rauthy) authentication.
-
- ## Configuration Source
- Secrets are read from the `:rauthy` key in the application configuration,
- which is typically set in `config/runtime.exs` from environment variables:
- - `OIDC_CLIENT_ID`
- - `OIDC_CLIENT_SECRET`
- - `OIDC_BASE_URL`
- - `OIDC_REDIRECT_URI`
-
- ## Usage
- This module is automatically called by AshAuthentication when resolving
- secrets for the User resource's OIDC strategy.
- """
use AshAuthentication.Secret
def secret_for(
diff --git a/lib/mv_web/auth_overrides.ex b/lib/mv_web/auth_overrides.ex
index 1367150..63cdcf9 100644
--- a/lib/mv_web/auth_overrides.ex
+++ b/lib/mv_web/auth_overrides.ex
@@ -1,16 +1,4 @@
defmodule MvWeb.AuthOverrides do
- @moduledoc """
- UI customizations for AshAuthentication Phoenix components.
-
- ## Overrides
- - `SignIn` - Restricts form width to prevent full-width display
- - `Banner` - Replaces default logo with "Mitgliederverwaltung" text
- - `HorizontalRule` - Translates "or" text to German
-
- ## Documentation
- For complete reference on available overrides, see:
- https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
- """
use AshAuthentication.Phoenix.Overrides
use Gettext, backend: MvWeb.Gettext
diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex
index ba7ba36..521d501 100644
--- a/lib/mv_web/live/member_live/form.ex
+++ b/lib/mv_web/live/member_live/form.ex
@@ -1,33 +1,4 @@
defmodule MvWeb.MemberLive.Form do
- @moduledoc """
- LiveView form for creating and editing members.
-
- ## Features
- - Create new members with personal information
- - Edit existing member details
- - Manage custom properties (dynamic fields)
- - Real-time validation with visual feedback
- - Link/unlink user accounts
-
- ## Form Fields
- **Required:**
- - first_name, last_name, email
-
- **Optional:**
- - birth_date, phone_number, address fields (city, street, house_number, postal_code)
- - join_date, exit_date
- - paid status
- - notes
-
- ## Custom Properties
- Members can have dynamic custom properties defined by PropertyTypes.
- The form dynamically renders inputs based on available PropertyTypes.
-
- ## Events
- - `validate` - Real-time form validation
- - `save` - Submit form (create or update member)
- - Property management events for adding/removing custom fields
- """
use MvWeb, :live_view
@impl true
diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex
index c933133..e8c6d56 100644
--- a/lib/mv_web/live/member_live/index.ex
+++ b/lib/mv_web/live/member_live/index.ex
@@ -1,37 +1,8 @@
defmodule MvWeb.MemberLive.Index do
- @moduledoc """
- LiveView for displaying and managing the member list.
-
- ## Features
- - Full-text search across member profiles using PostgreSQL tsvector
- - Sortable columns (name, email, address fields)
- - Bulk selection for future batch operations
- - Real-time updates via LiveView
- - Bookmarkable URLs with query parameters
-
- ## URL Parameters
- - `query` - Search query string for full-text search
- - `sort_field` - Field to sort by (e.g., :first_name, :email, :join_date)
- - `sort_order` - Sort direction (:asc or :desc)
-
- ## Events
- - `delete` - Remove a member from the database
- - `select_member` - Toggle individual member selection
- - `select_all` - Toggle selection of all visible members
-
- ## Implementation Notes
- - Search uses PostgreSQL full-text search (plainto_tsquery)
- - Sort state is synced with URL for bookmarkability
- - Components communicate via `handle_info` for decoupling
- """
use MvWeb, :live_view
+ import Ash.Expr
+ import Ash.Query
- @doc """
- Initializes the LiveView state.
-
- Sets up initial assigns for page title, search query, sort configuration,
- and member selection. Actual data loading happens in `handle_params/3`.
- """
@impl true
def mount(_params, _session, socket) do
socket =
@@ -50,14 +21,7 @@ defmodule MvWeb.MemberLive.Index do
# Handle Events
# -----------------------------------------------------------------
- @doc """
- Handles member-related UI events.
-
- ## Supported events:
- - `"delete"` - Removes a member from the database
- - `"select_member"` - Toggles individual member selection
- - `"select_all"` - Toggles selection of all visible members
- """
+ # Delete a member
@impl true
def handle_event("delete", %{"id" => id}, socket) do
member = Ash.get!(Mv.Membership.Member, id)
@@ -67,6 +31,7 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, assign(socket, :members, updated_members)}
end
+ # Selects one member in the list of members
@impl true
def handle_event("select_member", %{"id" => id}, socket) do
selected =
@@ -79,6 +44,7 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, assign(socket, :selected_members, selected)}
end
+ # Selects all members in the list of members
@impl true
def handle_event("select_all", _params, socket) do
members = socket.assigns.members
@@ -99,23 +65,57 @@ defmodule MvWeb.MemberLive.Index do
# Handle Infos from Child Components
# -----------------------------------------------------------------
- @doc """
- Handles messages from child components.
-
- ## Supported messages:
- - `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
- - `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
- """
+ # Sorts the list of members according to a field, when you click on the column header
@impl true
def handle_info({:sort, field_str}, socket) do
field = String.to_existing_atom(field_str)
- {new_field, new_order} = determine_new_sort(field, socket)
+ old_field = socket.assigns.sort_field
- socket
- |> update_sort_components(socket.assigns.sort_field, new_field, new_order)
- |> push_sort_url(new_field, new_order)
+ {new_order, new_field} =
+ if socket.assigns.sort_field == field do
+ {toggle_order(socket.assigns.sort_order), field}
+ else
+ {:asc, field}
+ end
+
+ active_id = :"sort_#{new_field}"
+ old_id = :"sort_#{old_field}"
+
+ # Update the new SortHeader
+ send_update(MvWeb.Components.SortHeaderComponent,
+ id: active_id,
+ sort_field: new_field,
+ sort_order: new_order
+ )
+
+ # Reset the current SortHeader
+ send_update(MvWeb.Components.SortHeaderComponent,
+ id: old_id,
+ sort_field: new_field,
+ sort_order: new_order
+ )
+
+ existing_search_query = socket.assigns.query
+
+ # Build the URL with queries
+ query_params = %{
+ "query" => existing_search_query,
+ "sort_field" => Atom.to_string(new_field),
+ "sort_order" => Atom.to_string(new_order)
+ }
+
+ # Set the new path with params
+ new_path = ~p"/members?#{query_params}"
+
+ # Push the new URL
+ {:noreply,
+ push_patch(socket,
+ to: new_path,
+ replace: true
+ )}
end
+ # Function to handle search
@impl true
def handle_info({:search_changed, q}, socket) do
socket = load_members(socket, q)
@@ -144,13 +144,6 @@ defmodule MvWeb.MemberLive.Index do
# -----------------------------------------------------------------
# Handle Params from the URL
# -----------------------------------------------------------------
- @doc """
- Handles URL parameter changes.
-
- Parses query parameters for search query, sort field, and sort order,
- then loads members accordingly. This enables bookmarkable URLs and
- browser back/forward navigation.
- """
@impl true
def handle_params(params, _url, socket) do
socket =
@@ -165,55 +158,6 @@ defmodule MvWeb.MemberLive.Index do
# -------------------------------------------------------------
# FUNCTIONS
# -------------------------------------------------------------
-
- # Determines new sort field and order based on current state
- defp determine_new_sort(field, socket) do
- if socket.assigns.sort_field == field do
- {field, toggle_order(socket.assigns.sort_order)}
- else
- {field, :asc}
- end
- end
-
- # Updates both the active and old SortHeader components
- defp update_sort_components(socket, old_field, new_field, new_order) do
- active_id = :"sort_#{new_field}"
- old_id = :"sort_#{old_field}"
-
- # Update the new SortHeader
- send_update(MvWeb.Components.SortHeaderComponent,
- id: active_id,
- sort_field: new_field,
- sort_order: new_order
- )
-
- # Reset the current SortHeader
- send_update(MvWeb.Components.SortHeaderComponent,
- id: old_id,
- sort_field: new_field,
- sort_order: new_order
- )
-
- socket
- end
-
- # Builds sort URL and pushes navigation patch
- defp push_sort_url(socket, field, order) do
- query_params = %{
- "query" => socket.assigns.query,
- "sort_field" => Atom.to_string(field),
- "sort_order" => Atom.to_string(order)
- }
-
- new_path = ~p"/members?#{query_params}"
-
- {:noreply,
- push_patch(socket,
- to: new_path,
- replace: true
- )}
- end
-
# Load members eg based on a query for sorting
defp load_members(socket, search_query) do
query =
@@ -250,9 +194,7 @@ defmodule MvWeb.MemberLive.Index do
defp apply_search_filter(query, search_query) do
if search_query && String.trim(search_query) != "" do
query
- |> Mv.Membership.Member.fuzzy_search(%{
- query: search_query
- })
+ |> filter(expr(fragment("search_vector @@ plainto_tsquery('simple', ?)", ^search_query)))
else
query
end
diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex
index 043915e..9a0ef40 100644
--- a/lib/mv_web/live/member_live/show.ex
+++ b/lib/mv_web/live/member_live/show.ex
@@ -1,26 +1,4 @@
defmodule MvWeb.MemberLive.Show do
- @moduledoc """
- LiveView for displaying a single member's details.
-
- ## Features
- - Display all member information (personal, contact, address)
- - Show linked user account (if exists)
- - Display custom properties
- - Navigate to edit form
- - Return to member list
-
- ## Displayed Information
- - Basic: name, email, dates (birth, join, exit)
- - Contact: phone number
- - Address: street, house number, postal code, city
- - Status: paid flag
- - Relationships: linked user account
- - Custom: dynamic properties from PropertyTypes
-
- ## Navigation
- - Back to member list
- - Edit member (with return_to parameter for back navigation)
- """
use MvWeb, :live_view
import Ash.Query
diff --git a/lib/mv_web/live/property_live/form.ex b/lib/mv_web/live/property_live/form.ex
index b85597d..a60a2e4 100644
--- a/lib/mv_web/live/property_live/form.ex
+++ b/lib/mv_web/live/property_live/form.ex
@@ -1,35 +1,4 @@
defmodule MvWeb.PropertyLive.Form do
- @moduledoc """
- LiveView form for creating and editing properties.
-
- ## Features
- - Create new properties with member and type selection
- - Edit existing property values
- - Value input adapts to property type (string, integer, boolean, date, email)
- - Real-time validation
-
- ## Form Fields
- **Required:**
- - member - Select which member owns this property
- - property_type - Select the type (defines value type)
- - value - The actual value (input type depends on property type)
-
- ## Value Types
- The form dynamically renders appropriate inputs based on property type:
- - String: text input
- - Integer: number input
- - Boolean: checkbox
- - Date: date picker
- - Email: email input with validation
-
- ## Events
- - `validate` - Real-time form validation
- - `save` - Submit form (create or update property)
-
- ## Note
- Properties are typically managed through the member edit form,
- not through this standalone form.
- """
use MvWeb, :live_view
@impl true
diff --git a/lib/mv_web/live/property_live/index.ex b/lib/mv_web/live/property_live/index.ex
index bc96bc0..70171ef 100644
--- a/lib/mv_web/live/property_live/index.ex
+++ b/lib/mv_web/live/property_live/index.ex
@@ -1,26 +1,4 @@
defmodule MvWeb.PropertyLive.Index do
- @moduledoc """
- LiveView for displaying and managing properties.
-
- ## Features
- - List all properties with their values and types
- - Show which member each property belongs to
- - Display property type information
- - Navigate to property details and edit forms
- - Delete properties
-
- ## Relationships
- Each property is linked to:
- - A member (the property owner)
- - A property type (defining value type and behavior)
-
- ## Events
- - `delete` - Remove a property from the database
-
- ## Note
- Properties are typically managed through the member edit form.
- This view provides a global overview of all properties.
- """
use MvWeb, :live_view
@impl true
diff --git a/lib/mv_web/live/property_live/show.ex b/lib/mv_web/live/property_live/show.ex
index 41e20c4..2a1e2ec 100644
--- a/lib/mv_web/live/property_live/show.ex
+++ b/lib/mv_web/live/property_live/show.ex
@@ -1,24 +1,4 @@
defmodule MvWeb.PropertyLive.Show do
- @moduledoc """
- LiveView for displaying a single property's details.
-
- ## Features
- - Display property value and type
- - Show linked member
- - Show property type definition
- - Navigate to edit form
- - Return to property list
-
- ## Displayed Information
- - Property value (formatted based on type)
- - Property type name and description
- - Member information (who owns this property)
- - Property metadata (ID, timestamps if added)
-
- ## Navigation
- - Back to property list
- - Edit property
- """
use MvWeb, :live_view
@impl true
diff --git a/lib/mv_web/live/property_type_live/form.ex b/lib/mv_web/live/property_type_live/form.ex
index 292de2b..8b8b452 100644
--- a/lib/mv_web/live/property_type_live/form.ex
+++ b/lib/mv_web/live/property_type_live/form.ex
@@ -1,38 +1,4 @@
defmodule MvWeb.PropertyTypeLive.Form do
- @moduledoc """
- LiveView form for creating and editing property types (admin).
-
- ## Features
- - Create new property type definitions
- - Edit existing property types
- - Select value type from supported types
- - Set immutable and required flags
- - Real-time validation
-
- ## Form Fields
- **Required:**
- - name - Unique identifier (e.g., "phone_mobile", "emergency_contact")
- - value_type - Data type (:string, :integer, :boolean, :date, :email)
-
- **Optional:**
- - description - Human-readable explanation
- - immutable - If true, values cannot be changed after creation (default: false)
- - required - If true, all members must have this property (default: false)
-
- ## Value Type Selection
- - `:string` - Text data (unlimited length)
- - `:integer` - Numeric data
- - `:boolean` - True/false flags
- - `:date` - Date values
- - `:email` - Validated email addresses
-
- ## Events
- - `validate` - Real-time form validation
- - `save` - Submit form (create or update property type)
-
- ## Security
- Property type management is restricted to admin users.
- """
use MvWeb, :live_view
@impl true
diff --git a/lib/mv_web/live/property_type_live/index.ex b/lib/mv_web/live/property_type_live/index.ex
index 2731414..dae4da0 100644
--- a/lib/mv_web/live/property_type_live/index.ex
+++ b/lib/mv_web/live/property_type_live/index.ex
@@ -1,28 +1,4 @@
defmodule MvWeb.PropertyTypeLive.Index do
- @moduledoc """
- LiveView for managing property type definitions (admin).
-
- ## Features
- - List all property types
- - Display type information (name, value type, description)
- - Show immutable and required flags
- - Create new property types
- - Edit existing property types
- - Delete property types (if no properties use them)
-
- ## Displayed Information
- - Name: Unique identifier for the property type
- - Value type: Data type constraint (string, integer, boolean, date, email)
- - Description: Human-readable explanation
- - Immutable: Whether property values can be changed after creation
- - Required: Whether all members must have this property (future feature)
-
- ## Events
- - `delete` - Remove a property type (only if no properties exist)
-
- ## Security
- Property type management is restricted to admin users.
- """
use MvWeb, :live_view
@impl true
diff --git a/lib/mv_web/live/property_type_live/show.ex b/lib/mv_web/live/property_type_live/show.ex
index b5c441c..ec2b0bf 100644
--- a/lib/mv_web/live/property_type_live/show.ex
+++ b/lib/mv_web/live/property_type_live/show.ex
@@ -1,27 +1,4 @@
defmodule MvWeb.PropertyTypeLive.Show do
- @moduledoc """
- LiveView for displaying a single property type's details (admin).
-
- ## Features
- - Display property type definition
- - Show all attributes (name, value type, description, flags)
- - Navigate to edit form
- - Return to property type list
-
- ## Displayed Information
- - Name: Unique identifier
- - Value type: Data type constraint
- - Description: Optional explanation
- - Immutable flag: Whether values can be changed
- - Required flag: Whether all members need this property
-
- ## Navigation
- - Back to property type list
- - Edit property type
-
- ## Security
- Property type details are restricted to admin users.
- """
use MvWeb, :live_view
@impl true
diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex
index cf7b687..c7fd2d0 100644
--- a/lib/mv_web/live/user_live/form.ex
+++ b/lib/mv_web/live/user_live/form.ex
@@ -1,36 +1,4 @@
defmodule MvWeb.UserLive.Form do
- @moduledoc """
- LiveView form for creating and editing users.
-
- ## Features
- - Create new users with email
- - Edit existing user details
- - Optional password setting (checkbox to toggle)
- - Link/unlink member accounts
- - Email synchronization with linked members
-
- ## Form Fields
- **Required:**
- - email
-
- **Optional:**
- - password (for password authentication strategy)
- - linked member (select from existing members)
-
- ## Password Management
- - New users: Can optionally set password with confirmation
- - Existing users: Can change password (no confirmation required, admin action)
- - Checkbox toggles password section visibility
-
- ## Member Linking
- Users can be linked to existing member accounts. When linked, emails are
- synchronized bidirectionally with User.email as the source of truth.
-
- ## Events
- - `validate` - Real-time form validation
- - `save` - Submit form (create or update user)
- - `toggle_password_section` - Show/hide password fields
- """
use MvWeb, :live_view
@impl true
diff --git a/lib/mv_web/live/user_live/index.ex b/lib/mv_web/live/user_live/index.ex
index 8803237..39ced23 100644
--- a/lib/mv_web/live/user_live/index.ex
+++ b/lib/mv_web/live/user_live/index.ex
@@ -1,25 +1,4 @@
defmodule MvWeb.UserLive.Index do
- @moduledoc """
- LiveView for displaying and managing the user list.
-
- ## Features
- - List all users with email and linked member
- - Sort users by email (default)
- - Delete users
- - Navigate to user details and edit forms
- - Bulk selection for future batch operations
-
- ## Relationships
- Displays linked member information when a user is connected to a member account.
-
- ## Events
- - `delete` - Remove a user from the database
- - `select_user` - Toggle individual user selection
- - `select_all` - Toggle selection of all visible users
-
- ## Security
- User deletion requires admin permissions (enforced by Ash policies).
- """
use MvWeb, :live_view
import MvWeb.TableComponents
diff --git a/lib/mv_web/live/user_live/show.ex b/lib/mv_web/live/user_live/show.ex
index 664f99f..bdd241b 100644
--- a/lib/mv_web/live/user_live/show.ex
+++ b/lib/mv_web/live/user_live/show.ex
@@ -1,29 +1,4 @@
defmodule MvWeb.UserLive.Show do
- @moduledoc """
- LiveView for displaying a single user's details.
-
- ## Features
- - Display user information (email, OIDC ID)
- - Show authentication methods (password, OIDC)
- - Display linked member account (if exists)
- - Navigate to edit form
- - Return to user list
-
- ## Displayed Information
- - Email address
- - OIDC ID (if authenticated via OIDC)
- - Password authentication status
- - Linked member (name and email)
-
- ## Authentication Status
- Shows which authentication methods are enabled for the user:
- - Password authentication (has hashed_password)
- - OIDC authentication (has oidc_id)
-
- ## Navigation
- - Back to user list
- - Edit user (with return_to parameter for back navigation)
- """
use MvWeb, :live_view
@impl true
diff --git a/lib/mv_web/live_helpers.ex b/lib/mv_web/live_helpers.ex
index 3563cfe..331bb5c 100644
--- a/lib/mv_web/live_helpers.ex
+++ b/lib/mv_web/live_helpers.ex
@@ -1,16 +1,4 @@
defmodule MvWeb.LiveHelpers do
- @moduledoc """
- Shared LiveView lifecycle hooks and helper functions.
-
- ## on_mount Hooks
- - `:default` - Sets the user's locale from session (defaults to "de")
-
- ## Usage
- Add to LiveView modules via:
- ```elixir
- on_mount {MvWeb.LiveHelpers, :default}
- ```
- """
def on_mount(:default, _params, session, socket) do
locale = session["locale"] || "de"
Gettext.put_locale(locale)
diff --git a/priv/repo/migrations/20251001141005_add_trigram_to_members.exs b/priv/repo/migrations/20251001141005_add_trigram_to_members.exs
deleted file mode 100644
index f502003..0000000
--- a/priv/repo/migrations/20251001141005_add_trigram_to_members.exs
+++ /dev/null
@@ -1,66 +0,0 @@
-defmodule Mv.Repo.Migrations.AddTrigramToMembers do
- @moduledoc """
- Updates resources based on their most recent snapshots.
-
- This file was autogenerated with `mix ash_postgres.generate_migrations`
- """
-
- use Ecto.Migration
-
- def up do
- # activate trigram-extension
- execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
-
- # -------------------------------------------------
- # Trigram‑Indizes (GIN) for fields we want to search in
- # -------------------------------------------------
- #
- # `gin_trgm_ops` ist the operator-class-name
- #
-
- execute("""
- CREATE INDEX members_first_name_trgm_idx
- ON members
- USING GIN (first_name gin_trgm_ops);
- """)
-
- execute("""
- CREATE INDEX members_last_name_trgm_idx
- ON members
- USING GIN (last_name gin_trgm_ops);
- """)
-
- execute("""
- CREATE INDEX members_email_trgm_idx
- ON members
- USING GIN (email gin_trgm_ops);
- """)
-
- execute("""
- CREATE INDEX members_city_trgm_idx
- ON members
- USING GIN (city gin_trgm_ops);
- """)
-
- execute("""
- CREATE INDEX members_street_trgm_idx
- ON members
- USING GIN (street gin_trgm_ops);
- """)
-
- execute("""
- CREATE INDEX members_notes_trgm_idx
- ON members
- USING GIN (notes gin_trgm_ops);
- """)
- end
-
- def down do
- execute("DROP INDEX IF EXISTS members_first_name_trgm_idx;")
- execute("DROP INDEX IF EXISTS members_last_name_trgm_idx;")
- execute("DROP INDEX IF EXISTS members_email_trgm_idx;")
- execute("DROP INDEX IF EXISTS members_city_trgm_idx;")
- execute("DROP INDEX IF EXISTS members_street_trgm_idx;")
- execute("DROP INDEX IF EXISTS members_notes_trgm_idx;")
- end
-end
diff --git a/priv/resource_snapshots/repo/members/20251001141005.json b/priv/resource_snapshots/repo/members/20251001141005.json
deleted file mode 100644
index a541fc0..0000000
--- a/priv/resource_snapshots/repo/members/20251001141005.json
+++ /dev/null
@@ -1,199 +0,0 @@
-{
- "attributes": [
- {
- "allow_nil?": false,
- "default": "fragment(\"uuid_generate_v7()\")",
- "generated?": false,
- "precision": null,
- "primary_key?": true,
- "references": null,
- "scale": null,
- "size": null,
- "source": "id",
- "type": "uuid"
- },
- {
- "allow_nil?": false,
- "default": "nil",
- "generated?": false,
- "precision": null,
- "primary_key?": false,
- "references": null,
- "scale": null,
- "size": null,
- "source": "first_name",
- "type": "text"
- },
- {
- "allow_nil?": false,
- "default": "nil",
- "generated?": false,
- "precision": null,
- "primary_key?": false,
- "references": null,
- "scale": null,
- "size": null,
- "source": "last_name",
- "type": "text"
- },
- {
- "allow_nil?": false,
- "default": "nil",
- "generated?": false,
- "precision": null,
- "primary_key?": false,
- "references": null,
- "scale": null,
- "size": null,
- "source": "email",
- "type": "text"
- },
- {
- "allow_nil?": true,
- "default": "nil",
- "generated?": false,
- "precision": null,
- "primary_key?": false,
- "references": null,
- "scale": null,
- "size": null,
- "source": "birth_date",
- "type": "date"
- },
- {
- "allow_nil?": true,
- "default": "nil",
- "generated?": false,
- "precision": null,
- "primary_key?": false,
- "references": null,
- "scale": null,
- "size": null,
- "source": "paid",
- "type": "boolean"
- },
- {
- "allow_nil?": true,
- "default": "nil",
- "generated?": false,
- "precision": null,
- "primary_key?": false,
- "references": null,
- "scale": null,
- "size": null,
- "source": "phone_number",
- "type": "text"
- },
- {
- "allow_nil?": true,
- "default": "nil",
- "generated?": false,
- "precision": null,
- "primary_key?": false,
- "references": null,
- "scale": null,
- "size": null,
- "source": "join_date",
- "type": "date"
- },
- {
- "allow_nil?": true,
- "default": "nil",
- "generated?": false,
- "precision": null,
- "primary_key?": false,
- "references": null,
- "scale": null,
- "size": null,
- "source": "exit_date",
- "type": "date"
- },
- {
- "allow_nil?": true,
- "default": "nil",
- "generated?": false,
- "precision": null,
- "primary_key?": false,
- "references": null,
- "scale": null,
- "size": null,
- "source": "notes",
- "type": "text"
- },
- {
- "allow_nil?": true,
- "default": "nil",
- "generated?": false,
- "precision": null,
- "primary_key?": false,
- "references": null,
- "scale": null,
- "size": null,
- "source": "city",
- "type": "text"
- },
- {
- "allow_nil?": true,
- "default": "nil",
- "generated?": false,
- "precision": null,
- "primary_key?": false,
- "references": null,
- "scale": null,
- "size": null,
- "source": "street",
- "type": "text"
- },
- {
- "allow_nil?": true,
- "default": "nil",
- "generated?": false,
- "precision": null,
- "primary_key?": false,
- "references": null,
- "scale": null,
- "size": null,
- "source": "house_number",
- "type": "text"
- },
- {
- "allow_nil?": true,
- "default": "nil",
- "generated?": false,
- "precision": null,
- "primary_key?": false,
- "references": null,
- "scale": null,
- "size": null,
- "source": "postal_code",
- "type": "text"
- },
- {
- "allow_nil?": true,
- "default": "nil",
- "generated?": false,
- "precision": null,
- "primary_key?": false,
- "references": null,
- "scale": null,
- "size": null,
- "source": "search_vector",
- "type": "tsvector"
- }
- ],
- "base_filter": null,
- "check_constraints": [],
- "custom_indexes": [],
- "custom_statements": [],
- "has_create_action": true,
- "hash": "9019AD59832AB926899B6A871A368CF65F757533795E4E38D5C0EE6AE58BE070",
- "identities": [],
- "multitenancy": {
- "attribute": null,
- "global": null,
- "strategy": null
- },
- "repo": "Elixir.Mv.Repo",
- "schema": null,
- "table": "members"
-}
\ No newline at end of file
diff --git a/test/membership/fuzzy_search_test.exs b/test/membership/fuzzy_search_test.exs
deleted file mode 100644
index 6ec582b..0000000
--- a/test/membership/fuzzy_search_test.exs
+++ /dev/null
@@ -1,443 +0,0 @@
-defmodule Mv.Membership.FuzzySearchTest do
- use Mv.DataCase, async: false
-
- test "fuzzy_search/2 function exists" do
- assert function_exported?(Mv.Membership.Member, :fuzzy_search, 2)
- end
-
- test "fuzzy_search returns only John Doe by fuzzy query 'john'" do
- {:ok, john} =
- Mv.Membership.create_member(%{
- first_name: "John",
- last_name: "Doe",
- email: "john.doe@example.com"
- })
-
- {:ok, _jane} =
- Mv.Membership.create_member(%{
- first_name: "Adriana",
- last_name: "Smith",
- email: "adriana.smith@example.com"
- })
-
- {:ok, alice} =
- Mv.Membership.create_member(%{
- first_name: "Alice",
- last_name: "Johnson",
- email: "alice.johnson@example.com"
- })
-
- result =
- Mv.Membership.Member
- |> Mv.Membership.Member.fuzzy_search(%{
- query: "john"
- })
- |> Ash.read!()
-
- assert Enum.map(result, & &1.id) == [john.id, alice.id]
- end
-
- test "fuzzy_search finds 'Thomas' when searching misspelled 'tomas'" do
- {:ok, thomas} =
- Mv.Membership.create_member(%{
- first_name: "Thomas",
- last_name: "Doe",
- email: "john.doe@example.com"
- })
-
- {:ok, jane} =
- Mv.Membership.create_member(%{
- first_name: "Jane",
- last_name: "Smith",
- email: "jane.smith@example.com"
- })
-
- {:ok, _alice} =
- Mv.Membership.create_member(%{
- first_name: "Alice",
- last_name: "Johnson",
- email: "alice.johnson@example.com"
- })
-
- result =
- Mv.Membership.Member
- |> Mv.Membership.Member.fuzzy_search(%{
- query: "tomas"
- })
- |> Ash.read!()
-
- ids = Enum.map(result, & &1.id)
- assert thomas.id in ids
- refute jane.id in ids
- assert length(ids) >= 1
- end
-
- test "empty query returns all members" do
- {:ok, a} =
- Mv.Membership.create_member(%{first_name: "A", last_name: "One", email: "a1@example.com"})
-
- {:ok, b} =
- Mv.Membership.create_member(%{first_name: "B", last_name: "Two", email: "b2@example.com"})
-
- result =
- Mv.Membership.Member
- |> Mv.Membership.Member.fuzzy_search(%{query: ""})
- |> Ash.read!()
-
- assert Enum.sort(Enum.map(result, & &1.id))
- |> Enum.uniq()
- |> Enum.sort()
- |> Enum.all?(fn id -> id in [a.id, b.id] end)
- end
-
- test "substring numeric search matches postal_code mid-string" do
- {:ok, m1} =
- Mv.Membership.create_member(%{
- first_name: "Num",
- last_name: "One",
- email: "n1@example.com",
- postal_code: "12345"
- })
-
- {:ok, _m2} =
- Mv.Membership.create_member(%{
- first_name: "Num",
- last_name: "Two",
- email: "n2@example.com",
- postal_code: "67890"
- })
-
- result =
- Mv.Membership.Member
- |> Mv.Membership.Member.fuzzy_search(%{query: "345"})
- |> Ash.read!()
-
- ids = Enum.map(result, & &1.id)
- assert m1.id in ids
- end
-
- test "substring numeric search matches house_number mid-string" do
- {:ok, m1} =
- Mv.Membership.create_member(%{
- first_name: "Home",
- last_name: "One",
- email: "h1@example.com",
- house_number: "A345B"
- })
-
- {:ok, _m2} =
- Mv.Membership.create_member(%{
- first_name: "Home",
- last_name: "Two",
- email: "h2@example.com",
- house_number: "77"
- })
-
- result =
- Mv.Membership.Member
- |> Mv.Membership.Member.fuzzy_search(%{query: "345"})
- |> Ash.read!()
-
- ids = Enum.map(result, & &1.id)
- assert m1.id in ids
- end
-
- test "fuzzy matches street misspelling" do
- {:ok, s1} =
- Mv.Membership.create_member(%{
- first_name: "Road",
- last_name: "Test",
- email: "s1@example.com",
- street: "Main Street"
- })
-
- {:ok, _s2} =
- Mv.Membership.create_member(%{
- first_name: "Road",
- last_name: "Other",
- email: "s2@example.com",
- street: "Second Avenue"
- })
-
- result =
- Mv.Membership.Member
- |> Mv.Membership.Member.fuzzy_search(%{query: "mainn"})
- |> Ash.read!()
-
- ids = Enum.map(result, & &1.id)
- assert s1.id in ids
- end
-
- test "substring in city matches mid-string" do
- {:ok, b} =
- Mv.Membership.create_member(%{
- first_name: "City",
- last_name: "One",
- email: "city1@example.com",
- city: "Berlin"
- })
-
- {:ok, _m} =
- Mv.Membership.create_member(%{
- first_name: "City",
- last_name: "Two",
- email: "city2@example.com",
- city: "München"
- })
-
- result =
- Mv.Membership.Member
- |> Mv.Membership.Member.fuzzy_search(%{query: "erl"})
- |> Ash.read!()
-
- ids = Enum.map(result, & &1.id)
- assert b.id in ids
- end
-
- test "blank character handling: query with spaces matches full name" do
- {:ok, member} =
- Mv.Membership.create_member(%{
- first_name: "John",
- last_name: "Doe",
- email: "john.doe@example.com"
- })
-
- {:ok, _other} =
- Mv.Membership.create_member(%{
- first_name: "Jane",
- last_name: "Smith",
- email: "jane.smith@example.com"
- })
-
- result =
- Mv.Membership.Member
- |> Mv.Membership.Member.fuzzy_search(%{query: "john doe"})
- |> Ash.read!()
-
- ids = Enum.map(result, & &1.id)
- assert member.id in ids
- end
-
- test "blank character handling: query with multiple spaces is handled" do
- {:ok, member} =
- Mv.Membership.create_member(%{
- first_name: "Mary",
- last_name: "Jane",
- email: "mary.jane@example.com"
- })
-
- result =
- Mv.Membership.Member
- |> Mv.Membership.Member.fuzzy_search(%{query: "mary jane"})
- |> Ash.read!()
-
- ids = Enum.map(result, & &1.id)
- assert member.id in ids
- end
-
- test "special character handling: @ symbol in query matches email" do
- {:ok, member} =
- Mv.Membership.create_member(%{
- first_name: "Test",
- last_name: "User",
- email: "test.user@example.com"
- })
-
- {:ok, _other} =
- Mv.Membership.create_member(%{
- first_name: "Other",
- last_name: "Person",
- email: "other.person@different.org"
- })
-
- result =
- Mv.Membership.Member
- |> Mv.Membership.Member.fuzzy_search(%{query: "example"})
- |> Ash.read!()
-
- ids = Enum.map(result, & &1.id)
- assert member.id in ids
- end
-
- test "special character handling: dot in query matches email" do
- {:ok, member} =
- Mv.Membership.create_member(%{
- first_name: "Dot",
- last_name: "Test",
- email: "dot.test@example.com"
- })
-
- {:ok, _other} =
- Mv.Membership.create_member(%{
- first_name: "No",
- last_name: "Dot",
- email: "nodot@example.com"
- })
-
- result =
- Mv.Membership.Member
- |> Mv.Membership.Member.fuzzy_search(%{query: "dot.test"})
- |> Ash.read!()
-
- ids = Enum.map(result, & &1.id)
- assert member.id in ids
- end
-
- test "special character handling: hyphen in query matches data" do
- {:ok, member} =
- Mv.Membership.create_member(%{
- first_name: "Mary-Jane",
- last_name: "Watson",
- email: "mary.jane@example.com"
- })
-
- {:ok, _other} =
- Mv.Membership.create_member(%{
- first_name: "Mary",
- last_name: "Smith",
- email: "mary.smith@example.com"
- })
-
- result =
- Mv.Membership.Member
- |> Mv.Membership.Member.fuzzy_search(%{query: "mary-jane"})
- |> Ash.read!()
-
- ids = Enum.map(result, & &1.id)
- assert member.id in ids
- end
-
- test "unicode character handling: umlaut ö in query matches data" do
- {:ok, member} =
- Mv.Membership.create_member(%{
- first_name: "Jörg",
- last_name: "Schmidt",
- email: "joerg.schmidt@example.com"
- })
-
- {:ok, _other} =
- Mv.Membership.create_member(%{
- first_name: "John",
- last_name: "Smith",
- email: "john.smith@example.com"
- })
-
- result =
- Mv.Membership.Member
- |> Mv.Membership.Member.fuzzy_search(%{query: "jörg"})
- |> Ash.read!()
-
- ids = Enum.map(result, & &1.id)
- assert member.id in ids
- end
-
- test "unicode character handling: umlaut ä in query matches data" do
- {:ok, member} =
- Mv.Membership.create_member(%{
- first_name: "Märta",
- last_name: "Andersson",
- email: "maerta.andersson@example.com"
- })
-
- {:ok, _other} =
- Mv.Membership.create_member(%{
- first_name: "Marta",
- last_name: "Johnson",
- email: "marta.johnson@example.com"
- })
-
- result =
- Mv.Membership.Member
- |> Mv.Membership.Member.fuzzy_search(%{query: "märta"})
- |> Ash.read!()
-
- ids = Enum.map(result, & &1.id)
- assert member.id in ids
- end
-
- test "unicode character handling: umlaut ü in query matches data" do
- {:ok, member} =
- Mv.Membership.create_member(%{
- first_name: "Günther",
- last_name: "Müller",
- email: "guenther.mueller@example.com"
- })
-
- {:ok, _other} =
- Mv.Membership.create_member(%{
- first_name: "Gunter",
- last_name: "Miller",
- email: "gunter.miller@example.com"
- })
-
- result =
- Mv.Membership.Member
- |> Mv.Membership.Member.fuzzy_search(%{query: "müller"})
- |> Ash.read!()
-
- ids = Enum.map(result, & &1.id)
- assert member.id in ids
- end
-
- test "unicode character handling: query without umlaut matches data with umlaut" do
- {:ok, member} =
- Mv.Membership.create_member(%{
- first_name: "Müller",
- last_name: "Schmidt",
- email: "mueller.schmidt@example.com"
- })
-
- {:ok, _other} =
- Mv.Membership.create_member(%{
- first_name: "Miller",
- last_name: "Smith",
- email: "miller.smith@example.com"
- })
-
- result =
- Mv.Membership.Member
- |> Mv.Membership.Member.fuzzy_search(%{query: "muller"})
- |> Ash.read!()
-
- ids = Enum.map(result, & &1.id)
- assert member.id in ids
- end
-
- test "very long search strings: handles long query without error" do
- {:ok, _member} =
- Mv.Membership.create_member(%{
- first_name: "Test",
- last_name: "User",
- email: "test@example.com"
- })
-
- long_query = String.duplicate("a", 1000)
-
- result =
- Mv.Membership.Member
- |> Mv.Membership.Member.fuzzy_search(%{query: long_query})
- |> Ash.read!()
-
- # Should not crash, may return empty or some results
- assert is_list(result)
- end
-
- test "very long search strings: handles extremely long query" do
- {:ok, _member} =
- Mv.Membership.create_member(%{
- first_name: "Test",
- last_name: "User",
- email: "test@example.com"
- })
-
- very_long_query = String.duplicate("test query ", 1000)
-
- result =
- Mv.Membership.Member
- |> Mv.Membership.Member.fuzzy_search(%{query: very_long_query})
- |> Ash.read!()
-
- # Should not crash, may return empty or some results
- assert is_list(result)
- end
-end