improvement: show an explanation when no policies apply

This commit is contained in:
Zach Daniel 2024-09-02 12:48:14 -04:00
parent dd44d5dcf2
commit 6f2a14715d
6 changed files with 151 additions and 52 deletions

View file

@ -432,7 +432,13 @@ config :ash, :policies, private_fields: :include
Policy breakdowns can be fetched on demand for a given forbidden error (either an `Ash.Error.Forbidden` that contains one ore more `Ash.Error.Forbidden.Policy` errors, or an `Ash.Error.Forbidden.Policy` error itself), via `Ash.Error.Forbidden.Policy.report/2`. Policy breakdowns can be fetched on demand for a given forbidden error (either an `Ash.Error.Forbidden` that contains one ore more `Ash.Error.Forbidden.Policy` errors, or an `Ash.Error.Forbidden.Policy` error itself), via `Ash.Error.Forbidden.Policy.report/2`.
Here is an example policy breakdown from tests: Additionally, you can request that they be provided in the error message for all raised forbidden errors (without the help text), by setting
```elixir
config :ash, :policies, show_policy_breakdowns?: true
```
Here is an example policy breakdown from tests.
```text ```text
Policy Breakdown Policy Breakdown

View file

@ -18,6 +18,7 @@ defmodule Ash.Error.Forbidden.Policy do
context_description: nil, context_description: nil,
policies: [], policies: [],
resource: nil, resource: nil,
domain: nil,
action: nil, action: nil,
changeset_doesnt_match_filter: false changeset_doesnt_match_filter: false
], ],
@ -90,6 +91,8 @@ defmodule Ash.Error.Forbidden.Policy do
filter, filter,
policies, policies,
Keyword.merge(opts, Keyword.merge(opts,
domain: error.domain,
resource: error.resource,
must_pass_strict_check?: must_pass_strict_check?, must_pass_strict_check?: must_pass_strict_check?,
context_description: error.context_description, context_description: error.context_description,
for_fields: error.for_fields for_fields: error.for_fields
@ -156,16 +159,48 @@ defmodule Ash.Error.Forbidden.Policy do
if Keyword.get(opts, :help_text?, true) do if Keyword.get(opts, :help_text?, true) do
["Policy Breakdown#{policy_context_description}", @help_text] ["Policy Breakdown#{policy_context_description}", @help_text]
else else
"Policy Breakdown#{policy_context_description}" ["Policy Breakdown#{policy_context_description}"]
end end
policy_explanation = policy_explanation =
policies policies
|> Kernel.||([]) |> Kernel.||([])
|> Enum.filter(&relevant?(&1, facts)) |> Enum.filter(&relevant?(&1, facts))
|> Enum.map(&explain_policy(&1, facts, opts[:success?] || false)) |> case do
|> Enum.intersperse("\n") [] ->
|> title(policy_breakdown_title, false) # If no policies are relevant, then we treat them all as relevant
title =
case policies do
[] ->
if opts[:domain] && opts[:resource] do
policy_breakdown_title ++
[
"No policies defined on `#{inspect(opts[:domain])}` or `#{inspect(opts[:resource])}`.\nFor safety, at least one policy must apply to all requests.\n"
]
else
policy_breakdown_title ++
[
"No policies defined.\n"
]
end
_ ->
[
"No policy conditions applied to this request.\nFor safety, at least one policy must apply to all requests.\n"
]
end
{policies, title}
relevant ->
{relevant, policy_breakdown_title}
end
|> then(fn {policies, title} ->
policies
|> Enum.map(&explain_policy(&1, facts, opts[:success?] || false))
|> Enum.intersperse("\n")
|> title(title, false)
end)
filter = filter =
if filter do if filter do
@ -217,7 +252,6 @@ defmodule Ash.Error.Forbidden.Policy do
end end
defp title(other, title, semicolon \\ true) defp title(other, title, semicolon \\ true)
defp title([], _, _), do: []
defp title(other, title, true), do: [title, ":\n", other] defp title(other, title, true), do: [title, ":\n", other]
defp title(other, title, false), do: [title, "\n", other] defp title(other, title, false), do: [title, "\n", other]
@ -275,59 +309,52 @@ defmodule Ash.Error.Forbidden.Policy do
defp describe_conditions(condition, facts) do defp describe_conditions(condition, facts) do
condition condition
|> List.wrap() |> List.wrap()
|> case do |> Enum.reduce({[], true}, fn condition, {conditions, status} ->
[{Ash.Policy.Check.Static, opts}] -> {mod, opts} =
{[], opts[:result]} case condition do
%{module: module, opts: opts} ->
{module, opts}
conditions -> {module, opts} ->
conditions {module, opts}
|> Enum.reduce({[], true}, fn condition, {conditions, status} -> end
{mod, opts} =
case condition do
%{module: module, opts: opts} ->
{module, opts}
{module, opts} -> new_status =
{module, opts} if status in [false, :unknown] do
end false
else
case Policy.fetch_fact(facts, {mod, opts}) do
{:ok, true} ->
true
new_status = {:ok, false} ->
if status in [false, :unknown] do
false false
else
case Policy.fetch_fact(facts, {mod, opts}) do
{:ok, true} ->
true
{:ok, false} -> _ ->
false :unknown
end
end
_ -> {[["condition: ", mod.describe(opts)] | conditions], new_status}
:unknown end)
end |> then(fn {conditions, status} ->
end conditions =
conditions
|> Enum.reverse()
|> case do
[] ->
[]
{[["condition: ", mod.describe(opts)] | conditions], new_status} conditions ->
end) [
|> then(fn {conditions, status} -> conditions
conditions = |> Enum.intersperse("\n"),
conditions "\n"
|> Enum.reverse() ]
|> case do end
[] ->
[]
conditions -> {conditions, status}
[ end)
conditions
|> Enum.intersperse("\n"),
"\n"
]
end
{conditions, status}
end)
end
end end
defp describe_checks(checks, facts, success?) do defp describe_checks(checks, facts, success?) do

View file

@ -486,6 +486,7 @@ defmodule Ash.Policy.Authorizer do
resource: Map.get(state, :resource), resource: Map.get(state, :resource),
action: Map.get(state, :action), action: Map.get(state, :action),
actor: Map.get(state, :actor), actor: Map.get(state, :actor),
domain: Map.get(state, :domain),
changeset_doesnt_match_filter: true, changeset_doesnt_match_filter: true,
filter: filter filter: filter
) )
@ -495,6 +496,7 @@ defmodule Ash.Policy.Authorizer do
Ash.Error.Forbidden.Policy.exception( Ash.Error.Forbidden.Policy.exception(
scenarios: Map.get(state, :scenarios), scenarios: Map.get(state, :scenarios),
facts: Map.get(state, :facts), facts: Map.get(state, :facts),
domain: Map.get(state, :domain),
policies: Map.get(state, :policies), policies: Map.get(state, :policies),
resource: Map.get(state, :resource), resource: Map.get(state, :resource),
action: Map.get(state, :action), action: Map.get(state, :action),
@ -506,6 +508,7 @@ defmodule Ash.Policy.Authorizer do
def exception(_, state) do def exception(_, state) do
Ash.Error.Forbidden.Policy.exception( Ash.Error.Forbidden.Policy.exception(
scenarios: Map.get(state, :scenarios), scenarios: Map.get(state, :scenarios),
domain: Map.get(state, :domain),
facts: Map.get(state, :facts), facts: Map.get(state, :facts),
policies: Map.get(state, :policies), policies: Map.get(state, :policies),
resource: Map.get(state, :resource), resource: Map.get(state, :resource),
@ -1507,6 +1510,7 @@ defmodule Ash.Policy.Authorizer do
{:error, {:error,
Ash.Error.Forbidden.Policy.exception( Ash.Error.Forbidden.Policy.exception(
facts: authorizer.facts, facts: authorizer.facts,
domain: Map.get(authorizer, :domain),
policies: authorizer.policies, policies: authorizer.policies,
context_description: opts[:context_description], context_description: opts[:context_description],
for_fields: opts[:for_fields], for_fields: opts[:for_fields],
@ -1529,6 +1533,7 @@ defmodule Ash.Policy.Authorizer do
{:error, {:error,
Ash.Error.Forbidden.Policy.exception( Ash.Error.Forbidden.Policy.exception(
facts: authorizer.facts, facts: authorizer.facts,
domain: Map.get(authorizer, :domain),
policies: authorizer.policies, policies: authorizer.policies,
context_description: opts[:context_description], context_description: opts[:context_description],
for_fields: opts[:for_fields], for_fields: opts[:for_fields],

View file

@ -9,7 +9,7 @@ defmodule Ash.Policy.Info do
@doc "Whether or not Ash policy authorizer is configured to show policy breakdowns in error messages" @doc "Whether or not Ash policy authorizer is configured to show policy breakdowns in error messages"
def show_policy_breakdowns? do def show_policy_breakdowns? do
Application.get_env(:ash, :policies)[:show_policy_breakdowns?] || false Keyword.get(Application.get_env(:ash, :policies, []), :show_policy_breakdowns?, true)
end end
@doc "Whether or not Ash policy authorizer is configured to log policy breakdowns" @doc "Whether or not Ash policy authorizer is configured to log policy breakdowns"

View file

@ -4,6 +4,7 @@ defmodule Ash.Test.Actions.BulkCreateTest do
import Ash.Expr, only: [expr: 1] import Ash.Expr, only: [expr: 1]
import ExUnit.CaptureLog import ExUnit.CaptureLog
require Ash.Expr
alias Ash.Test.Domain, as: Domain alias Ash.Test.Domain, as: Domain
@ -633,6 +634,7 @@ defmodule Ash.Test.Actions.BulkCreateTest do
tenant: org.id, tenant: org.id,
return_records?: true, return_records?: true,
sorted?: true, sorted?: true,
return_errors?: true,
authorize?: false authorize?: false
) )
@ -653,6 +655,7 @@ defmodule Ash.Test.Actions.BulkCreateTest do
return_records?: true, return_records?: true,
tenant: org.id, tenant: org.id,
upsert?: true, upsert?: true,
return_errors?: true,
upsert_identity: :unique_title, upsert_identity: :unique_title,
upsert_fields: [:title2], upsert_fields: [:title2],
upsert_condition: expr(upsert_conflict(:title) != "title3"), upsert_condition: expr(upsert_conflict(:title) != "title3"),

View file

@ -15,7 +15,47 @@ defmodule Ash.Test.Policy.SimpleTest do
User User
} }
defmodule ResourceWithNoPolicies do
use Ash.Resource,
domain: Ash.Test.Domain,
authorizers: [Ash.Policy.Authorizer]
attributes do
uuid_primary_key :id
end
actions do
defaults [:create, :read]
end
end
defmodule ResourceWithAPolicyThatDoesntApply do
use Ash.Resource,
domain: Ash.Test.Domain,
authorizers: [Ash.Policy.Authorizer]
attributes do
uuid_primary_key :id
end
actions do
defaults [:create, :read]
end
policies do
policy never() do
authorize_if always()
end
end
end
setup do setup do
Application.put_env(:ash, :policies, show_policy_breakdowns?: true)
on_exit(fn ->
Application.delete_env(:ash, :policies)
end)
[ [
user: Ash.create!(Ash.Changeset.for_create(User, :create), authorize?: false), user: Ash.create!(Ash.Changeset.for_create(User, :create), authorize?: false),
admin: admin:
@ -23,6 +63,24 @@ defmodule Ash.Test.Policy.SimpleTest do
] ]
end end
test "breakdowns for resources with no policies explain the error" do
assert_raise Ash.Error.Forbidden,
~r/No policies defined on `Ash.Test.Domain` or `Ash.Test.Policy.SimpleTest.ResourceWithNoPolicies`/,
fn ->
ResourceWithNoPolicies
|> Ash.read!()
end
end
test "breakdowns for action where no policies that apply explain the error" do
assert_raise Ash.Error.Forbidden,
~r/No policy conditions applied to this request/,
fn ->
ResourceWithAPolicyThatDoesntApply
|> Ash.read!()
end
end
test "bypass with condition does not apply subsequent filters", %{admin: admin, user: user} do test "bypass with condition does not apply subsequent filters", %{admin: admin, user: user} do
Ash.create!(Ash.Changeset.for_create(Tweet, :create), authorize?: false) Ash.create!(Ash.Changeset.for_create(Tweet, :create), authorize?: false)