Implement MvWeb.Authorization module with can?/3 and can_access_page?/2 functions for conditional rendering in LiveView templates. - can?/3 supports both resource atoms and record structs with scope checking - can_access_page?/2 checks page access permissions - All functions use PermissionSets module for consistency with backend - Graceful handling of nil users and invalid permission sets - Comprehensive test coverage with 17 test cases
202 lines
6.5 KiB
Elixir
202 lines
6.5 KiB
Elixir
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
|