From c6b5b7a22e9108b0deb0217148c0abe074f28e0d Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 6 Jan 2026 22:34:21 +0100 Subject: [PATCH] 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 --- lib/mv_web.ex | 3 + lib/mv_web/authorization.ex | 202 ++++++++++++++++++++++++++ test/mv_web/authorization_test.exs | 219 +++++++++++++++++++++++++++++ 3 files changed, 424 insertions(+) create mode 100644 lib/mv_web/authorization.ex create mode 100644 test/mv_web/authorization_test.exs 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..18ecd70 --- /dev/null +++ b/lib/mv_web/authorization.ex @@ -0,0 +1,202 @@ +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 diff --git a/test/mv_web/authorization_test.exs b/test/mv_web/authorization_test.exs new file mode 100644 index 0000000..17bbe4b --- /dev/null +++ b/test/mv_web/authorization_test.exs @@ -0,0 +1,219 @@ +defmodule MvWeb.AuthorizationTest do + @moduledoc """ + Tests for UI-level authorization helpers. + """ + use ExUnit.Case, async: true + + alias MvWeb.Authorization + alias Mv.Membership.Member + alias Mv.Accounts.User + + describe "can?/3 with resource atom" do + test "returns true when user has permission for resource+action" do + admin = %{ + id: "admin-123", + role: %{permission_set_name: "admin"} + } + + assert Authorization.can?(admin, :create, Mv.Membership.Member) == true + assert Authorization.can?(admin, :read, Mv.Membership.Member) == true + assert Authorization.can?(admin, :update, Mv.Membership.Member) == true + assert Authorization.can?(admin, :destroy, Mv.Membership.Member) == true + end + + test "returns false when user lacks permission" do + read_only_user = %{ + id: "read-only-123", + role: %{permission_set_name: "read_only"} + } + + assert Authorization.can?(read_only_user, :create, Mv.Membership.Member) == false + assert Authorization.can?(read_only_user, :read, Mv.Membership.Member) == true + assert Authorization.can?(read_only_user, :update, Mv.Membership.Member) == false + assert Authorization.can?(read_only_user, :destroy, Mv.Membership.Member) == false + end + + test "returns false for nil user" do + assert Authorization.can?(nil, :create, Mv.Membership.Member) == false + assert Authorization.can?(nil, :read, Mv.Membership.Member) == false + end + + test "admin can manage roles" do + admin = %{ + id: "admin-123", + role: %{permission_set_name: "admin"} + } + + assert Authorization.can?(admin, :create, Mv.Authorization.Role) == true + assert Authorization.can?(admin, :read, Mv.Authorization.Role) == true + assert Authorization.can?(admin, :update, Mv.Authorization.Role) == true + assert Authorization.can?(admin, :destroy, Mv.Authorization.Role) == true + end + + test "non-admin cannot manage roles" do + normal_user = %{ + id: "normal-123", + role: %{permission_set_name: "normal_user"} + } + + assert Authorization.can?(normal_user, :create, Mv.Authorization.Role) == false + assert Authorization.can?(normal_user, :read, Mv.Authorization.Role) == false + assert Authorization.can?(normal_user, :update, Mv.Authorization.Role) == false + assert Authorization.can?(normal_user, :destroy, Mv.Authorization.Role) == false + end + end + + describe "can?/3 with record struct - scope :all" do + test "admin can update any member" do + admin = %{ + id: "admin-123", + role: %{permission_set_name: "admin"} + } + + member1 = %Member{id: "member-1", user: %User{id: "other-user"}} + member2 = %Member{id: "member-2", user: %User{id: "another-user"}} + + assert Authorization.can?(admin, :update, member1) == true + assert Authorization.can?(admin, :update, member2) == true + end + + test "normal_user can update any member" do + normal_user = %{ + id: "normal-123", + role: %{permission_set_name: "normal_user"} + } + + member = %Member{id: "member-1", user: %User{id: "other-user"}} + + assert Authorization.can?(normal_user, :update, member) == true + end + end + + describe "can?/3 with record struct - scope :own" do + test "user can update own User record" do + user = %{ + id: "user-123", + role: %{permission_set_name: "own_data"} + } + + own_user_record = %User{id: "user-123"} + other_user_record = %User{id: "other-user"} + + assert Authorization.can?(user, :update, own_user_record) == true + assert Authorization.can?(user, :update, other_user_record) == false + end + end + + describe "can?/3 with record struct - scope :linked" do + test "user can update linked member" do + user = %{ + id: "user-123", + role: %{permission_set_name: "own_data"} + } + + # Member has_one :user (inverse relationship) + linked_member = %Member{id: "member-1", user: %User{id: "user-123"}} + unlinked_member = %Member{id: "member-2", user: nil} + unlinked_member_other = %Member{id: "member-3", user: %User{id: "other-user"}} + + assert Authorization.can?(user, :update, linked_member) == true + assert Authorization.can?(user, :update, unlinked_member) == false + assert Authorization.can?(user, :update, unlinked_member_other) == false + end + + test "user can update CustomFieldValue of linked member" do + user = %{ + id: "user-123", + role: %{permission_set_name: "own_data"} + } + + linked_cfv = %Mv.Membership.CustomFieldValue{ + id: "cfv-1", + member: %Member{id: "member-1", user: %User{id: "user-123"}} + } + + unlinked_cfv = %Mv.Membership.CustomFieldValue{ + id: "cfv-2", + member: %Member{id: "member-2", user: nil} + } + + unlinked_cfv_other = %Mv.Membership.CustomFieldValue{ + id: "cfv-3", + member: %Member{id: "member-3", user: %User{id: "other-user"}} + } + + assert Authorization.can?(user, :update, linked_cfv) == true + assert Authorization.can?(user, :update, unlinked_cfv) == false + assert Authorization.can?(user, :update, unlinked_cfv_other) == false + end + end + + describe "can_access_page?/2" do + test "admin can access all pages via wildcard" do + admin = %{ + id: "admin-123", + role: %{permission_set_name: "admin"} + } + + assert Authorization.can_access_page?(admin, "/admin/roles") == true + assert Authorization.can_access_page?(admin, "/members") == true + assert Authorization.can_access_page?(admin, "/any/page") == true + end + + test "read_only user can access allowed pages" do + read_only_user = %{ + id: "read-only-123", + role: %{permission_set_name: "read_only"} + } + + assert Authorization.can_access_page?(read_only_user, "/") == true + assert Authorization.can_access_page?(read_only_user, "/members") == true + assert Authorization.can_access_page?(read_only_user, "/members/123") == true + assert Authorization.can_access_page?(read_only_user, "/admin/roles") == false + end + + test "matches dynamic routes correctly" do + read_only_user = %{ + id: "read-only-123", + role: %{permission_set_name: "read_only"} + } + + assert Authorization.can_access_page?(read_only_user, "/members/123") == true + assert Authorization.can_access_page?(read_only_user, "/members/abc") == true + assert Authorization.can_access_page?(read_only_user, "/members/123/edit") == false + end + + test "returns false for nil user" do + assert Authorization.can_access_page?(nil, "/members") == false + assert Authorization.can_access_page?(nil, "/admin/roles") == false + end + end + + describe "error handling" do + test "user without role returns false" do + user_without_role = %{id: "user-123", role: nil} + + assert Authorization.can?(user_without_role, :create, Mv.Membership.Member) == false + assert Authorization.can_access_page?(user_without_role, "/members") == false + end + + test "user with invalid permission_set_name returns false" do + user_with_invalid_permission = %{ + id: "user-123", + role: %{permission_set_name: "invalid_set"} + } + + assert Authorization.can?(user_with_invalid_permission, :create, Mv.Membership.Member) == + false + + assert Authorization.can_access_page?(user_with_invalid_permission, "/members") == false + end + + test "handles missing fields gracefully" do + user_missing_role = %{id: "user-123"} + + assert Authorization.can?(user_missing_role, :create, Mv.Membership.Member) == false + assert Authorization.can_access_page?(user_missing_role, "/members") == false + end + end +end