Add PermissionSets for Group, MemberGroup, MembershipFeeType, MembershipFeeCycle

- Extend permission_sets.ex with resources and pages for new domains
- Adjust HasPermission check for resource/action/scope
- Update roles-and-permissions and implementation-plan docs
- Add permission_sets_test.exs coverage
This commit is contained in:
Moritz 2026-02-03 23:52:09 +01:00
parent 36b7031dca
commit 893f9453bd
5 changed files with 449 additions and 113 deletions

View file

@ -97,6 +97,10 @@ Control CRUD operations on:
- CustomFieldValue (custom field values)
- CustomField (custom field definitions)
- Role (role management)
- Group (group definitions; read all, create/update/destroy admin only)
- MemberGroup (membergroup associations; own_data read :linked, read_only read :all, normal_user/admin create/destroy)
- MembershipFeeType (fee type definitions; all read, admin-only create/update/destroy)
- MembershipFeeCycle (fee cycles; all read, normal_user/admin read+create+update+destroy; manual "Regenerate Cycles" for normal_user and admin)
**4. Page-Level Permissions**
@ -105,6 +109,7 @@ Control access to LiveView pages:
- Show pages (detail views)
- Form pages (create/edit)
- Admin pages
- Settings pages: `/settings` and `/membership_fee_settings` are admin-only (explicit in PermissionSets)
**5. Granular Scopes**
@ -121,6 +126,8 @@ Three scope levels for permissions:
- **Linked Member Email:** Only admins can edit email of member linked to user
- **System Roles:** "Mitglied" role cannot be deleted (is_system_role flag)
- **User-Member Linking:** Only admins can link/unlink users and members
- **User Role Assignment:** Only admins can change a user's role (via `update_user` with `role_id`). Last-admin validation ensures at least one user keeps the Admin role.
- **Settings Pages:** `/settings` and `/membership_fee_settings` are admin-only (explicit in PermissionSets pages).
**7. UI Consistency**
@ -684,6 +691,11 @@ Quick reference table showing what each permission set allows:
| **CustomFieldValue** (all) | - | R | R, C, U, D | R, C, U, D |
| **CustomField** (all) | R | R | R | R, C, U, D |
| **Role** (all) | - | - | - | R, C, U, D |
| **Group** (all) | R | R | R | 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
@ -1195,6 +1207,36 @@ end
*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

View file

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

View file

@ -50,6 +50,7 @@ defmodule Mv.Authorization.Checks.HasPermission do
- **:linked** - Filters based on resource type:
- 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)
- MemberGroup: `member_id == actor.member_id` (MemberGroup.member_id Member.id User.member_id)
## Error Handling
@ -278,36 +279,28 @@ defmodule Mv.Authorization.Checks.HasPermission do
# For :own scope with User resource: id == actor.id
# For :linked scope with Member resource: id == actor.member_id
defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
case {resource_name, record} do
{"User", %{id: user_id}} when not is_nil(user_id) ->
# Check if this user's ID matches the actor's ID (scope :own)
if user_id == actor.id do
{:ok, true}
else
{:ok, false}
end
result =
case {resource_name, record} do
# Scope :own
{"User", %{id: user_id}} when not is_nil(user_id) ->
user_id == actor.id
{"Member", %{id: member_id}} when not is_nil(member_id) ->
# Check if this member's ID matches the actor's member_id
if member_id == actor.member_id do
{:ok, true}
else
{:ok, false}
end
# Scope :linked
{"Member", %{id: member_id}} when not is_nil(member_id) ->
member_id == actor.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
if cfv_member_id == actor.member_id do
{:ok, true}
else
{:ok, false}
end
{"CustomFieldValue", %{member_id: cfv_member_id}} when not is_nil(cfv_member_id) ->
cfv_member_id == actor.member_id
_ ->
# For other cases or when record is not available, return :unknown
# This will cause Ash to use auto_filter instead
{:ok, :unknown}
end
{"MemberGroup", %{member_id: mg_member_id}} when not is_nil(mg_member_id) ->
mg_member_id == actor.member_id
_ ->
:unknown
end
out = if result == :unknown, do: {:ok, :unknown}, else: {:ok, result}
out
end
# 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
case resource_name do
"Member" ->
# User.member_id → Member.id (inverse relationship)
# Filter: member.id == actor.member_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
# User.member_id → Member.id (inverse relationship). Filter: member.id == actor.member_id
linked_filter_by_member_id(actor, :id)
"CustomFieldValue" ->
# CustomFieldValue.member_id → Member.id → User.member_id
# Filter: custom_field_value.member_id == actor.member_id
# If actor has no member_id, return no results
if is_nil(actor.member_id) do
{:filter, expr(false)}
else
{:filter, expr(member_id == ^actor.member_id)}
end
linked_filter_by_member_id(actor, :member_id)
"MemberGroup" ->
# MemberGroup.member_id → Member.id → User.member_id (own linked member's group associations)
linked_filter_by_member_id(actor, :member_id)
_ ->
# Fallback for other resources
@ -372,6 +357,17 @@ defmodule Mv.Authorization.Checks.HasPermission do
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)
defp log_auth_failure(actor, resource, action, reason) do
Logger.debug(fn ->

View file

@ -58,6 +58,18 @@ defmodule Mv.Authorization.PermissionSets do
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 """
Returns the list of all valid permission set names.
@ -94,29 +106,21 @@ defmodule Mv.Authorization.PermissionSets do
def get_permissions(:own_data) do
%{
resources: [
# User: Can read/update own credentials only
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
# All permission sets grant User.update :own to allow password changes.
%{resource: "User", action: :read, scope: :own, granted: true},
%{resource: "User", action: :update, scope: :own, granted: true},
# Member: Can read/update linked member
%{resource: "Member", action: :read, scope: :linked, granted: true},
%{resource: "Member", action: :update, scope: :linked, granted: true},
# CustomFieldValue: Can read/update/create/destroy custom field values of linked member
%{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true},
%{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true},
%{resource: "CustomFieldValue", action: :create, scope: :linked, granted: true},
%{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}
],
resources:
user_own_credentials() ++
[
perm("Member", :read, :linked),
perm("Member", :update, :linked),
perm("CustomFieldValue", :read, :linked),
perm("CustomFieldValue", :update, :linked),
perm("CustomFieldValue", :create, :linked),
perm("CustomFieldValue", :destroy, :linked)
] ++
custom_field_read_all() ++
group_read_all() ++
[perm("MemberGroup", :read, :linked)] ++
membership_fee_type_read_all() ++
membership_fee_cycle_read_all(),
pages: [
# No "/" - Mitglied must not see member index at root (same content as /members).
# Own profile (sidebar links to /users/:id) and own user edit
@ -133,25 +137,17 @@ defmodule Mv.Authorization.PermissionSets do
def get_permissions(:read_only) do
%{
resources: [
# User: Can read/update own credentials only
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
# All permission sets grant User.update :own to allow password changes.
%{resource: "User", action: :read, scope: :own, granted: true},
%{resource: "User", action: :update, scope: :own, granted: true},
# Member: Can read all members, no modifications
%{resource: "Member", action: :read, scope: :all, granted: true},
# CustomFieldValue: Can read all custom field values
%{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}
],
resources:
user_own_credentials() ++
[
perm("Member", :read, :all),
perm("CustomFieldValue", :read, :all)
] ++
custom_field_read_all() ++
group_read_all() ++
[perm("MemberGroup", :read, :all)] ++
membership_fee_type_read_all() ++
membership_fee_cycle_read_all(),
pages: [
"/",
# 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
%{
resources: [
# User: Can read/update own credentials only
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
# All permission sets grant User.update :own to allow password changes.
%{resource: "User", action: :read, scope: :own, granted: true},
%{resource: "User", action: :update, scope: :own, granted: true},
# Member: Full CRUD except destroy (safety)
%{resource: "Member", action: :read, scope: :all, granted: true},
%{resource: "Member", action: :create, scope: :all, granted: true},
%{resource: "Member", action: :update, scope: :all, granted: true},
# Note: destroy intentionally omitted for safety
# CustomFieldValue: Full CRUD
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
# CustomField: Read only (admin manages definitions)
%{resource: "CustomField", action: :read, scope: :all, granted: true},
# Group: Can read all
%{resource: "Group", action: :read, scope: :all, granted: true}
],
resources:
user_own_credentials() ++
[
perm("Member", :read, :all),
perm("Member", :create, :all),
perm("Member", :update, :all),
# destroy intentionally omitted for safety
perm("CustomFieldValue", :read, :all),
perm("CustomFieldValue", :create, :all),
perm("CustomFieldValue", :update, :all),
perm("CustomFieldValue", :destroy, :all)
] ++
custom_field_read_all() ++
[
perm("Group", :read, :all),
perm("Group", :create, :all),
perm("Group", :update, :all),
perm("Group", :destroy, :all)
] ++
[
perm("MemberGroup", :read, :all),
perm("MemberGroup", :create, :all),
perm("MemberGroup", :destroy, :all)
] ++
membership_fee_type_read_all() ++
[
perm("MembershipFeeCycle", :read, :all),
perm("MembershipFeeCycle", :create, :all),
perm("MembershipFeeCycle", :update, :all),
perm("MembershipFeeCycle", :destroy, :all)
],
pages: [
"/",
# 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",
# Groups overview
"/groups",
# Create group
"/groups/new",
# Group detail
"/groups/:slug"
"/groups/:slug",
# Edit group
"/groups/:slug/edit"
]
}
end
@ -264,9 +270,29 @@ defmodule Mv.Authorization.PermissionSets do
%{resource: "Group", action: :read, scope: :all, granted: true},
%{resource: "Group", action: :create, 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: [
# Explicit admin-only pages (for clarity and future restrictions)
"/settings",
"/membership_fee_settings",
# Wildcard: Admin can access all pages
"*"
]

View file

@ -496,6 +496,277 @@ defmodule Mv.Authorization.PermissionSetsTest do
assert "*" in permissions.pages
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
describe "valid_permission_set?/1" do