This commit is contained in:
Zach Daniel 2020-04-12 18:33:03 -04:00
parent 290a2e2048
commit 9766db8b92
No known key found for this signature in database
GPG key ID: C377365383138D4B
24 changed files with 1789 additions and 2006 deletions

View file

@ -167,3 +167,4 @@ end
- Figure out how to handle cross data layer filters for boolean.
- Is it possible/reasonable to do join tables that aren't unique on source_id/destination_id? Probably, but metadata would need to be organized differently.
- relationship changes are an artifact of the old way of doing things and are very ugly right now
- check if preparations have been done on a superset filter of a request and, if so, use it

View file

@ -15,11 +15,16 @@ defmodule Ash.Actions.Attributes do
resource: resource,
changeset: changeset,
action_type: action.type,
dependencies: [[:data]],
fetcher: fn _, %{data: data} -> {:ok, data} end,
state_key: :data,
relationship: [],
source: "change on `#{attribute.name}`"
data:
Ash.Engine.Request.UnresolvedField.data(
[[:data, :data]],
fn %{data: %{data: data}} ->
{:ok, data}
end
),
path: :data,
name: "change on `#{attribute.name}`",
strict_access?: false
)
end)
end

View file

@ -1,6 +1,7 @@
defmodule Ash.Actions.Create do
alias Ash.Engine
alias Ash.Actions.{Attributes, Relationships, SideLoad}
require Logger
@spec run(Ash.api(), Ash.resource(), Ash.action(), Ash.params()) ::
{:ok, Ash.record()} | {:error, Ecto.Changeset.t()} | {:error, Ash.error()}
@ -37,15 +38,36 @@ defmodule Ash.Actions.Create do
params <- Keyword.merge(params, attributes: attributes, relationships: relationships),
%{valid?: true} = changeset <- changeset(api, resource, params),
{:ok, side_load_requests} <-
SideLoad.requests(api, resource, side_loads, :create, side_load_filter),
{:ok, %{data: created} = state} <-
SideLoad.requests(api, resource, side_loads, side_load_filter, :create),
%{data: %{data: %{data: %^resource{} = created}}} = state <-
do_authorized(changeset, params, action, resource, api, side_load_requests) do
{:ok, SideLoad.attach_side_loads(created, state)}
else
%Ecto.Changeset{} = changeset ->
{:error, changeset}
%{errors: errors} when errors != %{} ->
if params[:authorization][:log_final_report?] do
case errors do
%{__engine__: errors} ->
for %Ash.Error.Forbidden{} = forbidden <- List.wrap(errors) do
Logger.info(Ash.Error.Forbidden.report_text(forbidden))
end
_ ->
:ok
end
end
{:error, errors}
{:error, error} ->
if params[:authorization][:log_final_report?] do
for %Ash.Error.Forbidden{} = forbidden <- List.wrap(error) do
Logger.info(Ash.Error.Forbidden.report_text(forbidden))
end
end
{:error, error}
end
end
@ -63,7 +85,7 @@ defmodule Ash.Actions.Create do
relationships = Keyword.get(params, :relationships, %{})
create_request =
Ash.Engine2.Request.new(
Ash.Engine.Request.new(
api: api,
rules: action.rules,
resource: resource,
@ -74,22 +96,29 @@ defmodule Ash.Actions.Create do
relationships
),
action_type: action.type,
strict_access?: false,
data:
Ash.Engine2.Request.UnresolvedField.data([], fn request, _data ->
Ash.Engine.Request.UnresolvedField.data(
[[:data, :changeset]],
fn %{data: %{changeset: changeset}} ->
resource
|> Ash.DataLayer.create(request.changeset)
|> Ash.DataLayer.create(changeset)
|> case do
{:ok, result} ->
request.changeset
changeset
|> Map.get(:__after_changes__, [])
|> Enum.reduce_while({:ok, result}, fn func, {:ok, result} ->
case func.(request.changeset, result) do
case func.(changeset, result) do
{:ok, result} -> {:cont, {:ok, result}}
{:error, error} -> {:halt, {:error, error}}
end
end)
{:error, error} ->
{:error, error}
end
end),
end
),
resolve_when_fetch_only?: true,
path: [:data],
name: "#{action.type} - `#{action.name}`"
@ -109,26 +138,18 @@ defmodule Ash.Actions.Create do
)
if params[:authorization] do
strict_access? =
case Keyword.fetch(params[:authorization], :strict_access?) do
{:ok, value} -> value
:error -> false
end
Engine.run(
params[:authorization][:user],
[create_request | attribute_requests] ++
relationship_read_requests ++ relationship_change_requests ++ side_load_requests,
strict_access?: strict_access?,
api,
user: params[:authorization][:user],
log_final_report?: params[:authorization][:log_final_report?] || false
)
else
authorization = params[:authorization] || []
Engine.run(
authorization[:user],
[create_request | attribute_requests] ++
relationship_read_requests ++ relationship_change_requests ++ side_load_requests,
api,
fetch_only?: true
)
end

View file

@ -4,43 +4,44 @@ defmodule Ash.Actions.Destroy do
@spec run(Ash.api(), Ash.record(), Ash.action(), Ash.params()) ::
{:ok, Ash.record()} | {:error, Ecto.Changeset.t()} | {:error, Ash.error()}
def run(_api, %resource{} = record, action, params) do
if Keyword.get(params, :side_load, []) in [[], nil] do
user = Keyword.get(params, :user)
raise "what"
# if Keyword.get(params, :side_load, []) in [[], nil] do
# user = Keyword.get(params, :user)
transaction_result =
Ash.DataLayer.transact(resource, fn ->
do_authorized(params, action, user, record)
end)
# transaction_result =
# Ash.DataLayer.transact(resource, fn ->
# do_authorized(params, action, user, record)
# end)
case transaction_result do
{:ok, value} -> value
{:error, error} -> {:error, error}
end
else
{:error, "Cannot side load on update currently"}
end
# case transaction_result do
# {:ok, value} -> value
# {:error, error} -> {:error, error}
# end
# else
# {:error, "Cannot side load on update currently"}
# end
end
defp do_authorized(params, action, user, %resource{} = record) do
if params[:authorization] do
auth_request =
Ash.Engine2.Request.new(
resource: resource,
rules: action.rules,
data:
Ash.Engine2.Request.UnresolvedField.data([], fn _request, _ ->
case Ash.data_layer(resource).destroy(record) do
:ok -> {:ok, record}
{:error, error} -> {:error, error}
end
end),
name: "destroy request",
resolve_when_fetch_only?: true
)
# defp do_authorized(params, action, user, %resource{} = record) do
# if params[:authorization] do
# auth_request =
# Ash.Engine.Request.new(
# resource: resource,
# rules: action.rules,
# data:
# Ash.Engine.Request.UnresolvedField.data([], fn _request, _ ->
# case Ash.data_layer(resource).destroy(record) do
# :ok -> {:ok, record}
# {:error, error} -> {:error, error}
# end
# end),
# name: "destroy request",
# resolve_when_fetch_only?: true
# )
Engine.run(user, [auth_request])
else
:authorized
end
end
# Engine.run(user, [auth_request])
# else
# :authorized
# end
# end
end

View file

@ -1,7 +1,8 @@
defmodule Ash.Actions.Read do
alias Ash.Engine2
alias Ash.Engine2.Request
alias Ash.Engine
alias Ash.Engine.Request
alias Ash.Actions.SideLoad
require Logger
def run(api, resource, action, params) do
transaction_result =
@ -39,7 +40,7 @@ defmodule Ash.Actions.Read do
SideLoad.requests(api, resource, side_loads, filter, side_load_filter),
{:ok, paginator} <-
Ash.Actions.Paginator.paginate(api, resource, action, sorted_query, page_params),
%{data: %{root: %{data: data}}, errors: errors} = engine when errors == %{} <-
%{data: %{data: %{data: data}}, errors: errors} = engine when errors == %{} <-
do_authorized(
paginator.query,
params,
@ -52,9 +53,29 @@ defmodule Ash.Actions.Read do
paginator <- %{paginator | results: data} do
{:ok, SideLoad.attach_side_loads(paginator, engine.data)}
else
%{errors: errors} -> {:error, errors}
%Ash.Filter{errors: errors} -> {:error, errors}
{:error, error} -> {:error, error}
%{errors: errors} ->
if params[:authorization][:log_final_report?] do
case errors do
%{__engine__: errors} ->
for %Ash.Error.Forbidden{} = forbidden <- List.wrap(errors) do
Logger.info(Ash.Error.Forbidden.report_text(forbidden))
end
_ ->
:ok
end
end
{:error, errors}
{:error, error} ->
if params[:authorization][:log_final_report?] do
for %Ash.Error.Forbidden{} = forbidden <- List.wrap(error) do
Logger.info(Ash.Error.Forbidden.report_text(forbidden))
end
end
{:error, error}
end
end
@ -66,8 +87,11 @@ defmodule Ash.Actions.Read do
filter: filter,
action_type: action.type,
data:
Request.UnresolvedField.data([], Ash.Filter.optional_paths(filter), fn request, data ->
fetch_filter = Ash.Filter.request_filter_for_fetch(request.filter, data)
Request.UnresolvedField.data(
[[:data, :filter]],
Ash.Filter.optional_paths(filter),
fn %{data: %{filter: filter}} = data ->
fetch_filter = Ash.Filter.request_filter_for_fetch(filter, data)
case Ash.DataLayer.filter(query, fetch_filter, resource) do
{:ok, final_query} ->
@ -76,21 +100,21 @@ defmodule Ash.Actions.Read do
{:error, error} ->
{:error, error}
end
end),
end
),
resolve_when_fetch_only?: true,
path: [:data],
name: "#{action.type} - `#{action.name}`"
)
if params[:authorization] do
Engine2.run(
Engine.run(
[request | requests],
api,
user: params[:authorization][:user],
log_final_report?: params[:authorization][:log_final_report?] || false
user: params[:authorization][:user]
)
else
Engine2.run([request | requests], api, fetch_only?: true)
Engine.run([request | requests], api, fetch_only?: true)
end
end
end

View file

@ -58,7 +58,7 @@ defmodule Ash.Actions.Relationships do
relationship ->
case validate_relationship_change(relationship, data, action_type) do
{:ok, input} ->
add_relationship_read_requests(changeset, api, relationship, input)
add_relationship_read_requests(changeset, api, relationship, input, action_type)
{:error, error} ->
{:error, error}
@ -74,22 +74,23 @@ defmodule Ash.Actions.Relationships do
[]
relationship ->
dependencies = [:data | Map.get(changeset, :__changes_depend_on__, [])]
dependencies = [[:data, :data] | Map.get(changeset, :__changes_depend_on__, [])]
request =
Ash.Engine2.Request.new(
Ash.Engine.Request.new(
api: api,
rules: relationship.write_rules,
resource: resource,
changeset: changeset(changeset, api, relationships),
action_type: action.type,
data:
Ash.Engine2.Request.UnresolvedField.data(dependencies, fn _request,
%{root: %{data: data}} ->
Ash.Engine.Request.UnresolvedField.data(dependencies, fn
%{data: %{data: data}} ->
{:ok, data}
end),
path: :data,
name: "#{relationship_name} edit"
name: "#{relationship_name} edit",
strict_access?: false
)
[request]
@ -97,13 +98,26 @@ defmodule Ash.Actions.Relationships do
end)
end
defp add_relationship_read_requests(changeset, api, relationship, input) do
defp add_relationship_read_requests(changeset, api, relationship, input, :update) do
changeset
|> add_replace_requests(api, relationship, input)
|> add_remove_requests(api, relationship, input)
|> add_add_requests(api, relationship, input)
end
defp add_relationship_read_requests(changeset, api, relationship, input, :create) do
input =
case Map.fetch(input, :replace) do
{:ok, replacing} ->
Map.update(input, :add, replacing, &Kernel.++(&1, replacing))
_ ->
input
end
add_add_requests(changeset, api, relationship, input)
end
defp add_add_requests(changeset, api, relationship, input) do
case Map.fetch(input, :add) do
{:ok, identifiers} ->
@ -203,7 +217,7 @@ defmodule Ash.Actions.Relationships do
end
request =
Ash.Engine2.Request.new(
Ash.Engine.Request.new(
api: api,
rules: default_read.rules,
resource: relationship.destination,
@ -212,7 +226,7 @@ defmodule Ash.Actions.Relationships do
resolve_when_fetch_only?: true,
path: [:relationships, relationship_name, type],
data:
Ash.Engine2.Request.UnresolvedField.data([], fn _, _ ->
Ash.Engine.Request.UnresolvedField.data([], fn _data ->
case api.read(destination, filter: filter, paginate: false) do
{:ok, %{results: results}} -> {:ok, results}
{:error, error} -> {:error, error}
@ -223,7 +237,7 @@ defmodule Ash.Actions.Relationships do
changeset
|> add_requests(request)
|> changes_depend_on([:relationships, relationship_name, type])
|> changes_depend_on([:relationships, relationship_name, type, :data])
end
defp validate_relationship_change(relationship, data, action_type) do
@ -345,14 +359,22 @@ defmodule Ash.Actions.Relationships do
else
dependencies = Map.get(changeset, :__changes_depend_on__, [])
Ash.Engine2.Request.UnresolvedField.field(dependencies, fn _request, _, %{root: data} ->
Ash.Engine.Request.UnresolvedField.field(dependencies, fn data ->
new_changeset =
data
|> Map.get(:relationships, %{})
|> Enum.reduce(changeset, fn {relationship, relationship_data}, changeset ->
relationship_data =
Enum.into(relationship_data, %{}, fn {key, value} ->
{key, value.data}
end)
relationship = Ash.relationship(changeset.data.__struct__, relationship)
add_relationship_to_changeset(changeset, api, relationship, relationship_data)
end)
{:ok, new_changeset}
end)
end
end
@ -425,12 +447,19 @@ defmodule Ash.Actions.Relationships do
) do
pkey = Ash.primary_key(destination)
relationship_data = Map.put_new(relationship_data, :current, [])
case relationship_data do
%{current: [], replace: [new]} ->
changeset
|> relate_belongs_to(relationship, new)
|> add_relationship_change_metadata(relationship.name, %{add: [new]})
%{current: [], add: [new]} ->
changeset
|> relate_belongs_to(relationship, new)
|> add_relationship_change_metadata(relationship.name, %{add: [new]})
%{current: [current], replace: []} ->
changeset
|> relate_belongs_to(relationship, nil)
@ -824,7 +853,7 @@ defmodule Ash.Actions.Relationships do
changeset
|> add_requests(requests)
|> changes_depend_on([:relationships, relationship.name, :current])
|> changes_depend_on([:relationships, relationship.name, :current, :data])
end
defp add_relationship_currently_related_request(
@ -841,7 +870,7 @@ defmodule Ash.Actions.Relationships do
filter = Ash.Filter.parse(destination, filter_statement)
request =
Ash.Engine2.Request.new(
Ash.Engine.Request.new(
api: api,
rules: default_read.rules,
resource: destination,
@ -850,8 +879,8 @@ defmodule Ash.Actions.Relationships do
resolve_when_fetch_only?: true,
filter: filter,
data:
Ash.Engine2.Request.UnresolvedField.data([], fn _, _ ->
case api.read(destination, filter: filter_statement, paginate: false) do
Ash.Engine.Request.UnresolvedField.data([], fn _data ->
case api.read(destination, filter: filter, paginate: false) do
{:ok, %{results: results}} -> {:ok, results}
{:error, error} -> {:error, error}
end
@ -863,7 +892,7 @@ defmodule Ash.Actions.Relationships do
changeset
|> add_requests(request)
|> changes_depend_on([:relationships, relationship.name, :current])
|> changes_depend_on([:relationships, relationship.name, :current, :data])
end
defp many_to_many_join_resource_request(
@ -878,7 +907,7 @@ defmodule Ash.Actions.Relationships do
filter_statement = [{relationship.source_field_on_join_table, value}]
filter = Ash.Filter.parse(through, filter_statement)
Ash.Engine2.Request.new(
Ash.Engine.Request.new(
api: api,
rules: default_read.rules,
resource: through,
@ -887,7 +916,7 @@ defmodule Ash.Actions.Relationships do
filter: filter,
resolve_when_fetch_only?: true,
data:
Ash.Engine2.Request.UnresolvedField.data([], fn _, _ ->
Ash.Engine.Request.UnresolvedField.data([], fn _data ->
case api.read(through, filter: filter_statement) do
{:ok, %{results: results}} -> {:ok, results}
{:error, error} -> {:error, error}
@ -906,7 +935,7 @@ defmodule Ash.Actions.Relationships do
Ash.primary_action(destination, :read) ||
raise "Must have default read for #{inspect(destination)}"
Ash.Engine2.Request.new(
Ash.Engine.Request.new(
api: api,
rules: default_read.rules,
resource: destination,
@ -914,9 +943,9 @@ defmodule Ash.Actions.Relationships do
resolve_when_fetch_only?: true,
path: [:relationships, name, :current],
filter:
Ash.Engine2.Request.UnresolvedField.field(
[[:relationships, name, :current_join]],
fn _, _, %{root: %{relationships: %{^name => %{current_join: current_join}}}} ->
Ash.Engine.Request.UnresolvedField.field(
[[:relationships, name, :current_join, :data]],
fn %{relationships: %{^name => %{current_join: %{data: current_join}}}} ->
field_values =
Enum.map(current_join, &Map.get(&1, relationship.destination_field_on_join_table))
@ -926,9 +955,9 @@ defmodule Ash.Actions.Relationships do
end
),
data:
Ash.Engine2.Request.UnresolvedField.field(
[[:relationships, name, :current_join]],
fn _, _, %{root: %{relationships: %{^name => %{current_join: current_join}}}} ->
Ash.Engine.Request.UnresolvedField.field(
[[:relationships, name, :current_join, :data]],
fn %{relationships: %{^name => %{current_join: %{data: current_join}}}} ->
field_values =
Enum.map(current_join, &Map.get(&1, relationship.destination_field_on_join_table))

View file

@ -1,20 +1,39 @@
defmodule Ash.Actions.SideLoad do
def requests(api, resource, side_load, side_load_filters, root_filter, path \\ [])
def requests(_, _, [], _, _, _), do: {:ok, []}
def requests(
api,
resource,
side_load,
side_load_filters,
root_filter,
path \\ [],
seed_data \\ nil
)
def requests(api, resource, side_load, side_load_filters, root_filter, path) do
def requests(_, _, [], _, _, _, _), do: {:ok, []}
def requests(api, resource, side_load, side_load_filters, root_filter, path, seed_data) do
Enum.reduce(side_load, {:ok, []}, fn
_, {:error, error} ->
{:error, error}
{key, true}, {:ok, acc} ->
do_requests(api, resource, side_load_filters, key, [], root_filter, path, acc)
do_requests(api, resource, side_load_filters, key, [], root_filter, path, acc, seed_data)
{key, further}, {:ok, acc} ->
do_requests(api, resource, side_load_filters, key, further, root_filter, path, acc)
do_requests(
api,
resource,
side_load_filters,
key,
further,
root_filter,
path,
acc,
seed_data
)
key, {:ok, acc} ->
do_requests(api, resource, side_load_filters, key, [], root_filter, path, acc)
do_requests(api, resource, side_load_filters, key, [], root_filter, path, acc, seed_data)
end)
end
@ -35,10 +54,10 @@ defmodule Ash.Actions.SideLoad do
end
def side_load(api, resource, data, side_load, root_filter, side_load_filters \\ %{}) do
case requests(api, resource, side_load, side_load_filters, root_filter) do
{:ok, requests} when is_list(requests) ->
case Ash.Engine.run(nil, requests, state: %{data: data}) do
{:ok, state} ->
case requests(api, resource, side_load, side_load_filters, root_filter, [], data) do
{:ok, [_req | _] = requests} ->
case Ash.Engine.run(requests, api) do
%{data: %{data: %{data: data} = state}, errors: errors} when errors == %{} ->
case data do
nil ->
{:ok, nil}
@ -53,10 +72,13 @@ defmodule Ash.Actions.SideLoad do
{:ok, List.first(attach_side_loads([data], state))}
end
{:error, error} ->
{:error, error}
%{errors: errors} ->
{:error, errors}
end
{:ok, []} ->
{:ok, data}
{:error, error} ->
{:error, error}
end
@ -66,13 +88,13 @@ defmodule Ash.Actions.SideLoad do
%{paginator | results: attach_side_loads(results, state)}
end
def attach_side_loads([%resource{} | _] = data, %{root: %{include: includes}})
def attach_side_loads([%resource{} | _] = data, %{include: includes})
when is_list(data) do
includes
|> Enum.sort_by(fn {key, _value} ->
length(key)
end)
|> Enum.reduce(data, fn {key, value}, data ->
|> Enum.reduce(data, fn {key, %{data: value}}, data ->
last_relationship = last_relationship!(resource, key)
case last_relationship do
@ -157,7 +179,7 @@ defmodule Ash.Actions.SideLoad do
last_relationship!(relationship.destination, rest)
end
defp do_requests(api, resource, filters, key, further, root_filter, path, acc) do
defp do_requests(api, resource, filters, key, further, root_filter, path, acc, seed_data) do
with {:rel, relationship} when not is_nil(relationship) <-
{:rel, Ash.relationship(resource, key)},
nested_path <- path ++ [relationship],
@ -167,11 +189,18 @@ defmodule Ash.Actions.SideLoad do
Ash.primary_action(relationship.destination, :read) ||
raise "Must set default read for #{inspect(resource)}"
data_dependency =
if seed_data do
[[:data]]
else
[]
end
dependencies =
if path == [] do
[:data]
data_dependency
else
[:data, [:include, Enum.map(path, &Map.get(&1, :name))]]
data_dependency ++ [[:include, Enum.map(path, &Map.get(&1, :name)), :data]]
end
source =
@ -180,7 +209,7 @@ defmodule Ash.Actions.SideLoad do
|> Enum.map_join(".", &Map.get(&1, :name))
request =
Ash.Engine2.Request.new(
Ash.Engine.Request.new(
action_type: :read,
resource: relationship.destination,
rules: default_read.rules,
@ -193,11 +222,22 @@ defmodule Ash.Actions.SideLoad do
relationship,
Map.get(filters || %{}, source, []),
nested_path,
root_filter
root_filter,
data_dependency,
seed_data
),
strict_access?: true,
data:
Ash.Engine2.Request.UnresolvedField.data(dependencies, fn _request, data ->
Ash.Engine.Request.UnresolvedField.data(dependencies, fn data ->
data =
if seed_data do
Map.update(data, :data, %{data: seed_data}, fn data_request ->
Map.put(data_request, :data, seed_data)
end)
else
data
end
# Because we have the records, we can optimize the filter by nillifying the reverse relationship,
# and regenerating.
# The reverse relationship is useful if you don't have the relationship keys for the related items (only pkeys)
@ -232,7 +272,7 @@ defmodule Ash.Actions.SideLoad do
Enum.reverse(Enum.map(path, & &1.name)) ++ [join_relationship.name]
side_load_request =
Ash.Engine2.Request.new(
Ash.Engine.Request.new(
action_type: :read,
resource: relationship.through,
rules: default_read.rules,
@ -246,11 +286,21 @@ defmodule Ash.Actions.SideLoad do
Ash.relationship(resource, join_relationship.name),
[],
nested_path,
root_filter
root_filter,
data_dependency,
seed_data
),
strict_access?: true,
data:
Ash.Engine2.Request.UnresolvedField.data(dependencies, fn _request, data ->
Ash.Engine.Request.UnresolvedField.data(dependencies, fn data ->
if seed_data do
Map.update(data, :data, %{data: seed_data}, fn data_request ->
Map.put(data_request, :data, seed_data)
end)
else
data
end
with {:ok, filter} <-
true_side_load_filter(
join_relationship,
@ -286,16 +336,33 @@ defmodule Ash.Actions.SideLoad do
%{reverse_relationship: nil, type: :many_to_many} = relationship,
_request_filter,
_prior_path,
_root_filter
_root_filter,
_,
_
) do
Ash.Engine2.Request.UnresolvedField.field([], fn _, _, _ ->
Ash.Engine.Request.UnresolvedField.field([], fn _ ->
{:error, "Required reverse relationship for #{inspect(relationship)}"}
end)
end
defp side_load_filter2(relationship, request_filter, prior_path, root_filter)
defp side_load_filter2(
relationship,
request_filter,
prior_path,
root_filter,
data_dependency,
seed_data
)
when root_filter in [:update, :create] do
Ash.Engine2.Request.UnresolvedField.field([:data], fn _, _, %{root: %{data: data}} ->
Ash.Engine.Request.UnresolvedField.field(data_dependency, fn data ->
data =
if seed_data do
seed_data
else
# I'm a failure
data.data.data
end
root_filter =
case data do
[%resource{} = item] ->
@ -318,12 +385,19 @@ defmodule Ash.Actions.SideLoad do
end)
end
defp side_load_filter2(relationship, request_filter, prior_path, root_filter) do
defp side_load_filter2(
relationship,
request_filter,
prior_path,
root_filter,
_data_dependency,
_seed_data
) do
# TODO: If the root request is non `strict_access?`, then we could actually
# do something like this, using the full path. For now, we'll just authorize
# with the filter that is provided, since adding id filters to that
# (if reverse relationship is nil)
# Ash.Engine2.Request.UnresolvedField.field(dependencies, fn
# Ash.Engine.Request.UnresolvedField.field(dependencies, fn
# %{path: [:include, [_]]}, _, %{data: data} ->
# new_values = Enum.map(data, &Map.get(&1, relationship.source_field))
@ -380,20 +454,20 @@ defmodule Ash.Actions.SideLoad do
source_data =
case path do
[] ->
Map.get(data.root, :data)
Map.get(data, :data)
path ->
Map.get(data, [:include, Enum.reverse(path)])
end
values = get_fields(source_data, pkey)
values = get_fields(source_data.data, pkey)
cond do
reverse_relationship ->
{:ok, put_nested_relationship(filter, [reverse_relationship], values)}
true ->
ids = Enum.map(source_data, &Map.get(&1, relationship.source_field))
ids = Enum.map(source_data.data, &Map.get(&1, relationship.source_field))
filter_value =
case ids do

View file

@ -1,6 +1,7 @@
defmodule Ash.Actions.Update do
alias Ash.Engine
alias Ash.Actions.{Attributes, Relationships, SideLoad}
require Logger
@spec run(Ash.api(), Ash.record(), Ash.action(), Ash.params()) ::
{:ok, Ash.record()} | {:error, Ecto.Changeset.t()} | {:error, Ash.error()}
@ -39,14 +40,35 @@ defmodule Ash.Actions.Update do
%{valid?: true} = changeset <- changeset(record, api, params),
{:ok, side_load_requests} <-
SideLoad.requests(api, resource, side_loads, :update, side_load_filter),
{:ok, %{data: updated}} = state <-
%{data: %{data: %{data: updated}}} = state <-
do_authorized(changeset, params, action, resource, api, side_load_requests) do
{:ok, SideLoad.attach_side_loads(updated, state)}
else
%Ecto.Changeset{} = changeset ->
{:error, changeset}
%{errors: errors} ->
if params[:authorization][:log_final_report?] do
case errors do
%{__engine__: errors} ->
for %Ash.Error.Forbidden{} = forbidden <- List.wrap(errors) do
Logger.info(Ash.Error.Forbidden.report_text(forbidden))
end
_ ->
:ok
end
end
{:error, errors}
{:error, error} ->
if params[:authorization][:log_final_report?] do
for %Ash.Error.Forbidden{} = forbidden <- List.wrap(error) do
Logger.info(Ash.Error.Forbidden.report_text(forbidden))
end
end
{:error, error}
end
end
@ -64,7 +86,7 @@ defmodule Ash.Actions.Update do
relationships = Keyword.get(params, :relationships)
update_request =
Ash.Engine2.Request.new(
Ash.Engine.Request.new(
api: api,
rules: action.rules,
changeset:
@ -75,21 +97,24 @@ defmodule Ash.Actions.Update do
),
action_type: action.type,
data:
Ash.Engine2.Request.UnresolvedField.data([], fn request, _data ->
Ash.Engine.Request.UnresolvedField.data(
[[:data, :changeset]],
fn %{data: %{changeset: changeset}} ->
resource
|> Ash.DataLayer.update(request.changeset)
|> Ash.DataLayer.update(changeset)
|> case do
{:ok, result} ->
request.changeset
changeset
|> Map.get(:__after_changes__, [])
|> Enum.reduce_while({:ok, result}, fn func, {:ok, result} ->
case func.(request.changeset, result) do
case func.(changeset, result) do
{:ok, result} -> {:cont, {:ok, result}}
{:error, error} -> {:halt, {:error, error}}
end
end)
end
end),
end
),
path: :data,
resolve_when_fetch_only?: true,
name: "#{action.type} - `#{action.name}`"
@ -107,17 +132,16 @@ defmodule Ash.Actions.Update do
end
Engine.run(
params[:authorization][:user],
[update_request | attribute_requests] ++ relationship_requests ++ side_load_requests,
api,
strict_access?: strict_access?,
user: params[:authorization][:user],
log_final_report?: params[:authorization][:log_final_report?] || false
)
else
authorization = params[:authorization] || []
Engine.run(
authorization[:user],
[update_request | attribute_requests] ++ relationship_requests ++ side_load_requests,
api,
fetch_only?: true
)
end

View file

@ -477,6 +477,11 @@ defmodule Ash.Api.Interface do
raise(Ash.Error.FrameworkError, message: "invalid changes #{inspect(changeset)}")
end
defp unwrap_or_raise!({:error, error}) when is_map(error) do
# TODO: format this better!
raise Ash.Error.FrameworkError, message: "Engine errors: #{inspect(error)}"
end
defp unwrap_or_raise!({:error, error}) when not is_list(error) do
raise error
end

View file

@ -2,4 +2,6 @@ defmodule Ash.Authorization.Check.AttributeBuiltInChecks do
def setting(opts) do
{Ash.Authorization.Check.SettingAttribute, Keyword.take(opts, [:to])}
end
def logged_in(), do: {Ash.Authorization.Check.LoggedIn, []}
end

View file

@ -47,4 +47,6 @@ defmodule Ash.Authorization.Check.BuiltInChecks do
def relationship_set(relationship_name) do
{Ash.Authorization.Check.RelationshipSet, [relationship_name: relationship_name]}
end
def logged_in(), do: {Ash.Authorization.Check.LoggedIn, []}
end

View file

@ -0,0 +1,12 @@
defmodule Ash.Authorization.Check.LoggedIn do
use Ash.Authorization.Check, action_types: [:read, :update, :delete, :create]
@impl true
def describe(_opts) do
"user is logged in"
end
@impl true
def strict_check(nil, _request, _options), do: {:ok, false}
def strict_check(_, _request, _options), do: {:ok, true}
end

View file

@ -8,4 +8,6 @@ defmodule Ash.Authorization.Check.RelationshipBuiltInChecks do
def relationship_set() do
{Ash.Authorization.Check.RelationshipSet, []}
end
def logged_in(), do: {Ash.Authorization.Check.LoggedIn, []}
end

View file

@ -1,6 +1,5 @@
defmodule Ash.Authorization.Check.UserAttributeMatchesRecord do
use Ash.Authorization.Check, action_types: [:read, :update, :delete]
alias Ash.Engine2.Request
@impl true
def describe(opts) do

View file

@ -19,46 +19,46 @@ defmodule Ash.Authorization.Checker do
alias Ash.Actions.SideLoad
alias Ash.Authorization.Clause
def strict_check(user, request, facts, strict_access?) do
if request.__struct__.can_strict_check?(request) do
new_facts =
request.rules
|> Enum.reduce(facts, fn {_step, clause}, facts ->
case Clause.find(facts, clause) do
{:ok, _boolean_result} ->
facts
# def strict_check(user, request, facts, strict_access?) do
# if request.__struct__.can_strict_check?(request) do
# new_facts =
# request.rules
# |> Enum.reduce(facts, fn {_step, clause}, facts ->
# case Clause.find(facts, clause) do
# {:ok, _boolean_result} ->
# facts
:error ->
case do_strict_check(clause, user, request, strict_access?) do
{:error, _error} ->
# TODO: Surface this error
facts
# :error ->
# case do_strict_check(clause, user, request, strict_access?) do
# {:error, _error} ->
# # TODO: Surface this error
# facts
:unknown ->
facts
# :unknown ->
# facts
:unknowable ->
Map.put(facts, clause, :unknowable)
# :unknowable ->
# Map.put(facts, clause, :unknowable)
:irrelevant ->
Map.put(facts, clause, :irrelevant)
# :irrelevant ->
# Map.put(facts, clause, :irrelevant)
boolean ->
Map.put(facts, clause, boolean)
end
end
end)
# boolean ->
# Map.put(facts, clause, boolean)
# end
# end
# end)
Logger.debug("Completed strict_check for #{request.name}")
# Logger.debug("Completed strict_check for #{request.name}")
{Map.put(request, :strict_check_completed?, true), new_facts}
else
{request, facts}
end
end
# {Map.put(request, :strict_check_completed?, true), new_facts}
# else
# {request, facts}
# end
# end
def strict_check2(user, request, facts) do
if Ash.Engine2.Request.can_strict_check?(request) do
if Ash.Engine.Request.can_strict_check?(request) do
new_facts =
request.rules
|> Enum.reduce(facts, fn {_step, clause}, facts ->
@ -95,316 +95,316 @@ defmodule Ash.Authorization.Checker do
end
end
def run_checks(scenarios, user, requests, facts, state, strict_access?) do
all_checkable_clauses = all_checkable_clauses_from_scenarios(scenarios, facts)
# def run_checks(scenarios, user, requests, facts, state, strict_access?) do
# all_checkable_clauses = all_checkable_clauses_from_scenarios(scenarios, facts)
case clauses_checkable_without_fetching_data(all_checkable_clauses, requests, state) do
{[], []} ->
:all_scenarios_known
# case clauses_checkable_without_fetching_data(all_checkable_clauses, requests, state) do
# {[], []} ->
# :all_scenarios_known
{[], _clauses_requiring_fetch} ->
case fetch_requests(requests, state, strict_access?) do
{:ok, {new_requests, new_state}} ->
{:ok, new_requests, facts, new_state}
# {[], _clauses_requiring_fetch} ->
# case fetch_requests(requests, state, strict_access?) do
# {:ok, {new_requests, new_state}} ->
# {:ok, new_requests, facts, new_state}
:all_scenarios_known ->
:all_scenarios_known
# :all_scenarios_known ->
# :all_scenarios_known
{:error, error} ->
{:error, error}
end
# {:error, error} ->
# {:error, error}
# end
{clauses, _} ->
# TODO: We could limit/smartly choose the checks that we prepare and run here as an optimization
case prepare_checks(clauses, requests, state) do
{:ok, new_state} ->
case do_run_checks(clauses, user, requests, facts, new_state, strict_access?) do
{:ok, new_facts, new_state} -> {:ok, requests, new_facts, new_state}
{:error, error} -> {:error, error}
end
# {clauses, _} ->
# # TODO: We could limit/smartly choose the checks that we prepare and run here as an optimization
# case prepare_checks(clauses, requests, state) do
# {:ok, new_state} ->
# case do_run_checks(clauses, user, requests, facts, new_state, strict_access?) do
# {:ok, new_facts, new_state} -> {:ok, requests, new_facts, new_state}
# {:error, error} -> {:error, error}
# end
{:error, error} ->
{:error, error}
end
end
end
# {:error, error} ->
# {:error, error}
# end
# end
# end
# TODO: We could be smart here, and likely fetch multiple requests at a time
defp fetch_requests(requests, state, strict_access?) do
{fetchable_requests, other_requests} =
Enum.split_with(requests, fn request ->
bypass_strict? =
if strict_access? do
request.bypass_strict_access?
else
true
end
# defp fetch_requests(requests, state, strict_access?) do
# {fetchable_requests, other_requests} =
# Enum.split_with(requests, fn request ->
# bypass_strict? =
# if strict_access? do
# request.bypass_strict_access?
# else
# true
# end
bypass_strict? && !Request.fetched?(state, request) &&
Request.dependencies_met?(state, request)
# bypass_strict? && !Request.fetched?(state, request) &&
# Request.dependencies_met?(state, request)
# TODO: In the new engine, we need to authorize requests as their
# individual rules are met/be able to check if an individual request
# can be authorized, so we can fetch it if other checks depend on it
# (Enum.any?(requests, &Request.depends_on?(request, &1)) &&
# passes_via_strict_check?(request, state))
end)
# # TODO: In the new engine, we need to authorize requests as their
# # individual rules are met/be able to check if an individual request
# # can be authorized, so we can fetch it if other checks depend on it
# # (Enum.any?(requests, &Request.depends_on?(request, &1)) &&
# # passes_via_strict_check?(request, state))
# end)
fetchable_requests_with_dependent_fields =
Enum.reduce_while(fetchable_requests, {:ok, []}, fn request, {:ok, requests} ->
case Request.fetch_dependent_fields(state, request) do
{:ok, request} -> {:cont, {:ok, [request | requests]}}
{:error, error} -> {:halt, {:error, error}}
end
end)
# fetchable_requests_with_dependent_fields =
# Enum.reduce_while(fetchable_requests, {:ok, []}, fn request, {:ok, requests} ->
# case Request.fetch_dependent_fields(state, request) do
# {:ok, request} -> {:cont, {:ok, [request | requests]}}
# {:error, error} -> {:halt, {:error, error}}
# end
# end)
case fetchable_requests_with_dependent_fields do
{:error, error} ->
{:error, error}
# case fetchable_requests_with_dependent_fields do
# {:error, error} ->
# {:error, error}
{:ok, fetchable_requests_with_changeset} ->
fetchable_requests_with_changeset
|> Enum.sort_by(fn request ->
# Requests that bypass strict access should generally perform well
# as they would generally be more efficient checks
{request.strict_check_completed?, -Enum.count(request.relationship),
not request.bypass_strict_access?, request.relationship}
end)
|> case do
[request | rest] = requests ->
case Request.fetch(state, request) do
{:ok, new_state} ->
new_requests = [%{request | is_fetched: true} | rest] ++ other_requests
{:ok, {new_requests, new_state}}
# {:ok, fetchable_requests_with_changeset} ->
# fetchable_requests_with_changeset
# |> Enum.sort_by(fn request ->
# # Requests that bypass strict access should generally perform well
# # as they would generally be more efficient checks
# {request.strict_check_completed?, -Enum.count(request.relationship),
# not request.bypass_strict_access?, request.relationship}
# end)
# |> case do
# [request | rest] = requests ->
# case Request.fetch(state, request) do
# {:ok, new_state} ->
# new_requests = [%{request | is_fetched: true} | rest] ++ other_requests
# {:ok, {new_requests, new_state}}
{:error, _} ->
{:ok, {requests ++ other_requests, state}}
end
# {:error, _} ->
# {:ok, {requests ++ other_requests, state}}
# end
_ ->
:all_scenarios_known
end
end
end
# _ ->
# :all_scenarios_known
# end
# end
# end
defp do_run_checks(clauses, user, requests, facts, state, strict_access?) do
Enum.reduce_while(clauses, {:ok, facts, state}, fn clause, {:ok, facts, state} ->
request =
requests
# This puts all requests with `bypass_strict_access?` in the front
# because if we can we want to find one of those first for the check below
|> Enum.sort_by(fn request ->
not request.bypass_strict_access?
end)
|> Enum.find(fn request ->
Request.contains_clause?(request, clause)
end) ||
raise "Internal assumption failed"
# defp do_run_checks(clauses, user, requests, facts, state, strict_access?) do
# Enum.reduce_while(clauses, {:ok, facts, state}, fn clause, {:ok, facts, state} ->
# request =
# requests
# # This puts all requests with `bypass_strict_access?` in the front
# # because if we can we want to find one of those first for the check below
# |> Enum.sort_by(fn request ->
# not request.bypass_strict_access?
# end)
# |> Enum.find(fn request ->
# Request.contains_clause?(request, clause)
# end) ||
# raise "Internal assumption failed"
{:ok, request_state} = Request.fetch_request_state(state, request)
request_state = List.wrap(request_state)
# {:ok, request_state} = Request.fetch_request_state(state, request)
# request_state = List.wrap(request_state)
check_module = clause.check_module
check_opts = clause.check_opts
# check_module = clause.check_module
# check_opts = clause.check_opts
cond do
request_state == [] and strict_access? and !request.bypass_strict_access? ->
{:cont, {:ok, Map.put(facts, clause, :unknowable), state}}
# cond do
# request_state == [] and strict_access? and !request.bypass_strict_access? ->
# {:cont, {:ok, Map.put(facts, clause, :unknowable), state}}
request_state == [] ->
{:cont, {:ok, Map.put(facts, clause, :irrelevant), state}}
# request_state == [] ->
# {:cont, {:ok, Map.put(facts, clause, :irrelevant), state}}
true ->
# TODO: Determine whether or not checks need the ability to generate additional state.
# If they do, we need to store that additional check state in `state` and pass it in here
case check_module.check(user, request_state, %{}, check_opts) do
{:error, error} ->
{:halt, {:error, error}}
# true ->
# # TODO: Determine whether or not checks need the ability to generate additional state.
# # If they do, we need to store that additional check state in `state` and pass it in here
# case check_module.check(user, request_state, %{}, check_opts) do
# {:error, error} ->
# {:halt, {:error, error}}
{:ok, check_result} ->
{:cont,
{:ok, add_check_results_to_facts(clause, check_result, request_state, facts),
state}}
end
end
end)
end
# {:ok, check_result} ->
# {:cont,
# {:ok, add_check_results_to_facts(clause, check_result, request_state, facts),
# state}}
# end
# end
# end)
# end
defp clauses_checkable_without_fetching_data([], _, _), do: {[], []}
# defp clauses_checkable_without_fetching_data([], _, _), do: {[], []}
defp clauses_checkable_without_fetching_data(clauses, requests, state) do
Enum.split_with(clauses, fn clause ->
Enum.any?(requests, fn request ->
Request.fetched?(state, request) && Request.contains_clause?(request, clause) &&
Request.dependencies_met?(state, request) && Request.dependent_fields_fetched?(request)
end)
end)
end
# defp clauses_checkable_without_fetching_data(clauses, requests, state) do
# Enum.split_with(clauses, fn clause ->
# Enum.any?(requests, fn request ->
# Request.fetched?(state, request) && Request.contains_clause?(request, clause) &&
# Request.dependencies_met?(state, request) && Request.dependent_fields_fetched?(request)
# end)
# end)
# end
defp all_checkable_clauses_from_scenarios(scenarios, facts) do
scenarios
|> Enum.flat_map(fn scenario ->
scenario
|> Map.drop([true, false])
|> Enum.map(&elem(&1, 0))
end)
|> Enum.reject(fn clause ->
match?({:ok, _}, Ash.Authorization.Clause.find(facts, clause))
end)
end
# defp all_checkable_clauses_from_scenarios(scenarios, facts) do
# scenarios
# |> Enum.flat_map(fn scenario ->
# scenario
# |> Map.drop([true, false])
# |> Enum.map(&elem(&1, 0))
# end)
# |> Enum.reject(fn clause ->
# match?({:ok, _}, Ash.Authorization.Clause.find(facts, clause))
# end)
# end
# Check returning `{:ok, true}` means all records are authorized
# while `{:ok, false}` means all records are not
defp add_check_results_to_facts(clause, boolean, _data, facts) when is_boolean(boolean) do
Map.put(facts, clause, boolean)
end
# # Check returning `{:ok, true}` means all records are authorized
# # while `{:ok, false}` means all records are not
# defp add_check_results_to_facts(clause, boolean, _data, facts) when is_boolean(boolean) do
# Map.put(facts, clause, boolean)
# end
defp add_check_results_to_facts(clause, [], _data, facts), do: Map.put(facts, clause, false)
# defp add_check_results_to_facts(clause, [], _data, facts), do: Map.put(facts, clause, false)
defp add_check_results_to_facts(clause, [%resource{} | _] = records, data, facts) do
pkey = Ash.primary_key(resource)
record_pkeys = Enum.map(records, &Map.take(&1, pkey))
# defp add_check_results_to_facts(clause, [%resource{} | _] = records, data, facts) do
# pkey = Ash.primary_key(resource)
# record_pkeys = Enum.map(records, &Map.take(&1, pkey))
case Enum.split_with(data, fn record ->
Map.take(record, pkey) in record_pkeys
end) do
{[], _} ->
Map.put(facts, clause, false)
# case Enum.split_with(data, fn record ->
# Map.take(record, pkey) in record_pkeys
# end) do
# {[], _} ->
# Map.put(facts, clause, false)
{_, []} ->
Map.put(facts, clause, true)
# {_, []} ->
# Map.put(facts, clause, true)
{true_data, false_data} ->
facts = set_records_to(true_data, facts, clause, true, pkey)
# {true_data, false_data} ->
# facts = set_records_to(true_data, facts, clause, true, pkey)
set_records_to(false_data, facts, clause, false, pkey)
end
end
# set_records_to(false_data, facts, clause, false, pkey)
# end
# end
defp set_records_to(data, facts, clause, value, pkey) do
Enum.reduce(data, facts, fn record, facts ->
pkey_clause = %{clause | pkey: Map.take(record, pkey)}
# defp set_records_to(data, facts, clause, value, pkey) do
# Enum.reduce(data, facts, fn record, facts ->
# pkey_clause = %{clause | pkey: Map.take(record, pkey)}
facts
|> Map.put(pkey_clause, value)
end)
end
# facts
# |> Map.put(pkey_clause, value)
# end)
# end
defp prepare_checks(checks, requests, state) do
checks
|> group_fetched_checks_by_request(requests, state)
|> Enum.reduce_while({:ok, state}, fn {request, checks}, {:ok, state} ->
{:ok, data} = Request.fetch_request_state(state, request)
# def prepare_checks(checks, requests, state) do
# checks
# |> group_fetched_checks_by_request(requests)
# |> Enum.reduce_while({:ok, state}, fn {request, checks}, {:ok, state} ->
# {:ok, data} = Request.fetch_request_state(state, request)
case get_preparation(checks) do
{:ok, preparations} ->
case run_preparations(request, data, preparations) do
{:ok, new_data} ->
{:cont, {:ok, Request.put_request_state(state, request, new_data)}}
# case get_preparation(checks) do
# {:ok, preparations} ->
# case run_preparations(request, data, preparations) do
# {:ok, new_data} ->
# {:cont, {:ok, Request.put_request_state(state, request, new_data)}}
{:error, error} ->
{:halt, {:error, error}}
end
# {:error, error} ->
# {:halt, {:error, error}}
# end
{:error, error} ->
{:halt, {:error, error}}
end
end)
end
# {:error, error} ->
# {:halt, {:error, error}}
# end
# end)
# end
defp run_preparations(request, data, preparations) do
Enum.reduce_while(preparations, {:ok, data}, fn {name, value}, {:ok, data} ->
case run_preparation(request, data, name, value) do
{:ok, new_data} -> {:cont, {:ok, new_data}}
{:error, error} -> {:halt, {:error, error}}
end
end)
end
# defp run_preparations(request, data, preparations) do
# Enum.reduce_while(preparations, {:ok, data}, fn {name, value}, {:ok, data} ->
# case run_preparation(request, data, name, value) do
# {:ok, new_data} -> {:cont, {:ok, new_data}}
# {:error, error} -> {:halt, {:error, error}}
# end
# end)
# end
defp run_preparation(_, [], :side_load, _), do: {:ok, []}
defp run_preparation(_, nil, :side_load, _), do: {:ok, nil}
# defp run_preparation(_, [], :side_load, _), do: {:ok, []}
# defp run_preparation(_, nil, :side_load, _), do: {:ok, nil}
defp run_preparation(request, data, :side_load, side_load) do
SideLoad.side_load(request.api, request.resource, data, [], side_load)
end
# defp run_preparation(request, data, :side_load, side_load) do
# SideLoad.side_load(request.api, request.resource, data, [], side_load)
# end
defp run_preparation(_, _, preparation, _), do: {:error, "Unknown preparation #{preparation}"}
# defp run_preparation(_, _, preparation, _), do: {:error, "Unknown preparation #{preparation}"}
defp get_preparation(checks) do
Enum.reduce_while(checks, {:ok, %{}}, fn check, {:ok, preparations} ->
case check.check_module.prepare(check.check_opts) do
[] ->
{:cont, {:ok, preparations}}
# defp get_preparation(checks) do
# Enum.reduce_while(checks, {:ok, %{}}, fn check, {:ok, preparations} ->
# case check.check_module.prepare(check.check_opts) do
# [] ->
# {:cont, {:ok, preparations}}
new_preparations ->
case do_add_preparations(new_preparations, preparations) do
{:ok, combined_preparations} -> {:cont, {:ok, combined_preparations}}
{:error, error} -> {:halt, {:error, error}}
end
end
end)
end
# new_preparations ->
# case do_add_preparations(new_preparations, preparations) do
# {:ok, combined_preparations} -> {:cont, {:ok, combined_preparations}}
# {:error, error} -> {:halt, {:error, error}}
# end
# end
# end)
# end
defp do_add_preparations(new_preparations, preparations) do
Enum.reduce_while(new_preparations, {:ok, preparations}, fn {name, value},
{:ok, preparations} ->
case add_preparation(name, value, preparations) do
{:ok, preparations} -> {:cont, {:ok, preparations}}
{:error, error} -> {:halt, {:error, error}}
end
end)
end
# defp do_add_preparations(new_preparations, preparations) do
# Enum.reduce_while(new_preparations, {:ok, preparations}, fn {name, value},
# {:ok, preparations} ->
# case add_preparation(name, value, preparations) do
# {:ok, preparations} -> {:cont, {:ok, preparations}}
# {:error, error} -> {:halt, {:error, error}}
# end
# end)
# end
defp add_preparation(:side_load, side_load, preparations) do
{:ok, Map.update(preparations, :side_load, side_load, &SideLoad.merge(&1, side_load))}
end
# defp add_preparation(:side_load, side_load, preparations) do
# {:ok, Map.update(preparations, :side_load, side_load, &SideLoad.merge(&1, side_load))}
# end
defp add_preparation(preparation, _, _) do
{:error, "Unkown preparation #{preparation}"}
end
# defp add_preparation(preparation, _, _) do
# {:error, "Unkown preparation #{preparation}"}
# end
defp group_fetched_checks_by_request(clauses, requests, state) do
requests =
Enum.sort_by(requests, fn request ->
# Requests that bypass strict access should generally perform well
# as they would generally be more efficient checks
{Enum.count(request.relationship), not request.bypass_strict_access?,
request.relationship}
end)
# defp group_fetched_checks_by_request(clauses, requests) do
# requests =
# Enum.sort_by(requests, fn request ->
# # Requests that bypass strict access should generally perform well
# # as they would generally be more efficient checks
# {Enum.count(request.relationship), not request.bypass_strict_access?,
# request.relationship}
# end)
Enum.group_by(clauses, fn clause ->
Enum.find(requests, fn request ->
Request.fetched?(state, request) && Request.contains_clause?(request, clause)
end) || raise "Assumption failed"
end)
end
# Enum.group_by(clauses, fn clause ->
# Enum.find(requests, fn request ->
# Request.data_resolved?(request) && Request.contains_clause?(request, clause)
# end) || raise "Assumption failed"
# end)
# end
defp do_strict_check(%{check_module: module, check_opts: opts}, user, request, strict_access?) do
case module.strict_check(user, request, opts) do
{:error, error} ->
{:error, error}
# defp do_strict_check(%{check_module: module, check_opts: opts}, user, request, strict_access?) do
# case module.strict_check(user, request, opts) do
# {:error, error} ->
# {:error, error}
{:ok, boolean} when is_boolean(boolean) ->
boolean
# {:ok, boolean} when is_boolean(boolean) ->
# boolean
{:ok, :irrelevant} ->
:irrelevant
# {:ok, :irrelevant} ->
# :irrelevant
{:ok, :unknown} ->
cond do
strict_access? && not request.bypass_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
# {:ok, :unknown} ->
# cond do
# strict_access? && not request.bypass_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
# Ash.Authorization.Check.defines_check?(module) ->
# :unknown
true ->
:unknowable
end
end
end
# true ->
# :unknowable
# end
# end
# end
defp do_strict_check2(%{check_module: module, check_opts: opts}, user, request) do
case module.strict_check(user, request, opts) do

View file

@ -1,10 +1,8 @@
defmodule Ash.Authorization.Clause do
defstruct [:path, :resource, :source, :check_module, :check_opts, :filter]
defstruct [:path, :resource, :check_module, :check_opts, :filter]
def new(_path, resource, {mod, opts}, source, filter \\ nil) do
def new(resource, {mod, opts}, filter \\ nil) do
%__MODULE__{
# path: path,
source: source,
resource: resource,
check_module: mod,
check_opts: opts,
@ -12,15 +10,6 @@ defmodule Ash.Authorization.Clause do
}
end
# TODO: Should we for sure special case this? I see no reason not to.
def put_new_fact(facts, _path, _resource, {Ash.Authorization.Clause.Static, _}, _) do
facts
end
def put_new_fact(facts, path, resource, {mod, opts}, value, filter \\ nil) do
Map.put(facts, new(path, resource, {mod, opts}, filter), value)
end
def find(_clauses, %{check_module: Ash.Authorization.Check.Static, check_opts: check_opts}) do
{:ok, check_opts[:result]}
end
@ -41,18 +30,47 @@ defmodule Ash.Authorization.Clause do
{:or, clause, %{clause | filter: nil}}
end
def prune_facts(facts) do
new_facts = do_prune_facts(facts)
if new_facts == facts do
new_facts
else
do_prune_facts(new_facts)
end
end
defp do_prune_facts(facts) do
Enum.reduce(facts, facts, fn {clause, _value}, facts ->
without_clause = Map.delete(facts, clause)
case find(without_clause, clause) do
nil ->
without_clause
_ ->
facts
end
end)
end
defp is_matching_clause?(clause, clause), do: true
defp is_matching_clause?(clause, other_clause)
when is_boolean(clause) or is_boolean(other_clause),
do: false
defp is_matching_clause?(_, %__MODULE__{filter: nil}), do: true
defp is_matching_clause?(clause, %__MODULE__{filter: nil} = potential_matching) do
Map.take(clause, [:resource, :check_module, :check_opts]) ==
Map.take(potential_matching, [:resource, :check_module, :check_opts])
end
defp is_matching_clause?(%__MODULE__{filter: nil}, _), do: false
defp is_matching_clause?(clause, potential_matching) do
Map.put(clause, :filter, nil) == Map.put(potential_matching, :filter, nil) &&
Ash.Filter.strict_subset_of?(potential_matching.filter, clause.filter)
Ash.Filter.strict_subset_of?(potential_matching.filter, clause.filter) &&
Map.take(clause, [:resource, :check_module, :check_opts]) ==
Map.take(potential_matching, [:resource, :check_module, :check_opts])
end
end
@ -67,17 +85,8 @@ defimpl Inspect, for: Ash.Authorization.Clause do
""
end
source =
case clause.source do
:root ->
""
source ->
to_string(source)
end
terminator =
if filter != "" || source != "" do
if filter != "" do
": "
else
""
@ -85,7 +94,6 @@ defimpl Inspect, for: Ash.Authorization.Clause do
concat([
"#Clause<",
source,
filter,
terminator,
to_doc(clause.check_module.describe(clause.check_opts), opts),

View file

@ -5,9 +5,7 @@ defmodule Ash.Authorization.Report do
:scenarios,
:requests,
:facts,
:strict_check_facts,
:state,
:strict_access?,
:header,
:authorized?,
:reason,
@ -23,27 +21,11 @@ defmodule Ash.Authorization.Report do
def report(report) do
header = (report.header || "Authorization Report") <> "\n"
explained_steps =
case report.state do
%{data: data} when data not in [[], nil] ->
explain_steps_with_data(
report.requests,
report.facts,
List.wrap(data),
report.strict_access?
)
facts = Ash.Authorization.Clause.prune_facts(report.facts)
_ ->
if report.strict_access? do
"\n\n\nAuthorization run with `strict_access?: true`. This is the only safe way to authorize requests for lists of filtered data.\n" <>
"Some checks may still fetch data from the database, like filters on related data when their primary key was given.\n" <>
explain_steps(report.requests, report.facts, report.strict_access?)
else
explain_steps(report.requests, report.facts, report.strict_access?)
end
end
explained_steps = explain_steps(report.requests, facts)
explained_facts = explain_facts(report.facts, report.strict_check_facts || %{})
explained_facts = explain_facts(facts)
reason =
if report.reason do
@ -76,203 +58,220 @@ defmodule Ash.Authorization.Report do
"""
end
defp explain_steps_with_data(requests, facts, data, strict_access?) do
title = "\n\nAuthorization Steps:\n\n"
# defp explain_steps_with_data(requests, facts, data) do
# title = "\n\nAuthorization Steps:\n\n"
contents =
requests
|> Enum.map_join("\n---\n", fn request ->
relationship = request.relationship
resource = request.resource
# contents =
# requests
# |> Enum.map_join("\n---\n", fn request ->
# relationship = request.relationship
# resource = request.resource
inner_title =
if relationship == [] do
request.source <> " -> " <> inspect(resource) <> ": "
else
Enum.join(relationship, ".") <> " - " <> inspect(resource) <> ": "
end
# inner_title =
# if relationship == [] do
# request.source <> " -> " <> inspect(resource) <> ": "
# else
# Enum.join(relationship, ".") <> " - " <> inspect(resource) <> ": "
# end
full_inner_title =
if request.bypass_strict_access? && strict_access? do
inner_title <> " (bypass strict access)"
else
inner_title
end
# full_inner_title =
# if request.strict_access? do
# inner_title <> " (strict access)"
# else
# inner_title
# end
rules_legend =
request.rules
|> Enum.with_index()
|> Enum.map_join("\n", fn {{step, check}, index} ->
"#{index + 1}| " <>
to_string(step) <> ": " <> check.check_module.describe(check.check_opts)
# rules_legend =
# request.rules
# |> Enum.with_index()
# |> Enum.map_join("\n", fn {{step, check}, index} ->
# "#{index + 1}| " <>
# to_string(step) <> ": " <> check.check_module.describe(check.check_opts)
# end)
# pkey = Ash.primary_key(resource)
# # TODO: data has to change with relationships
# data_info =
# data
# |> Enum.map(fn item ->
# formatted =
# item
# |> Map.take(pkey)
# |> format_pkey()
# {formatted, Map.take(item, pkey)}
# end)
# |> add_header_line(indent("Record"))
# |> pad()
# |> add_step_info(request.rules, facts)
# full_inner_title <>
# ":\n" <> indent(rules_legend <> "\n\n" <> data_info <> "\n")
# end)
# title <> indent(contents)
# end
# defp add_step_info([header | rest], steps, facts) do
# key = Enum.join(1..Enum.count(steps), "|")
# header <>
# indent(
# " |" <>
# key <>
# "|\n" <>
# do_add_step_info(rest, steps, facts)
# )
# end
# defp do_add_step_info(pkeys, steps, facts) do
# Enum.map_join(pkeys, "\n", fn {pkey_line, pkey} ->
# steps
# |> Enum.reduce({true, pkey_line <> " "}, fn
# {_step, _clause}, {false, string} ->
# {false, string <> "|~"}
# {step, clause}, {true, string} ->
# status =
# case Clause.find(facts, %{clause | pkey: pkey}) do
# {:ok, value} -> value
# _ -> nil
# end
# mark = step_to_mark(step, status)
# new_mark =
# if mark == "↓" do
# "→"
# else
# mark
# end
# continue? = new_mark not in ["✓", "✗"]
# {continue?, string <> "|" <> new_mark}
# end)
# |> elem(1)
# |> Kernel.<>("|")
# end)
# end
# defp add_header_line(lines, title) do
# [title | lines]
# end
# defp pad(lines) do
# longest =
# lines
# |> Enum.map(fn
# {line, _pkey} ->
# String.length(line)
# line ->
# String.length(line)
# end)
# |> Enum.max()
# Enum.map(
# lines,
# fn
# {line, pkey} ->
# length = String.length(line)
# {line <> String.duplicate(" ", longest - length), pkey}
# line ->
# length = String.length(line)
# line <> String.duplicate(" ", longest - length)
# end
# )
# end
defp count_of_clauses(nil), do: 0
defp count_of_clauses(filter) do
relationship_clauses =
filter.relationships
|> Map.values()
|> Enum.map(fn related_filter ->
1 + count_of_clauses(related_filter)
end)
|> Enum.sum()
pkey = Ash.primary_key(resource)
or_clauses =
filter.ors
|> Kernel.||([])
|> Enum.map(&count_of_clauses/1)
|> Enum.sum()
# TODO: data has to change with relationships
data_info =
data
|> Enum.map(fn item ->
formatted =
item
|> Map.take(pkey)
|> format_pkey()
not_clauses = count_of_clauses(filter.not)
{formatted, Map.take(item, pkey)}
end)
|> add_header_line(indent("Record"))
|> pad()
|> add_step_info(request.rules, facts)
full_inner_title <>
":\n" <> indent(rules_legend <> "\n\n" <> data_info <> "\n")
end)
title <> indent(contents)
Enum.count(filter.attributes) + relationship_clauses + or_clauses + not_clauses
end
defp add_step_info([header | rest], steps, facts) do
key = Enum.join(1..Enum.count(steps), "|")
defp explain_facts(facts) when facts == %{}, do: "No facts gathered."
header <>
indent(
" |" <>
key <>
"|\n" <>
do_add_step_info(rest, steps, facts)
)
end
defp do_add_step_info(pkeys, steps, facts) do
Enum.map_join(pkeys, "\n", fn {pkey_line, pkey} ->
steps
|> Enum.reduce({true, pkey_line <> " "}, fn
{_step, _clause}, {false, string} ->
{false, string <> "|~"}
{step, clause}, {true, string} ->
status =
case Clause.find(facts, %{clause | pkey: pkey}) do
{:ok, value} -> value
_ -> nil
end
mark = step_to_mark(step, status)
new_mark =
if mark == "" do
""
else
mark
end
continue? = new_mark not in ["", ""]
{continue?, string <> "|" <> new_mark}
end)
|> elem(1)
|> Kernel.<>("|")
end)
end
defp add_header_line(lines, title) do
[title | lines]
end
defp pad(lines) do
longest =
lines
|> Enum.map(fn
{line, _pkey} ->
String.length(line)
line ->
String.length(line)
end)
|> Enum.max()
Enum.map(
lines,
fn
{line, pkey} ->
length = String.length(line)
{line <> String.duplicate(" ", longest - length), pkey}
line ->
length = String.length(line)
line <> String.duplicate(" ", longest - length)
end
)
end
defp explain_facts(facts, strict_check_facts) do
defp explain_facts(facts) do
facts
|> Map.drop([true, false])
|> Enum.group_by(fn {clause, _status} ->
clause.pkey
|> Enum.map(fn {%{filter: filter} = key, value} ->
{key, value, count_of_clauses(filter)}
end)
|> Enum.sort_by(fn {pkey, _} -> not is_nil(pkey) end)
|> Enum.map_join("\n---\n", fn {pkey, clauses_and_statuses} ->
title = format_pkey(pkey) <> " facts"
contents =
clauses_and_statuses
|> Enum.group_by(fn {clause, _} ->
{clause.source, clause.path}
|> Enum.sort_by(fn {_, _, count_of_clauses} ->
count_of_clauses
end)
|> Enum.sort_by(fn {{_, relationship}, _} ->
{Enum.count(relationship), relationship}
end)
|> Enum.map_join("\n", fn {{source, relationship}, clauses_and_statuses} ->
contents =
Enum.map_join(clauses_and_statuses, "\n", fn {clause, status} ->
gets_star? =
Clause.find(strict_check_facts, clause) in [
{:ok, true},
{:ok, false}
]
star =
if gets_star? do
""
# TODO: nest child filters under parent filters?
|> Enum.map_join("\n", fn {clause, value, count_of_clauses} ->
if count_of_clauses == 0 do
clause.check_module.describe(clause.check_opts) <> " " <> status_to_mark(value)
else
""
end
mod = clause.check_module
opts = clause.check_opts
status_to_mark(status) <> " " <> mod.describe(opts) <> star
end)
if relationship == [] do
indent(contents)
else
operation =
if source == :side_load do
"SideLoad "
else
"Related "
end
operation <> Enum.join(relationship, ".") <> ":\n" <> indent(contents)
inspect(clause.filter) <>
": " <> clause.check_module.describe(clause.check_opts) <> " " <> status_to_mark(value)
end
end)
title <> ":\n" <> contents
end)
end
# |> Enum.group_by(fn {clause, _status} ->
# clause.filter
# end)
# |> Enum.sort_by(fn {filter, _} -> not is_nil(filter) end)
# |> Enum.map_join("\n---\n", fn {pkey, clauses_and_statuses} ->
# title = format_pkey(pkey) <> " facts"
defp format_pkey(nil), do: "Root"
# contents =
# clauses_and_statuses
# |> Enum.group_by(fn {clause, _} ->
# {clause.source, clause.path}
# end)
# |> Enum.sort_by(fn {{_, relationship}, _} ->
# {Enum.count(relationship), relationship}
# end)
# |> Enum.map_join("\n", fn {{source, relationship}, clauses_and_statuses} ->
# contents =
# Enum.map_join(clauses_and_statuses, "\n", fn {clause, status} ->
# mod = clause.check_module
# opts = clause.check_opts
defp format_pkey(pkey) do
if Enum.count(pkey) == 1 do
pkey |> Enum.at(0) |> elem(1) |> to_string()
else
Enum.map_join(pkey, ",", fn {key, value} -> to_string(key) <> ":" <> to_string(value) end)
end
# status_to_mark(status) <> " " <> mod.describe(opts)
# end)
# if relationship == [] do
# indent(contents)
# else
# operation =
# if source == :side_load do
# "SideLoad "
# else
# "Related "
# end
# operation <> Enum.join(relationship, ".") <> ":\n" <> indent(contents)
# end
# end)
# title <> ":\n" <> contents
# end)
end
defp status_to_mark(true), do: ""
@ -288,23 +287,22 @@ defmodule Ash.Authorization.Report do
|> Enum.join("\n")
end
defp explain_steps(requests, facts, strict_access?) do
defp explain_steps(requests, facts) do
title = "\n\nAuthorization Steps:\n"
contents =
Enum.map_join(requests, "\n------\n", fn request ->
requests
|> Enum.sort_by(fn request -> Enum.count(request.path) end)
|> Enum.map_join("\n------\n", fn request ->
title =
if request.bypass_strict_access? && strict_access? do
request.source <> " (bypass strict access)"
if request.strict_access? do
request.name <> " (strict access)"
else
request.source
request.name
end
contents =
request.rules
|> Enum.sort_by(fn {_step, clause} ->
{Enum.count(clause.path), clause.path}
end)
|> Enum.map(fn {step, clause} ->
status =
case Clause.find(facts, clause) do
@ -326,7 +324,7 @@ defmodule Ash.Authorization.Report do
step_mark <>
" | " <>
to_string(step) <>
": #{Enum.join(relationship, ".")}: " <>
": #{Enum.join(relationship || [], ".")}: " <>
mod.describe(opts) <> " " <> status_mark
end
end)

View file

@ -3,30 +3,7 @@ defmodule Ash.Authorization.SatSolver do
@dialyzer {:no_return, :"picosat_solve/1"}
def solve(requests, facts, negations, ids) when is_nil(ids) do
requests
|> Enum.map(&Map.get(&1, :rules))
|> build_requirements_expression(facts, nil)
|> add_negations_and_solve(negations)
end
def solve(requests, facts, negations, ids) do
sets_of_rules = Enum.map(requests, &Map.get(&1, :rules))
ids
|> Enum.reduce(nil, fn id, expr ->
requirements_expression = build_requirements_expression(sets_of_rules, facts, id)
if expr do
{:and, expr, requirements_expression}
else
requirements_expression
end
end)
|> add_negations_and_solve(negations)
end
def solve2(rules_with_filters, facts) do
def solve(rules_with_filters, facts) do
expression =
Enum.reduce(rules_with_filters, nil, fn rules_with_filter, expr ->
{rules, filter} =
@ -67,8 +44,8 @@ defmodule Ash.Authorization.SatSolver do
defp get_all_scenarios({:ok, scenario}, expression, scenarios) do
expression
|> add_negations_and_solve([scenario | scenarios])
|> get_all_scenarios(expression, [scenario | scenarios])
|> add_negations_and_solve([Map.drop(scenario, [true, false]) | scenarios])
|> get_all_scenarios(expression, [Map.drop(scenario, [true, false]) | scenarios])
end
defp remove_irrelevant_clauses(scenarios) do
@ -136,18 +113,20 @@ defmodule Ash.Authorization.SatSolver do
requirements_expression
end
{bindings, expression} = extract_bindings(full_expression)
expression_with_constants = {:and, true, {:and, {:not, false}, full_expression}}
{bindings, expression} = extract_bindings(expression_with_constants)
expression
|> to_conjunctive_normal_form()
|> lift_clauses()
|> negations_to_negative_numbers()
|> picosat_solve()
|> satsolver_solve()
|> solutions_to_predicate_values(bindings)
end
defp picosat_solve(equation) do
Picosat.solve(equation)
def satsolver_solve(input) do
Picosat.solve(input)
end
defp facts_to_statement(facts) do
@ -188,7 +167,7 @@ defmodule Ash.Authorization.SatSolver do
end
end)
facts_expression = facts_to_statement(facts)
facts_expression = facts_to_statement(Map.drop(facts, [true, false]))
if facts_expression do
{:and, facts_expression, rules_expression}

View file

@ -1,375 +1,426 @@
defmodule Ash.Engine do
@moduledoc """
Runs a list of requests, fetching them incrementally and checking at each point
if authorization is still possible. This module has a lot of growing to do.
"""
@type result :: :authorized | :forbidden
alias Ash.Authorization.{Report, SatSolver}
alias Ash.Engine.Request
require Logger
alias Ash.Engine.Request
# TODO: Add ability to configure "resolver error behavior"
# graphql will want to continue on failures, but the
# code interface/JSON API will want to bail on the first error
# TODO: user should be an opt
def run(user, requests, opts \\ []) do
strict_access? = Keyword.get(opts, :strict_access?, true)
alias Ash.Authorization.SatSolver
requests =
if opts[:fetch_only?] do
Enum.map(requests, &Request.authorize_always/1)
else
defstruct [
:api,
:requests,
:user,
:log_transitions?,
:failure_mode,
errors: %{},
completed_preparations: %{},
data: %{},
state: :init,
facts: %{
true: true,
false: false
},
scenarios: []
]
@states [
:init,
:resolve_fields,
:strict_check,
:generate_scenarios,
:reality_check,
:resolve_some,
:resolve_complete,
:complete
]
def run(requests, api, opts \\ []) do
requests
|> new(api, opts)
|> loop_until_complete()
end
case Enum.find(requests, fn request -> Enum.empty?(request.rules) end) do
nil ->
{new_requests, facts} = strict_check_facts(user, requests, strict_access?)
defp loop_until_complete(engine) do
case next(engine) do
%{state: :complete} = new_engine ->
if all_resolved_or_unnecessary?(new_engine.requests) do
new_engine
else
add_error(engine, [:__engine__], "Completed without all data resolved.")
end
solve(
new_requests,
user,
facts,
facts,
opts[:state] || %{},
strict_access?,
opts[:log_final_report?] || false
)
new_engine when new_engine == engine ->
transition(new_engine, :complete, %{
errors: {:__engine__, "State machine stuck in infinite loop"}
})
new_engine ->
loop_until_complete(new_engine)
end
end
defp all_resolved_or_unnecessary?(requests) do
requests
|> Enum.filter(& &1.resolve_when_fetch_only?)
|> Enum.all?(&Request.data_resolved?/1)
end
defp next(%{failure_mode: :complete, errors: errors} = engine) when errors != %{} do
transition(engine, :complete)
end
defp next(%{state: :init} = engine) do
engine.requests
|> Enum.reduce(engine, &replace_request(&2, &1))
|> transition(:strict_check)
end
defp next(%{state: :strict_check} = engine) do
strict_check(engine)
end
defp next(%{state: :generate_scenarios} = engine) do
generate_scenarios(engine)
end
defp next(%{state: :reality_check} = engine) do
reality_check(engine)
end
defp next(%{state: :resolve_some} = engine) do
# TODO: We should probably find requests that can be fetched in parallel
# and fetch them asynchronously (if their data layer allows it)
case resolvable_requests(engine) do
[request | _rest] ->
# TODO: run any preparations on the data here, and then store what preparations have been run on what data so
# we don't run them again.
engine
|> resolve_data(request)
|> transition(:strict_check)
[] ->
transition(engine, :complete, %{message: "No requests to resolve"})
end
end
defp next(%{state: :resolve_complete} = engine) do
case Enum.find(engine.requests, &resolve_for_resolve_complete?(&1, engine)) do
nil ->
transition(engine, :complete, %{message: "No remaining requests that must be resolved"})
request ->
exception = Ash.Error.Forbidden.exception(no_steps_configured: request)
if opts[:log_final_report?] do
Logger.info(Ash.Error.Forbidden.report_text(exception))
end
{:error, exception}
engine
|> resolve_data(request)
|> remain(%{message: "Resolved #{request.name}"})
end
end
def solve(
requests,
user,
facts,
initial_strict_check_facts,
state,
strict_access?,
log_final_report?
) do
requests_with_dependent_fields =
Enum.reduce_while(requests, {:ok, []}, fn request, {:ok, requests} ->
if Request.dependencies_met?(state, request) do
case Request.fetch_dependent_fields(state, request) do
{:ok, request} -> {:cont, {:ok, [request | requests]}}
{:error, error} -> {:halt, {:error, error}}
end
else
{:cont, {:ok, [request | requests]}}
end
end)
case requests_with_dependent_fields do
{:error, error} ->
{:error, error}
{:ok, requests_with_changeset} ->
{new_requests, new_facts} =
strict_check_facts(user, requests_with_changeset, strict_access?, facts)
case sat_solver(new_requests, new_facts, [], state) do
{:error, :unsatisfiable} ->
exception =
Ash.Error.Forbidden.exception(
requests: new_requests,
facts: new_facts,
strict_check_facts: initial_strict_check_facts,
strict_access?: strict_access?,
state: state,
reason: "No scenario leads to authorization"
)
if log_final_report? do
Logger.info(Ash.Error.Forbidden.report_text(exception))
end
{:error, exception}
{:ok, scenario} ->
new_requests
|> get_all_scenarios(scenario, new_facts, state)
|> Enum.uniq()
|> remove_irrelevant_clauses()
|> verify_scenarios(
user,
new_requests,
new_facts,
initial_strict_check_facts,
state,
strict_access?,
log_final_report?
)
end
end
end
defp remove_irrelevant_clauses(scenarios) do
new_scenarios =
scenarios
|> Enum.uniq()
|> Enum.map(fn scenario ->
unnecessary_fact =
Enum.find_value(scenario, fn
{_fact, :unknowable} ->
defp resolve_for_resolve_complete?(request, engine) do
if Request.data_resolved?(request) do
false
else
case Request.all_dependencies_met?(request, engine.data) do
{true, _must_resolve} ->
request.resolve_when_fetch_only? || is_hard_depended_on?(request, engine.requests)
# TODO: Is this acceptable?
# If the check refers to empty data, and its meant to bypass strict checks
# Then we consider that fact an irrelevant fact? Probably.
{_fact, :irrelevant} ->
true
false ->
false
end
end
end
{fact, value_in_this_scenario} ->
matching =
Enum.find(scenarios, fn potential_irrelevant_maker ->
potential_irrelevant_maker != scenario &&
Map.delete(scenario, fact) == Map.delete(potential_irrelevant_maker, fact)
defp is_hard_depended_on?(request, all_requests) do
remaining_requests = all_requests -- [request]
all_requests
|> Enum.reject(& &1.error?)
|> Enum.reject(&Request.data_resolved?/1)
|> Enum.filter(&Request.depends_on?(&1, request))
|> Enum.any?(fn other_request ->
other_request.resolve_when_fetch_only? ||
is_hard_depended_on?(other_request, remaining_requests)
end)
end
defp prepare(engine, request) do
# Right now the only preparation is a side_load
side_loads =
Enum.reduce(request.rules, [], fn {_, clause}, preloads ->
clause.check_opts
|> clause.check_module.prepare()
|> Enum.reduce(preloads, fn {:side_load, path}, preloads ->
Ash.Actions.SideLoad.merge(preloads, path)
end)
end)
case matching do
%{^fact => value} when is_boolean(value) and value != value_in_this_scenario ->
fact
case Request.fetch_request_state(engine.data, request) do
{:ok, %{data: data}} ->
case Ash.Actions.SideLoad.side_load(
engine.api,
request.resource,
data,
side_loads,
request.filter
) do
{:ok, new_request_data} ->
new_request = %{request | data: new_request_data}
replace_request(engine, new_request)
{:error, error} ->
remain(engine, %{errors: [error]})
end
_ ->
false
engine
end
end
end)
Map.delete(scenario, unnecessary_fact)
end)
|> Enum.uniq()
if new_scenarios == scenarios do
new_scenarios
defp replace_request(engine, new_request, replace_data? \\ true) do
new_requests =
Enum.map(engine.requests, fn request ->
if request.id == new_request.id do
new_request
else
remove_irrelevant_clauses(new_scenarios)
request
end
end)
if replace_data? do
new_engine_data = Request.put_request(engine.data, new_request)
%{engine | data: new_engine_data, requests: new_requests}
else
%{engine | requests: new_requests}
end
end
defp get_all_scenarios(
requests,
scenario,
facts,
state,
negations \\ [],
scenarios \\ []
) do
defp resolvable_requests(engine) do
Enum.filter(engine.requests, fn request ->
!request.error? && not request.strict_access? &&
match?(%Request.UnresolvedField{}, request.data) &&
match?({true, _}, Request.all_dependencies_met?(request, engine.data))
end)
end
defp resolve_data(engine, request) do
result =
engine
|> prepare(request)
|> resolve_required_paths(request)
with {:ok, new_engine} <- result,
{:ok, resolved} <- Request.resolve_data(new_engine.data, request) do
replace_request(new_engine, resolved)
else
{:error, path, message, engine} ->
add_error(engine, path, message)
{:error, error} ->
new_request = %{request | error?: true}
engine
|> replace_request(new_request)
|> add_error(request.path ++ [:data], error)
end
end
defp resolve_required_paths(engine, request) do
case Request.all_dependencies_met?(request, engine.data) do
false ->
raise "Unreachable case"
{true, dependency_paths} ->
do_resolve_required_paths(dependency_paths, engine, request)
end
end
defp do_resolve_required_paths(dependency_paths, engine, request) do
resolution_result =
dependency_paths
|> Enum.sort_by(&Enum.count/1)
|> Enum.reduce_while({:ok, engine, []}, fn path, {:ok, engine, skipped} ->
case resolve_by_path(path, engine.data, engine.data) do
{data, requests} ->
{:cont,
{:ok, Enum.reduce(requests, %{engine | data: data}, &replace_request(&2, &1, false)),
skipped}}
{:unmet_dependencies, new_data, new_requests} ->
new_engine =
Enum.reduce(
new_requests,
%{engine | data: new_data},
&replace_request(&2, &1, false)
)
{:cont, {:ok, new_engine, skipped ++ [path]}}
{:error, new_data, new_requests, path, error} ->
new_engine =
engine
|> Map.put(:data, new_data)
|> replace_request(%{request | error?: true})
|> add_error(request.path, error)
{:halt,
{:error, path, error,
Enum.reduce(new_requests, new_engine, &replace_request(&2, &1, false))}}
end
end)
case resolution_result do
{:ok, engine, ^dependency_paths} when dependency_paths != [] ->
[first | rest] = dependency_paths
{:error, first, "Codependent requests.",
Enum.reduce(rest, engine, &add_error(&2, &1, "Codependent requests."))}
{:ok, engine, []} ->
{:ok, engine}
{:ok, engine, skipped} ->
do_resolve_required_paths(skipped, engine, request)
end
end
defp resolve_by_path(path, current_data, all_data, requests \\ [], path_prefix \\ [])
defp resolve_by_path([head | tail], current_data, all_data, requests, path_prefix)
when is_map(current_data) do
case Map.fetch(current_data, head) do
{:ok, %Request{} = request} ->
case resolve_by_path(tail, request, all_data, requests, [head | path_prefix]) do
{:error, new_request, new_requests, error_path, message} ->
{:error, Map.put(current_data, request, new_request),
[%{new_request | error?: true} | new_requests], error_path, message}
{new_request, new_requests} ->
{Map.put(current_data, head, new_request), [new_request | new_requests]}
{:unmet_dependencies, new_request, new_requests} ->
{:unmet_dependencies, Map.put(current_data, request, new_request),
[new_request | new_requests]}
end
{:ok, %Request.UnresolvedField{}} when tail != [] ->
{:error, current_data, requests, Enum.reverse(path_prefix) ++ [head],
"Unresolved field while resolving path"}
{:ok, value} ->
case resolve_by_path(tail, value, all_data, requests, [head | path_prefix]) do
{:error, nested_data, new_requests, error_path, message} ->
{:error, Map.put(current_data, value, nested_data), new_requests, error_path, message}
{new_value, new_requests} ->
{Map.put(current_data, head, new_value), new_requests}
{:unmet_dependencies, new_value, new_requests} ->
{:unmet_dependencies, Map.put(current_data, head, new_value), new_requests}
end
nil ->
{:error, current_data, requests, Enum.reverse(path_prefix) ++ [head],
"Missing field while resolving path"}
end
end
defp resolve_by_path([], value, all_data, requests, path_prefix) do
case value do
%Request.UnresolvedField{} = unresolved ->
case Request.dependencies_met?(all_data, unresolved.depends_on) do
{true, []} ->
case Request.resolve_field(all_data, unresolved) do
{:ok, value} -> {value, requests}
{:error, error} -> {:error, value, requests, Enum.reverse(path_prefix), error}
end
{true, _needs} ->
{:unmet_dependencies, unresolved, requests}
false ->
{:error, value, requests, Enum.reverse(path_prefix),
"Unmet dependencies while resolving path"}
end
other ->
{other, requests}
end
end
defp resolve_by_path(path, current_data, _all_data, requests, path_prefix) do
{:error, current_data, requests, Enum.reverse(path_prefix) ++ path,
"Invalid data while resolving path."}
end
# defp resolve_fields(engine) do
# {errors, new_requests} =
# engine.requests
# |> Enum.map(&Request.resolve_fields(&1, engine.data))
# |> find_errors()
# cond do
# new_requests == engine.requests ->
# transition(engine, :strict_check, %{
# message: "Resolving resulted in no changes",
# errors: errors
# })
# true ->
# engine =
# Enum.reduce(new_requests, engine, fn request, engine ->
# %{engine | data: Request.put_request(engine.data, request)}
# end)
# remain(engine, %{
# errors: errors,
# requests: new_requests,
# message: "Resolved fields. Triggering another pass."
# })
# end
# end
# defp find_errors(requests) do
# {errors, good_requests} =
# Enum.reduce(requests, {[], []}, fn request, {errors, good_requests} ->
# case Request.errors(request) do
# request_errors when request_errors == %{} ->
# {errors, [request | good_requests]}
# request_errors ->
# new_request_errors =
# Enum.reduce(request_errors, errors, fn {key, error}, request_error_acc ->
# Map.put(request_error_acc, [request.name, key], error)
# end)
# {new_request_errors, good_requests}
# end
# end)
# {errors, Enum.reverse(good_requests)}
# end
defp reality_check(engine) do
case find_real_scenario(engine.scenarios, engine.facts) do
nil ->
transition(engine, :resolve_some, %{message: "No scenario was reality"})
scenario ->
scenario = Map.drop(scenario, [true, false])
scenarios = [scenario | scenarios]
case scenario_is_reality(scenario, facts) do
:reality ->
scenarios
:not_reality ->
raise "SAT SOLVER ERROR"
:maybe ->
negations_assuming_scenario_false = [scenario | negations]
case sat_solver(
requests,
facts,
negations_assuming_scenario_false,
state
) do
{:ok, scenario_after_negation} ->
get_all_scenarios(
requests,
scenario_after_negation,
facts,
state,
negations_assuming_scenario_false,
scenarios
)
{:error, :unsatisfiable} ->
scenarios
end
end
end
defp sat_solver(requests, facts, negations, state) do
case state do
%{data: [%resource{} | _] = data} ->
# TODO: Needs primary key, looks like some kind of primary key is necessary for
# almost everything ash does :/
pkey = Ash.primary_key(resource)
ids = Enum.map(data, &Map.take(&1, pkey))
SatSolver.solve(requests, facts, negations, ids)
_ ->
SatSolver.solve(requests, facts, negations, nil)
transition(engine, :resolve_complete, %{
message: "Scenario was reality: #{inspect(scenario)}"
})
end
end
defp verify_scenarios(
scenarios,
user,
requests,
facts,
strict_check_facts,
state,
strict_access?,
log_final_report?
) do
if any_scenarios_reality?(scenarios, facts) do
if log_final_report? do
report = %Report{
scenarios: scenarios,
requests: requests,
facts: facts,
strict_check_facts: strict_check_facts,
state: state,
strict_access?: strict_access?,
authorized?: true
}
Logger.info(Report.report(report))
end
fetch_must_fetch(requests, state)
defp find_real_scenario(scenarios, facts) do
Enum.find_value(scenarios, fn scenario ->
if scenario_is_reality(scenario, facts) == :reality do
scenario
else
case Ash.Authorization.Checker.run_checks(
scenarios,
user,
requests,
facts,
state,
strict_access?
) do
:all_scenarios_known ->
exception =
Ash.Error.Forbidden.exception(
scenarios: scenarios,
requests: requests,
facts: facts,
strict_check_facts: strict_check_facts,
state: state,
strict_access?: strict_access?,
reason: "All fetchable information was fetched, and no scenario is reality."
)
if log_final_report? do
Logger.info(Ash.Error.Forbidden.report_text(exception))
false
end
{:error, exception}
{:error, error} ->
{:error, error}
{:ok, new_requests, new_facts, new_state} ->
if new_requests == requests && new_facts == facts && state == new_state do
exception =
Ash.Error.Forbidden.exception(
scenarios: scenarios,
requests: requests,
facts: facts,
strict_check_facts: strict_check_facts,
state: state,
strict_access?: strict_access?,
reason: "No new information could be generated, and no scenario is reality."
)
if log_final_report? do
Logger.info(Ash.Error.Forbidden.report_text(exception))
end
{:error, exception}
else
solve(
new_requests,
user,
new_facts,
strict_check_facts,
new_state,
strict_access?,
log_final_report?
)
end
end
end
end
defp fetch_must_fetch(requests, state) do
unfetched = Enum.reject(requests, &Request.fetched?(state, &1))
{safe_to_fetch, unmet} =
Enum.split_with(unfetched, fn request -> Request.dependencies_met?(state, request) end)
must_fetch = filter_must_fetch(safe_to_fetch)
case must_fetch do
[] ->
if unmet == [] do
{:ok, state}
else
unmet_deps =
unmet
|> Enum.map(&Request.unmet_dependencies(state, &1))
|> Enum.concat()
|> Enum.map(&List.wrap/1)
|> Enum.uniq()
|> Enum.map_join(", ", &inspect/1)
{:error,
"Could not fetch all required data due to data dependency issues, unmet dependencies existed: " <>
unmet_deps}
end
must_fetch ->
new_state =
must_fetch
|> Enum.sort_by(fn request -> -length(request.relationship) end)
|> Enum.reduce_while({:ok, state}, fn request, {:ok, state} ->
with {:ok, request} <- Request.fetch_dependent_fields(state, request),
{:ok, new_state} <- Request.fetch(state, request) do
{:cont, {:ok, new_state}}
else
{:error, error} -> {:halt, {:error, error}}
end
end)
case new_state do
{:ok, new_state} ->
if new_state == state do
{:error,
"Could not fetch all required data due to data dependency issues, no step affected state"}
else
fetch_must_fetch(unfetched, new_state)
end
{:error, error} ->
{:error, error}
end
end
end
defp filter_must_fetch(requests) do
Enum.filter(requests, &must_fetch?(&1, requests))
end
defp must_fetch?(request, other_requests) do
request.must_fetch? ||
Enum.any?(other_requests, fn other_request ->
must_fetch?(other_request, other_requests -- [other_request]) and
Request.depends_on?(request, other_request)
end)
end
defp any_scenarios_reality?(scenarios, facts) do
Enum.any?(scenarios, fn scenario ->
scenario_is_reality(scenario, facts) == :reality
end)
end
@ -399,12 +450,223 @@ defmodule Ash.Engine do
end)
end
defp strict_check_facts(user, requests, strict_access?, initial \\ %{true: true, false: false}) do
Enum.reduce(requests, {[], initial}, fn request, {requests, facts} ->
defp generate_scenarios(engine) do
rules_with_data =
Enum.flat_map(engine.requests, fn request ->
if Request.data_resolved?(request) do
request.data
|> List.wrap()
|> Enum.map(fn item ->
{request.rules, get_pkeys(item, engine.api)}
end)
else
[request.rules]
end
end)
case SatSolver.solve(rules_with_data, engine.facts) do
{:ok, scenarios} ->
transition(engine, :reality_check, %{scenarios: scenarios})
{:error, :unsatisfiable} ->
error =
Ash.Error.Forbidden.exception(
requests: engine.requests,
facts: engine.facts,
state: engine.data,
reason: "No scenario leads to authorization"
)
transition(engine, :complete, %{errors: {:__engine__, error}})
end
end
defp get_pkeys(%resource{} = item, api) do
pkey_filter =
item
|> Map.take(Ash.primary_key(resource))
|> Map.to_list()
Ash.Filter.parse(resource, pkey_filter, api)
end
defp strict_check(engine) do
{requests, facts} =
Enum.reduce(engine.requests, {[], engine.facts}, fn request, {requests, facts} ->
{new_request, new_facts} =
Ash.Authorization.Checker.strict_check(user, request, facts, strict_access?)
Ash.Authorization.Checker.strict_check2(
engine.user,
request,
facts
)
{[new_request | requests], new_facts}
end)
transition(engine, :generate_scenarios, %{requests: Enum.reverse(requests), facts: facts})
end
defp new(request, api, opts) when not is_list(request), do: new([request], api, opts)
defp new(requests, api, opts) do
# TODO: We should put any pre-resolved data into state
requests =
if opts[:fetch_only?] do
Enum.map(requests, &Request.authorize_always/1)
else
requests
end
engine = %__MODULE__{
requests: requests,
user: opts[:user],
api: api,
failure_mode: opts[:failure_mode] || :complete,
log_transitions?: Keyword.get(opts, :log_transitions, true)
}
if engine.log_transitions? do
Logger.debug(
"Initializing engine with requests: #{Enum.map_join(requests, ", ", & &1.name)}"
)
end
case Enum.find(requests, &Enum.empty?(&1.rules)) do
nil ->
engine
request ->
exception = Ash.Error.Forbidden.exception(no_steps_configured: request)
if opts[:log_final_report?] do
Logger.info(Ash.Error.Forbidden.report_text(exception))
end
transition(engine, :complete, %{errors: {:__engine__, exception}})
end
end
defp format_args(%{message: message} = args) do
case clean_args(args) do
"" ->
" | #{message}"
output ->
" | #{message}#{output}"
end
end
defp format_args(args) do
clean_args(args)
end
defp clean_args(args) do
args
|> case do
%{scenarios: scenarios} = args ->
Map.put(args, :scenarios, "...#{Enum.count(scenarios)} scenarios")
other ->
other
end
|> Map.delete(:message)
|> case do
args when args == %{} ->
""
args ->
" | " <> inspect(args)
end
end
defp remain(engine, args) do
if engine.log_transitions? do
Logger.debug("Remaining in #{engine.state}#{format_args(args)}")
end
engine
|> handle_args(args)
end
defp transition(engine, state, args \\ %{}) do
if engine.log_transitions? do
Logger.debug("Moving from #{engine.state} to #{state}#{format_args(args)}")
end
engine
|> handle_args(args)
|> do_transition(state, args)
end
defp do_transition(engine, state, _args) when state not in @states do
do_transition(engine, :complete, %{errors: %{__engine__: "No such state #{state}"}})
end
defp do_transition(engine, state, _args) do
%{engine | state: state}
end
defp handle_args(engine, args) do
engine
|> handle_request_updates(args)
|> handle_scenarios_updates(args)
|> handle_facts_updates(args)
|> handle_errors(args)
end
defp handle_scenarios_updates(engine, %{scenarios: scenarios}) do
%{engine | scenarios: scenarios}
end
defp handle_scenarios_updates(engine, _), do: engine
defp handle_facts_updates(engine, %{facts: facts}) do
%{engine | facts: facts}
end
defp handle_facts_updates(engine, _), do: engine
defp handle_request_updates(engine, %{requests: requests}) do
%{engine | requests: requests}
end
defp handle_request_updates(engine, _), do: engine
defp handle_errors(engine, %{errors: error}) when not is_list(error) do
handle_errors(engine, %{errors: List.wrap(error)})
end
defp handle_errors(engine, %{errors: errors}) when errors != [] do
Enum.reduce(errors, engine, fn {path, error}, engine ->
add_error(engine, path, error)
end)
end
defp handle_errors(engine, _), do: engine
defp put_nested_error(map, [key], error) do
case map do
value when is_map(value) ->
Map.update(value, key, %{errors: [error]}, fn nested_value ->
if is_map(nested_value) do
Map.update(nested_value, :errors, [error], fn errors -> [error | errors] end)
else
%{errors: [value] ++ List.wrap(error)}
end
end)
value ->
%{key => %{errors: [value] ++ List.wrap(error)}}
end
end
defp put_nested_error(map, [key | rest], error) do
map
|> Map.put_new(key, %{})
|> Map.update!(key, &put_nested_error(&1, rest, error))
end
defp add_error(engine, path, error) do
%{engine | errors: put_nested_error(engine.errors, List.wrap(path), error)}
end
end

View file

@ -1,120 +1,204 @@
defmodule Ash.Engine.Request do
require Logger
alias Ash.Authorization.{Check, Clause}
@fields_that_change_sometimes [
:changeset,
:is_fetched,
:strict_check_completed?
]
defmodule UnresolvedField do
defstruct [:resolver, depends_on: [], can_use: [], data?: false]
def data(dependencies, can_use \\ [], func) do
%__MODULE__{
resolver: func,
depends_on: deps(dependencies),
can_use: deps(can_use),
data?: true
}
end
def field(dependencies, can_use \\ [], func) do
%__MODULE__{
resolver: func,
depends_on: deps(dependencies),
can_use: deps(can_use),
data?: false
}
end
defp deps(deps) do
deps
|> List.wrap()
|> Enum.map(fn dep -> List.wrap(dep) end)
end
end
defimpl Inspect, for: UnresolvedField do
import Inspect.Algebra
def inspect(field, opts) do
data =
if field.data? do
"data! "
else
""
end
concat([
"#UnresolvedField<",
data,
"needs: ",
to_doc(field.depends_on, opts),
", can_use: ",
to_doc(field.can_use, opts),
">"
])
end
end
defmodule ResolveError do
defstruct [:error]
end
defstruct [
:resource,
:id,
:error?,
:rules,
:filter,
:strict_check_complete?,
:strict_access?,
:resource,
:changeset,
:path,
:action_type,
:dependencies,
:bypass_strict_access?,
:relationship,
:fetcher,
:source,
:optional_state,
:must_fetch?,
:is_fetched,
:state_key,
:strict_check_completed?,
:api,
:changeset
:data,
:resolve_when_fetch_only?,
:name,
:filter,
:context
]
@type t :: %__MODULE__{
action_type: atom,
resource: Ash.resource(),
rules: list(term),
filter: Ash.Filter.t(),
changeset: Ecto.Changeset.t(),
dependencies: list(term),
optional_state: list(term),
is_fetched: (term -> boolean),
fetcher: term,
relationship: list(atom),
bypass_strict_access?: boolean,
strict_check_completed?: boolean,
source: String.t(),
must_fetch?: boolean,
state_key: term,
api: Ash.api()
}
def new(opts) do
opts =
opts
|> Keyword.put_new(:relationship, [])
|> Keyword.put_new(:rules, [])
|> Keyword.put_new(:bypass_strict_access?, false)
|> Keyword.update(:dependencies, [], &List.wrap/1)
|> Keyword.update(:optional_state, [], &List.wrap/1)
|> Keyword.put_new(:strict_check_completed?, false)
|> Keyword.put_new(:is_fetched, fn _ -> true end)
|> Keyword.put_new(:must_fetch?, false)
|> Keyword.delete(:clause_source)
|> Keyword.update!(:rules, fn steps ->
Enum.map(steps, fn {step, fact} ->
{step,
filter =
case opts[:filter] do
%UnresolvedField{} ->
nil
%Ash.Filter{} = filter ->
filter
nil ->
nil
other ->
Ash.Filter.parse(opts[:resource], other)
end
rules =
Enum.map(opts[:rules] || [], fn {rule, fact} ->
{rule,
Ash.Authorization.Clause.new(
opts[:relationship] || [],
opts[:resource],
fact,
opts[:clause_source] || :root
filter
)}
end)
end)
struct!(__MODULE__, opts)
%__MODULE__{
id: Ecto.UUID.generate(),
rules: rules,
strict_access?: Keyword.get(opts, :strict_access?, true),
resource: opts[:resource],
changeset: opts[:changeset],
path: List.wrap(opts[:path]),
action_type: opts[:action_type],
data: opts[:data],
resolve_when_fetch_only?: opts[:resolve_when_fetch_only?],
filter: filter,
name: opts[:name],
context: opts[:context] || %{}
}
end
def can_strict_check?(%__MODULE__{strict_check_complete?: true}), do: false
def can_strict_check?(request) do
request
|> Map.from_struct()
|> Enum.all?(fn {_key, value} ->
!match?(%UnresolvedField{data?: false}, value)
end)
end
def authorize_always(request) do
%{
clause = Clause.new(request.resource, {Check.Static, result: true})
%{request | rules: [authorize_if: clause]}
end
def errors(request) do
request
| rules: [
authorize_if:
Ash.Authorization.Clause.new(
request.relationship,
request.resource,
{Ash.Authorization.Check.Static, result: true},
:root
)
]
}
end
def can_strict_check?(%{changeset: changeset}) when is_function(changeset), do: false
def can_strict_check?(%{filter: filter}) when is_function(filter), do: false
def can_strict_check?(%{strict_check_completed?: false}), do: true
def can_strict_check?(_), do: false
def dependencies_met?(_state, %{dependencies: []}), do: true
def dependencies_met?(_state, %{dependencies: nil}), do: true
def dependencies_met?(state, %{dependencies: dependencies}) do
Enum.all?(dependencies, fn dependency ->
case fetch_nested_value(state, dependency) do
{:ok, _} -> true
_ -> false
end
|> Map.from_struct()
|> Enum.filter(fn {_key, value} ->
match?(%ResolveError{}, value)
end)
|> Enum.into(%{})
end
def unmet_dependencies(_state, %{dependencies: []}), do: []
def unmet_dependencies(_state, %{dependencies: nil}), do: []
# def resolve_fields(
# request,
# data,
# include_data? \\ false
# ) do
# request
# |> Map.from_struct()
# |> Enum.reduce(request, fn {key, value}, request ->
# case value do
# %UnresolvedField{depends_on: dependencies, data?: data?}
# when include_data? or data? == false ->
# if dependencies_met?(data, dependencies) do
# case resolve_field(data, value) do
# {:ok, new_value} ->
# Map.put(request, key, new_value)
def unmet_dependencies(state, %{dependencies: dependencies}) do
Enum.reject(dependencies, fn dependency ->
case fetch_nested_value(state, dependency) do
{:ok, _} -> true
_ -> false
# %UnresolvedField{} = new_field ->
# Map.put(request, key, new_field)
# {:error, error} ->
# Map.put(request, key, %ResolveError{error: error})
# end
# else
# request
# end
# _ ->
# request
# end
# end)
# end
def data_resolved?(%__MODULE__{data: %UnresolvedField{}}), do: false
def data_resolved?(_), do: true
def resolve_field(data, %UnresolvedField{resolver: resolver} = unresolved) do
context = resolver_context(data, unresolved)
resolver.(context)
end
end)
def resolve_data(data, %{data: %UnresolvedField{resolver: resolver} = unresolved} = request) do
# {new_data = resolve_
context = resolver_context(data, unresolved)
case resolver.(context) do
{:ok, resolved} -> {:ok, Map.put(request, :data, resolved)}
{:error, error} -> {:error, error}
end
rescue
e ->
if is_map(e) do
{:error, Map.put(e, :__stacktrace__, __STACKTRACE__)}
else
{:error, e}
end
end
def resolve_data(_, request), do: {:ok, request}
def contains_clause?(request, clause) do
Enum.any?(request.rules, fn {_step, request_clause} ->
@ -122,85 +206,82 @@ defmodule Ash.Engine.Request do
end)
end
def fetched?(_, %{is_fetched: boolean}) when is_boolean(boolean) do
boolean
end
def fetched?(state, request) do
case fetch_request_state(state, request) do
{:ok, value} ->
request.is_fetched.(value)
:error ->
false
end
end
def depends_on?(request, other_request) do
state_key(request) in other_request.dependencies
end
def state_key(%{state_key: state_key} = request) do
List.wrap(state_key || Map.drop(request, @fields_that_change_sometimes))
end
def put_request_state(state, request, value) do
key = state_key(request)
put_nested_key(state, key, value)
def put_request(state, request) do
put_nested_key(state, request.path, request)
end
def fetch_request_state(state, request) do
key = state_key(request)
fetch_nested_value(state, key)
fetch_nested_value(state, request.path)
end
def fetch(
state,
%{fetcher: fetcher, changeset: changeset} = request
) do
fetcher_state =
%{}
|> add_dependent_state(state, request)
|> add_optional_state(state, request)
defp resolver_context(state, %{depends_on: depends_on, can_use: can_use}) do
with_dependencies =
Enum.reduce(depends_on, %{}, fn dependency, acc ->
{:ok, value} = fetch_nested_value(state, dependency)
put_nested_key(acc, dependency, value)
end)
Logger.debug("Fetching: #{request.source}")
case fetcher.(changeset, fetcher_state) do
{:ok, value} ->
{:ok, put_request_state(state, request, value)}
{:error, error} ->
{:error, error}
Enum.reduce(can_use, with_dependencies, fn can_use, acc ->
case fetch_nested_value(state, can_use) do
{:ok, value} -> put_nested_key(acc, can_use, value)
_ -> acc
end
end)
end
def dependent_fields_fetched?(%{changeset: changeset}) when is_function(changeset), do: false
def dependent_fields_fetched?(%{filter: filter}) when is_function(filter), do: false
def dependent_fields_fetched?(%{changeset: _}), do: true
def fetch_dependent_fields(state, request) do
fetcher_state =
%{}
|> add_dependent_state(state, request)
|> add_optional_state(state, request)
Logger.debug("Fetching changeset for #{request.source}")
case fetch_changeset(fetcher_state, request) do
{:ok, request} ->
fetch_filter(fetcher_state, request)
{:error, error} ->
{:error, error}
def all_dependencies_met?(request, state) do
dependencies_met?(state, get_dependencies(request))
end
def dependencies_met?(state, dependencies, sources \\ [])
def dependencies_met?(_state, [], _sources), do: {true, []}
def dependencies_met?(_state, nil, _sources), do: {true, []}
def dependencies_met?(state, dependencies, sources) do
Enum.reduce(dependencies, {true, []}, fn
_, false ->
false
dependency, {true, if_resolved} ->
if dependency in sources do
# Prevent infinite loop on co-dependent requests
# Does it make sense to have to do this?
false
else
case fetch_nested_value(state, dependency) do
{:ok, %UnresolvedField{depends_on: nested_dependencies}} ->
case dependencies_met?(state, nested_dependencies, [dependency | sources]) do
{true, nested_if_resolved} ->
{true, [dependency | if_resolved] ++ nested_if_resolved}
false ->
false
end
{:ok, _} ->
{true, if_resolved}
_ ->
false
end
end
end)
end
def depends_on?(request, other_request) do
dependencies = get_dependencies(request)
Enum.any?(dependencies, fn dep ->
List.starts_with?(dep, other_request.path)
end)
end
def fetch_nested_value(state, [key]) when is_map(state) do
Map.fetch(state, key)
end
def fetch_nested_value(%UnresolvedField{}, _), do: :error
def fetch_nested_value(state, [key | rest]) when is_map(state) do
case Map.fetch(state, key) do
{:ok, value} -> fetch_nested_value(value, rest)
@ -212,68 +293,21 @@ defmodule Ash.Engine.Request do
Map.fetch(state, key)
end
defp add_dependent_state(arg, state, %{dependencies: dependencies}) do
Enum.reduce(dependencies, arg, fn dependency, acc ->
{:ok, value} = fetch_nested_value(state, dependency)
put_nested_key(acc, dependency, value)
end)
end
defp get_dependencies(request) do
request
|> Map.from_struct()
|> Enum.flat_map(fn {_key, value} ->
case value do
%UnresolvedField{depends_on: values} ->
values
defp add_optional_state(arg, state, %{optional_state: optional_state}) do
Enum.reduce(optional_state, arg, fn optional, arg ->
case fetch_nested_value(state, optional) do
{:ok, value} -> put_nested_key(arg, optional, value)
:error -> arg
_ ->
[]
end
end)
|> Enum.uniq()
end
defp fetch_changeset(state, %{dependencies: dependencies, changeset: changeset} = request)
when is_function(changeset) do
arg =
Enum.reduce(dependencies, %{}, fn dependency, acc ->
{:ok, value} = fetch_nested_value(state, dependency)
put_nested_key(acc, dependency, value)
end)
case changeset.(arg) do
%Ecto.Changeset{} = new_changeset ->
{:ok, %{request | changeset: new_changeset}}
{:ok, new_changeset} ->
{:ok, %{request | changeset: new_changeset}}
{:error, error} ->
{:error, error}
end
end
defp fetch_changeset(_state, request), do: {:ok, request}
defp fetch_filter(state, %{dependencies: dependencies, filter: filter} = request)
when is_function(filter) do
arg =
Enum.reduce(dependencies, %{}, fn dependency, acc ->
{:ok, value} = fetch_nested_value(state, dependency)
put_nested_key(acc, dependency, value)
end)
Logger.debug("Fetching filter: #{request.source}")
case filter.(arg) do
%Ash.Filter{} = new_filter ->
{:ok, %{request | filter: new_filter}}
{:ok, new_filter} ->
{:ok, %{request | filter: new_filter}}
{:error, error} ->
{:error, error}
end
end
defp fetch_filter(_state, request), do: {:ok, request}
defp put_nested_key(state, [key], value) do
Map.put(state, key, value)
end

View file

@ -1,427 +0,0 @@
defmodule Ash.Engine2 do
require Logger
alias Ash.Engine2.Request
# TODO: Add ability to configure "resolver error behavior"
# graphql will want to continue on failures, but the
# code interface/JSON API will want to bail on the first error
alias Ash.Authorization.{Report, SatSolver}
defstruct [
:api,
:requests,
:user,
:log_transitions?,
:failure_mode,
errors: %{},
data: %{},
state: :init,
facts: %{
true: true,
false: false
},
scenarios: []
]
@states [
:init,
:resolve_fields,
:strict_check,
:generate_scenarios,
:reality_check,
:resolve_some,
:resolve_complete,
:complete
]
def run(requests, api, opts \\ []) do
requests
|> new(api, opts)
|> loop_until_complete()
end
defp loop_until_complete(engine) do
case next(engine) do
%{state: :complete} = new_engine ->
new_engine
new_engine when new_engine == engine ->
transition(new_engine, :complete, %{
errors: {:__engine__, "State machine stuck in infinite loop"}
})
new_engine ->
loop_until_complete(new_engine)
end
end
defp next(%{failure_mode: :complete, errors: errors} = engine) when errors != %{} do
transition(engine, :complete)
end
defp next(%{state: :init} = engine) do
transition(engine, :resolve_fields)
end
defp next(%{state: :resolve_fields} = engine) do
resolve_fields(engine)
end
defp next(%{state: :strict_check} = engine) do
strict_check(engine)
end
defp next(%{state: :generate_scenarios} = engine) do
generate_scenarios(engine)
end
defp next(%{state: :reality_check} = engine) do
reality_check(engine)
end
defp next(%{state: :resolve_some} = engine) do
# TODO: We should probably find requests that can be fetched in parallel
# and fetch them asynchronously (if their data layer allows it)
case choose_request_to_resolve(engine) do
{:ok, request, other_requests} ->
{engine, request} = resolve_data(engine, request)
transition(engine, :strict_check, %{requests: [request | other_requests]})
:error ->
transition(engine, :complete, %{message: "No requests to resolve"})
end
end
defp next(%{state: :resolve_complete} = engine) do
{engine, requests} =
Enum.reduce(engine.requests, {engine, []}, fn request, {engine, requests} ->
if request.resolve_when_fetch_only? ||
Enum.any?(engine.requests, fn other_request ->
other_request.resolve_when_fetch_only? &&
Request.depends_on?(other_request, request)
end) do
{engine, request} = resolve_data(engine, request)
{engine, [request | requests]}
else
{engine, [request | requests]}
end
end)
transition(engine, :complete, %{requests: Enum.reverse(requests)})
end
defp choose_request_to_resolve(engine) do
{can_resolve_data, cant_resolve_data} =
Enum.split_with(engine.requests, fn request ->
not request.strict_access? && match?(%Request.UnresolvedField{}, request.data) &&
Request.all_dependencies_met?(request, engine.data)
end)
case can_resolve_data do
[request | rest] -> {:ok, request, rest ++ cant_resolve_data}
[] -> :error
end
end
defp resolve_data(engine, request) do
case Request.resolve_data(engine.data, request) do
{:ok, resolved} ->
{%{engine | data: put_nested_path(engine.data, request.path, resolved.data)}, resolved}
{:error, error} ->
{%{engine | errors: put_nested_path(engine.errors, request.path, error)}, request}
end
end
defp resolve_fields(engine) do
{errors, new_requests} =
engine.requests
|> Enum.map(&Request.resolve_fields(&1, engine.data))
|> find_errors()
cond do
new_requests == engine.requests ->
transition(engine, :strict_check, %{
message: "Resolving resulted in no changes",
errors: errors
})
true ->
remain(engine, %{
errors: errors,
requests: new_requests,
message: "Resolved fields. Triggering another pass."
})
end
end
defp find_errors(requests) do
{errors, good_requests} =
Enum.reduce(requests, {[], []}, fn request, {errors, good_requests} ->
case Request.errors(request) do
request_errors when request_errors == %{} ->
{errors, [request | good_requests]}
request_errors ->
new_request_errors =
Enum.reduce(request_errors, errors, fn {key, error}, request_error_acc ->
[{[request.name, key], error} | request_error_acc]
end)
{new_request_errors, good_requests}
end
end)
{errors, Enum.reverse(good_requests)}
end
defp reality_check(engine) do
case find_real_scenario(engine.scenarios, engine.facts) do
nil ->
transition(engine, :resolve_some, %{message: "No scenario was reality"})
scenario ->
scenario = Map.drop(scenario, [true, false])
transition(engine, :resolve_complete, %{
message: "Scenario was reality: #{inspect(scenario)}"
})
end
end
defp find_real_scenario(scenarios, facts) do
Enum.find_value(scenarios, fn scenario ->
if scenario_is_reality(scenario, facts) == :reality do
scenario
else
false
end
end)
end
defp scenario_is_reality(scenario, facts) do
scenario
|> Map.drop([true, false])
|> Enum.reduce_while(:reality, fn {fact, requirement}, status ->
case Map.fetch(facts, fact) do
{:ok, value} ->
cond do
value == requirement ->
{:cont, status}
value == :irrelevant ->
{:cont, status}
value == :unknowable ->
{:halt, :not_reality}
true ->
{:halt, :not_reality}
end
:error ->
{:cont, :maybe}
end
end)
end
defp generate_scenarios(engine) do
rules_with_data =
Enum.flat_map(engine.requests, fn request ->
if Request.data_resolved?(request) do
request.data
|> List.wrap()
|> Enum.map(fn item ->
{request.rules, get_pkeys(item, engine.api)}
end)
else
[request.rules]
end
end)
case SatSolver.solve2(rules_with_data, engine.facts) do
{:ok, scenarios} ->
transition(engine, :reality_check, %{scenarios: scenarios})
{:error, :unsatisfiable} ->
# TODO: Errors
transition(engine, :complete, %{errors: {:__engine__, "Unauthorized"}})
end
end
defp get_pkeys(%resource{} = item, api) do
pkey_filter =
item
|> Map.take(Ash.primary_key(resource))
|> Map.to_list()
Ash.Filter.parse(resource, pkey_filter, api)
end
defp strict_check(engine) do
{requests, facts} =
Enum.reduce(engine.requests, {[], engine.facts}, fn request, {requests, facts} ->
{new_request, new_facts} =
Ash.Authorization.Checker.strict_check2(
engine.user,
request,
facts
)
{[new_request | requests], new_facts}
end)
transition(engine, :generate_scenarios, %{requests: Enum.reverse(requests), facts: facts})
end
defp new(requests, api, opts) do
requests =
if opts[:fetch_only?] do
Enum.map(requests, &Request.authorize_always/1)
else
requests
end
engine = %__MODULE__{
requests: requests,
user: opts[:user],
api: api,
failure_mode: opts[:failure_mode] || :complete,
log_transitions?: Keyword.get(opts, :log_transitions, true)
}
if engine.log_transitions? do
Logger.debug(
"Initializing engine with requests: #{Enum.map_join(requests, ", ", & &1.name)}"
)
end
case Enum.find(requests, &Enum.empty?(&1.rules)) do
nil ->
engine
request ->
exception = Ash.Error.Forbidden.exception(no_steps_configured: request)
if opts[:log_final_report?] do
Logger.info(Ash.Error.Forbidden.report_text(exception))
end
transition(engine, :complete, %{errors: {:__engine__, exception}})
end
end
defp format_args(%{message: message} = args) do
case clean_args(args) do
"" ->
" | #{message}"
output ->
" | #{message}#{output}"
end
end
defp format_args(args) do
clean_args(args)
end
defp clean_args(args) do
args
|> case do
%{scenarios: scenarios} = args ->
Map.put(args, :scenarios, "...#{Enum.count(scenarios)} scenarios")
other ->
other
end
|> Map.delete(:message)
|> case do
args when args == %{} ->
""
args ->
" | " <> inspect(args)
end
end
defp remain(engine, args) do
if engine.log_transitions? do
Logger.debug("Remaining in #{engine.state}#{format_args(args)}")
end
engine
|> handle_args(args)
end
defp transition(engine, state, args \\ %{}) do
if engine.log_transitions? do
Logger.debug("Moving from #{engine.state} to #{state}#{format_args(args)}")
end
engine
|> handle_args(args)
|> do_transition(state, args)
end
defp do_transition(engine, state, _args) when state not in @states do
do_transition(engine, :complete, %{errors: {:__engine__, "No such state #{state}"}})
end
defp do_transition(engine, state, _args) do
%{engine | state: state}
end
defp handle_args(engine, args) do
engine
|> handle_request_updates(args)
|> handle_scenarios_updates(args)
|> handle_facts_updates(args)
|> handle_errors(args)
end
defp handle_scenarios_updates(engine, %{scenarios: scenarios}) do
%{engine | scenarios: scenarios}
end
defp handle_scenarios_updates(engine, _), do: engine
defp handle_facts_updates(engine, %{facts: facts}) do
%{engine | facts: facts}
end
defp handle_facts_updates(engine, _), do: engine
defp handle_request_updates(engine, %{requests: requests}) do
%{engine | requests: requests}
end
defp handle_request_updates(engine, _), do: engine
defp handle_errors(engine, %{errors: error}) when not is_list(error) do
handle_errors(engine, %{errors: List.wrap(error)})
end
defp handle_errors(engine, %{errors: errors}) when errors != [] do
Enum.reduce(errors, engine, fn {path, error}, engine ->
%{engine | errors: put_nested_path(engine.errors, List.wrap(path), error)}
end)
end
defp handle_errors(engine, _), do: engine
defp put_nested_path(errors, path, error) when is_list(errors) do
Enum.map(errors, &put_nested_path(&1, path, error))
end
defp put_nested_path(errors, [key], error) do
case errors do
%{^key => value} when is_list(value) -> Map.put(errors, key, [error | value])
value when is_map(value) -> Map.put(value, key, error)
end
end
defp put_nested_path(errors, [key | rest], error) do
Map.put(errors, key, put_nested_path(Map.get(errors, key, %{}), rest, error))
end
end

View file

@ -1,274 +0,0 @@
defmodule Ash.Engine2.Request do
alias Ash.Authorization.{Check, Clause}
defmodule UnresolvedField do
defstruct [:resolver, depends_on: [], can_use: [], data?: false]
def data(dependencies, can_use \\ [], func) do
%__MODULE__{
resolver: func,
depends_on: deps(dependencies),
can_use: deps(can_use),
data?: true
}
end
def field(dependencies, can_use \\ [], func) do
%__MODULE__{
resolver: func,
depends_on: deps(dependencies),
can_use: deps(can_use),
data?: false
}
end
defp deps(deps) do
deps
|> List.wrap()
|> Enum.map(fn dep -> [:root | List.wrap(dep)] end)
end
end
defimpl Inspect, for: UnresolvedField do
import Inspect.Algebra
def inspect(field, opts) do
data =
if field.data? do
"data! "
else
""
end
concat([
"#UnresolvedField<",
data,
"needs: ",
to_doc(field.depends_on, opts),
", can_use: ",
to_doc(field.can_use, opts),
">"
])
end
end
defmodule ResolveError do
defstruct [:error]
end
defstruct [
:rules,
:strict_check_complete?,
:strict_access?,
:resource,
:changeset,
:path,
:action_type,
:data,
:resolve_when_fetch_only?,
:name,
:filter,
:context
]
def new(opts) do
rule_filter =
case opts[:filter] do
%UnresolvedField{} ->
nil
other ->
other
end
rules =
Enum.map(opts[:rules] || [], fn {rule, fact} ->
{rule,
Ash.Authorization.Clause.new(
opts[:relationship] || [],
opts[:resource],
fact,
opts[:clause_source] || :root,
rule_filter
)}
end)
%__MODULE__{
rules: rules,
strict_access?: Keyword.get(opts, :strict_access?, true),
resource: opts[:resource],
changeset: opts[:changeset],
path: [:root | opts[:path] || []],
action_type: opts[:action_type],
data: opts[:data],
resolve_when_fetch_only?: opts[:resolve_when_fetch_only?],
filter: opts[:filter],
name: opts[:name],
context: opts[:context] || %{}
}
end
def can_strict_check?(%__MODULE__{strict_check_complete?: true}), do: false
def can_strict_check?(request) do
request
|> Map.from_struct()
|> Enum.all?(fn {_key, value} ->
!match?(%UnresolvedField{data?: false}, value)
end)
end
def authorize_always(request) do
clause = Clause.new(request.path, request.resource, {Check.Static, result: true}, :root)
%{request | rules: [authorize_if: clause]}
end
def errors(request) do
request
|> Map.from_struct()
|> Enum.filter(fn {_key, value} ->
match?(%ResolveError{}, value)
end)
|> Enum.into(%{})
end
def resolve_fields(
request,
data,
include_data? \\ false
) do
request
|> Map.from_struct()
|> Enum.reduce(request, fn {key, value}, request ->
case value do
%UnresolvedField{depends_on: dependencies, data?: data?}
when include_data? or data? == false ->
if dependencies_met?(data, dependencies) do
case resolve_field(data, request, value) do
{:ok, new_value} ->
Map.put(request, key, new_value)
%UnresolvedField{} = new_field ->
Map.put(request, key, new_field)
{:error, error} ->
Map.put(request, key, %ResolveError{error: error})
end
else
request
end
_ ->
request
end
end)
end
def data_resolved?(%__MODULE__{data: %UnresolvedField{}}), do: false
def data_resolved?(_), do: true
defp resolve_field(data, request, %UnresolvedField{resolver: resolver} = unresolved) do
context = resolver_context(data, unresolved)
resolver.(request, unresolved, context)
end
def resolve_data(data, %{data: %UnresolvedField{resolver: resolver} = unresolved} = request) do
context = resolver_context(data, unresolved)
case resolver.(request, context) do
{:ok, resolved} -> {:ok, Map.put(request, :data, resolved)}
{:error, error} -> {:error, error}
end
end
def resolve_data(_, request), do: {:ok, request}
defp resolver_context(state, %{depends_on: depends_on, can_use: can_use}) do
with_dependencies =
Enum.reduce(depends_on, %{}, fn dependency, acc ->
{:ok, value} = fetch_nested_value(state, dependency)
put_nested_key(acc, dependency, value)
end)
Enum.reduce(can_use, with_dependencies, fn can_use, acc ->
case fetch_nested_value(state, can_use) do
{:ok, value} -> put_nested_key(acc, can_use, value)
_ -> acc
end
end)
end
def all_dependencies_met?(request, state) do
dependencies_met?(state, get_dependencies(request))
end
defp dependencies_met?(_state, []), do: true
defp dependencies_met?(_state, nil), do: true
defp dependencies_met?(state, dependencies) do
Enum.all?(dependencies, fn dependency ->
case fetch_nested_value(state, dependency) do
{:ok, _} -> true
_ -> false
end
end)
end
def depends_on?(request, other_request) do
dependencies = get_dependencies(request)
Enum.any?(dependencies, fn dep ->
List.starts_with?(dep, other_request.path)
end)
end
defp get_dependencies(request) do
request
|> Map.from_struct()
|> Enum.flat_map(fn {_key, value} ->
case value do
%UnresolvedField{depends_on: values} ->
values
_ ->
[]
end
end)
|> Enum.uniq()
end
defp put_nested_key(state, [key], value) do
Map.put(state, key, value)
end
defp put_nested_key(state, [key | rest], value) do
case Map.fetch(state, key) do
{:ok, nested_state} when is_map(nested_state) ->
Map.put(state, key, put_nested_key(nested_state, rest, value))
:error ->
Map.put(state, key, put_nested_key(%{}, rest, value))
end
end
defp put_nested_key(state, key, value) do
Map.put(state, key, value)
end
defp fetch_nested_value(state, [key]) when is_map(state) do
Map.fetch(state, key)
end
defp fetch_nested_value(state, [key | rest]) when is_map(state) do
case Map.fetch(state, key) do
{:ok, value} -> fetch_nested_value(value, rest)
:error -> :error
end
end
defp fetch_nested_value(state, key) when is_map(state) do
Map.fetch(state, key)
end
end

View file

@ -7,7 +7,6 @@ defmodule Ash.Error.Forbidden do
:scenarios,
:requests,
:facts,
:strict_check_facts,
:state,
:strict_access?,
:reason,
@ -20,9 +19,7 @@ defmodule Ash.Error.Forbidden do
scenarios: error.scenarios,
requests: error.requests,
facts: error.facts,
strict_check_facts: error.strict_check_facts,
state: error.state,
strict_access?: error.strict_access?,
no_steps_configured: error.no_steps_configured,
header: "forbidden:",
authorized?: false
@ -37,9 +34,7 @@ defmodule Ash.Error.Forbidden do
scenarios: error.scenarios,
requests: error.requests,
facts: error.facts,
strict_check_facts: error.strict_check_facts,
state: error.state,
strict_access?: error.strict_access?,
no_steps_configured: error.no_steps_configured,
header: header,
authorized?: false

View file

@ -62,24 +62,27 @@ defmodule Ash.Filter do
parsed_filter
else
request =
Ash.Engine2.Request.new(
Ash.Engine.Request.new(
resource: resource,
api: api,
rules: Ash.primary_action(resource, :read).rules,
filter: parsed_filter,
path: [:filter, path],
data:
Ash.Engine2.Request.UnresolvedField.data([], fn request, _data ->
Ash.Engine.Request.UnresolvedField.data(
[:filter, path, :filter],
fn %{filter: %{^path => %{filter: filter}}} ->
query = Ash.DataLayer.resource_to_query(resource)
case Ash.DataLayer.filter(query, request.filter, resource) do
case Ash.DataLayer.filter(query, filter, resource) do
{:ok, filtered_query} ->
Ash.DataLayer.run_query(filtered_query, resource)
{:error, error} ->
{:error, error}
end
end),
end
),
action_type: :read,
# TODO: replace `bypass_strict_access?/1` with `strict_access?/1`
strict_access?: not bypass_strict_access?(parsed_filter),
@ -126,7 +129,7 @@ defmodule Ash.Filter do
|> paths_and_data(data)
|> most_specific_paths()
|> Enum.reduce(filter, fn {path, related_data}, filter ->
[:root, :filter, relationship_path] = path
[:filter, relationship_path] = path
filter
|> add_records_to_relationship_filter(
@ -169,6 +172,10 @@ defmodule Ash.Filter do
def strict_subset_of?(_, nil), do: false
def strict_subset_of?(%{resource: resource}, %{resource: other_resource})
when resource != other_resource,
do: false
def strict_subset_of?(filter, candidate) do
# TODO: Finish this!
unless filter.ors in [[], nil], do: raise("Can't do ors contains yet")