diff --git a/lib/mv_web.ex b/lib/mv_web.ex index 46e4e8b..8589be1 100644 --- a/lib/mv_web.ex +++ b/lib/mv_web.ex @@ -89,6 +89,9 @@ defmodule MvWeb do # Core UI components import MvWeb.CoreComponents + # Authorization helpers + import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2] + # Common modules used in templates alias Phoenix.LiveView.JS alias MvWeb.Layouts diff --git a/lib/mv_web/authorization.ex b/lib/mv_web/authorization.ex new file mode 100644 index 0000000..95a8524 --- /dev/null +++ b/lib/mv_web/authorization.ex @@ -0,0 +1,206 @@ +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() | Phoenix.VerifiedRoutes.unverified_path()) :: + boolean() + def can_access_page?(nil, _page_path), do: false + + def can_access_page?(user, page_path) do + # Convert verified route to string if needed + page_path_str = if is_binary(page_path), do: page_path, else: to_string(page_path) + + 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_str) + 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 diff --git a/lib/mv_web/components/layouts/navbar.ex b/lib/mv_web/components/layouts/navbar.ex index adc3444..e3e9319 100644 --- a/lib/mv_web/components/layouts/navbar.ex +++ b/lib/mv_web/components/layouts/navbar.ex @@ -7,6 +7,7 @@ defmodule MvWeb.Layouts.Navbar do use MvWeb, :verified_routes alias Mv.Membership + import MvWeb.Authorization attr :current_user, :map, required: true, @@ -26,7 +27,21 @@ defmodule MvWeb.Layouts.Navbar do {@club_name}
+ <.icon name="hero-exclamation-circle" class="size-5" /> + {msg} +
+ <% end %> + <% end %> +