This commit is contained in:
Zach Daniel 2019-12-26 16:33:35 -05:00
parent 694b7b362e
commit 4d3688532f
No known key found for this signature in database
GPG key ID: A57053A671EE649E
32 changed files with 777 additions and 264 deletions

View file

@ -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.

View file

@ -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

View file

@ -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])

View file

@ -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

View file

@ -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

View file

@ -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}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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
View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
}}

View file

@ -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:

View file

@ -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

View 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

View file

@ -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