diff --git a/.drone.yml b/.drone.yml index bee4295..dea2d57 100644 --- a/.drone.yml +++ b/.drone.yml @@ -166,7 +166,7 @@ environment: steps: - name: renovate - image: renovate/renovate:42.75 + image: renovate/renovate:42.74 environment: RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_TOKEN: diff --git a/lib/mv/authorization/checks/has_permission.ex b/lib/mv/authorization/checks/has_permission.ex deleted file mode 100644 index 345d6e4..0000000 --- a/lib/mv/authorization/checks/has_permission.ex +++ /dev/null @@ -1,239 +0,0 @@ -defmodule Mv.Authorization.Checks.HasPermission do - @moduledoc """ - Custom Ash Policy Check that evaluates permissions from the PermissionSets module. - - This check: - 1. Reads the actor's role and permission_set_name - 2. Looks up permissions from PermissionSets.get_permissions/1 - 3. Finds matching permission for current resource + action - 4. Applies scope filter (:own, :linked, :all) - - ## Usage in Ash Resource - - policies do - policy action_type(:read) do - authorize_if Mv.Authorization.Checks.HasPermission - end - end - - ## Scope Behavior - - - **:all** - Authorizes without filtering (returns all records) - - **:own** - Filters to records where record.id == actor.id - - **:linked** - Filters based on resource type: - - Member: member.user.id == actor.id (via has_one :user relationship) - - CustomFieldValue: custom_field_value.member.user.id == actor.id (traverses member → user relationship!) - - ## Error Handling - - Returns `false` for: - - Missing actor - - Actor without role - - Invalid permission_set_name - - No matching permission found - - All errors result in Forbidden (policy fails). - - ## Examples - - # In a resource policy - policies do - policy action_type([:read, :create, :update, :destroy]) do - authorize_if Mv.Authorization.Checks.HasPermission - end - end - """ - - use Ash.Policy.Check - require Ash.Query - import Ash.Expr - alias Mv.Authorization.PermissionSets - require Logger - - @impl true - def describe(_opts) do - "checks if actor has permission via their role's permission set" - end - - @impl true - def strict_check(actor, authorizer, _opts) do - resource = authorizer.resource - action = get_action_from_authorizer(authorizer) - - cond do - is_nil(actor) -> - log_auth_failure(actor, resource, action, "no actor") - {:ok, false} - - is_nil(action) -> - log_auth_failure( - actor, - resource, - action, - "authorizer subject shape unsupported (no action)" - ) - - {:ok, false} - - true -> - strict_check_with_permissions(actor, resource, action) - end - end - - # Helper function to reduce nesting depth - defp strict_check_with_permissions(actor, resource, action) do - with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor, - {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), - permissions <- PermissionSets.get_permissions(ps_atom), - resource_name <- get_resource_name(resource) do - case check_permission( - permissions.resources, - resource_name, - action, - actor, - resource_name - ) do - :authorized -> {:ok, true} - {:filter, _} -> {:ok, :unknown} - false -> {:ok, false} - end - else - %{role: nil} -> - log_auth_failure(actor, resource, action, "no role assigned") - {:ok, false} - - %{role: %{permission_set_name: nil}} -> - log_auth_failure(actor, resource, action, "role has no permission_set_name") - {:ok, false} - - {:error, :invalid_permission_set} -> - log_auth_failure(actor, resource, action, "invalid permission_set_name") - {:ok, false} - - _ -> - log_auth_failure(actor, resource, action, "missing data") - {:ok, false} - end - end - - @impl true - def auto_filter(actor, authorizer, _opts) do - resource = authorizer.resource - action = get_action_from_authorizer(authorizer) - - cond do - is_nil(actor) -> nil - is_nil(action) -> nil - true -> auto_filter_with_permissions(actor, resource, action) - end - end - - # Helper function to reduce nesting depth - defp auto_filter_with_permissions(actor, resource, action) do - with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor, - {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), - permissions <- PermissionSets.get_permissions(ps_atom), - resource_name <- get_resource_name(resource) do - case check_permission( - permissions.resources, - resource_name, - action, - actor, - resource_name - ) do - :authorized -> nil - {:filter, filter_expr} -> filter_expr - false -> nil - end - else - _ -> nil - end - end - - # Helper to extract action from authorizer - defp get_action_from_authorizer(authorizer) do - case authorizer.subject do - %{action: %{name: action}} -> action - %{action: action} when is_atom(action) -> action - _ -> nil - end - end - - # Extract resource name from module (e.g., Mv.Membership.Member -> "Member") - defp get_resource_name(resource) when is_atom(resource) do - resource |> Module.split() |> List.last() - end - - # Find matching permission and apply scope - defp check_permission(resource_perms, resource_name, action, actor, resource_name_for_logging) do - case Enum.find(resource_perms, fn perm -> - perm.resource == resource_name and perm.action == action and perm.granted - end) do - nil -> - log_auth_failure(actor, resource_name_for_logging, action, "no matching permission found") - false - - perm -> - apply_scope(perm.scope, actor, resource_name) - end - end - - # Scope: all - No filtering, access to all records - defp apply_scope(:all, _actor, _resource) do - :authorized - end - - # Scope: own - Filter to records where record.id == actor.id - # Used for User resource (users can access their own user record) - defp apply_scope(:own, actor, _resource) do - {:filter, expr(id == ^actor.id)} - end - - # Scope: linked - Filter based on user relationship (resource-specific!) - # Uses Ash relationships: Member has_one :user, CustomFieldValue belongs_to :member - defp apply_scope(:linked, actor, resource_name) do - case resource_name do - "Member" -> - # Member has_one :user → filter by user.id == actor.id - {:filter, expr(user.id == ^actor.id)} - - "CustomFieldValue" -> - # CustomFieldValue belongs_to :member → member has_one :user - # Traverse: custom_field_value.member.user.id == actor.id - {:filter, expr(member.user.id == ^actor.id)} - - _ -> - # Fallback for other resources: try user relationship first, then user_id - {:filter, expr(user.id == ^actor.id or user_id == ^actor.id)} - end - end - - # Log authorization failures for debugging (lazy evaluation) - defp log_auth_failure(actor, resource, action, reason) do - Logger.debug(fn -> - actor_id = if is_map(actor), do: Map.get(actor, :id), else: "nil" - resource_name = get_resource_name_for_logging(resource) - - """ - Authorization failed: - Actor: #{actor_id} - Resource: #{resource_name} - Action: #{inspect(action)} - Reason: #{reason} - """ - end) - end - - # Helper to extract resource name for logging (handles both atoms and strings) - defp get_resource_name_for_logging(resource) when is_atom(resource) do - resource |> Module.split() |> List.last() - end - - defp get_resource_name_for_logging(resource) when is_binary(resource) do - resource - end - - defp get_resource_name_for_logging(_resource) do - "unknown" - end -end diff --git a/lib/mv_web.ex b/lib/mv_web.ex index 8589be1..46e4e8b 100644 --- a/lib/mv_web.ex +++ b/lib/mv_web.ex @@ -89,9 +89,6 @@ 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 deleted file mode 100644 index 95a8524..0000000 --- a/lib/mv_web/authorization.ex +++ /dev/null @@ -1,206 +0,0 @@ -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 e3e9319..adc3444 100644 --- a/lib/mv_web/components/layouts/navbar.ex +++ b/lib/mv_web/components/layouts/navbar.ex @@ -7,7 +7,6 @@ defmodule MvWeb.Layouts.Navbar do use MvWeb, :verified_routes alias Mv.Membership - import MvWeb.Authorization attr :current_user, :map, required: true, @@ -27,21 +26,7 @@ defmodule MvWeb.Layouts.Navbar do {@club_name}