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 <%= if can?(@current_user, :create, Mv.Membership.Member) do %> <.link patch={~p"/members/new"}>New Member <% end %> <%= if can?(@current_user, :update, @member) do %> <.button>Edit <% end %> <%= if can_access_page?(@current_user, "/admin/roles") do %> <.link navigate="/admin/roles">Manage Roles <% 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