Role CRUD LiveViews closes #325 #326
3 changed files with 424 additions and 0 deletions
|
|
@ -89,6 +89,9 @@ defmodule MvWeb do
|
||||||
# Core UI components
|
# Core UI components
|
||||||
import MvWeb.CoreComponents
|
import MvWeb.CoreComponents
|
||||||
|
|
||||||
|
# Authorization helpers
|
||||||
|
import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2]
|
||||||
|
|
||||||
# Common modules used in templates
|
# Common modules used in templates
|
||||||
alias Phoenix.LiveView.JS
|
alias Phoenix.LiveView.JS
|
||||||
alias MvWeb.Layouts
|
alias MvWeb.Layouts
|
||||||
|
|
|
||||||
202
lib/mv_web/authorization.ex
Normal file
202
lib/mv_web/authorization.ex
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
defmodule MvWeb.Authorization do
|
||||||
|
@moduledoc """
|
||||||
|
UI-level authorization helpers for LiveView templates.
|
||||||
|
|
||||||
|
These functions check if the current user has permission to perform actions
|
||||||
|
or access pages. They use the same PermissionSets module as the backend policies,
|
||||||
|
ensuring UI and backend authorization are consistent.
|
||||||
|
|
||||||
|
## Usage in Templates
|
||||||
|
|
||||||
|
<!-- Conditional button rendering -->
|
||||||
|
<%= if can?(@current_user, :create, Mv.Membership.Member) do %>
|
||||||
|
<.link patch={~p"/members/new"}>New Member</.link>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Record-level check -->
|
||||||
|
<%= if can?(@current_user, :update, @member) do %>
|
||||||
|
<.button>Edit</.button>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Page access check -->
|
||||||
|
<%= if can_access_page?(@current_user, "/admin/roles") do %>
|
||||||
|
<.link navigate="/admin/roles">Manage Roles</.link>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
All checks are pure function calls using the hardcoded PermissionSets module.
|
||||||
|
No database queries, < 1 microsecond per check.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Mv.Authorization.PermissionSets
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Checks if user has permission for an action on a resource.
|
||||||
|
|
||||||
|
This function has two variants:
|
||||||
|
1. Resource atom: Checks if user has permission for action on resource type
|
||||||
|
2. Record struct: Checks if user has permission for action on specific record (with scope checking)
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
# Resource-level check (atom)
|
||||||
|
iex> admin = %{role: %{permission_set_name: "admin"}}
|
||||||
|
iex> can?(admin, :create, Mv.Membership.Member)
|
||||||
|
true
|
||||||
|
|
||||||
|
iex> mitglied = %{role: %{permission_set_name: "own_data"}}
|
||||||
|
iex> can?(mitglied, :create, Mv.Membership.Member)
|
||||||
|
false
|
||||||
|
|
||||||
|
# Record-level check (struct with scope)
|
||||||
|
iex> user = %{id: "user-123", role: %{permission_set_name: "own_data"}}
|
||||||
|
iex> member = %Member{id: "member-456", user: %User{id: "user-123"}}
|
||||||
|
iex> can?(user, :update, member)
|
||||||
|
true
|
||||||
|
"""
|
||||||
|
@spec can?(map() | nil, atom(), atom() | struct()) :: boolean()
|
||||||
|
def can?(nil, _action, _resource), do: false
|
||||||
|
|
||||||
|
def can?(user, action, resource) when is_atom(action) and is_atom(resource) do
|
||||||
|
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- user,
|
||||||
|
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
|
||||||
|
permissions <- PermissionSets.get_permissions(ps_atom) do
|
||||||
|
resource_name = get_resource_name(resource)
|
||||||
|
|
||||||
|
Enum.any?(permissions.resources, fn perm ->
|
||||||
|
perm.resource == resource_name and perm.action == action and perm.granted
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def can?(user, action, %resource{} = record) when is_atom(action) do
|
||||||
|
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- user,
|
||||||
|
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
|
||||||
|
permissions <- PermissionSets.get_permissions(ps_atom) do
|
||||||
|
resource_name = get_resource_name(resource)
|
||||||
|
|
||||||
|
# Find matching permission
|
||||||
|
matching_perm =
|
||||||
|
Enum.find(permissions.resources, fn perm ->
|
||||||
|
perm.resource == resource_name and perm.action == action and perm.granted
|
||||||
|
end)
|
||||||
|
|
||||||
|
case matching_perm do
|
||||||
|
nil -> false
|
||||||
|
perm -> check_scope(perm.scope, user, record, resource_name)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Checks if user can access a specific page.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> admin = %{role: %{permission_set_name: "admin"}}
|
||||||
|
iex> can_access_page?(admin, "/admin/roles")
|
||||||
|
true
|
||||||
|
|
||||||
|
iex> mitglied = %{role: %{permission_set_name: "own_data"}}
|
||||||
|
iex> can_access_page?(mitglied, "/members")
|
||||||
|
false
|
||||||
|
"""
|
||||||
|
@spec can_access_page?(map() | nil, String.t()) :: boolean()
|
||||||
|
def can_access_page?(nil, _page_path), do: false
|
||||||
|
|
||||||
|
def can_access_page?(user, page_path) do
|
||||||
|
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- user,
|
||||||
|
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
|
||||||
|
permissions <- PermissionSets.get_permissions(ps_atom) do
|
||||||
|
page_matches?(permissions.pages, page_path)
|
||||||
|
else
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if scope allows access to record
|
||||||
|
defp check_scope(:all, _user, _record, _resource_name), do: true
|
||||||
|
|
||||||
|
defp check_scope(:own, user, record, _resource_name) do
|
||||||
|
record.id == user.id
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_scope(:linked, user, record, resource_name) do
|
||||||
|
case resource_name do
|
||||||
|
"Member" -> check_member_linked(user, record)
|
||||||
|
"CustomFieldValue" -> check_custom_field_value_linked(user, record)
|
||||||
|
_ -> check_fallback_linked(user, record)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_member_linked(user, record) do
|
||||||
|
# Member has_one :user (inverse of User belongs_to :member)
|
||||||
|
# Check if member.user.id == user.id (user must be preloaded)
|
||||||
|
case Map.get(record, :user) do
|
||||||
|
%{id: user_id} -> user_id == user.id
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_custom_field_value_linked(user, record) do
|
||||||
|
# Need to traverse: custom_field_value.member.user.id
|
||||||
|
# Note: In UI, custom_field_value should have member.user preloaded
|
||||||
|
case Map.get(record, :member) do
|
||||||
|
%{user: %{id: member_user_id}} -> member_user_id == user.id
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_fallback_linked(user, record) do
|
||||||
|
# Fallback: try user_id or user relationship
|
||||||
|
case Map.get(record, :user_id) do
|
||||||
|
nil -> check_user_relationship_linked(user, record)
|
||||||
|
user_id -> user_id == user.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_user_relationship_linked(user, record) do
|
||||||
|
# Try user relationship
|
||||||
|
case Map.get(record, :user) do
|
||||||
|
%{id: user_id} -> user_id == user.id
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if page path matches any allowed pattern
|
||||||
|
defp page_matches?(allowed_pages, requested_path) do
|
||||||
|
Enum.any?(allowed_pages, fn pattern ->
|
||||||
|
cond do
|
||||||
|
pattern == "*" -> true
|
||||||
|
pattern == requested_path -> true
|
||||||
|
String.contains?(pattern, ":") -> match_pattern?(pattern, requested_path)
|
||||||
|
true -> false
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Match dynamic route pattern
|
||||||
|
defp match_pattern?(pattern, path) do
|
||||||
|
pattern_segments = String.split(pattern, "/", trim: true)
|
||||||
|
path_segments = String.split(path, "/", trim: true)
|
||||||
|
|
||||||
|
if length(pattern_segments) == length(path_segments) do
|
||||||
|
Enum.zip(pattern_segments, path_segments)
|
||||||
|
|> Enum.all?(fn {pattern_seg, path_seg} ->
|
||||||
|
String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Extract resource name from module
|
||||||
|
defp get_resource_name(resource) when is_atom(resource) do
|
||||||
|
resource |> Module.split() |> List.last()
|
||||||
|
end
|
||||||
|
end
|
||||||
219
test/mv_web/authorization_test.exs
Normal file
219
test/mv_web/authorization_test.exs
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
defmodule MvWeb.AuthorizationTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for UI-level authorization helpers.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias MvWeb.Authorization
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
alias Mv.Accounts.User
|
||||||
|
|
||||||
|
describe "can?/3 with resource atom" do
|
||||||
|
test "returns true when user has permission for resource+action" do
|
||||||
|
admin = %{
|
||||||
|
id: "admin-123",
|
||||||
|
role: %{permission_set_name: "admin"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert Authorization.can?(admin, :create, Mv.Membership.Member) == true
|
||||||
|
assert Authorization.can?(admin, :read, Mv.Membership.Member) == true
|
||||||
|
assert Authorization.can?(admin, :update, Mv.Membership.Member) == true
|
||||||
|
assert Authorization.can?(admin, :destroy, Mv.Membership.Member) == true
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns false when user lacks permission" do
|
||||||
|
read_only_user = %{
|
||||||
|
id: "read-only-123",
|
||||||
|
role: %{permission_set_name: "read_only"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert Authorization.can?(read_only_user, :create, Mv.Membership.Member) == false
|
||||||
|
assert Authorization.can?(read_only_user, :read, Mv.Membership.Member) == true
|
||||||
|
assert Authorization.can?(read_only_user, :update, Mv.Membership.Member) == false
|
||||||
|
assert Authorization.can?(read_only_user, :destroy, Mv.Membership.Member) == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns false for nil user" do
|
||||||
|
assert Authorization.can?(nil, :create, Mv.Membership.Member) == false
|
||||||
|
assert Authorization.can?(nil, :read, Mv.Membership.Member) == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "admin can manage roles" do
|
||||||
|
admin = %{
|
||||||
|
id: "admin-123",
|
||||||
|
role: %{permission_set_name: "admin"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert Authorization.can?(admin, :create, Mv.Authorization.Role) == true
|
||||||
|
assert Authorization.can?(admin, :read, Mv.Authorization.Role) == true
|
||||||
|
assert Authorization.can?(admin, :update, Mv.Authorization.Role) == true
|
||||||
|
assert Authorization.can?(admin, :destroy, Mv.Authorization.Role) == true
|
||||||
|
end
|
||||||
|
|
||||||
|
test "non-admin cannot manage roles" do
|
||||||
|
normal_user = %{
|
||||||
|
id: "normal-123",
|
||||||
|
role: %{permission_set_name: "normal_user"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert Authorization.can?(normal_user, :create, Mv.Authorization.Role) == false
|
||||||
|
assert Authorization.can?(normal_user, :read, Mv.Authorization.Role) == false
|
||||||
|
assert Authorization.can?(normal_user, :update, Mv.Authorization.Role) == false
|
||||||
|
assert Authorization.can?(normal_user, :destroy, Mv.Authorization.Role) == false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "can?/3 with record struct - scope :all" do
|
||||||
|
test "admin can update any member" do
|
||||||
|
admin = %{
|
||||||
|
id: "admin-123",
|
||||||
|
role: %{permission_set_name: "admin"}
|
||||||
|
}
|
||||||
|
|
||||||
|
member1 = %Member{id: "member-1", user: %User{id: "other-user"}}
|
||||||
|
member2 = %Member{id: "member-2", user: %User{id: "another-user"}}
|
||||||
|
|
||||||
|
assert Authorization.can?(admin, :update, member1) == true
|
||||||
|
assert Authorization.can?(admin, :update, member2) == true
|
||||||
|
end
|
||||||
|
|
||||||
|
test "normal_user can update any member" do
|
||||||
|
normal_user = %{
|
||||||
|
id: "normal-123",
|
||||||
|
role: %{permission_set_name: "normal_user"}
|
||||||
|
}
|
||||||
|
|
||||||
|
member = %Member{id: "member-1", user: %User{id: "other-user"}}
|
||||||
|
|
||||||
|
assert Authorization.can?(normal_user, :update, member) == true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "can?/3 with record struct - scope :own" do
|
||||||
|
test "user can update own User record" do
|
||||||
|
user = %{
|
||||||
|
id: "user-123",
|
||||||
|
role: %{permission_set_name: "own_data"}
|
||||||
|
}
|
||||||
|
|
||||||
|
own_user_record = %User{id: "user-123"}
|
||||||
|
other_user_record = %User{id: "other-user"}
|
||||||
|
|
||||||
|
assert Authorization.can?(user, :update, own_user_record) == true
|
||||||
|
assert Authorization.can?(user, :update, other_user_record) == false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "can?/3 with record struct - scope :linked" do
|
||||||
|
test "user can update linked member" do
|
||||||
|
user = %{
|
||||||
|
id: "user-123",
|
||||||
|
role: %{permission_set_name: "own_data"}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Member has_one :user (inverse relationship)
|
||||||
|
linked_member = %Member{id: "member-1", user: %User{id: "user-123"}}
|
||||||
|
unlinked_member = %Member{id: "member-2", user: nil}
|
||||||
|
unlinked_member_other = %Member{id: "member-3", user: %User{id: "other-user"}}
|
||||||
|
|
||||||
|
assert Authorization.can?(user, :update, linked_member) == true
|
||||||
|
assert Authorization.can?(user, :update, unlinked_member) == false
|
||||||
|
assert Authorization.can?(user, :update, unlinked_member_other) == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "user can update CustomFieldValue of linked member" do
|
||||||
|
user = %{
|
||||||
|
id: "user-123",
|
||||||
|
role: %{permission_set_name: "own_data"}
|
||||||
|
}
|
||||||
|
|
||||||
|
linked_cfv = %Mv.Membership.CustomFieldValue{
|
||||||
|
id: "cfv-1",
|
||||||
|
member: %Member{id: "member-1", user: %User{id: "user-123"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
unlinked_cfv = %Mv.Membership.CustomFieldValue{
|
||||||
|
id: "cfv-2",
|
||||||
|
member: %Member{id: "member-2", user: nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
unlinked_cfv_other = %Mv.Membership.CustomFieldValue{
|
||||||
|
id: "cfv-3",
|
||||||
|
member: %Member{id: "member-3", user: %User{id: "other-user"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert Authorization.can?(user, :update, linked_cfv) == true
|
||||||
|
assert Authorization.can?(user, :update, unlinked_cfv) == false
|
||||||
|
assert Authorization.can?(user, :update, unlinked_cfv_other) == false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "can_access_page?/2" do
|
||||||
|
test "admin can access all pages via wildcard" do
|
||||||
|
admin = %{
|
||||||
|
id: "admin-123",
|
||||||
|
role: %{permission_set_name: "admin"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert Authorization.can_access_page?(admin, "/admin/roles") == true
|
||||||
|
assert Authorization.can_access_page?(admin, "/members") == true
|
||||||
|
assert Authorization.can_access_page?(admin, "/any/page") == true
|
||||||
|
end
|
||||||
|
|
||||||
|
test "read_only user can access allowed pages" do
|
||||||
|
read_only_user = %{
|
||||||
|
id: "read-only-123",
|
||||||
|
role: %{permission_set_name: "read_only"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert Authorization.can_access_page?(read_only_user, "/") == true
|
||||||
|
assert Authorization.can_access_page?(read_only_user, "/members") == true
|
||||||
|
assert Authorization.can_access_page?(read_only_user, "/members/123") == true
|
||||||
|
assert Authorization.can_access_page?(read_only_user, "/admin/roles") == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "matches dynamic routes correctly" do
|
||||||
|
read_only_user = %{
|
||||||
|
id: "read-only-123",
|
||||||
|
role: %{permission_set_name: "read_only"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert Authorization.can_access_page?(read_only_user, "/members/123") == true
|
||||||
|
assert Authorization.can_access_page?(read_only_user, "/members/abc") == true
|
||||||
|
assert Authorization.can_access_page?(read_only_user, "/members/123/edit") == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns false for nil user" do
|
||||||
|
assert Authorization.can_access_page?(nil, "/members") == false
|
||||||
|
assert Authorization.can_access_page?(nil, "/admin/roles") == false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "error handling" do
|
||||||
|
test "user without role returns false" do
|
||||||
|
user_without_role = %{id: "user-123", role: nil}
|
||||||
|
|
||||||
|
assert Authorization.can?(user_without_role, :create, Mv.Membership.Member) == false
|
||||||
|
assert Authorization.can_access_page?(user_without_role, "/members") == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "user with invalid permission_set_name returns false" do
|
||||||
|
user_with_invalid_permission = %{
|
||||||
|
id: "user-123",
|
||||||
|
role: %{permission_set_name: "invalid_set"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert Authorization.can?(user_with_invalid_permission, :create, Mv.Membership.Member) ==
|
||||||
|
false
|
||||||
|
|
||||||
|
assert Authorization.can_access_page?(user_with_invalid_permission, "/members") == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles missing fields gracefully" do
|
||||||
|
user_missing_role = %{id: "user-123"}
|
||||||
|
|
||||||
|
assert Authorization.can?(user_missing_role, :create, Mv.Membership.Member) == false
|
||||||
|
assert Authorization.can_access_page?(user_missing_role, "/members") == false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Add a link
Reference in a new issue