mitgliederverwaltung/lib/mv_web/authorization.ex
Moritz ff9c8d2d64
feat: add UI-level authorization helpers
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
2026-01-08 16:16:53 +01:00

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