mirror of
https://github.com/ash-project/ash.git
synced 2024-09-20 05:23:03 +12:00
WIP
This commit is contained in:
parent
290a2e2048
commit
9766db8b92
24 changed files with 1789 additions and 2006 deletions
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
12
lib/ash/authorization/check/logged_in.ex
Normal file
12
lib/ash/authorization/check/logged_in.ex
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
Loading…
Reference in a new issue