This commit is contained in:
Zach Daniel 2020-01-06 23:01:15 -05:00
parent de9a790626
commit a6ca37f537
No known key found for this signature in database
GPG key ID: A57053A671EE649E
6 changed files with 306 additions and 222 deletions

View file

@ -7,7 +7,7 @@ defmodule Ash.Actions.Create do
def run(api, resource, action, params) do
if Keyword.get(params, :side_load, []) in [[], nil] do
Ash.DataLayer.transact(resource, fn ->
with %{valid?: true} = changeset <- changeset(resource, params),
with %{valid?: true} = changeset <- changeset(api, resource, params),
{:ok, %{data: created}} <-
do_authorized(changeset, params, action, resource, api) do
{:ok, created}
@ -26,91 +26,34 @@ defmodule Ash.Actions.Create do
# TODO: Rewrite attributes that reference foreign keys to the primary relationship
# for that foriegn key. Also require a "primary relationship" for a foreign key
def changeset(resource, params) do
# if the foreign key doesn't reference the
def changeset(api, resource, params) do
attributes = Keyword.get(params, :attributes, %{})
relationships = Keyword.get(params, :relationships, %{})
case prepare_create_attributes(resource, attributes) do
%{valid?: true} = changeset ->
relationships
|> Enum.reduce(changeset, fn {key, value}, changeset ->
case Ash.relationship(resource, key) do
%{cardinality: :many, destination: destination, name: name} ->
case Ash.Actions.PrimaryKeyHelpers.values_to_primary_key_filters(
destination,
value
) do
{:ok, values} ->
Map.update!(changeset, :__ash_relationships__, fn ash_relationships ->
Map.put(ash_relationships, key, %{add: values})
end)
{:error, _error} ->
Ecto.Changeset.add_error(changeset, name, "Invalid Identifiers")
end
%{type: :has_one, destination: destination, name: name} ->
case Ash.Actions.PrimaryKeyHelpers.value_to_primary_key_filter(
destination,
value
) do
{:ok, value} ->
Map.update!(changeset, :__ash_relationships__, fn ash_relationships ->
Map.put(ash_relationships, key, %{add: value})
end)
{:error, _error} ->
Ecto.Changeset.add_error(changeset, name, "Invalid Identifier")
end
%{
type: :belongs_to,
destination: destination,
name: name,
source_field: source_field,
destination_field: destination_field
} ->
case Ash.Actions.PrimaryKeyHelpers.value_to_primary_key_filter(destination, value) do
{:ok, value} ->
changeset
|> Map.update!(:__ash_relationships__, fn ash_relationships ->
Map.put(ash_relationships, key, %{add: value})
end)
# Does this assumption hold?
|> Ecto.Changeset.put_change(
source_field,
Keyword.fetch!(value, destination_field)
)
|> Map.put_new(:__ash_skip_authorization_fields__, [])
|> Map.update!(:__ash_skip_authorization_fields__, fn fields ->
[source_field | fields]
end)
{:error, _error} ->
Ecto.Changeset.add_error(changeset, name, "Invalid Identifier(s)")
end
_ ->
Ecto.Changeset.add_error(changeset, key, "No such relationship")
end
end)
changeset ->
changeset
end
resource
|> prepare_create_attributes(attributes)
|> Ash.Actions.Relationships.handle_create_relationships(api, relationships)
end
defp do_authorized(changeset, params, action, resource, api) do
relationships = Keyword.get(params, :relationships, %{})
create_authorization_request =
Ash.Authorization.Request.new(
api: api,
authorization_steps: action.authorization_steps,
resource: resource,
changeset: changeset,
changeset:
Ash.Actions.Relationships.authorization_changeset_with_foreign_keys(
changeset,
relationships
),
action_type: action.type,
fetcher: fn ->
do_create(resource, changeset)
end,
dependencies: Map.get(changeset, :__changes_depend_on__) || [],
state_key: :data,
must_fetch?: true,
relationship: [],
@ -144,14 +87,13 @@ defmodule Ash.Actions.Create do
)
end)
relationship_auths =
Ash.Actions.Relationships.relationship_change_authorizations(api, resource, changeset)
relationship_auths = Map.get(changeset, :__authorizations__, [])
if params[:authorization] do
strict_access? =
case Keyword.fetch(params[:authorization], :strict_access?) do
{:ok, value} -> value
:error -> true
:error -> false
end
Authorizer.authorize(

View file

@ -1,19 +1,98 @@
defmodule Ash.Actions.Relationships do
def relationship_change_authorizations(api, resource, changeset) do
resource
|> Ash.relationships()
|> Enum.filter(fn relationship ->
Map.has_key?(changeset.__ash_relationships__, relationship.name)
end)
|> Enum.flat_map(fn relationship ->
add_related_authorizations(api, relationship, changeset)
def handle_create_relationships(changeset, api, relationships_input) do
Enum.reduce(relationships_input, changeset, fn {name, data}, changeset ->
case Ash.relationship(changeset.data.__struct__, name) do
nil ->
Ecto.Changeset.add_error(changeset, name, "Invalid relationship")
relationship ->
cond do
!Keyword.keyword?(data) ->
add_create_authorizations(api, relationship, data, changeset)
Keyword.keys(data) == [:add] ->
add_create_authorizations(api, relationship, data[:add], changeset)
Keyword.keys(data) == [:replace] ->
add_create_authorizations(api, relationship, data[:replace], changeset)
Keyword.keys(data) == [:add, :replace] ->
Ecto.Changeset.add_error(
changeset,
relationship.name,
"Cannot add to a relationship and replace it at the same time."
)
Keyword.has_key?(data, :remove) ->
Ecto.Changeset.add_error(
changeset,
relationship.name,
"Cannot remove from a relationship on create."
)
true ->
Ecto.Changeset.add_error(
changeset,
relationship.name,
"Invalid relationship data provided"
)
end
end
end)
end
defp add_related_authorizations(
def authorization_changeset_with_foreign_keys(changeset, relationships) do
relationships
|> Enum.reduce_while({:ok, []}, fn {name, data}, {:ok, relationships} ->
case Ash.relationship(changeset.data.__struct__, name) do
nil ->
{:halt, {:error, name}}
%{type: :belongs_to, destination_field: destination_field} = relationship ->
case identifier_or_identifiers(relationship, data) do
{:ok, identifier} ->
if Keyword.has_key?(identifier, destination_field) do
{:cont, {:ok, relationships}}
else
{:cont, {:ok, [relationship | relationships]}}
end
{:error, _} ->
{:halt, {:error, name}}
end
_ ->
{:cont, {:ok, relationships}}
end
end)
|> case do
{:error, name} ->
Ecto.Changeset.add_error(changeset, name, "Invalid relationship")
{:ok, []} ->
changeset
{:ok, relationships_needing_fetch} ->
fn data ->
Enum.reduce(relationships_needing_fetch, changeset, fn relationship, changeset ->
related = get_in(data, [:relationships, relationship.name, :to_add])
if related do
value = Map.get(related, relationship.destination_field)
Ecto.Changeset.cast(changeset, relationship.source_field, value)
else
Ecto.Changeset.add_error(changeset, relationship.name, "Invalid relationship data")
end
end)
end
end
end
defp read_related_authorization(
api,
%{destination: destination} = relationship,
changeset
value,
key
) do
default_read =
Ash.primary_action(destination, :read) ||
@ -21,11 +100,6 @@ defmodule Ash.Actions.Relationships do
relationship_name = relationship.name
value =
changeset.__ash_relationships__
|> Map.get(relationship_name)
|> Map.get(:add, [])
filter =
case relationship.cardinality do
:many ->
@ -35,31 +109,192 @@ defmodule Ash.Actions.Relationships do
value
end
read_request =
Ash.Authorization.Request.new(
api: api,
authorization_steps: default_read.authorization_steps,
resource: relationship.destination,
action_type: :read,
filter: filter,
must_fetch?: true,
state_key: [:relationships, relationship_name, :to_add],
fetcher: fn ->
case api.read(destination, filter: filter, paginate: false) do
{:ok, %{results: results}} -> {:ok, results}
{:error, error} -> {:error, error}
end
end,
relationship: [relationship.name],
source: "read prior to write related #{relationship.name}"
)
related_requests = related_add_authorization_requests(api, value, relationship, changeset)
[read_request | related_requests]
Ash.Authorization.Request.new(
api: api,
authorization_steps: default_read.authorization_steps,
resource: relationship.destination,
action_type: :read,
filter: filter,
must_fetch?: true,
state_key: [:relationships, relationship_name, key],
fetcher: fn ->
case api.read(destination, filter: filter, paginate: false) do
{:ok, %{results: results}} -> {:ok, results}
{:error, error} -> {:error, error}
end
end,
relationship: [relationship.name],
source: "read prior to write related #{relationship.name}"
)
end
defp related_add_authorization_requests(
defp identifier_or_identifiers(relationship, data) do
case relationship.cardinality do
:many ->
Ash.Actions.PrimaryKeyHelpers.values_to_primary_key_filters(
relationship.destination,
data
)
:one ->
Ash.Actions.PrimaryKeyHelpers.value_to_primary_key_filter(
relationship.destination,
data
)
end
end
defp add_create_authorizations(api, relationship, data, changeset) do
case identifier_or_identifiers(relationship, data) do
{:ok, identifiers} ->
changeset =
add_to_relationship_change_metadata(changeset, relationship, :add, identifiers)
read_authorization = read_related_authorization(api, relationship, identifiers, :to_add)
add_authorization =
add_to_relationship_authorization(api, identifiers, relationship, changeset)
changeset
|> add_authorizations(read_authorization)
|> add_authorizations(add_authorization)
|> set_belongs_to_change(relationship, identifiers)
{:error, error} ->
{:error, error}
end
end
defp add_to_relationship_change_metadata(changeset, relationship, key, identifiers) do
changeset
|> Map.put_new(:__ash_relationships__, %{})
|> Map.update!(:__ash_relationships__, fn ash_relationships ->
ash_relationships
|> Map.put_new(relationship.name, %{})
|> Map.update!(relationship.name, fn changes -> Map.put(changes, key, identifiers) end)
end)
end
defp set_belongs_to_change(
changeset,
%{
type: :belongs_to,
source_field: source_field,
name: name,
destination_field: destination_field
},
value
) do
if Keyword.has_key?(value, destination_field) do
changeset
|> Ecto.Changeset.put_change(
source_field,
Keyword.fetch!(value, destination_field)
)
|> Map.put_new(:__ash_skip_authorization_fields__, [])
|> Map.update!(:__ash_skip_authorization_fields__, fn fields ->
[source_field | fields]
end)
else
Map.put_new(changeset, :__changes_depend_on__, [:relationships, name, :to_add])
end
end
defp set_belongs_to_change(changeset, _, _) do
changeset
end
defp add_authorizations(changeset, authorizations) do
authorizations = List.wrap(authorizations)
Map.update(changeset, :__authorizations__, authorizations, &Kernel.++(&1, authorizations))
end
defp add_to_relationship_authorization(
api,
identifier,
%{type: :has_one, name: name, destination: destination} = relationship,
changeset
) do
pkey = Ash.primary_key(destination)
Ash.Authorization.Request.new(
api: api,
authorization_steps: relationship.authorization_steps,
resource: relationship.source,
changeset: changeset,
action_type: :create,
state_key: :data,
must_fetch?: true,
dependencies: [:data, [:relationships, name, :to_add]],
is_fetched: fn data ->
case data do
%{^name => %Ecto.Association.NotLoaded{}} -> false
_ -> true
end
end,
fetcher: fn %{data: data, relationships: %{^name => %{:to_add => to_add}}} ->
pkey_value = Keyword.take(identifier, pkey)
related =
Enum.find(to_add, fn to_relate ->
to_relate
|> Map.take(pkey)
|> Map.to_list()
|> Kernel.==(pkey_value)
end)
updated =
api.update(related,
attributes: %{
relationship.destination_field => Map.get(data, relationship.source_field)
}
)
case updated do
{:ok, updated} -> {:ok, Map.put(data, relationship.name, updated)}
{:error, error} -> {:error, error}
end
end,
relationship: [],
bypass_strict_access?: true,
source: "Update relationship #{name}"
)
end
defp add_to_relationship_authorization(
api,
_identifier,
%{type: :belongs_to, name: name} = relationship,
changeset
) do
Ash.Authorization.Request.new(
api: api,
authorization_steps: relationship.authorization_steps,
resource: relationship.source,
action_type: :update,
state_key: :data,
is_fetched: fn data ->
case data do
%{^name => %Ecto.Association.NotLoaded{}} -> false
_ -> true
end
end,
must_fetch?: true,
dependencies: [:data, [:relationships, name, :to_add]],
bypass_strict_access?: true,
changeset: changeset,
fetcher: fn %{data: data, relationships: %{^name => %{:to_add => to_add}}} ->
case to_add do
[item] -> {:ok, Map.put(data, name, item)}
_ -> raise "Internal relationship assumption failed."
end
end,
relationship: [],
source: "Set relationship #{relationship.name}"
)
end
defp add_to_relationship_authorization(
api,
identifiers,
%{destination: destination, name: name, type: :many_to_many} = relationship,
@ -95,7 +330,7 @@ defmodule Ash.Actions.Relationships do
Map.fetch!(data, relationship.source_field_on_join_table)
}
changeset = Ash.Actions.Create.changeset(relationship.through, attributes)
changeset = Ash.Actions.Create.changeset(api, relationship.through, attributes)
if changeset.valid? do
{:ok, changeset}
@ -184,7 +419,7 @@ defmodule Ash.Actions.Relationships do
end)
end
defp related_add_authorization_requests(
defp add_to_relationship_authorization(
api,
identifiers,
%{destination: destination, name: name, type: :has_many} = relationship,
@ -232,10 +467,7 @@ defmodule Ash.Actions.Relationships do
api.update(related,
attributes: %{
relationship.destination_field => Map.get(data, relationship.source_field)
},
# TODO: This does nothing, but is intended for use when we disallow writing to fields that point to
# relationships
system?: true
}
)
case updated do
@ -262,96 +494,4 @@ defmodule Ash.Actions.Relationships do
]
end)
end
defp related_add_authorization_requests(
api,
_identifier,
%{type: :belongs_to, name: name} = relationship,
changeset
) do
[
Ash.Authorization.Request.new(
api: api,
authorization_steps: relationship.authorization_steps,
resource: relationship.source,
action_type: :update,
state_key: :data,
is_fetched: fn data ->
case data do
%{^name => %Ecto.Association.NotLoaded{}} -> false
_ -> true
end
end,
must_fetch?: true,
dependencies: [:data, [:relationships, name, :to_add]],
bypass_strict_access?: true,
changeset: changeset,
fetcher: fn %{data: data, relationships: %{^name => %{:to_add => to_add}}} ->
case to_add do
[item] -> {:ok, Map.put(data, name, item)}
_ -> raise "Internal relationship assumption failed."
end
end,
relationship: [],
source: "Set relationship #{relationship.name}"
)
]
end
defp related_add_authorization_requests(
api,
identifier,
%{type: :has_one, name: name, destination: destination} = relationship,
changeset
) do
pkey = Ash.primary_key(destination)
[
Ash.Authorization.Request.new(
api: api,
authorization_steps: relationship.authorization_steps,
resource: relationship.source,
changeset: changeset,
action_type: :create,
state_key: :data,
must_fetch?: true,
dependencies: [:data, [:relationships, name, :to_add]],
is_fetched: fn data ->
case data do
%{^name => %Ecto.Association.NotLoaded{}} -> false
_ -> true
end
end,
fetcher: fn %{data: data, relationships: %{^name => %{:to_add => to_add}}} ->
pkey_value = Keyword.take(identifier, pkey)
related =
Enum.find(to_add, fn to_relate ->
to_relate
|> Map.take(pkey)
|> Map.to_list()
|> Kernel.==(pkey_value)
end)
updated =
api.update(related,
attributes: %{
relationship.destination_field => Map.get(data, relationship.source_field)
},
# TODO: This does nothing, but is intended for use when we disallow writing to fields that point to
# relationships
system?: true
)
case updated do
{:ok, updated} -> {:ok, Map.put(data, relationship.name, updated)}
{:error, error} -> {:error, error}
end
end,
relationship: [],
bypass_strict_access?: true,
source: "Update relationship #{name}"
)
]
end
end

View file

@ -7,7 +7,6 @@ defmodule Ash.Authorization.Check do
@type options :: Keyword.t()
@callback strict_check(Ash.user(), Ash.Authorization.request(), options) :: boolean | :unknown
@callback prepare(options) ::
list(Ash.Authorization.prepare_instruction()) | {:error, Ash.error()}
@callback check(Ash.user(), list(Ash.record()), map, options) ::

View file

@ -54,7 +54,13 @@ defmodule Ash.Authorization.Checker do
{[], _clauses_requiring_fetch} ->
case fetch_requests(requests, state, strict_access?) do
{:ok, {new_requests, new_state}} ->
run_checks(scenarios, user, new_requests, facts, new_state, strict_access?)
{new_requests, new_facts} =
Enum.reduce(new_requests, {[], facts}, fn request, {requests, facts} ->
{request, new_facts} = strict_check(user, request, facts, strict_access?)
{[request | requests], new_facts}
end)
run_checks(scenarios, user, new_requests, new_facts, new_state, strict_access?)
:all_scenarios_known ->
:all_scenarios_known
@ -106,10 +112,11 @@ defmodule Ash.Authorization.Checker do
{Enum.count(request.relationship), not request.bypass_strict_access?, request.relationship}
end)
|> case do
[request | _] = requests ->
[request | rest] = requests ->
case Request.fetch(state, request) do
{:ok, new_state} ->
{:ok, {requests ++ other_requests, new_state}}
new_requests = [%{request | is_fetched: true} | rest] ++ other_requests
{:ok, {new_requests, new_state}}
:error ->
{:ok, {requests ++ other_requests, state}}

View file

@ -75,6 +75,10 @@ defmodule Ash.Authorization.Request do
end)
end
def fetched?(_, %{is_fetched: boolean}) when is_boolean(boolean) do
boolean
end
def fetched?(state, request) do
case fetch_request_state(state, request) do
{:ok, value} ->

View file

@ -59,16 +59,8 @@ defmodule Ash.Test.Authorization.CreateAuthorizationTest do
authorization_steps: false
attribute :self_manager, :boolean, authorization_steps: false
# [
# authorize_if: user_attribute(:admin, true)
# ]
attribute :fired, :boolean, authorization_steps: false
# [
# authorize_if: user_attribute(:admin, true),
# forbid_if: attribute_equals(:self_manager, true),
# authorize_if: always()
# ]
end
relationships do