mirror of
https://github.com/ash-project/ash.git
synced 2024-09-20 05:23:03 +12:00
WIP
This commit is contained in:
parent
694b7b362e
commit
4d3688532f
32 changed files with 777 additions and 264 deletions
|
@ -129,3 +129,6 @@ end
|
|||
contain *part* of the filter, requiring that the whole thing is covered by all
|
||||
of the `and`s at least
|
||||
* add `init` to checks, and error check their construction when building the DSL
|
||||
* Support filtering side loads. Especially useful in authorization code?
|
||||
* Booleans need to not support `nil` values. That has to be a different type.
|
||||
boolean filter/authorization logic is greatly enhanced if that is the case.
|
||||
|
|
|
@ -32,7 +32,8 @@ defmodule Ash.Actions.Create do
|
|||
Ash.Authorization.Request.new(
|
||||
resource: resource,
|
||||
authorization_steps: action.authorization_steps,
|
||||
changeset: changeset
|
||||
changeset: changeset,
|
||||
source: "create request"
|
||||
)
|
||||
|
||||
Authorizer.authorize(user, %{}, [auth_request])
|
||||
|
@ -96,10 +97,21 @@ defmodule Ash.Actions.Create do
|
|||
end
|
||||
end)
|
||||
|
||||
changeset =
|
||||
resource
|
||||
|> struct()
|
||||
|> Ecto.Changeset.cast(attributes_with_defaults, allowed_keys)
|
||||
|> Map.put(:action, :create)
|
||||
|
||||
resource
|
||||
|> Ash.attributes()
|
||||
|> Enum.reject(&Map.get(&1, :allow_nil?))
|
||||
|> Enum.reduce(changeset, fn attr, changeset ->
|
||||
case Ecto.Changeset.get_field(changeset, attr.name) do
|
||||
nil -> Ecto.Changeset.add_error(changeset, attr.name, "must not be nil")
|
||||
_value -> changeset
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp default(%{default: {:constant, value}}), do: value
|
||||
|
|
|
@ -28,7 +28,8 @@ defmodule Ash.Actions.Destroy do
|
|||
Ash.Authorization.Request.new(
|
||||
resource: resource,
|
||||
authorization_steps: action.authorization_steps,
|
||||
destroy: record
|
||||
destroy: record,
|
||||
source: "destroy request"
|
||||
)
|
||||
|
||||
Authorizer.authorize(user, %{}, [auth_request])
|
||||
|
|
|
@ -8,11 +8,13 @@ defmodule Ash.Actions.Read do
|
|||
side_loads = Keyword.get(params, :side_load, [])
|
||||
page_params = Keyword.get(params, :page, [])
|
||||
|
||||
# TODO: Going to have to figure out side loads. I don't
|
||||
# think that they can actually reasonably share facts :/
|
||||
|
||||
with %Ash.Filter{errors: [], authorizations: filter_auths} = filter <-
|
||||
Ash.Filter.parse(resource, filter),
|
||||
{:ok, side_load_auths} <- SideLoad.process(resource, side_loads, filter),
|
||||
{:auth, :authorized} <-
|
||||
{:auth, do_authorize(params, side_load_auths ++ filter_auths)},
|
||||
:ok <- do_authorize(params, side_load_auths ++ filter_auths),
|
||||
query <- Ash.DataLayer.resource_to_query(resource),
|
||||
{:ok, sort} <- Ash.Actions.Sort.process(resource, sort),
|
||||
{:ok, sorted_query} <- Ash.DataLayer.sort(query, sort, resource),
|
||||
|
@ -26,16 +28,23 @@ defmodule Ash.Actions.Read do
|
|||
SideLoad.side_load(resource, paginator, side_loads, api)
|
||||
else
|
||||
%Ash.Filter{errors: errors} -> {:error, errors}
|
||||
{:auth, :forbidden} -> {:error, "forbidden"}
|
||||
{:error, error} -> {:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_authorize(params, auths) do
|
||||
if params[:authorization] do
|
||||
strict_access =
|
||||
case Keyword.fetch(params[:authorization], :strict_access?) do
|
||||
{:ok, value} -> value
|
||||
:error -> true
|
||||
end
|
||||
|
||||
auths = Enum.map(auths, fn auth -> %{auth | strict_access?: strict_access} end)
|
||||
|
||||
Authorizer.authorize(params[:authorization][:user], %{}, auths)
|
||||
else
|
||||
:authorized
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -38,7 +38,8 @@ defmodule Ash.Actions.Update do
|
|||
Ash.Authorization.Request.new(
|
||||
resource: resource,
|
||||
authorization_steps: action.authorization_steps,
|
||||
changeset: changeset
|
||||
changeset: changeset,
|
||||
source: "update action"
|
||||
)
|
||||
|
||||
Authorizer.authorize(user, %{}, [auth_request])
|
||||
|
@ -88,8 +89,22 @@ defmodule Ash.Actions.Update do
|
|||
|> Ash.attributes()
|
||||
|> Enum.map(& &1.name)
|
||||
|
||||
changeset =
|
||||
record
|
||||
|> Ecto.Changeset.cast(attributes, allowed_keys)
|
||||
|> Map.put(:action, :update)
|
||||
|
||||
resource
|
||||
|> Ash.attributes()
|
||||
|> Enum.reject(&Map.get(&1, :allow_nil?))
|
||||
|> Enum.reduce(changeset, fn attr, changeset ->
|
||||
case Ecto.Changeset.fetch_change(changeset, attr.name) do
|
||||
{:ok, nil} ->
|
||||
Ecto.Changeset.add_error(changeset, attr.name, "must not be nil")
|
||||
|
||||
_ ->
|
||||
changeset
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,16 +3,25 @@ defmodule Ash.Api do
|
|||
opts: [
|
||||
interface?: :boolean,
|
||||
max_page_size: :integer,
|
||||
default_page_size: :integer
|
||||
default_page_size: :integer,
|
||||
# TODO: Support configuring this from env variables
|
||||
authorization_explanations: [:boolean]
|
||||
],
|
||||
defaults: [
|
||||
interface?: true,
|
||||
max_page_size: 100,
|
||||
default_page_size: 25,
|
||||
authorization_explanations: false
|
||||
],
|
||||
defaults: [interface?: true, max_page_size: 100, default_page_size: 25],
|
||||
describe: [
|
||||
interface?:
|
||||
"If set to false, no code interface is defined for this resource e.g `MyApi.create(...)` is not defined.",
|
||||
max_page_size:
|
||||
"The maximum page size for any read action. Any request for a higher page size will simply use this number. Uses the smaller of the Api's or Resource's value.",
|
||||
default_page_size:
|
||||
"The default page size for any read action. If no page size is specified, this value is used. Uses the smaller of the Api's or Resource's value."
|
||||
"The default page size for any read action. If no page size is specified, this value is used. Uses the smaller of the Api's or Resource's value.",
|
||||
authorization_explanations:
|
||||
"A setting that determines whether or not verbose authorization errors should be returned."
|
||||
],
|
||||
constraints: [
|
||||
max_page_size:
|
||||
|
@ -63,6 +72,7 @@ defmodule Ash.Api do
|
|||
@interface? opts[:interface?]
|
||||
@side_load_type :simple
|
||||
@side_load_config []
|
||||
@authorization_explanations opts[:authorization_explanations] || false
|
||||
|
||||
Module.register_attribute(__MODULE__, :mix_ins, accumulate: true)
|
||||
Module.register_attribute(__MODULE__, :resources, accumulate: true)
|
||||
|
@ -169,6 +179,7 @@ defmodule Ash.Api do
|
|||
def mix_ins(), do: @mix_ins
|
||||
def resources(), do: @resources
|
||||
def side_load_config(), do: {@side_load_type, @side_load_config}
|
||||
def authorization_explanations(), do: @authorization_explanations
|
||||
|
||||
def get_resource(mod) when mod in @resources, do: {:ok, mod}
|
||||
|
||||
|
|
|
@ -11,12 +11,6 @@ defmodule Ash.Authorization do
|
|||
|
||||
@type request :: Ash.Authorization.Request.t()
|
||||
|
||||
# Required sideloads before checks are run
|
||||
# @type side_load_instruction :: {:side_load, Ash.side_load()}
|
||||
# The result for this check is predetermined for all records
|
||||
# that could be passed in from this request.
|
||||
@type decision :: {:decision, boolean}
|
||||
# @type precheck_context :: {:context, %{optional(atom) => term}}
|
||||
# @type precheck_result :: side_load_instruction() | decision() | precheck_context()
|
||||
@type precheck_result :: decision()
|
||||
@type side_load :: {:side_load, Keyword.t()}
|
||||
@type prepare_instruction :: side_load
|
||||
end
|
||||
|
|
|
@ -22,29 +22,30 @@ defmodule Ash.Authorization.Authorizer do
|
|||
|
||||
authorization_steps = authorization_steps_with_relationship_path(requests_by_relationship)
|
||||
|
||||
{facts, instructions} = strict_check_facts(user, requests)
|
||||
facts = strict_check_facts(user, requests)
|
||||
|
||||
solve(authorization_steps, facts, instructions)
|
||||
solve(authorization_steps, facts, facts)
|
||||
end
|
||||
|
||||
defp solve(authorization_steps, facts, instructions) do
|
||||
defp solve(authorization_steps, facts, strict_check_facts) do
|
||||
case SatSolver.solve(authorization_steps, facts) do
|
||||
{:error, :unsatisfiable} ->
|
||||
:forbidden
|
||||
{:error,
|
||||
Ash.Error.Forbidden.exception(
|
||||
authorization_steps: authorization_steps,
|
||||
facts: facts,
|
||||
strict_check_facts: strict_check_facts
|
||||
)}
|
||||
|
||||
{:ok, scenario} ->
|
||||
case get_all_scenarios(authorization_steps, scenario, facts, instructions) do
|
||||
{:ok, []} ->
|
||||
:forbidden
|
||||
scenarios = get_all_scenarios(authorization_steps, scenario, facts)
|
||||
|
||||
{:ok, scenarios} ->
|
||||
irrelevant_clauses = irrelevant_clauses(scenarios)
|
||||
|
||||
scenarios
|
||||
|> Enum.map(&Map.drop(&1, irrelevant_clauses))
|
||||
|> Enum.uniq()
|
||||
|> verify_scenarios(facts, instructions)
|
||||
end
|
||||
|> verify_scenarios(authorization_steps, facts, strict_check_facts)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -72,15 +73,14 @@ defmodule Ash.Authorization.Authorizer do
|
|||
authorization_steps,
|
||||
scenario,
|
||||
facts,
|
||||
instructions,
|
||||
negations \\ [],
|
||||
scenarios \\ nil
|
||||
scenarios \\ []
|
||||
) do
|
||||
scenarios = scenarios || [scenario]
|
||||
scenarios = [scenario | scenarios]
|
||||
|
||||
case scenario_is_reality(scenario, facts) do
|
||||
:reality ->
|
||||
{:ok, scenarios}
|
||||
scenarios
|
||||
|
||||
:not_reality ->
|
||||
raise "SAT SOLVER ERROR"
|
||||
|
@ -94,27 +94,39 @@ defmodule Ash.Authorization.Authorizer do
|
|||
authorization_steps,
|
||||
scenario_after_negation,
|
||||
facts,
|
||||
instructions,
|
||||
negations_assuming_scenario_false,
|
||||
scenarios
|
||||
)
|
||||
|
||||
{:error, :unsatisfiable} ->
|
||||
{:ok, [scenario | scenarios]}
|
||||
scenarios
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_scenarios(scenarios, facts, _instructions) do
|
||||
defp verify_scenarios(scenarios, authorization_steps, facts, strict_check_facts) do
|
||||
if any_scenarios_reality?(scenarios, facts) do
|
||||
:authorized
|
||||
:ok
|
||||
else
|
||||
# TODO: Start gathering facts. If no facts remain to be gathered,
|
||||
# and no scenario is reality, then we are forbidden.
|
||||
# Use instructions from strict checks here.
|
||||
:forbidden
|
||||
case fetch_facts(scenarios, facts) do
|
||||
:all_facts_fetched ->
|
||||
{:error,
|
||||
Ash.Error.Forbidden.exception(
|
||||
scenarios: scenarios,
|
||||
authorization_steps: authorization_steps,
|
||||
facts: facts,
|
||||
strict_check_facts: strict_check_facts
|
||||
)}
|
||||
|
||||
{:ok, new_facts} ->
|
||||
solve(authorization_steps, new_facts, strict_check_facts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_facts(scenarios, facts) do
|
||||
Ash.Authorization.FactFinder.find_facts(scenarios, facts)
|
||||
end
|
||||
|
||||
defp any_scenarios_reality?(scenarios, facts) do
|
||||
Enum.any?(scenarios, fn scenario ->
|
||||
|
@ -123,16 +135,19 @@ defmodule Ash.Authorization.Authorizer do
|
|||
end
|
||||
|
||||
defp scenario_is_reality(scenario, facts) do
|
||||
Enum.reduce_while(scenario, :reality, fn {fact, requirement}, status ->
|
||||
scenario
|
||||
|> Map.drop([true, false])
|
||||
|> Enum.reduce_while(:reality, fn {fact, requirement}, status ->
|
||||
case Map.fetch(facts, fact) do
|
||||
{:ok, value} ->
|
||||
if value == requirement do
|
||||
if status == :reality do
|
||||
{:cont, :reality}
|
||||
else
|
||||
cond do
|
||||
value == requirement ->
|
||||
{:cont, status}
|
||||
end
|
||||
else
|
||||
|
||||
value == :unknowable ->
|
||||
{:cont, :maybe}
|
||||
|
||||
true ->
|
||||
{:halt, :not_reality}
|
||||
end
|
||||
|
||||
|
@ -143,10 +158,8 @@ defmodule Ash.Authorization.Authorizer do
|
|||
end
|
||||
|
||||
defp strict_check_facts(user, requests) do
|
||||
Enum.reduce(requests, {%{true: true, false: false}, []}, fn request, {facts, instructions} ->
|
||||
{new_facts, new_instructions} = Ash.Authorization.Checker.strict_check(user, request, facts)
|
||||
|
||||
{new_facts, instructions ++ new_instructions}
|
||||
Enum.reduce(requests, %{true: true, false: false}, fn request, facts ->
|
||||
Ash.Authorization.Checker.strict_check(user, request, facts)
|
||||
end)
|
||||
end
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ defmodule Ash.Authorization.Check.AttributeEquals do
|
|||
|
||||
@impl true
|
||||
def describe(opts) do
|
||||
"record.#{opts[:field]} == #{inspect(opts[:value])}"
|
||||
"this_record.#{opts[:field]} == #{inspect(opts[:value])}"
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
@ -17,19 +17,21 @@ defmodule Ash.Authorization.Check.AttributeEquals do
|
|||
|
||||
case Ash.Filter.parse(request.resource, [{field, eq: value}]) do
|
||||
%{errors: []} = parsed ->
|
||||
cond do
|
||||
Ash.Filter.contains?(parsed, request.filter) ->
|
||||
[decision: true]
|
||||
|
||||
request.strict_access? ->
|
||||
[decision: false]
|
||||
|
||||
true ->
|
||||
[]
|
||||
if Ash.Filter.strict_subset_of?(parsed, request.filter) do
|
||||
{:ok, true}
|
||||
else
|
||||
case Ash.Filter.parse(request.resource, [{field, not_eq: value}]) do
|
||||
%{errors: []} = parsed ->
|
||||
if Ash.Filter.strict_subset_of?(parsed, request.filter) do
|
||||
{:ok, false}
|
||||
else
|
||||
{:ok, :unknown}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
%{errors: errors} ->
|
||||
[error: errors]
|
||||
{:error, errors}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,16 +6,28 @@ defmodule Ash.Authorization.Check do
|
|||
|
||||
@type options :: Keyword.t()
|
||||
|
||||
@callback strict_check(Ash.user(), Ash.Authorization.request(), options) ::
|
||||
list(Ash.Authorization.precheck_result())
|
||||
@callback check(Ash.user(), Ash.Authorization.request(), options) :: boolean
|
||||
@callback strict_check(Ash.user(), Ash.Authorization.request(), options) :: boolean | :unknown
|
||||
|
||||
@callback prepare(Ash.user(), Ash.Authorization.request(), options) ::
|
||||
list(Ash.Authorization.prepare_instruction()) | {:error, Ash.error()}
|
||||
@callback check(Ash.user(), list(Ash.record()), Ash.Authorization.request(), options) ::
|
||||
{:ok, boolean | list(Ash.record())} | {:error, Ash.error()}
|
||||
@callback describe(options()) :: String.t()
|
||||
|
||||
@optional_callbacks check: 3
|
||||
@optional_callbacks check: 4, prepare: 3
|
||||
|
||||
def defines_check?(module) do
|
||||
:erlang.function_exported(module, :check, 4)
|
||||
end
|
||||
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
@behaviour Ash.Authorization.Check
|
||||
|
||||
@impl true
|
||||
def prepare(_, _, _), do: []
|
||||
|
||||
defoverridable prepare: 3
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,8 +6,8 @@ defmodule Ash.Authorization.Check.RelatedToUserVia do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def describe(relationship) do
|
||||
"#{Enum.join(relationship, ".")} is the user"
|
||||
def describe(opts) do
|
||||
"#{Enum.join(opts[:relationship], ".")} is the user"
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
@ -20,22 +20,48 @@ defmodule Ash.Authorization.Check.RelatedToUserVia do
|
|||
|
||||
case Ash.Filter.parse(request.resource, candidate_filter) do
|
||||
%{errors: []} = parsed ->
|
||||
cond do
|
||||
Ash.Filter.contains?(parsed, request.filter) ->
|
||||
[decision: true]
|
||||
|
||||
request.strict_access? ->
|
||||
[decision: false]
|
||||
|
||||
true ->
|
||||
[]
|
||||
if Ash.Filter.strict_subset_of?(parsed, request.filter) do
|
||||
{:ok, true}
|
||||
else
|
||||
{:ok, :unknown}
|
||||
end
|
||||
|
||||
%{errors: errors} ->
|
||||
[error: errors]
|
||||
{:error, errors}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def prepare(_user, _request, opts) do
|
||||
[side_load: put_into_relationship_path(opts[:relationship], [])]
|
||||
end
|
||||
|
||||
@impl true
|
||||
def check(user, records, _request, options) do
|
||||
matches =
|
||||
Enum.filter(records, fn record ->
|
||||
related_records = get_related(record, options[:relationship])
|
||||
|
||||
Enum.any?(related_records, fn related ->
|
||||
primary_key = Ash.primary_key(user)
|
||||
Map.take(related, primary_key) == Map.take(user, primary_key)
|
||||
end)
|
||||
end)
|
||||
|
||||
{:ok, matches}
|
||||
end
|
||||
|
||||
defp get_related(record, []), do: record
|
||||
|
||||
defp get_related(record, [relationship | rest]) do
|
||||
Enum.flat_map(record, fn record ->
|
||||
record
|
||||
|> Map.get(relationship)
|
||||
|> List.wrap()
|
||||
|> Enum.map(&get_related(&1, rest))
|
||||
end)
|
||||
end
|
||||
|
||||
defp put_into_relationship_path([], value), do: value
|
||||
|
||||
defp put_into_relationship_path([item | rest], value) do
|
||||
|
|
|
@ -16,6 +16,6 @@ defmodule Ash.Authorization.Check.Static do
|
|||
|
||||
@impl true
|
||||
def strict_check(_user, _request, options) do
|
||||
[decision: options[:result]]
|
||||
{:ok, options[:result]}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -12,6 +12,6 @@ defmodule Ash.Authorization.Check.UserAttribute do
|
|||
|
||||
@impl true
|
||||
def strict_check(user, _request, options) do
|
||||
[decision: Map.get(user, options[:field]) == options[:value]]
|
||||
{:ok, Map.fetch(user, options[:field]) == {:ok, options[:value]}}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -7,7 +7,7 @@ defmodule Ash.Authorization.Check.UserAttributeMatchesRecord do
|
|||
|
||||
@impl true
|
||||
def describe(opts) do
|
||||
"user.#{opts[:user_field]} == record.#{opts[:record_field]}"
|
||||
"user.#{opts[:user_field]} == this_record.#{opts[:record_field]}"
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
@ -17,19 +17,14 @@ defmodule Ash.Authorization.Check.UserAttributeMatchesRecord do
|
|||
|
||||
case Ash.Filter.parse(request.resource, [{record_field, eq: Map.get(user, user_field)}]) do
|
||||
%{errors: []} = parsed ->
|
||||
cond do
|
||||
Ash.Filter.contains?(parsed, request.filter) ->
|
||||
[decision: true]
|
||||
|
||||
request.strict_access? ->
|
||||
[decision: false]
|
||||
|
||||
true ->
|
||||
[]
|
||||
if Ash.Filter.strict_subset_of?(parsed, request.filter) do
|
||||
{:ok, true}
|
||||
else
|
||||
{:ok, :unknown}
|
||||
end
|
||||
|
||||
%{errors: errors} ->
|
||||
[error: errors]
|
||||
{:error, errors}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,37 +1,44 @@
|
|||
defmodule Ash.Authorization.Checker do
|
||||
def strict_check(user, request, facts) do
|
||||
Enum.reduce(
|
||||
request.authorization_steps,
|
||||
{facts, []},
|
||||
fn {
|
||||
_step_type,
|
||||
condition
|
||||
},
|
||||
{facts, instructions} ->
|
||||
request.authorization_steps
|
||||
|> Enum.reduce(facts, fn {_step, condition}, facts ->
|
||||
case Map.fetch(facts, {request.relationship, condition}) do
|
||||
{:ok, _boolean_result} ->
|
||||
{facts, instructions}
|
||||
facts
|
||||
|
||||
:error ->
|
||||
case do_strict_check(condition, user, request) do
|
||||
{:unknown, new_instructions} ->
|
||||
{facts, instructions ++ new_instructions}
|
||||
:unknown ->
|
||||
facts
|
||||
|
||||
{boolean, new_instructions} ->
|
||||
{Map.put(facts, {request.relationship, condition}, boolean),
|
||||
instructions ++ new_instructions}
|
||||
:unknowable ->
|
||||
Map.put(facts, {request.relationship, condition}, :unknowable)
|
||||
|
||||
boolean ->
|
||||
Map.put(facts, {request.relationship, condition}, boolean)
|
||||
end
|
||||
end
|
||||
end
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_strict_check({module, opts}, user, request) do
|
||||
strict_check_results = module.strict_check(user, request, opts)
|
||||
case module.strict_check(user, request, opts) do
|
||||
{:ok, boolean} when is_boolean(boolean) ->
|
||||
boolean
|
||||
|
||||
case Keyword.fetch(strict_check_results, :decision) do
|
||||
{:ok, boolean} -> {boolean, []}
|
||||
:error -> {:unknown, []}
|
||||
{:ok, :unknown} ->
|
||||
cond do
|
||||
request.strict_access? ->
|
||||
# This means "we needed a fact that we have no way of getting"
|
||||
# Because the fact was needed in the `strict_check` step
|
||||
:unknowable
|
||||
|
||||
Ash.Authorization.Check.defines_check?(module) ->
|
||||
:unknown
|
||||
|
||||
true ->
|
||||
:unknowable
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
8
lib/ash/authorization/fact_finder.ex
Normal file
8
lib/ash/authorization/fact_finder.ex
Normal file
|
@ -0,0 +1,8 @@
|
|||
defmodule Ash.Authorization.FactFinder do
|
||||
def find_facts(scenarios, facts) do
|
||||
# IO.inspect(scenarios, label: "Scenarios")
|
||||
# IO.inspect(facts, label: "Facts")
|
||||
|
||||
:all_facts_fetched
|
||||
end
|
||||
end
|
|
@ -5,7 +5,8 @@ defmodule Ash.Authorization.Request do
|
|||
:filter,
|
||||
:action_type,
|
||||
:relationship,
|
||||
:strict_access?
|
||||
:strict_access?,
|
||||
:source
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
|
@ -14,7 +15,8 @@ defmodule Ash.Authorization.Request do
|
|||
authorization_steps: list(term),
|
||||
filter: Ash.Filter.t(),
|
||||
relationship: list(atom),
|
||||
strict_access?: boolean
|
||||
strict_access?: boolean,
|
||||
source: String.t()
|
||||
}
|
||||
|
||||
def new(opts) do
|
||||
|
|
|
@ -11,6 +11,15 @@ defmodule Ash.Authorization.SatSolver do
|
|||
end
|
||||
end)
|
||||
|
||||
facts =
|
||||
Enum.reduce(facts, %{}, fn {key, value}, acc ->
|
||||
if value == :unknowable do
|
||||
acc
|
||||
else
|
||||
Map.put(acc, key, value)
|
||||
end
|
||||
end)
|
||||
|
||||
facts_expression = facts_to_statement(facts)
|
||||
|
||||
negations =
|
||||
|
@ -82,11 +91,11 @@ defmodule Ash.Authorization.SatSolver do
|
|||
|
||||
defp solutions_to_predicate_values({:error, error}, _), do: {:error, error}
|
||||
|
||||
# Is it really this easy
|
||||
defp compile_authorization_steps_expression([{:authorize_if, clause}], facts) do
|
||||
case Map.fetch(facts, clause) do
|
||||
{:ok, true} -> true
|
||||
{:ok, false} -> false
|
||||
{:ok, :unknowable} -> false
|
||||
:error -> clause
|
||||
end
|
||||
end
|
||||
|
@ -99,6 +108,9 @@ defmodule Ash.Authorization.SatSolver do
|
|||
{:ok, false} ->
|
||||
compile_authorization_steps_expression(rest, facts)
|
||||
|
||||
{:ok, :unknowable} ->
|
||||
compile_authorization_steps_expression(rest, facts)
|
||||
|
||||
:error ->
|
||||
{:or, clause, compile_authorization_steps_expression(rest, facts)}
|
||||
end
|
||||
|
@ -106,31 +118,56 @@ defmodule Ash.Authorization.SatSolver do
|
|||
|
||||
defp compile_authorization_steps_expression([{:authorize_unless, clause}], facts) do
|
||||
case Map.fetch(facts, clause) do
|
||||
{:ok, true} -> false
|
||||
{:ok, false} -> true
|
||||
:error -> {:not, clause}
|
||||
{:ok, true} ->
|
||||
false
|
||||
|
||||
{:ok, false} ->
|
||||
true
|
||||
|
||||
{:ok, :unknowable} ->
|
||||
false
|
||||
|
||||
:error ->
|
||||
{:not, clause}
|
||||
end
|
||||
end
|
||||
|
||||
defp compile_authorization_steps_expression([{:authorize_unless, clause} | rest], facts) do
|
||||
case Map.fetch(facts, clause) do
|
||||
{:ok, true} -> compile_authorization_steps_expression(rest, facts)
|
||||
{:ok, false} -> true
|
||||
:error -> {:or, {:not, clause}, compile_authorization_steps_expression(rest, facts)}
|
||||
{:ok, true} ->
|
||||
compile_authorization_steps_expression(rest, facts)
|
||||
|
||||
{:ok, false} ->
|
||||
true
|
||||
|
||||
{:ok, :unknowable} ->
|
||||
compile_authorization_steps_expression(rest, facts)
|
||||
|
||||
:error ->
|
||||
{:or, {:not, clause}, compile_authorization_steps_expression(rest, facts)}
|
||||
end
|
||||
end
|
||||
|
||||
defp compile_authorization_steps_expression([{:forbid_if, clause}], facts) do
|
||||
case Map.fetch(facts, clause) do
|
||||
{:ok, true} -> false
|
||||
{:ok, false} -> true
|
||||
:error -> {:not, clause}
|
||||
{:ok, true} ->
|
||||
false
|
||||
|
||||
{:ok, false} ->
|
||||
true
|
||||
|
||||
{:ok, :unknowable} ->
|
||||
false
|
||||
|
||||
:error ->
|
||||
{:not, clause}
|
||||
end
|
||||
end
|
||||
|
||||
defp compile_authorization_steps_expression([{:forbid_if, clause} | rest], facts) do
|
||||
case Map.fetch(facts, clause) do
|
||||
{:ok, true} -> false
|
||||
{:ok, :unknowable} -> false
|
||||
{:ok, false} -> compile_authorization_steps_expression(rest, facts)
|
||||
:error -> {:and, {:not, clause}, compile_authorization_steps_expression(rest, facts)}
|
||||
end
|
||||
|
@ -140,6 +177,7 @@ defmodule Ash.Authorization.SatSolver do
|
|||
case Map.fetch(facts, clause) do
|
||||
{:ok, true} -> true
|
||||
{:ok, false} -> false
|
||||
{:ok, :unknowable} -> false
|
||||
:error -> clause
|
||||
end
|
||||
end
|
||||
|
@ -148,6 +186,7 @@ defmodule Ash.Authorization.SatSolver do
|
|||
case Map.fetch(facts, clause) do
|
||||
{:ok, true} -> compile_authorization_steps_expression(rest, facts)
|
||||
{:ok, false} -> false
|
||||
{:ok, :unknowable} -> false
|
||||
:error -> {:and, clause, compile_authorization_steps_expression(rest, facts)}
|
||||
end
|
||||
end
|
||||
|
|
103
lib/ash/error/forbidden.ex
Normal file
103
lib/ash/error/forbidden.ex
Normal file
|
@ -0,0 +1,103 @@
|
|||
defmodule Ash.Error.Forbidden do
|
||||
@moduledoc "Raised when authorization for an action fails"
|
||||
defexception [:scenarios, :authorization_steps, :facts, :strict_check_facts]
|
||||
# TODO: Use better logic to format this
|
||||
|
||||
def message(error) do
|
||||
header = "forbidden:\n\n"
|
||||
explained_steps = explain_steps(error.authorization_steps, error.facts)
|
||||
|
||||
explained_facts = explain_facts(error.facts, error.strict_check_facts || %{})
|
||||
|
||||
main_message =
|
||||
header <>
|
||||
"Facts Gathered\n" <>
|
||||
indent(explained_facts) <> "\n\nAuthorization Steps:\n" <> indent(explained_steps)
|
||||
|
||||
main_message <> "\n\nScenarios:\n" <> indent(explain_scenarios(error.scenarios))
|
||||
end
|
||||
|
||||
defp explain_scenarios(scenarios) when scenarios in [nil, []] do
|
||||
"""
|
||||
No scenarios found. Under construction.
|
||||
Eventually, scenarios will explain what data you could change to make the request possible.
|
||||
"""
|
||||
end
|
||||
|
||||
defp explain_scenarios(scenarios) do
|
||||
"""
|
||||
#{Enum.count(scenarios)} found. Under construction.
|
||||
Eventually, scenarios will explain what data you could change to make the request possible.
|
||||
"""
|
||||
end
|
||||
|
||||
defp explain_facts(facts, strict_check_facts) do
|
||||
facts
|
||||
|> Map.drop([true, false])
|
||||
|> Enum.map_join("\n", fn {{rel, {mod, opts}}, status} ->
|
||||
gets_star? =
|
||||
Map.fetch(strict_check_facts, {rel, {mod, opts}}) in [
|
||||
{:ok, true},
|
||||
{:ok, false}
|
||||
]
|
||||
|
||||
star =
|
||||
if gets_star? do
|
||||
" ⭑"
|
||||
else
|
||||
""
|
||||
end
|
||||
|
||||
if rel == [] do
|
||||
status_to_mark(status) <> " " <> mod.describe(opts) <> star
|
||||
else
|
||||
status_to_mark(status) <> " " <> Enum.join(rel, ".") <> " " <> mod.describe(opts) <> star
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp status_to_mark(true), do: "✓"
|
||||
defp status_to_mark(false), do: "✗"
|
||||
defp status_to_mark(:unknowable), do: "?!"
|
||||
defp status_to_mark(nil), do: "?"
|
||||
|
||||
defp indent(string) do
|
||||
string
|
||||
|> String.split("\n")
|
||||
|> Enum.map(fn line -> " " <> line end)
|
||||
|> Enum.join("\n")
|
||||
end
|
||||
|
||||
defp explain_steps(sets_of_authorization_steps, facts) do
|
||||
Enum.map_join(sets_of_authorization_steps, "---", fn authorization_steps ->
|
||||
authorization_steps
|
||||
|> Enum.map(fn {step, {relationship, {mod, opts}}} ->
|
||||
mark = step_to_mark(step, Map.get(facts, {relationship, {mod, opts}}))
|
||||
|
||||
if relationship == [] do
|
||||
to_string(step) <> ": " <> mod.describe(opts) <> " " <> mark
|
||||
else
|
||||
to_string(step) <>
|
||||
": #{Enum.join(relationship, ".")} " <> mod.describe(opts) <> " " <> mark
|
||||
end
|
||||
end)
|
||||
|> Enum.join("\n")
|
||||
end)
|
||||
end
|
||||
|
||||
defp step_to_mark(:authorize_if, true), do: "✓"
|
||||
defp step_to_mark(:authorize_if, false), do: "↓"
|
||||
defp step_to_mark(:authorize_if, _), do: "↓"
|
||||
|
||||
defp step_to_mark(:forbid_if, true), do: "✗"
|
||||
defp step_to_mark(:forbid_if, false), do: "↓"
|
||||
defp step_to_mark(:forbid_if, _), do: "✗"
|
||||
|
||||
defp step_to_mark(:authorize_unless, true), do: "↓"
|
||||
defp step_to_mark(:authorize_unless, false), do: "✓"
|
||||
defp step_to_mark(:authorize_unless, _), do: "↓"
|
||||
|
||||
defp step_to_mark(:forbid_unless, true), do: "↓"
|
||||
defp step_to_mark(:forbid_unless, false), do: "✗"
|
||||
defp step_to_mark(:forbid_unless, _), do: "✗"
|
||||
end
|
|
@ -26,10 +26,10 @@ defmodule Ash.Filter.And do
|
|||
%__MODULE__{left: left, right: right}
|
||||
end
|
||||
|
||||
def contains?(%{left: left, right: right}, predicate) do
|
||||
Ash.Filter.predicate_contains?(left, predicate) or
|
||||
Ash.Filter.predicate_contains?(right, predicate)
|
||||
def strict_subset_of?(attribute, %{left: left, right: right}, predicate) do
|
||||
Ash.Filter.predicate_strict_subset_of?(attribute, left, predicate) or
|
||||
Ash.Filter.predicate_strict_subset_of?(attribute, right, predicate)
|
||||
end
|
||||
|
||||
def contains?(_, _), do: false
|
||||
def strict_subset_of?(_, _), do: false
|
||||
end
|
||||
|
|
|
@ -11,13 +11,29 @@ defmodule Ash.Filter.Eq do
|
|||
end
|
||||
end
|
||||
|
||||
def contains?(%__MODULE__{value: value}, %__MODULE__{value: candidate}) do
|
||||
def strict_subset_of?(
|
||||
%{type: :boolean, allow_nil?: false},
|
||||
%__MODULE__{value: true},
|
||||
%Ash.Filter.NotEq{value: false}
|
||||
) do
|
||||
true
|
||||
end
|
||||
|
||||
def strict_subset_of?(
|
||||
%{type: :boolean, allow_nil?: false},
|
||||
%__MODULE__{value: false},
|
||||
%Ash.Filter.NotEq{value: true}
|
||||
) do
|
||||
true
|
||||
end
|
||||
|
||||
def strict_subset_of?(_attribute, %__MODULE__{value: value}, %__MODULE__{value: candidate}) do
|
||||
value == candidate
|
||||
end
|
||||
|
||||
def contains?(%__MODULE__{value: value}, %Ash.Filter.In{values: [candidate]}) do
|
||||
def strict_subset_of?(_attribute, %__MODULE__{value: value}, %Ash.Filter.In{values: [candidate]}) do
|
||||
value == candidate
|
||||
end
|
||||
|
||||
def contains?(_, _), do: false
|
||||
def strict_subset_of?(_, _, _), do: false
|
||||
end
|
||||
|
|
|
@ -49,54 +49,43 @@ defmodule Ash.Filter do
|
|||
authorization_steps: authorization_steps,
|
||||
filter: parsed_filter,
|
||||
action_type: :read,
|
||||
relationship: path
|
||||
relationship: path,
|
||||
source: "#{Enum.join(path, ".")} filter"
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
@doc "Returns true if the second argument is a strict subset (always returns the same or less data) as the first"
|
||||
def strict_subset?(%{not: nil, ors: [], relationships: rels, attributes: attrs}, %{
|
||||
not: nil,
|
||||
ors: [],
|
||||
relationships: rels,
|
||||
attributes: attrs
|
||||
})
|
||||
when attrs == %{} and rels == %{} do
|
||||
true
|
||||
end
|
||||
@doc """
|
||||
Returns true if the second argument is a strict subset (always returns the same or less data) of the first
|
||||
"""
|
||||
def strict_subset_of?(nil, nil), do: true
|
||||
|
||||
def strict_subset?(_, %{not: nil, ors: [], relationships: rels, attributes: attrs})
|
||||
when attrs == %{} and rels == %{} do
|
||||
end
|
||||
def strict_subset_of?(_, nil), do: false
|
||||
|
||||
def contains?(nil, nil), do: true
|
||||
|
||||
def contains?(_, nil), do: false
|
||||
|
||||
def contains?(filter, candidate) do
|
||||
def strict_subset_of?(filter, candidate) do
|
||||
unless filter.ors in [[], nil], do: raise("Can't do ors contains yet")
|
||||
unless filter.not in [[], nil], do: raise("Can't do not contains yet")
|
||||
unless candidate.ors in [[], nil], do: raise("Can't do ors contains yet")
|
||||
unless candidate.not in [[], nil], do: raise("Can't do not contains yet")
|
||||
|
||||
attributes_contained? =
|
||||
Enum.all?(filter.attributes, fn {attr, predicate} ->
|
||||
Enum.any?(filter.attributes, fn {attr, predicate} ->
|
||||
contains_attribute?(candidate, attr, predicate)
|
||||
end)
|
||||
|
||||
relationships_contained? =
|
||||
Enum.all?(filter.relationships, fn {relationship, relationship_filter} ->
|
||||
Enum.any?(filter.relationships, fn {relationship, relationship_filter} ->
|
||||
contains_relationship?(candidate, relationship, relationship_filter)
|
||||
end)
|
||||
|
||||
# TODO: put these behind functions to optimize them.
|
||||
attributes_contained? && relationships_contained?
|
||||
attributes_contained? or relationships_contained?
|
||||
end
|
||||
|
||||
defp contains_relationship?(filter, relationship, candidate_relationship_filter) do
|
||||
case filter.relationships do
|
||||
%{^relationship => relationship_filter} ->
|
||||
contains?(relationship_filter, candidate_relationship_filter)
|
||||
strict_subset_of?(relationship_filter, candidate_relationship_filter)
|
||||
|
||||
_ ->
|
||||
false
|
||||
|
@ -105,8 +94,13 @@ defmodule Ash.Filter do
|
|||
|
||||
defp contains_attribute?(filter, attr, candidate_predicate) do
|
||||
case filter.attributes do
|
||||
%{^attr => predicate} -> predicate_contains?(predicate, candidate_predicate)
|
||||
_ -> false
|
||||
%{^attr => predicate} ->
|
||||
attribute = Ash.attribute(filter.resource, attr)
|
||||
|
||||
predicate_strict_subset_of?(attribute, predicate, candidate_predicate)
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -122,8 +116,8 @@ defmodule Ash.Filter do
|
|||
end
|
||||
end
|
||||
|
||||
def predicate_contains?(%left_struct{} = left, right) do
|
||||
left_struct.contains?(left, right)
|
||||
def predicate_strict_subset_of?(attribute, %left_struct{} = left, right) do
|
||||
left_struct.strict_subset_of?(attribute, left, right)
|
||||
end
|
||||
|
||||
def add_to_filter(filter, additions) do
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
defmodule Ash.Filter.In do
|
||||
defstruct [:values]
|
||||
|
||||
def new(resource, attr_type, [value]) do
|
||||
Ash.Filter.Eq.new(resource, attr_type, value)
|
||||
end
|
||||
|
||||
def new(_resource, attr_type, values) do
|
||||
casted =
|
||||
values
|
||||
|
@ -28,13 +32,13 @@ defmodule Ash.Filter.In do
|
|||
end
|
||||
end
|
||||
|
||||
def contains?(%__MODULE__{values: values}, %__MODULE__{values: candidate_values}) do
|
||||
def strict_subset_of?(_attr, %__MODULE__{values: values}, %__MODULE__{values: candidate_values}) do
|
||||
Enum.all?(candidate_values, fn candidate -> candidate in values end)
|
||||
end
|
||||
|
||||
def contains?(%__MODULE__{values: values}, %Ash.Filter.Eq{value: candidate}) do
|
||||
def strict_subset_of?(_attr, %__MODULE__{values: values}, %Ash.Filter.Eq{value: candidate}) do
|
||||
candidate in values
|
||||
end
|
||||
|
||||
def contains?(_, _), do: false
|
||||
def strict_subset_of?(_attr, _, _), do: false
|
||||
end
|
||||
|
|
|
@ -109,7 +109,7 @@ defimpl Inspect, for: Ash.Filter do
|
|||
and_clauses ->
|
||||
Inspect.Algebra.container_doc("(", and_clauses, ")", opts, fn term, _ -> term end,
|
||||
break: :flex,
|
||||
separator: " and "
|
||||
separator: " and"
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
@ -11,13 +11,33 @@ defmodule Ash.Filter.NotEq do
|
|||
end
|
||||
end
|
||||
|
||||
def contains?(%__MODULE__{value: value}, %__MODULE__{value: candidate}) do
|
||||
value != candidate
|
||||
def strict_subset_of?(
|
||||
%{type: :boolean, allow_nil?: false},
|
||||
%__MODULE__{value: true},
|
||||
%Ash.Filter.NotEq{value: false}
|
||||
) do
|
||||
true
|
||||
end
|
||||
|
||||
def contains?(%__MODULE__{value: value}, %Ash.Filter.In{values: candidate_values}) do
|
||||
def strict_subset_of?(
|
||||
%{type: :boolean, allow_nil?: false},
|
||||
%__MODULE__{value: false},
|
||||
%Ash.Filter.NotEq{value: true}
|
||||
) do
|
||||
true
|
||||
end
|
||||
|
||||
def strict_subset_of?(_attr, %__MODULE__{value: value}, %__MODULE__{value: candidate}) do
|
||||
value == candidate
|
||||
end
|
||||
|
||||
def strict_subset_of?(_attr, %__MODULE__{value: value}, %Ash.Filter.Eq{value: other_value}) do
|
||||
value != other_value
|
||||
end
|
||||
|
||||
def strict_subset_of?(_attr, %__MODULE__{value: value}, %Ash.Filter.In{values: candidate_values}) do
|
||||
value not in candidate_values
|
||||
end
|
||||
|
||||
def contains?(_, _), do: false
|
||||
def strict_subset_of?(_, _, _), do: false
|
||||
end
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
defmodule Ash.Filter.NotIn do
|
||||
defstruct [:values]
|
||||
|
||||
def new(resource, attr_type, [value]) do
|
||||
Ash.Filter.NotEq.new(resource, attr_type, value)
|
||||
end
|
||||
|
||||
def new(_resource, attr_type, values) do
|
||||
casted =
|
||||
values
|
||||
|
@ -28,20 +32,22 @@ defmodule Ash.Filter.NotIn do
|
|||
end
|
||||
end
|
||||
|
||||
def contains?(%__MODULE__{values: values}, %__MODULE__{values: candidate_values}) do
|
||||
def strict_subset_of?(_attr, %__MODULE__{values: values}, %__MODULE__{values: candidate_values}) do
|
||||
Enum.all?(candidate_values, fn candidate -> candidate not in values end)
|
||||
end
|
||||
|
||||
def contains?(%__MODULE__{values: values}, %Ash.Filter.Eq{value: candidate}) do
|
||||
def strict_subset_of?(_attr, %__MODULE__{values: values}, %Ash.Filter.Eq{value: candidate}) do
|
||||
candidate not in values
|
||||
end
|
||||
|
||||
# TODO: Something like `foo != 1 and foo != 2` should be considered as contained in the
|
||||
# filter `not in [1, 2]`. This will only ever work if we simplify "and" filters wherever
|
||||
# possible.
|
||||
def contains?(%__MODULE__{values: [single_value]}, %Ash.Filter.NotEq{value: candidate}) do
|
||||
def strict_subset_of?(_attr, %__MODULE__{values: [single_value]}, %Ash.Filter.NotEq{
|
||||
value: candidate
|
||||
}) do
|
||||
single_value == candidate
|
||||
end
|
||||
|
||||
def contains?(_, _), do: false
|
||||
def strict_subset_of?(_, _, _), do: false
|
||||
end
|
||||
|
|
|
@ -22,10 +22,10 @@ defmodule Ash.Filter.Or do
|
|||
new(resource, attr_type, [left, right])
|
||||
end
|
||||
|
||||
def contains?(%{left: left, right: right}, predicate) do
|
||||
Ash.Filter.predicate_contains?(left, predicate) and
|
||||
Ash.Filter.predicate_contains?(right, predicate)
|
||||
def strict_subset_of?(attr, %{left: left, right: right}, predicate) do
|
||||
Ash.Filter.predicate_strict_subset_of?(attr, left, predicate) and
|
||||
Ash.Filter.predicate_strict_subset_of?(attr, right, predicate)
|
||||
end
|
||||
|
||||
def contains?(_, _), do: false
|
||||
def strict_subset_of?(_, _, _), do: false
|
||||
end
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
defmodule Ash.Resource.Attributes.Attribute do
|
||||
@doc false
|
||||
|
||||
defstruct [:name, :type, :primary_key?, :default]
|
||||
defstruct [:name, :type, :allow_nil?, :primary_key?, :default]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
name: atom(),
|
||||
|
@ -13,6 +13,7 @@ defmodule Ash.Resource.Attributes.Attribute do
|
|||
@schema Ashton.schema(
|
||||
opts: [
|
||||
primary_key?: :boolean,
|
||||
allow_nil?: :boolean,
|
||||
default: [
|
||||
{:function, 0},
|
||||
{:tuple, {:module, :atom}},
|
||||
|
@ -20,9 +21,14 @@ defmodule Ash.Resource.Attributes.Attribute do
|
|||
]
|
||||
],
|
||||
defaults: [
|
||||
primary_key?: false
|
||||
primary_key?: false,
|
||||
allow_nil?: true
|
||||
],
|
||||
describe: [
|
||||
allow_nil?: """
|
||||
Whether or not to allow `null` values. Ash can perform optimizations with this information, so if you do not
|
||||
expect any null values, make sure to set this switch.
|
||||
""",
|
||||
primary_key?:
|
||||
"Whether this field is, or is part of, the primary key of a resource.",
|
||||
default:
|
||||
|
@ -41,6 +47,7 @@ defmodule Ash.Resource.Attributes.Attribute do
|
|||
%__MODULE__{
|
||||
name: name,
|
||||
type: type,
|
||||
allow_nil?: opts[:allow_nil?],
|
||||
primary_key?: opts[:primary_key?],
|
||||
default: default
|
||||
}}
|
||||
|
|
|
@ -36,6 +36,15 @@ defmodule Ash.Resource.Attributes do
|
|||
path: [:attributes, :attribute]
|
||||
end
|
||||
|
||||
unless is_atom(type) do
|
||||
raise Ash.Error.ResourceDslError,
|
||||
message:
|
||||
"Attribute type must be a built in type or a type module, got: #{inspect(type)}",
|
||||
path: [:attributes, :attribute, name]
|
||||
end
|
||||
|
||||
type = Ash.Type.get_type(type)
|
||||
|
||||
unless type in Ash.Type.builtins() or Ash.Type.ash_type?(type) do
|
||||
raise Ash.Error.ResourceDslError,
|
||||
message:
|
||||
|
|
|
@ -20,8 +20,8 @@ defmodule Ash.Type do
|
|||
@builtins [
|
||||
string: [ecto_type: :string, filters: [:eq, :in, :not_eq, :not_in], sortable?: true],
|
||||
integer: [ecto_type: :integer, filters: [:eq, :in, :not_eq, :not_in], sortable?: true],
|
||||
int: [ecto_type: :integer, filters: [:eq, :in, :not_eq, :not_in], sortable?: true],
|
||||
boolean: [ecto_type: :boolean, filters: [:eq, :not_eq], sortable?: true],
|
||||
int: [ecto_type: :integer, filters: [:eq, :in, :not_eq, :not_in], sortable?: true],
|
||||
uuid: [ecto_type: :binary_id, filters: [:eq, :in, :not_eq, :not_in], sortable?: true],
|
||||
utc_datetime: [
|
||||
ecto_type: :utc_datetime,
|
||||
|
@ -30,10 +30,20 @@ defmodule Ash.Type do
|
|||
]
|
||||
]
|
||||
|
||||
@short_names []
|
||||
|
||||
@builtin_names Keyword.keys(@builtins)
|
||||
|
||||
@type t :: module | atom
|
||||
|
||||
@spec get_type(atom | module) :: atom | module
|
||||
def get_type(value) do
|
||||
case Keyword.fetch(@short_names, value) do
|
||||
{:ok, mod} -> mod
|
||||
:error -> value
|
||||
end
|
||||
end
|
||||
|
||||
@spec supports_filter?(t(), Ash.DataLayer.filter_type(), Ash.data_layer()) :: boolean
|
||||
def supports_filter?(type, filter_type, data_layer) when type in @builtin_names do
|
||||
data_layer.can?({:filter, filter_type}) and filter_type in @builtins[type][:filters]
|
||||
|
@ -85,7 +95,7 @@ defmodule Ash.Type do
|
|||
def ash_type?(atom) when atom in @builtin_names, do: true
|
||||
|
||||
def ash_type?(module) when is_atom(module) do
|
||||
:erlang.function_exported(module, :__info__, 1) and ash_type_module?(module)
|
||||
Code.ensure_compiled?(module) and ash_type_module?(module)
|
||||
end
|
||||
|
||||
def ash_type?(_), do: false
|
||||
|
|
200
test/authorization/get_authorization_test.exs
Normal file
200
test/authorization/get_authorization_test.exs
Normal file
|
@ -0,0 +1,200 @@
|
|||
defmodule Ash.Test.Authorization.GetAuthorizationTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
defmodule Author do
|
||||
use Ash.Resource, name: "authors", type: "author"
|
||||
use Ash.DataLayer.Ets, private?: true
|
||||
|
||||
actions do
|
||||
read :default,
|
||||
authorization_steps: [
|
||||
# You can see yourself
|
||||
authorize_if: user_attribute_matches_record(:id, :id),
|
||||
# You can't see anything else unless you're a manager
|
||||
forbid_unless: user_attribute(:manager, true),
|
||||
# No one can see a fired author
|
||||
forbid_if: attribute_equals(:fired, true),
|
||||
# Managers can't see `self_manager` authors
|
||||
authorize_unless: attribute_equals(:self_manager, true)
|
||||
]
|
||||
|
||||
create :default
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :name, :string
|
||||
attribute :self_manager, :boolean
|
||||
attribute :fired, :boolean
|
||||
end
|
||||
|
||||
relationships do
|
||||
many_to_many :posts, Ash.Test.Authorization.AuthorizationTest.Post,
|
||||
through: Ash.Test.Authorization.AuthorizationTest.AuthorPost
|
||||
end
|
||||
end
|
||||
|
||||
defmodule User do
|
||||
use Ash.Resource, name: "users", type: "user"
|
||||
use Ash.DataLayer.Ets, private?: true
|
||||
|
||||
actions do
|
||||
read :default
|
||||
create :default
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :name, :string
|
||||
attribute :manager, :boolean
|
||||
end
|
||||
end
|
||||
|
||||
defmodule AuthorPost do
|
||||
use Ash.Resource, name: "author_posts", type: "author_post", primary_key: false
|
||||
use Ash.DataLayer.Ets, private?: true
|
||||
|
||||
actions do
|
||||
read :default
|
||||
create :default
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :name, :string
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :post, Ash.Test.Authorization.AuthorizationTest.Post, primary_key?: true
|
||||
belongs_to :author, Author, primary_key?: true
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Post do
|
||||
use Ash.Resource, name: "posts", type: "post"
|
||||
use Ash.DataLayer.Ets, private?: true
|
||||
|
||||
actions do
|
||||
read :default,
|
||||
authorization_steps: [
|
||||
authorize_if: attribute_equals(:published, true),
|
||||
authorize_if: related_to_user_via(:authors)
|
||||
]
|
||||
|
||||
create :default
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :title, :string
|
||||
attribute :contents, :string
|
||||
attribute :published, :boolean
|
||||
end
|
||||
|
||||
relationships do
|
||||
has_many :author_posts, AuthorPost
|
||||
many_to_many :authors, Author, through: :author_posts
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Api do
|
||||
use Ash.Api
|
||||
|
||||
resources [Post, Author, AuthorPost, User]
|
||||
end
|
||||
|
||||
test "it succeeds if you match a strict_check" do
|
||||
author = Api.create!(Author, attributes: %{name: "foo"})
|
||||
user = Api.create!(User, attributes: %{id: author.id})
|
||||
|
||||
Api.get!(Author, author.id, authorization: [user: user])
|
||||
end
|
||||
|
||||
test "it succeeds if you match the data checks" do
|
||||
author = Api.create!(Author, attributes: %{name: "foo", fired: false, self_manager: false})
|
||||
user = Api.create!(User, attributes: %{manager: true})
|
||||
|
||||
Api.get!(Author, author.id, authorization: [user: user])
|
||||
end
|
||||
|
||||
# test "it succeeds if you match the first rule" do
|
||||
# end
|
||||
|
||||
# test "it succeeds if you match the second rule" do
|
||||
# user = Api.create!(User)
|
||||
|
||||
# Api.read!(Post,
|
||||
# authorization: [user: user],
|
||||
# filter: [published: true]
|
||||
# )
|
||||
# end
|
||||
|
||||
# test "it succeeds if you match both rules" do
|
||||
# author = Api.create!(Author, attributes: %{name: "foo"})
|
||||
# user = Api.create!(User, attributes: %{id: author.id})
|
||||
|
||||
# Api.read!(Post,
|
||||
# authorization: [user: user],
|
||||
# filter: [published: true, authors: [id: author.id]]
|
||||
# )
|
||||
# end
|
||||
|
||||
# test "it fails if you don't match either" do
|
||||
# user = Api.create!(User)
|
||||
|
||||
# assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||
# Api.read!(Post,
|
||||
# authorization: [user: user],
|
||||
# filter: [published: false]
|
||||
# )
|
||||
# end
|
||||
# end
|
||||
|
||||
# test "it fails if it can't confirm that you match either" do
|
||||
# user = Api.create!(User)
|
||||
|
||||
# assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||
# Api.read!(Post,
|
||||
# authorization: [user: user]
|
||||
# )
|
||||
# end
|
||||
# end
|
||||
|
||||
# test "authorize_if falls through properly" do
|
||||
# user = Api.create!(User, attributes: %{manager: true})
|
||||
|
||||
# Api.read!(Author,
|
||||
# filter: [fired: [not_eq: true], self_manager: false],
|
||||
# authorization: [user: user]
|
||||
# )
|
||||
# end
|
||||
|
||||
# test "authorize_unless doesn't trigger if its check is not true" do
|
||||
# user = Api.create!(User, attributes: %{manager: true})
|
||||
|
||||
# assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||
# Api.read!(Author,
|
||||
# filter: [fired: false, self_manager: true],
|
||||
# authorization: [user: user]
|
||||
# )
|
||||
# end
|
||||
# end
|
||||
|
||||
# test "forbid_if triggers if its check is true" do
|
||||
# user = Api.create!(User, attributes: %{manager: true})
|
||||
|
||||
# assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||
# Api.read!(Author,
|
||||
# filter: [fired: true, self_manager: false],
|
||||
# authorization: [user: user]
|
||||
# )
|
||||
# end
|
||||
# end
|
||||
|
||||
# test "forbid_unless doesn't trigger if its check is true" do
|
||||
# user = Api.create!(User, attributes: %{manager: false})
|
||||
|
||||
# assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||
# Api.read!(Author,
|
||||
# filter: [fired: false, self_manager: false],
|
||||
# authorization: [user: user]
|
||||
# )
|
||||
# end
|
||||
# end
|
||||
end
|
|
@ -99,7 +99,6 @@ defmodule Ash.Test.Authorization.ReadAuthorizationTest do
|
|||
resources [Post, Author, AuthorPost, User]
|
||||
end
|
||||
|
||||
describe "read authorization" do
|
||||
test "it succeeds if you match the first rule" do
|
||||
author = Api.create!(Author, attributes: %{name: "foo"})
|
||||
user = Api.create!(User, attributes: %{id: author.id})
|
||||
|
@ -132,7 +131,7 @@ defmodule Ash.Test.Authorization.ReadAuthorizationTest do
|
|||
test "it fails if you don't match either" do
|
||||
user = Api.create!(User)
|
||||
|
||||
assert_raise Ash.Error.FrameworkError, ~r/forbidden/, fn ->
|
||||
assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||
Api.read!(Post,
|
||||
authorization: [user: user],
|
||||
filter: [published: false]
|
||||
|
@ -143,21 +142,18 @@ defmodule Ash.Test.Authorization.ReadAuthorizationTest do
|
|||
test "it fails if it can't confirm that you match either" do
|
||||
user = Api.create!(User)
|
||||
|
||||
assert_raise Ash.Error.FrameworkError, ~r/forbidden/, fn ->
|
||||
assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||
Api.read!(Post,
|
||||
authorization: [user: user]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# forbid_unless: user_attribute(:manager, true),
|
||||
# forbid_if: attribute(:fired, true),
|
||||
|
||||
test "authorize_if falls through properly" do
|
||||
user = Api.create!(User, attributes: %{manager: true})
|
||||
|
||||
Api.read!(Author,
|
||||
filter: [fired: false, self_manager: false],
|
||||
filter: [fired: [not_eq: true], self_manager: [not_eq: true]],
|
||||
authorization: [user: user]
|
||||
)
|
||||
end
|
||||
|
@ -165,7 +161,7 @@ defmodule Ash.Test.Authorization.ReadAuthorizationTest do
|
|||
test "authorize_unless doesn't trigger if its check is not true" do
|
||||
user = Api.create!(User, attributes: %{manager: true})
|
||||
|
||||
assert_raise Ash.Error.FrameworkError, ~r/forbidden/, fn ->
|
||||
assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||
Api.read!(Author,
|
||||
filter: [fired: false, self_manager: true],
|
||||
authorization: [user: user]
|
||||
|
@ -176,7 +172,7 @@ defmodule Ash.Test.Authorization.ReadAuthorizationTest do
|
|||
test "forbid_if triggers if its check is true" do
|
||||
user = Api.create!(User, attributes: %{manager: true})
|
||||
|
||||
assert_raise Ash.Error.FrameworkError, ~r/forbidden/, fn ->
|
||||
assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||
Api.read!(Author,
|
||||
filter: [fired: true, self_manager: false],
|
||||
authorization: [user: user]
|
||||
|
@ -187,12 +183,11 @@ defmodule Ash.Test.Authorization.ReadAuthorizationTest do
|
|||
test "forbid_unless doesn't trigger if its check is true" do
|
||||
user = Api.create!(User, attributes: %{manager: false})
|
||||
|
||||
assert_raise Ash.Error.FrameworkError, ~r/forbidden/, fn ->
|
||||
assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||
Api.read!(Author,
|
||||
filter: [fired: false, self_manager: false],
|
||||
authorization: [user: user]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue