Complete Permissions for Groups, Membership Fees, and User Role Assignment closes #404 #405
5 changed files with 449 additions and 113 deletions
|
|
@ -97,6 +97,10 @@ Control CRUD operations on:
|
||||||
- CustomFieldValue (custom field values)
|
- CustomFieldValue (custom field values)
|
||||||
- CustomField (custom field definitions)
|
- CustomField (custom field definitions)
|
||||||
- Role (role management)
|
- Role (role management)
|
||||||
|
- Group (group definitions; read all, create/update/destroy admin only)
|
||||||
|
- MemberGroup (member–group associations; own_data read :linked, read_only read :all, normal_user/admin create/destroy)
|
||||||
|
- MembershipFeeType (fee type definitions; all read, admin-only create/update/destroy)
|
||||||
|
- MembershipFeeCycle (fee cycles; all read, normal_user/admin read+create+update+destroy; manual "Regenerate Cycles" for normal_user and admin)
|
||||||
|
|
||||||
**4. Page-Level Permissions**
|
**4. Page-Level Permissions**
|
||||||
|
|
||||||
|
|
@ -105,6 +109,7 @@ Control access to LiveView pages:
|
||||||
- Show pages (detail views)
|
- Show pages (detail views)
|
||||||
- Form pages (create/edit)
|
- Form pages (create/edit)
|
||||||
- Admin pages
|
- Admin pages
|
||||||
|
- Settings pages: `/settings` and `/membership_fee_settings` are admin-only (explicit in PermissionSets)
|
||||||
|
|
||||||
**5. Granular Scopes**
|
**5. Granular Scopes**
|
||||||
|
|
||||||
|
|
@ -121,6 +126,8 @@ Three scope levels for permissions:
|
||||||
- **Linked Member Email:** Only admins can edit email of member linked to user
|
- **Linked Member Email:** Only admins can edit email of member linked to user
|
||||||
- **System Roles:** "Mitglied" role cannot be deleted (is_system_role flag)
|
- **System Roles:** "Mitglied" role cannot be deleted (is_system_role flag)
|
||||||
- **User-Member Linking:** Only admins can link/unlink users and members
|
- **User-Member Linking:** Only admins can link/unlink users and members
|
||||||
|
- **User Role Assignment:** Only admins can change a user's role (via `update_user` with `role_id`). Last-admin validation ensures at least one user keeps the Admin role.
|
||||||
|
- **Settings Pages:** `/settings` and `/membership_fee_settings` are admin-only (explicit in PermissionSets pages).
|
||||||
|
|
||||||
**7. UI Consistency**
|
**7. UI Consistency**
|
||||||
|
|
||||||
|
|
@ -684,6 +691,11 @@ Quick reference table showing what each permission set allows:
|
||||||
| **CustomFieldValue** (all) | - | R | R, C, U, D | R, C, U, D |
|
| **CustomFieldValue** (all) | - | R | R, C, U, D | R, C, U, D |
|
||||||
| **CustomField** (all) | R | R | R | R, C, U, D |
|
| **CustomField** (all) | R | R | R | R, C, U, D |
|
||||||
| **Role** (all) | - | - | - | R, C, U, D |
|
| **Role** (all) | - | - | - | R, C, U, D |
|
||||||
|
| **Group** (all) | R | R | R | R, C, U, D |
|
||||||
|
| **MemberGroup** (linked) | R | - | - | - |
|
||||||
|
| **MemberGroup** (all) | - | R | R, C, D | R, C, D |
|
||||||
|
| **MembershipFeeType** (all) | R | R | R | R, C, U, D |
|
||||||
|
| **MembershipFeeCycle** (all) | R | R | R, C, U, D | R, C, U, D |
|
||||||
|
|
||||||
**Legend:** R=Read, C=Create, U=Update, D=Destroy
|
**Legend:** R=Read, C=Create, U=Update, D=Destroy
|
||||||
|
|
||||||
|
|
@ -1195,6 +1207,36 @@ end
|
||||||
|
|
||||||
*Cannot destroy if `is_system_role=true`
|
*Cannot destroy if `is_system_role=true`
|
||||||
|
|
||||||
|
### User Role Assignment (Admin-Only)
|
||||||
|
|
||||||
|
**Location:** `lib/accounts/user.ex` (update_user action), `lib/mv_web/live/user_live/form.ex`
|
||||||
|
|
||||||
|
Only admins can change a user's role. The `update_user` action accepts `role_id`; the User form shows a role dropdown when `can?(actor, :update, Mv.Authorization.Role)`. **Last-admin validation:** If the only non-system admin tries to change their role, the change is rejected with "At least one user must keep the Admin role." (System user is excluded from the admin count.) See [User-Member Linking](#user-member-linking) for the same admin-only pattern.
|
||||||
|
|
||||||
|
### Group Resource Policies
|
||||||
|
|
||||||
|
**Location:** `lib/membership/group.ex`
|
||||||
|
|
||||||
|
Policies use `HasPermission` for read/create/update/destroy. All permission sets can read; only admin can create, update, destroy. No bypass (scope :all only in PermissionSets).
|
||||||
|
|
||||||
|
### MemberGroup Resource Policies
|
||||||
|
|
||||||
|
**Location:** `lib/membership/member_group.ex`
|
||||||
|
|
||||||
|
Bypass for read with `expr(member_id == ^actor(:member_id))` (own_data list); HasPermission for read (read_only/normal_user/admin :all) and create/destroy (normal_user + admin only). HasPermission applies `:linked` scope for MemberGroup (see HasPermission apply_scope).
|
||||||
|
|
||||||
|
### MembershipFeeType Resource Policies
|
||||||
|
|
||||||
|
**Location:** `lib/membership_fees/membership_fee_type.ex`
|
||||||
|
|
||||||
|
Policies use `HasPermission` for read/create/update/destroy. All permission sets can read; only admin can create, update, destroy.
|
||||||
|
|
||||||
|
### MembershipFeeCycle Resource Policies
|
||||||
|
|
||||||
|
**Location:** `lib/membership_fees/membership_fee_cycle.ex`
|
||||||
|
|
||||||
|
Policies use `HasPermission` for read/create/update/destroy. All can read; read_only cannot update/create/destroy; normal_user and admin can read, create, update, and destroy (including mark_as_paid and manual "Regenerate Cycles" in the member detail view; UI button is shown when `can_create_cycle`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Page Permission System
|
## Page Permission System
|
||||||
|
|
|
||||||
|
|
@ -78,10 +78,11 @@ Stored in database `roles` table, each referencing a `permission_set_name`:
|
||||||
- ✅ Hardcoded PermissionSets module with 4 permission sets
|
- ✅ Hardcoded PermissionSets module with 4 permission sets
|
||||||
- ✅ Role database table and CRUD interface
|
- ✅ Role database table and CRUD interface
|
||||||
- ✅ Custom Ash Policy Check (`HasPermission`) that reads from PermissionSets
|
- ✅ Custom Ash Policy Check (`HasPermission`) that reads from PermissionSets
|
||||||
- ✅ Policies on all resources (Member, User, CustomFieldValue, CustomField, Role)
|
- ✅ Policies on all resources (Member, User, CustomFieldValue, CustomField, Role, Group, MemberGroup, MembershipFeeType, MembershipFeeCycle)
|
||||||
- ✅ Page-level permissions via Phoenix Plug
|
- ✅ Page-level permissions via Phoenix Plug (including admin-only `/settings` and `/membership_fee_settings`)
|
||||||
- ✅ UI authorization helpers for conditional rendering
|
- ✅ UI authorization helpers for conditional rendering
|
||||||
- ✅ Special case: Member email validation for linked users
|
- ✅ Special case: Member email validation for linked users
|
||||||
|
- ✅ User role assignment: admin-only `role_id` in update_user; Last-Admin validation; role dropdown in User form when `can?(actor, :update, Role)`
|
||||||
- ✅ Seed data for 5 roles
|
- ✅ Seed data for 5 roles
|
||||||
|
|
||||||
**Benefits of Hardcoded Approach:**
|
**Benefits of Hardcoded Approach:**
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
- **:linked** - Filters based on resource type:
|
- **:linked** - Filters based on resource type:
|
||||||
- Member: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
|
- Member: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
|
||||||
- CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id → Member.id → User.member_id)
|
- CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id → Member.id → User.member_id)
|
||||||
|
- MemberGroup: `member_id == actor.member_id` (MemberGroup.member_id → Member.id → User.member_id)
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
|
|
@ -278,36 +279,28 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
# For :own scope with User resource: id == actor.id
|
# For :own scope with User resource: id == actor.id
|
||||||
# For :linked scope with Member resource: id == actor.member_id
|
# For :linked scope with Member resource: id == actor.member_id
|
||||||
defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
|
defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
|
||||||
case {resource_name, record} do
|
result =
|
||||||
{"User", %{id: user_id}} when not is_nil(user_id) ->
|
case {resource_name, record} do
|
||||||
# Check if this user's ID matches the actor's ID (scope :own)
|
# Scope :own
|
||||||
if user_id == actor.id do
|
{"User", %{id: user_id}} when not is_nil(user_id) ->
|
||||||
{:ok, true}
|
user_id == actor.id
|
||||||
else
|
|
||||||
{:ok, false}
|
|
||||||
end
|
|
||||||
|
|
||||||
{"Member", %{id: member_id}} when not is_nil(member_id) ->
|
# Scope :linked
|
||||||
# Check if this member's ID matches the actor's member_id
|
{"Member", %{id: member_id}} when not is_nil(member_id) ->
|
||||||
if member_id == actor.member_id do
|
member_id == actor.member_id
|
||||||
{:ok, true}
|
|
||||||
else
|
|
||||||
{:ok, false}
|
|
||||||
end
|
|
||||||
|
|
||||||
{"CustomFieldValue", %{member_id: cfv_member_id}} when not is_nil(cfv_member_id) ->
|
{"CustomFieldValue", %{member_id: cfv_member_id}} when not is_nil(cfv_member_id) ->
|
||||||
# Check if this CFV's member_id matches the actor's member_id
|
cfv_member_id == actor.member_id
|
||||||
if cfv_member_id == actor.member_id do
|
|
||||||
{:ok, true}
|
|
||||||
else
|
|
||||||
{:ok, false}
|
|
||||||
end
|
|
||||||
|
|
||||||
_ ->
|
{"MemberGroup", %{member_id: mg_member_id}} when not is_nil(mg_member_id) ->
|
||||||
# For other cases or when record is not available, return :unknown
|
mg_member_id == actor.member_id
|
||||||
# This will cause Ash to use auto_filter instead
|
|
||||||
{:ok, :unknown}
|
_ ->
|
||||||
end
|
:unknown
|
||||||
|
end
|
||||||
|
|
||||||
|
out = if result == :unknown, do: {:ok, :unknown}, else: {:ok, result}
|
||||||
|
out
|
||||||
end
|
end
|
||||||
|
|
||||||
# Extract resource name from module (e.g., Mv.Membership.Member -> "Member")
|
# Extract resource name from module (e.g., Mv.Membership.Member -> "Member")
|
||||||
|
|
@ -347,24 +340,16 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
defp apply_scope(:linked, actor, resource_name) do
|
defp apply_scope(:linked, actor, resource_name) do
|
||||||
case resource_name do
|
case resource_name do
|
||||||
"Member" ->
|
"Member" ->
|
||||||
# User.member_id → Member.id (inverse relationship)
|
# User.member_id → Member.id (inverse relationship). Filter: member.id == actor.member_id
|
||||||
# Filter: member.id == actor.member_id
|
linked_filter_by_member_id(actor, :id)
|
||||||
# If actor has no member_id, return no results (use false or impossible condition)
|
|
||||||
if is_nil(actor.member_id) do
|
|
||||||
{:filter, expr(false)}
|
|
||||||
else
|
|
||||||
{:filter, expr(id == ^actor.member_id)}
|
|
||||||
end
|
|
||||||
|
|
||||||
"CustomFieldValue" ->
|
"CustomFieldValue" ->
|
||||||
# CustomFieldValue.member_id → Member.id → User.member_id
|
# CustomFieldValue.member_id → Member.id → User.member_id
|
||||||
# Filter: custom_field_value.member_id == actor.member_id
|
linked_filter_by_member_id(actor, :member_id)
|
||||||
# If actor has no member_id, return no results
|
|
||||||
if is_nil(actor.member_id) do
|
"MemberGroup" ->
|
||||||
{:filter, expr(false)}
|
# MemberGroup.member_id → Member.id → User.member_id (own linked member's group associations)
|
||||||
else
|
linked_filter_by_member_id(actor, :member_id)
|
||||||
{:filter, expr(member_id == ^actor.member_id)}
|
|
||||||
end
|
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
# Fallback for other resources
|
# Fallback for other resources
|
||||||
|
|
@ -372,6 +357,17 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Returns {:filter, expr(false)} if actor has no member_id; otherwise {:filter, expr(field == ^actor.member_id)}.
|
||||||
|
# Used for :linked scope on Member (field :id), CustomFieldValue and MemberGroup (field :member_id).
|
||||||
|
defp linked_filter_by_member_id(actor, _field) when is_nil(actor.member_id) do
|
||||||
|
{:filter, expr(false)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp linked_filter_by_member_id(actor, :id), do: {:filter, expr(id == ^actor.member_id)}
|
||||||
|
|
||||||
|
defp linked_filter_by_member_id(actor, :member_id),
|
||||||
|
do: {:filter, expr(member_id == ^actor.member_id)}
|
||||||
|
|
||||||
# Log authorization failures for debugging (lazy evaluation)
|
# Log authorization failures for debugging (lazy evaluation)
|
||||||
defp log_auth_failure(actor, resource, action, reason) do
|
defp log_auth_failure(actor, resource, action, reason) do
|
||||||
Logger.debug(fn ->
|
Logger.debug(fn ->
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,18 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
pages: [String.t()]
|
pages: [String.t()]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# DRY helpers for shared resource permission lists (used in own_data, read_only, normal_user)
|
||||||
|
defp perm(resource, action, scope),
|
||||||
|
do: %{resource: resource, action: action, scope: scope, granted: true}
|
||||||
|
|
||||||
|
# User: read/update own credentials only (all non-admin sets allow password changes)
|
||||||
|
defp user_own_credentials, do: [perm("User", :read, :own), perm("User", :update, :own)]
|
||||||
|
|
||||||
|
defp group_read_all, do: [perm("Group", :read, :all)]
|
||||||
|
defp custom_field_read_all, do: [perm("CustomField", :read, :all)]
|
||||||
|
defp membership_fee_type_read_all, do: [perm("MembershipFeeType", :read, :all)]
|
||||||
|
defp membership_fee_cycle_read_all, do: [perm("MembershipFeeCycle", :read, :all)]
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the list of all valid permission set names.
|
Returns the list of all valid permission set names.
|
||||||
|
|
||||||
|
|
@ -94,29 +106,21 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
|
|
||||||
def get_permissions(:own_data) do
|
def get_permissions(:own_data) do
|
||||||
%{
|
%{
|
||||||
resources: [
|
resources:
|
||||||
# User: Can read/update own credentials only
|
user_own_credentials() ++
|
||||||
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
|
[
|
||||||
# All permission sets grant User.update :own to allow password changes.
|
perm("Member", :read, :linked),
|
||||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
perm("Member", :update, :linked),
|
||||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
perm("CustomFieldValue", :read, :linked),
|
||||||
|
perm("CustomFieldValue", :update, :linked),
|
||||||
# Member: Can read/update linked member
|
perm("CustomFieldValue", :create, :linked),
|
||||||
%{resource: "Member", action: :read, scope: :linked, granted: true},
|
perm("CustomFieldValue", :destroy, :linked)
|
||||||
%{resource: "Member", action: :update, scope: :linked, granted: true},
|
] ++
|
||||||
|
custom_field_read_all() ++
|
||||||
# CustomFieldValue: Can read/update/create/destroy custom field values of linked member
|
group_read_all() ++
|
||||||
%{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true},
|
[perm("MemberGroup", :read, :linked)] ++
|
||||||
%{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true},
|
membership_fee_type_read_all() ++
|
||||||
%{resource: "CustomFieldValue", action: :create, scope: :linked, granted: true},
|
membership_fee_cycle_read_all(),
|
||||||
%{resource: "CustomFieldValue", action: :destroy, scope: :linked, granted: true},
|
|
||||||
|
|
||||||
# CustomField: Can read all (needed for forms)
|
|
||||||
%{resource: "CustomField", action: :read, scope: :all, granted: true},
|
|
||||||
|
|
||||||
# Group: Can read all (needed for viewing groups)
|
|
||||||
%{resource: "Group", action: :read, scope: :all, granted: true}
|
|
||||||
],
|
|
||||||
pages: [
|
pages: [
|
||||||
# No "/" - Mitglied must not see member index at root (same content as /members).
|
# No "/" - Mitglied must not see member index at root (same content as /members).
|
||||||
# Own profile (sidebar links to /users/:id) and own user edit
|
# Own profile (sidebar links to /users/:id) and own user edit
|
||||||
|
|
@ -133,25 +137,17 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
|
|
||||||
def get_permissions(:read_only) do
|
def get_permissions(:read_only) do
|
||||||
%{
|
%{
|
||||||
resources: [
|
resources:
|
||||||
# User: Can read/update own credentials only
|
user_own_credentials() ++
|
||||||
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
|
[
|
||||||
# All permission sets grant User.update :own to allow password changes.
|
perm("Member", :read, :all),
|
||||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
perm("CustomFieldValue", :read, :all)
|
||||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
] ++
|
||||||
|
custom_field_read_all() ++
|
||||||
# Member: Can read all members, no modifications
|
group_read_all() ++
|
||||||
%{resource: "Member", action: :read, scope: :all, granted: true},
|
[perm("MemberGroup", :read, :all)] ++
|
||||||
|
membership_fee_type_read_all() ++
|
||||||
# CustomFieldValue: Can read all custom field values
|
membership_fee_cycle_read_all(),
|
||||||
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
|
|
||||||
|
|
||||||
# CustomField: Can read all
|
|
||||||
%{resource: "CustomField", action: :read, scope: :all, granted: true},
|
|
||||||
|
|
||||||
# Group: Can read all
|
|
||||||
%{resource: "Group", action: :read, scope: :all, granted: true}
|
|
||||||
],
|
|
||||||
pages: [
|
pages: [
|
||||||
"/",
|
"/",
|
||||||
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
|
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
|
||||||
|
|
@ -176,31 +172,37 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
|
|
||||||
def get_permissions(:normal_user) do
|
def get_permissions(:normal_user) do
|
||||||
%{
|
%{
|
||||||
resources: [
|
resources:
|
||||||
# User: Can read/update own credentials only
|
user_own_credentials() ++
|
||||||
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
|
[
|
||||||
# All permission sets grant User.update :own to allow password changes.
|
perm("Member", :read, :all),
|
||||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
perm("Member", :create, :all),
|
||||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
perm("Member", :update, :all),
|
||||||
|
# destroy intentionally omitted for safety
|
||||||
# Member: Full CRUD except destroy (safety)
|
perm("CustomFieldValue", :read, :all),
|
||||||
%{resource: "Member", action: :read, scope: :all, granted: true},
|
perm("CustomFieldValue", :create, :all),
|
||||||
%{resource: "Member", action: :create, scope: :all, granted: true},
|
perm("CustomFieldValue", :update, :all),
|
||||||
%{resource: "Member", action: :update, scope: :all, granted: true},
|
perm("CustomFieldValue", :destroy, :all)
|
||||||
# Note: destroy intentionally omitted for safety
|
] ++
|
||||||
|
custom_field_read_all() ++
|
||||||
# CustomFieldValue: Full CRUD
|
[
|
||||||
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
|
perm("Group", :read, :all),
|
||||||
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
|
perm("Group", :create, :all),
|
||||||
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
|
perm("Group", :update, :all),
|
||||||
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
|
perm("Group", :destroy, :all)
|
||||||
|
] ++
|
||||||
# CustomField: Read only (admin manages definitions)
|
[
|
||||||
%{resource: "CustomField", action: :read, scope: :all, granted: true},
|
perm("MemberGroup", :read, :all),
|
||||||
|
perm("MemberGroup", :create, :all),
|
||||||
# Group: Can read all
|
perm("MemberGroup", :destroy, :all)
|
||||||
%{resource: "Group", action: :read, scope: :all, granted: true}
|
] ++
|
||||||
],
|
membership_fee_type_read_all() ++
|
||||||
|
[
|
||||||
|
perm("MembershipFeeCycle", :read, :all),
|
||||||
|
perm("MembershipFeeCycle", :create, :all),
|
||||||
|
perm("MembershipFeeCycle", :update, :all),
|
||||||
|
perm("MembershipFeeCycle", :destroy, :all)
|
||||||
|
],
|
||||||
pages: [
|
pages: [
|
||||||
"/",
|
"/",
|
||||||
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
|
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
|
||||||
|
|
@ -221,8 +223,12 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
"/custom_field_values/:id/edit",
|
"/custom_field_values/:id/edit",
|
||||||
# Groups overview
|
# Groups overview
|
||||||
"/groups",
|
"/groups",
|
||||||
|
# Create group
|
||||||
|
"/groups/new",
|
||||||
# Group detail
|
# Group detail
|
||||||
"/groups/:slug"
|
"/groups/:slug",
|
||||||
|
# Edit group
|
||||||
|
"/groups/:slug/edit"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
@ -264,9 +270,29 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
%{resource: "Group", action: :read, scope: :all, granted: true},
|
%{resource: "Group", action: :read, scope: :all, granted: true},
|
||||||
%{resource: "Group", action: :create, scope: :all, granted: true},
|
%{resource: "Group", action: :create, scope: :all, granted: true},
|
||||||
%{resource: "Group", action: :update, scope: :all, granted: true},
|
%{resource: "Group", action: :update, scope: :all, granted: true},
|
||||||
%{resource: "Group", action: :destroy, scope: :all, granted: true}
|
%{resource: "Group", action: :destroy, scope: :all, granted: true},
|
||||||
|
|
||||||
|
# MemberGroup: Full CRUD
|
||||||
|
%{resource: "MemberGroup", action: :read, scope: :all, granted: true},
|
||||||
|
%{resource: "MemberGroup", action: :create, scope: :all, granted: true},
|
||||||
|
%{resource: "MemberGroup", action: :destroy, scope: :all, granted: true},
|
||||||
|
|
||||||
|
# MembershipFeeType: Full CRUD (admin manages fee types)
|
||||||
|
%{resource: "MembershipFeeType", action: :read, scope: :all, granted: true},
|
||||||
|
%{resource: "MembershipFeeType", action: :create, scope: :all, granted: true},
|
||||||
|
%{resource: "MembershipFeeType", action: :update, scope: :all, granted: true},
|
||||||
|
%{resource: "MembershipFeeType", action: :destroy, scope: :all, granted: true},
|
||||||
|
|
||||||
|
# MembershipFeeCycle: Full CRUD
|
||||||
|
%{resource: "MembershipFeeCycle", action: :read, scope: :all, granted: true},
|
||||||
|
%{resource: "MembershipFeeCycle", action: :create, scope: :all, granted: true},
|
||||||
|
%{resource: "MembershipFeeCycle", action: :update, scope: :all, granted: true},
|
||||||
|
%{resource: "MembershipFeeCycle", action: :destroy, scope: :all, granted: true}
|
||||||
],
|
],
|
||||||
pages: [
|
pages: [
|
||||||
|
# Explicit admin-only pages (for clarity and future restrictions)
|
||||||
|
"/settings",
|
||||||
|
"/membership_fee_settings",
|
||||||
# Wildcard: Admin can access all pages
|
# Wildcard: Admin can access all pages
|
||||||
"*"
|
"*"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -496,6 +496,277 @@ defmodule Mv.Authorization.PermissionSetsTest do
|
||||||
|
|
||||||
assert "*" in permissions.pages
|
assert "*" in permissions.pages
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "admin pages include explicit /settings and /membership_fee_settings" do
|
||||||
|
permissions = PermissionSets.get_permissions(:admin)
|
||||||
|
|
||||||
|
assert "/settings" in permissions.pages
|
||||||
|
assert "/membership_fee_settings" in permissions.pages
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_permissions/1 - MemberGroup resource" do
|
||||||
|
test "own_data has MemberGroup read with scope :linked only" do
|
||||||
|
permissions = PermissionSets.get_permissions(:own_data)
|
||||||
|
|
||||||
|
mg_read =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :read
|
||||||
|
end)
|
||||||
|
|
||||||
|
mg_create =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :create
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mg_read != nil
|
||||||
|
assert mg_read.scope == :linked
|
||||||
|
assert mg_read.granted == true
|
||||||
|
assert mg_create == nil || mg_create.granted == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "read_only has MemberGroup read with scope :all, no create/destroy" do
|
||||||
|
permissions = PermissionSets.get_permissions(:read_only)
|
||||||
|
|
||||||
|
mg_read =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :read
|
||||||
|
end)
|
||||||
|
|
||||||
|
mg_create =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :create
|
||||||
|
end)
|
||||||
|
|
||||||
|
mg_destroy =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :destroy
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mg_read != nil
|
||||||
|
assert mg_read.scope == :all
|
||||||
|
assert mg_read.granted == true
|
||||||
|
assert mg_create == nil || mg_create.granted == false
|
||||||
|
assert mg_destroy == nil || mg_destroy.granted == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "normal_user has MemberGroup read/create/destroy with scope :all" do
|
||||||
|
permissions = PermissionSets.get_permissions(:normal_user)
|
||||||
|
|
||||||
|
mg_read =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :read
|
||||||
|
end)
|
||||||
|
|
||||||
|
mg_create =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :create
|
||||||
|
end)
|
||||||
|
|
||||||
|
mg_destroy =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :destroy
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mg_read != nil
|
||||||
|
assert mg_read.scope == :all
|
||||||
|
assert mg_read.granted == true
|
||||||
|
assert mg_create != nil
|
||||||
|
assert mg_create.scope == :all
|
||||||
|
assert mg_create.granted == true
|
||||||
|
assert mg_destroy != nil
|
||||||
|
assert mg_destroy.scope == :all
|
||||||
|
assert mg_destroy.granted == true
|
||||||
|
end
|
||||||
|
|
||||||
|
test "admin has MemberGroup read/create/destroy with scope :all" do
|
||||||
|
permissions = PermissionSets.get_permissions(:admin)
|
||||||
|
|
||||||
|
mg_read =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :read
|
||||||
|
end)
|
||||||
|
|
||||||
|
mg_create =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :create
|
||||||
|
end)
|
||||||
|
|
||||||
|
mg_destroy =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MemberGroup" && p.action == :destroy
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mg_read != nil
|
||||||
|
assert mg_read.scope == :all
|
||||||
|
assert mg_read.granted == true
|
||||||
|
assert mg_create != nil
|
||||||
|
assert mg_create.granted == true
|
||||||
|
assert mg_destroy != nil
|
||||||
|
assert mg_destroy.granted == true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_permissions/1 - MembershipFeeType resource" do
|
||||||
|
test "all permission sets have MembershipFeeType read with scope :all" do
|
||||||
|
for set <- PermissionSets.all_permission_sets() do
|
||||||
|
permissions = PermissionSets.get_permissions(set)
|
||||||
|
|
||||||
|
mft_read =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeType" && p.action == :read
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mft_read != nil, "Permission set #{set} should have MembershipFeeType read"
|
||||||
|
assert mft_read.scope == :all
|
||||||
|
assert mft_read.granted == true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "only admin has MembershipFeeType create/update/destroy" do
|
||||||
|
for set <- [:own_data, :read_only, :normal_user] do
|
||||||
|
permissions = PermissionSets.get_permissions(set)
|
||||||
|
|
||||||
|
mft_create =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeType" && p.action == :create
|
||||||
|
end)
|
||||||
|
|
||||||
|
mft_update =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeType" && p.action == :update
|
||||||
|
end)
|
||||||
|
|
||||||
|
mft_destroy =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeType" && p.action == :destroy
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mft_create == nil || mft_create.granted == false,
|
||||||
|
"Permission set #{set} should not allow MembershipFeeType create"
|
||||||
|
|
||||||
|
assert mft_update == nil || mft_update.granted == false,
|
||||||
|
"Permission set #{set} should not allow MembershipFeeType update"
|
||||||
|
|
||||||
|
assert mft_destroy == nil || mft_destroy.granted == false,
|
||||||
|
"Permission set #{set} should not allow MembershipFeeType destroy"
|
||||||
|
end
|
||||||
|
|
||||||
|
admin_permissions = PermissionSets.get_permissions(:admin)
|
||||||
|
|
||||||
|
mft_create =
|
||||||
|
Enum.find(admin_permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeType" && p.action == :create
|
||||||
|
end)
|
||||||
|
|
||||||
|
mft_update =
|
||||||
|
Enum.find(admin_permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeType" && p.action == :update
|
||||||
|
end)
|
||||||
|
|
||||||
|
mft_destroy =
|
||||||
|
Enum.find(admin_permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeType" && p.action == :destroy
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mft_create != nil
|
||||||
|
assert mft_create.scope == :all
|
||||||
|
assert mft_create.granted == true
|
||||||
|
assert mft_update != nil
|
||||||
|
assert mft_update.granted == true
|
||||||
|
assert mft_destroy != nil
|
||||||
|
assert mft_destroy.granted == true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_permissions/1 - MembershipFeeCycle resource" do
|
||||||
|
test "all permission sets have MembershipFeeCycle read with scope :all" do
|
||||||
|
for set <- PermissionSets.all_permission_sets() do
|
||||||
|
permissions = PermissionSets.get_permissions(set)
|
||||||
|
|
||||||
|
mfc_read =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeCycle" && p.action == :read
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mfc_read != nil, "Permission set #{set} should have MembershipFeeCycle read"
|
||||||
|
assert mfc_read.scope == :all
|
||||||
|
assert mfc_read.granted == true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "read_only has MembershipFeeCycle read only, no update" do
|
||||||
|
permissions = PermissionSets.get_permissions(:read_only)
|
||||||
|
|
||||||
|
mfc_update =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeCycle" && p.action == :update
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mfc_update == nil || mfc_update.granted == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "normal_user has MembershipFeeCycle read/create/update/destroy with scope :all" do
|
||||||
|
permissions = PermissionSets.get_permissions(:normal_user)
|
||||||
|
|
||||||
|
mfc_read =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeCycle" && p.action == :read
|
||||||
|
end)
|
||||||
|
|
||||||
|
mfc_create =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeCycle" && p.action == :create
|
||||||
|
end)
|
||||||
|
|
||||||
|
mfc_update =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeCycle" && p.action == :update
|
||||||
|
end)
|
||||||
|
|
||||||
|
mfc_destroy =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeCycle" && p.action == :destroy
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mfc_read != nil && mfc_read.granted == true
|
||||||
|
assert mfc_create != nil && mfc_create.scope == :all && mfc_create.granted == true
|
||||||
|
assert mfc_update != nil && mfc_update.granted == true
|
||||||
|
assert mfc_destroy != nil && mfc_destroy.scope == :all && mfc_destroy.granted == true
|
||||||
|
end
|
||||||
|
|
||||||
|
test "admin has MembershipFeeCycle read/create/update/destroy with scope :all" do
|
||||||
|
permissions = PermissionSets.get_permissions(:admin)
|
||||||
|
|
||||||
|
mfc_read =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeCycle" && p.action == :read
|
||||||
|
end)
|
||||||
|
|
||||||
|
mfc_create =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeCycle" && p.action == :create
|
||||||
|
end)
|
||||||
|
|
||||||
|
mfc_update =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeCycle" && p.action == :update
|
||||||
|
end)
|
||||||
|
|
||||||
|
mfc_destroy =
|
||||||
|
Enum.find(permissions.resources, fn p ->
|
||||||
|
p.resource == "MembershipFeeCycle" && p.action == :destroy
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert mfc_read != nil
|
||||||
|
assert mfc_read.granted == true
|
||||||
|
assert mfc_create != nil
|
||||||
|
assert mfc_create.granted == true
|
||||||
|
assert mfc_update != nil
|
||||||
|
assert mfc_update.granted == true
|
||||||
|
assert mfc_destroy != nil
|
||||||
|
assert mfc_destroy.granted == true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "valid_permission_set?/1" do
|
describe "valid_permission_set?/1" do
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue