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. - 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. - 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 - 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, resource: resource,
changeset: changeset, changeset: changeset,
action_type: action.type, action_type: action.type,
dependencies: [[:data]], data:
fetcher: fn _, %{data: data} -> {:ok, data} end, Ash.Engine.Request.UnresolvedField.data(
state_key: :data, [[:data, :data]],
relationship: [], fn %{data: %{data: data}} ->
source: "change on `#{attribute.name}`" {:ok, data}
end
),
path: :data,
name: "change on `#{attribute.name}`",
strict_access?: false
) )
end) end)
end end

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -477,6 +477,11 @@ defmodule Ash.Api.Interface do
raise(Ash.Error.FrameworkError, message: "invalid changes #{inspect(changeset)}") raise(Ash.Error.FrameworkError, message: "invalid changes #{inspect(changeset)}")
end 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 defp unwrap_or_raise!({:error, error}) when not is_list(error) do
raise error raise error
end end

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,8 @@
defmodule Ash.Authorization.Clause do 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__{ %__MODULE__{
# path: path,
source: source,
resource: resource, resource: resource,
check_module: mod, check_module: mod,
check_opts: opts, check_opts: opts,
@ -12,15 +10,6 @@ defmodule Ash.Authorization.Clause do
} }
end 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 def find(_clauses, %{check_module: Ash.Authorization.Check.Static, check_opts: check_opts}) do
{:ok, check_opts[:result]} {:ok, check_opts[:result]}
end end
@ -41,18 +30,47 @@ defmodule Ash.Authorization.Clause do
{:or, clause, %{clause | filter: nil}} {:or, clause, %{clause | filter: nil}}
end 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, clause), do: true
defp is_matching_clause?(clause, other_clause) defp is_matching_clause?(clause, other_clause)
when is_boolean(clause) or is_boolean(other_clause), when is_boolean(clause) or is_boolean(other_clause),
do: false 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?(%__MODULE__{filter: nil}, _), do: false
defp is_matching_clause?(clause, potential_matching) do 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
end end
@ -67,17 +85,8 @@ defimpl Inspect, for: Ash.Authorization.Clause do
"" ""
end end
source =
case clause.source do
:root ->
""
source ->
to_string(source)
end
terminator = terminator =
if filter != "" || source != "" do if filter != "" do
": " ": "
else else
"" ""
@ -85,7 +94,6 @@ defimpl Inspect, for: Ash.Authorization.Clause do
concat([ concat([
"#Clause<", "#Clause<",
source,
filter, filter,
terminator, terminator,
to_doc(clause.check_module.describe(clause.check_opts), opts), to_doc(clause.check_module.describe(clause.check_opts), opts),

View file

@ -5,9 +5,7 @@ defmodule Ash.Authorization.Report do
:scenarios, :scenarios,
:requests, :requests,
:facts, :facts,
:strict_check_facts,
:state, :state,
:strict_access?,
:header, :header,
:authorized?, :authorized?,
:reason, :reason,
@ -23,27 +21,11 @@ defmodule Ash.Authorization.Report do
def report(report) do def report(report) do
header = (report.header || "Authorization Report") <> "\n" header = (report.header || "Authorization Report") <> "\n"
explained_steps = facts = Ash.Authorization.Clause.prune_facts(report.facts)
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?
)
_ -> explained_steps = explain_steps(report.requests, 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_facts = explain_facts(report.facts, report.strict_check_facts || %{}) explained_facts = explain_facts(facts)
reason = reason =
if report.reason do if report.reason do
@ -76,203 +58,220 @@ defmodule Ash.Authorization.Report do
""" """
end end
defp explain_steps_with_data(requests, facts, data, strict_access?) do # defp explain_steps_with_data(requests, facts, data) do
title = "\n\nAuthorization Steps:\n\n" # title = "\n\nAuthorization Steps:\n\n"
contents = # contents =
requests # requests
|> Enum.map_join("\n---\n", fn request -> # |> Enum.map_join("\n---\n", fn request ->
relationship = request.relationship # relationship = request.relationship
resource = request.resource # resource = request.resource
inner_title = # inner_title =
if relationship == [] do # if relationship == [] do
request.source <> " -> " <> inspect(resource) <> ": " # request.source <> " -> " <> inspect(resource) <> ": "
else # else
Enum.join(relationship, ".") <> " - " <> inspect(resource) <> ": " # Enum.join(relationship, ".") <> " - " <> inspect(resource) <> ": "
end # end
full_inner_title = # full_inner_title =
if request.bypass_strict_access? && strict_access? do # if request.strict_access? do
inner_title <> " (bypass strict access)" # inner_title <> " (strict access)"
else # else
inner_title # inner_title
end # end
rules_legend = # rules_legend =
request.rules # request.rules
|> Enum.with_index() # |> Enum.with_index()
|> Enum.map_join("\n", fn {{step, check}, index} -> # |> Enum.map_join("\n", fn {{step, check}, index} ->
"#{index + 1}| " <> # "#{index + 1}| " <>
to_string(step) <> ": " <> check.check_module.describe(check.check_opts) # to_string(step) <> ": " <> check.check_module.describe(check.check_opts)
end) # end)
pkey = Ash.primary_key(resource) # pkey = Ash.primary_key(resource)
# TODO: data has to change with relationships # # TODO: data has to change with relationships
data_info = # data_info =
data # data
|> Enum.map(fn item -> # |> Enum.map(fn item ->
formatted = # formatted =
item # item
|> Map.take(pkey) # |> Map.take(pkey)
|> format_pkey() # |> format_pkey()
{formatted, Map.take(item, pkey)} # {formatted, Map.take(item, pkey)}
end) # end)
|> add_header_line(indent("Record")) # |> add_header_line(indent("Record"))
|> pad() # |> pad()
|> add_step_info(request.rules, facts) # |> add_step_info(request.rules, facts)
full_inner_title <> # full_inner_title <>
":\n" <> indent(rules_legend <> "\n\n" <> data_info <> "\n") # ":\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) end)
|> Enum.sum()
title <> indent(contents) or_clauses =
filter.ors
|> Kernel.||([])
|> Enum.map(&count_of_clauses/1)
|> Enum.sum()
not_clauses = count_of_clauses(filter.not)
Enum.count(filter.attributes) + relationship_clauses + or_clauses + not_clauses
end end
defp add_step_info([header | rest], steps, facts) do defp explain_facts(facts) when facts == %{}, do: "No facts gathered."
key = Enum.join(1..Enum.count(steps), "|")
header <> defp explain_facts(facts) do
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
facts facts
|> Map.drop([true, false]) |> Map.drop([true, false])
|> Enum.group_by(fn {clause, _status} -> |> Enum.map(fn {%{filter: filter} = key, value} ->
clause.pkey {key, value, count_of_clauses(filter)}
end) end)
|> Enum.sort_by(fn {pkey, _} -> not is_nil(pkey) end) |> Enum.sort_by(fn {_, _, count_of_clauses} ->
|> Enum.map_join("\n---\n", fn {pkey, clauses_and_statuses} -> count_of_clauses
title = format_pkey(pkey) <> " facts" end)
# TODO: nest child filters under parent filters?
contents = |> Enum.map_join("\n", fn {clause, value, count_of_clauses} ->
clauses_and_statuses if count_of_clauses == 0 do
|> Enum.group_by(fn {clause, _} -> clause.check_module.describe(clause.check_opts) <> " " <> status_to_mark(value)
{clause.source, clause.path} else
end) inspect(clause.filter) <>
|> Enum.sort_by(fn {{_, relationship}, _} -> ": " <> clause.check_module.describe(clause.check_opts) <> " " <> status_to_mark(value)
{Enum.count(relationship), relationship} end
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
""
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)
end
end)
title <> ":\n" <> contents
end) end)
end
defp format_pkey(nil), do: "Root" # |> 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(pkey) do # contents =
if Enum.count(pkey) == 1 do # clauses_and_statuses
pkey |> Enum.at(0) |> elem(1) |> to_string() # |> Enum.group_by(fn {clause, _} ->
else # {clause.source, clause.path}
Enum.map_join(pkey, ",", fn {key, value} -> to_string(key) <> ":" <> to_string(value) end) # end)
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
# 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 end
defp status_to_mark(true), do: "" defp status_to_mark(true), do: ""
@ -288,23 +287,22 @@ defmodule Ash.Authorization.Report do
|> Enum.join("\n") |> Enum.join("\n")
end end
defp explain_steps(requests, facts, strict_access?) do defp explain_steps(requests, facts) do
title = "\n\nAuthorization Steps:\n" title = "\n\nAuthorization Steps:\n"
contents = 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 = title =
if request.bypass_strict_access? && strict_access? do if request.strict_access? do
request.source <> " (bypass strict access)" request.name <> " (strict access)"
else else
request.source request.name
end end
contents = contents =
request.rules request.rules
|> Enum.sort_by(fn {_step, clause} ->
{Enum.count(clause.path), clause.path}
end)
|> Enum.map(fn {step, clause} -> |> Enum.map(fn {step, clause} ->
status = status =
case Clause.find(facts, clause) do case Clause.find(facts, clause) do
@ -326,7 +324,7 @@ defmodule Ash.Authorization.Report do
step_mark <> step_mark <>
" | " <> " | " <>
to_string(step) <> to_string(step) <>
": #{Enum.join(relationship, ".")}: " <> ": #{Enum.join(relationship || [], ".")}: " <>
mod.describe(opts) <> " " <> status_mark mod.describe(opts) <> " " <> status_mark
end end
end) end)

View file

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

View file

@ -1,375 +1,426 @@
defmodule Ash.Engine do 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 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 alias Ash.Authorization.SatSolver
def run(user, requests, opts \\ []) do
strict_access? = Keyword.get(opts, :strict_access?, true)
requests = defstruct [
if opts[:fetch_only?] do :api,
Enum.map(requests, &Request.authorize_always/1) :requests,
else :user,
requests :log_transitions?,
end :failure_mode,
errors: %{},
completed_preparations: %{},
data: %{},
state: :init,
facts: %{
true: true,
false: false
},
scenarios: []
]
case Enum.find(requests, fn request -> Enum.empty?(request.rules) end) do @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 ->
if all_resolved_or_unnecessary?(new_engine.requests) do
new_engine
else
add_error(engine, [:__engine__], "Completed without all data resolved.")
end
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 -> nil ->
{new_requests, facts} = strict_check_facts(user, requests, strict_access?) transition(engine, :complete, %{message: "No remaining requests that must be resolved"})
solve(
new_requests,
user,
facts,
facts,
opts[:state] || %{},
strict_access?,
opts[:log_final_report?] || false
)
request -> request ->
exception = Ash.Error.Forbidden.exception(no_steps_configured: request) engine
|> resolve_data(request)
if opts[:log_final_report?] do |> remain(%{message: "Resolved #{request.name}"})
Logger.info(Ash.Error.Forbidden.report_text(exception))
end
{:error, exception}
end end
end end
def solve( defp resolve_for_resolve_complete?(request, engine) do
requests, if Request.data_resolved?(request) do
user, false
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} ->
false
# 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
{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)
end)
case matching do
%{^fact => value} when is_boolean(value) and value != value_in_this_scenario ->
fact
_ ->
false
end
end)
Map.delete(scenario, unnecessary_fact)
end)
|> Enum.uniq()
if new_scenarios == scenarios do
new_scenarios
else else
remove_irrelevant_clauses(new_scenarios) case Request.all_dependencies_met?(request, engine.data) do
{true, _must_resolve} ->
request.resolve_when_fetch_only? || is_hard_depended_on?(request, engine.requests)
false ->
false
end
end end
end end
defp get_all_scenarios( defp is_hard_depended_on?(request, all_requests) do
requests, remaining_requests = all_requests -- [request]
scenario,
facts,
state,
negations \\ [],
scenarios \\ []
) do
scenario = Map.drop(scenario, [true, false])
scenarios = [scenario | scenarios]
case scenario_is_reality(scenario, facts) do all_requests
:reality -> |> Enum.reject(& &1.error?)
scenarios |> 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
:not_reality -> defp prepare(engine, request) do
raise "SAT SOLVER ERROR" # 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)
:maybe -> case Request.fetch_request_state(engine.data, request) do
negations_assuming_scenario_false = [scenario | negations] {:ok, %{data: data}} ->
case Ash.Actions.SideLoad.side_load(
case sat_solver( engine.api,
requests, request.resource,
facts, data,
negations_assuming_scenario_false, side_loads,
state request.filter
) do ) do
{:ok, scenario_after_negation} -> {:ok, new_request_data} ->
get_all_scenarios( new_request = %{request | data: new_request_data}
requests, replace_request(engine, new_request)
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)
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)
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))
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} ->
{:error, error} remain(engine, %{errors: [error]})
end end
_ ->
engine
end end
end end
defp filter_must_fetch(requests) do defp replace_request(engine, new_request, replace_data? \\ true) do
Enum.filter(requests, &must_fetch?(&1, requests)) new_requests =
end Enum.map(engine.requests, fn request ->
if request.id == new_request.id do
defp must_fetch?(request, other_requests) do new_request
request.must_fetch? || else
Enum.any?(other_requests, fn other_request -> request
must_fetch?(other_request, other_requests -- [other_request]) and end
Request.depends_on?(request, other_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 end
defp any_scenarios_reality?(scenarios, facts) do defp resolvable_requests(engine) do
Enum.any?(scenarios, fn scenario -> Enum.filter(engine.requests, fn request ->
scenario_is_reality(scenario, facts) == :reality !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])
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)
end end
@ -399,12 +450,223 @@ defmodule Ash.Engine do
end) end)
end end
defp strict_check_facts(user, requests, strict_access?, initial \\ %{true: true, false: false}) do defp generate_scenarios(engine) do
Enum.reduce(requests, {[], initial}, fn request, {requests, facts} -> rules_with_data =
{new_request, new_facts} = Enum.flat_map(engine.requests, fn request ->
Ash.Authorization.Checker.strict_check(user, request, facts, strict_access?) 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)
{[new_request | requests], new_facts} 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_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)
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 end

View file

@ -1,206 +1,287 @@
defmodule Ash.Engine.Request do defmodule Ash.Engine.Request do
require Logger alias Ash.Authorization.{Check, Clause}
@fields_that_change_sometimes [ defmodule UnresolvedField do
:changeset, defstruct [:resolver, depends_on: [], can_use: [], data?: false]
:is_fetched,
:strict_check_completed?
]
defstruct [ def data(dependencies, can_use \\ [], func) do
:resource, %__MODULE__{
:rules, resolver: func,
:filter, depends_on: deps(dependencies),
:action_type, can_use: deps(can_use),
:dependencies, data?: true
:bypass_strict_access?, }
:relationship, end
:fetcher,
:source,
:optional_state,
:must_fetch?,
:is_fetched,
:state_key,
:strict_check_completed?,
:api,
:changeset
]
@type t :: %__MODULE__{ def field(dependencies, can_use \\ [], func) do
action_type: atom, %__MODULE__{
resource: Ash.resource(), resolver: func,
rules: list(term), depends_on: deps(dependencies),
filter: Ash.Filter.t(), can_use: deps(can_use),
changeset: Ecto.Changeset.t(), data?: false
dependencies: list(term), }
optional_state: list(term), end
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 defp deps(deps) do
opts = deps
opts |> List.wrap()
|> Keyword.put_new(:relationship, []) |> Enum.map(fn dep -> List.wrap(dep) end)
|> Keyword.put_new(:rules, []) end
|> 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,
Ash.Authorization.Clause.new(
opts[:relationship] || [],
opts[:resource],
fact,
opts[:clause_source] || :root
)}
end)
end)
struct!(__MODULE__, opts)
end end
def authorize_always(request) do defimpl Inspect, for: UnresolvedField do
%{ import Inspect.Algebra
request
| rules: [ def inspect(field, opts) do
authorize_if: data =
Ash.Authorization.Clause.new( if field.data? do
request.relationship, "data! "
request.resource, else
{Ash.Authorization.Check.Static, result: true}, ""
:root 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 [
:id,
:error?,
:rules,
:strict_check_complete?,
:strict_access?,
:resource,
:changeset,
:path,
:action_type,
:data,
:resolve_when_fetch_only?,
:name,
:filter,
:context
]
def new(opts) do
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[:resource],
fact,
filter
)}
end)
%__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 end
def can_strict_check?(%{changeset: changeset}) when is_function(changeset), do: false def can_strict_check?(%__MODULE__{strict_check_complete?: true}), 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 can_strict_check?(request) do
def dependencies_met?(_state, %{dependencies: nil}), do: true request
|> Map.from_struct()
def dependencies_met?(state, %{dependencies: dependencies}) do |> Enum.all?(fn {_key, value} ->
Enum.all?(dependencies, fn dependency -> !match?(%UnresolvedField{data?: false}, value)
case fetch_nested_value(state, dependency) do
{:ok, _} -> true
_ -> false
end
end) end)
end end
def unmet_dependencies(_state, %{dependencies: []}), do: [] def authorize_always(request) do
def unmet_dependencies(_state, %{dependencies: nil}), do: [] clause = Clause.new(request.resource, {Check.Static, result: true})
def unmet_dependencies(state, %{dependencies: dependencies}) do %{request | rules: [authorize_if: clause]}
Enum.reject(dependencies, fn dependency ->
case fetch_nested_value(state, dependency) do
{:ok, _} -> true
_ -> false
end
end)
end 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, 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
def resolve_field(data, %UnresolvedField{resolver: resolver} = unresolved) do
context = resolver_context(data, unresolved)
resolver.(context)
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 def contains_clause?(request, clause) do
Enum.any?(request.rules, fn {_step, request_clause} -> Enum.any?(request.rules, fn {_step, request_clause} ->
clause == request_clause clause == request_clause
end) end)
end end
def fetched?(_, %{is_fetched: boolean}) when is_boolean(boolean) do def put_request(state, request) do
boolean put_nested_key(state, request.path, request)
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)
end end
def fetch_request_state(state, request) do def fetch_request_state(state, request) do
key = state_key(request) fetch_nested_value(state, request.path)
fetch_nested_value(state, key)
end end
def fetch( defp resolver_context(state, %{depends_on: depends_on, can_use: can_use}) do
state, with_dependencies =
%{fetcher: fetcher, changeset: changeset} = request Enum.reduce(depends_on, %{}, fn dependency, acc ->
) do {:ok, value} = fetch_nested_value(state, dependency)
fetcher_state = put_nested_key(acc, dependency, value)
%{} end)
|> add_dependent_state(state, request)
|> add_optional_state(state, request)
Logger.debug("Fetching: #{request.source}") Enum.reduce(can_use, with_dependencies, fn can_use, acc ->
case fetch_nested_value(state, can_use) do
case fetcher.(changeset, fetcher_state) do {:ok, value} -> put_nested_key(acc, can_use, value)
{:ok, value} -> _ -> acc
{:ok, put_request_state(state, request, value)} end
end)
{:error, error} ->
{:error, error}
end
end end
def dependent_fields_fetched?(%{changeset: changeset}) when is_function(changeset), do: false def all_dependencies_met?(request, state) do
def dependent_fields_fetched?(%{filter: filter}) when is_function(filter), do: false dependencies_met?(state, get_dependencies(request))
def dependent_fields_fetched?(%{changeset: _}), do: true end
def fetch_dependent_fields(state, request) do def dependencies_met?(state, dependencies, sources \\ [])
fetcher_state = def dependencies_met?(_state, [], _sources), do: {true, []}
%{} def dependencies_met?(_state, nil, _sources), do: {true, []}
|> add_dependent_state(state, request)
|> add_optional_state(state, request)
Logger.debug("Fetching changeset for #{request.source}") def dependencies_met?(state, dependencies, sources) do
Enum.reduce(dependencies, {true, []}, fn
_, false ->
false
case fetch_changeset(fetcher_state, request) do dependency, {true, if_resolved} ->
{:ok, request} -> if dependency in sources do
fetch_filter(fetcher_state, request) # 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}
{:error, error} -> false ->
{:error, error} false
end 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 end
def fetch_nested_value(state, [key]) when is_map(state) do def fetch_nested_value(state, [key]) when is_map(state) do
Map.fetch(state, key) Map.fetch(state, key)
end end
def fetch_nested_value(%UnresolvedField{}, _), do: :error
def fetch_nested_value(state, [key | rest]) when is_map(state) do def fetch_nested_value(state, [key | rest]) when is_map(state) do
case Map.fetch(state, key) do case Map.fetch(state, key) do
{:ok, value} -> fetch_nested_value(value, rest) {:ok, value} -> fetch_nested_value(value, rest)
@ -212,68 +293,21 @@ defmodule Ash.Engine.Request do
Map.fetch(state, key) Map.fetch(state, key)
end end
defp add_dependent_state(arg, state, %{dependencies: dependencies}) do defp get_dependencies(request) do
Enum.reduce(dependencies, arg, fn dependency, acc -> request
{:ok, value} = fetch_nested_value(state, dependency) |> Map.from_struct()
put_nested_key(acc, dependency, value) |> Enum.flat_map(fn {_key, value} ->
end) case value do
end %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
end) end)
|> Enum.uniq()
end 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 defp put_nested_key(state, [key], value) do
Map.put(state, key, value) Map.put(state, key, value)
end 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, :scenarios,
:requests, :requests,
:facts, :facts,
:strict_check_facts,
:state, :state,
:strict_access?, :strict_access?,
:reason, :reason,
@ -20,9 +19,7 @@ defmodule Ash.Error.Forbidden do
scenarios: error.scenarios, scenarios: error.scenarios,
requests: error.requests, requests: error.requests,
facts: error.facts, facts: error.facts,
strict_check_facts: error.strict_check_facts,
state: error.state, state: error.state,
strict_access?: error.strict_access?,
no_steps_configured: error.no_steps_configured, no_steps_configured: error.no_steps_configured,
header: "forbidden:", header: "forbidden:",
authorized?: false authorized?: false
@ -37,9 +34,7 @@ defmodule Ash.Error.Forbidden do
scenarios: error.scenarios, scenarios: error.scenarios,
requests: error.requests, requests: error.requests,
facts: error.facts, facts: error.facts,
strict_check_facts: error.strict_check_facts,
state: error.state, state: error.state,
strict_access?: error.strict_access?,
no_steps_configured: error.no_steps_configured, no_steps_configured: error.no_steps_configured,
header: header, header: header,
authorized?: false authorized?: false

View file

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