173 lines
5.7 KiB
Elixir
173 lines
5.7 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
|
|
alias MvWeb.Plugs.CheckPagePermission
|
|
|
|
@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() | Phoenix.VerifiedRoutes.unverified_path()) ::
|
|
boolean()
|
|
def can_access_page?(nil, _page_path), do: false
|
|
|
|
def can_access_page?(user, page_path) do
|
|
# Delegate to plug logic so UI uses same rules (reserved "new", own/linked path checks).
|
|
page_path_str = if is_binary(page_path), do: page_path, else: to_string(page_path)
|
|
CheckPagePermission.user_can_access_page?(user, page_path_str, router: MvWeb.Router)
|
|
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
|
|
|
|
# Extract resource name from module
|
|
defp get_resource_name(resource) when is_atom(resource) do
|
|
resource |> Module.split() |> List.last()
|
|
end
|
|
end
|