fix: correct relationship filter paths in HasPermission check
All checks were successful
continuous-integration/drone/push Build is passing

- Use user.id instead of user_id for Member linked scope
- Use member.user.id for CustomFieldValue linked scope
- Add lazy logger evaluation
- Improve action nil handling
- Add integration tests for filter expressions
This commit is contained in:
Moritz 2026-01-08 17:44:44 +01:00
parent 288002f404
commit db0a187058
Signed by: moritz
GPG key ID: 1020A035E5DD0824
2 changed files with 186 additions and 63 deletions

View file

@ -21,8 +21,8 @@ defmodule Mv.Authorization.Checks.HasPermission do
- **: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
- CustomFieldValue: custom_field_value.member.user_id == actor.id (traverses relationship!)
- 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
@ -60,37 +60,59 @@ defmodule Mv.Authorization.Checks.HasPermission do
resource = authorizer.resource
action = get_action_from_authorizer(authorizer)
# Explicit nil check first (fail fast, clear error message)
if is_nil(actor) do
log_auth_failure(actor, resource, action, "no actor")
{:ok, false}
else
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}
cond do
is_nil(actor) ->
log_auth_failure(actor, resource, action, "no actor")
{:ok, false}
%{role: %{permission_set_name: nil}} ->
log_auth_failure(actor, resource, action, "role has no permission_set_name")
{:ok, false}
is_nil(action) ->
log_auth_failure(
actor,
resource,
action,
"authorizer subject shape unsupported (no action)"
)
{:error, :invalid_permission_set} ->
log_auth_failure(actor, resource, action, "invalid permission_set_name")
{:ok, false}
{:ok, false}
_ ->
log_auth_failure(actor, resource, action, "missing data")
{: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
@ -99,22 +121,32 @@ defmodule Mv.Authorization.Checks.HasPermission do
resource = authorizer.resource
action = get_action_from_authorizer(authorizer)
# Explicit nil check first
if is_nil(actor) do
nil
else
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
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
@ -133,12 +165,12 @@ defmodule Mv.Authorization.Checks.HasPermission do
end
# Find matching permission and apply scope
defp check_permission(resource_perms, resource_name, action, actor, resource_module_name) do
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_module_name, action, "no matching permission found")
log_auth_failure(actor, resource_name_for_logging, action, "no matching permission found")
false
perm ->
@ -157,35 +189,39 @@ defmodule Mv.Authorization.Checks.HasPermission do
{:filter, expr(id == ^actor.id)}
end
# Scope: linked - Filter based on user_id relationship (resource-specific!)
# 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.user_id == actor.id (direct relationship)
{:filter, expr(user_id == ^actor.id)}
# Member has_one :user → filter by user.id == actor.id
{:filter, expr(user.id == ^actor.id)}
"CustomFieldValue" ->
# CustomFieldValue.member.user_id == actor.id (traverse through member!)
{:filter, expr(member.user_id == ^actor.id)}
# 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 direct user_id
{:filter, expr(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
# Log authorization failures for debugging (lazy evaluation)
defp log_auth_failure(actor, resource, action, reason) do
actor_id = if is_map(actor), do: Map.get(actor, :id), else: "nil"
resource_name = get_resource_name_for_logging(resource)
Logger.debug(fn ->
actor_id = if is_map(actor), do: Map.get(actor, :id), else: "nil"
resource_name = get_resource_name_for_logging(resource)
Logger.debug("""
Authorization failed:
Actor: #{actor_id}
Resource: #{resource_name}
Action: #{action}
Reason: #{reason}
""")
"""
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)