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
e5ccacfc76
commit
e31718510b
35 changed files with 1093 additions and 221 deletions
12
README.md
12
README.md
|
@ -138,4 +138,14 @@ end
|
||||||
variable from data somehow. Authorization fetchers will need to take state as
|
variable from data somehow. Authorization fetchers will need to take state as
|
||||||
an argument or something like that, and maybe need to specify dependencies?.
|
an argument or something like that, and maybe need to specify dependencies?.
|
||||||
* Validate that checks have the correct action type when compiling an action
|
* Validate that checks have the correct action type when compiling an action
|
||||||
|
* Make sure updating foreign key attributes behaves the same as setting a
|
||||||
|
relationship, or just disallow having editable attributes for relationship fkeys
|
||||||
|
* Validate `dependencies` and `must_fetch` (all `must_fetch` with dependencies
|
||||||
|
must have those dependencies as `must_fetch` also)
|
||||||
|
* Support branching/more complicated control flow in authorization steps
|
||||||
|
* The Authorization flow for creates/updates may be insufficient. Instead of
|
||||||
|
adding requests if relationships/attributes are changing, we may instead want
|
||||||
|
to embed that knowledge inside the sat solver itself. Basically a
|
||||||
|
`relationship_foo_is_changing?` fact, *and*ed with the resulting conditions.
|
||||||
|
I'm not even sure if thats possible though.
|
||||||
|
* We need to validate incoming attributes/relationships better.
|
||||||
|
|
|
@ -111,7 +111,7 @@ defmodule Ash.Actions.ChangesetHelpers do
|
||||||
authorization
|
authorization
|
||||||
) do
|
) do
|
||||||
before_change(changeset, fn changeset ->
|
before_change(changeset, fn changeset ->
|
||||||
case api.get(destination, filter, authorization: authorization) do
|
case api.get(destination, filter, authorization: false) do
|
||||||
{:ok, record} when not is_nil(record) ->
|
{:ok, record} when not is_nil(record) ->
|
||||||
changeset
|
changeset
|
||||||
|> Ecto.Changeset.put_change(source_field, Map.get(record, destination_field))
|
|> Ecto.Changeset.put_change(source_field, Map.get(record, destination_field))
|
||||||
|
@ -147,7 +147,7 @@ defmodule Ash.Actions.ChangesetHelpers do
|
||||||
filter: [{destination_field, value}],
|
filter: [{destination_field, value}],
|
||||||
limit: 1,
|
limit: 1,
|
||||||
paginate?: false,
|
paginate?: false,
|
||||||
authorization: authorization
|
authorization: false
|
||||||
) do
|
) do
|
||||||
{:ok, %{results: []}} ->
|
{:ok, %{results: []}} ->
|
||||||
changeset
|
changeset
|
||||||
|
@ -176,11 +176,11 @@ defmodule Ash.Actions.ChangesetHelpers do
|
||||||
value = Map.get(result, source_field)
|
value = Map.get(result, source_field)
|
||||||
|
|
||||||
with {:ok, record} <-
|
with {:ok, record} <-
|
||||||
api.get(destination, filter, authorization: authorization),
|
api.get(destination, filter, authorization: false),
|
||||||
{:ok, updated_record} <-
|
{:ok, updated_record} <-
|
||||||
api.update(record,
|
api.update(record,
|
||||||
attributes: %{destination_field => value},
|
attributes: %{destination_field => value},
|
||||||
authorization: authorization
|
authorization: false
|
||||||
) do
|
) do
|
||||||
{:ok, Map.put(result, relationship.name, updated_record)}
|
{:ok, Map.put(result, relationship.name, updated_record)}
|
||||||
end
|
end
|
||||||
|
@ -251,7 +251,7 @@ defmodule Ash.Actions.ChangesetHelpers do
|
||||||
params = [
|
params = [
|
||||||
filter: [{relationship.reverse_relationship, currently_related_filter}],
|
filter: [{relationship.reverse_relationship, currently_related_filter}],
|
||||||
paginate?: false,
|
paginate?: false,
|
||||||
authorization: authorization
|
authorization: false
|
||||||
]
|
]
|
||||||
|
|
||||||
with {:ok, %{results: related}} <-
|
with {:ok, %{results: related}} <-
|
||||||
|
@ -274,7 +274,7 @@ defmodule Ash.Actions.ChangesetHelpers do
|
||||||
|
|
||||||
case api.read(rel.destination,
|
case api.read(rel.destination,
|
||||||
filter: [{rel.reverse_relationship, pkey_filter}],
|
filter: [{rel.reverse_relationship, pkey_filter}],
|
||||||
authorization: authorization
|
authorization: false
|
||||||
) do
|
) do
|
||||||
{:ok, %{results: currently_related}} ->
|
{:ok, %{results: currently_related}} ->
|
||||||
Enum.reduce_while(filters, {:ok, []}, fn filter, {:ok, records} ->
|
Enum.reduce_while(filters, {:ok, []}, fn filter, {:ok, records} ->
|
||||||
|
@ -319,7 +319,7 @@ defmodule Ash.Actions.ChangesetHelpers do
|
||||||
{:not, [or: identifiers]}
|
{:not, [or: identifiers]}
|
||||||
]
|
]
|
||||||
|
|
||||||
case api.read(rel.destination, filter: filter, authorization: authorization) do
|
case api.read(rel.destination, filter: filter, authorization: false) do
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
{:error, error}
|
{:error, error}
|
||||||
|
|
||||||
|
@ -332,7 +332,7 @@ defmodule Ash.Actions.ChangesetHelpers do
|
||||||
{rel.source_field_on_join_table, source_field_value},
|
{rel.source_field_on_join_table, source_field_value},
|
||||||
{rel.destination_field_on_join_table, destination_field_value}
|
{rel.destination_field_on_join_table, destination_field_value}
|
||||||
]),
|
]),
|
||||||
{:ok, _} <- api.destroy(record, authorization: authorization) do
|
{:ok, _} <- api.destroy(record, authorization: false) do
|
||||||
{:cont, :ok}
|
{:cont, :ok}
|
||||||
else
|
else
|
||||||
{:ok, nil} -> {:cont, :ok}
|
{:ok, nil} -> {:cont, :ok}
|
||||||
|
@ -343,7 +343,7 @@ defmodule Ash.Actions.ChangesetHelpers do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp do_fetch_and_ensure_related(api, result, id_filter, rel, authorization) do
|
defp do_fetch_and_ensure_related(api, result, id_filter, rel, authorization) do
|
||||||
case api.get(rel.destination, id_filter, authorization: authorization) do
|
case api.get(rel.destination, id_filter, authorization: false) do
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
{:error, error}
|
{:error, error}
|
||||||
|
|
||||||
|
@ -362,11 +362,11 @@ defmodule Ash.Actions.ChangesetHelpers do
|
||||||
# unless we feel that verifying at *runtime* that the record exists before-hand is a good
|
# unless we feel that verifying at *runtime* that the record exists before-hand is a good
|
||||||
# idea
|
# idea
|
||||||
|
|
||||||
case api.get(rel.through, filter, authorization: authorization) do
|
case api.get(rel.through, filter, authorization: false) do
|
||||||
{:ok, nil} ->
|
{:ok, nil} ->
|
||||||
create_result =
|
create_result =
|
||||||
api.create(rel.through,
|
api.create(rel.through,
|
||||||
authorization: authorization,
|
authorization: false,
|
||||||
attributes: %{
|
attributes: %{
|
||||||
rel.destination_field_on_join_table => destination_field_value,
|
rel.destination_field_on_join_table => destination_field_value,
|
||||||
rel.source_field_on_join_table => source_field_value
|
rel.source_field_on_join_table => source_field_value
|
||||||
|
@ -392,7 +392,7 @@ defmodule Ash.Actions.ChangesetHelpers do
|
||||||
to_be_related, {:ok, now_related} ->
|
to_be_related, {:ok, now_related} ->
|
||||||
case api.update(to_be_related,
|
case api.update(to_be_related,
|
||||||
attributes: %{destination_field => destination_field_value},
|
attributes: %{destination_field => destination_field_value},
|
||||||
authorization: authorization
|
authorization: false
|
||||||
) do
|
) do
|
||||||
{:ok, newly_related} -> {:ok, [newly_related | now_related]}
|
{:ok, newly_related} -> {:ok, [newly_related | now_related]}
|
||||||
{:error, error} -> {:error, error}
|
{:error, error} -> {:error, error}
|
||||||
|
@ -408,7 +408,7 @@ defmodule Ash.Actions.ChangesetHelpers do
|
||||||
record, :ok ->
|
record, :ok ->
|
||||||
case api.update(record,
|
case api.update(record,
|
||||||
attributes: %{destination_key => nil},
|
attributes: %{destination_key => nil},
|
||||||
authorization: authorization
|
authorization: false
|
||||||
) do
|
) do
|
||||||
{:ok, _} -> :ok
|
{:ok, _} -> :ok
|
||||||
{:error, error} -> {:error, error}
|
{:error, error} -> {:error, error}
|
||||||
|
@ -438,7 +438,7 @@ defmodule Ash.Actions.ChangesetHelpers do
|
||||||
# TODO: Only fetch the ones that we don't already have
|
# TODO: Only fetch the ones that we don't already have
|
||||||
Enum.reduce(filters, {:ok, []}, fn
|
Enum.reduce(filters, {:ok, []}, fn
|
||||||
filter, {:ok, to_relate} ->
|
filter, {:ok, to_relate} ->
|
||||||
case api.get(destination, filter, authorization: authorization) do
|
case api.get(destination, filter, authorization: false) do
|
||||||
{:ok, to_relate_item} -> {:ok, [to_relate_item | to_relate]}
|
{:ok, to_relate_item} -> {:ok, [to_relate_item | to_relate]}
|
||||||
{:error, errors} -> {:error, errors}
|
{:error, errors} -> {:error, errors}
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,8 +7,9 @@ defmodule Ash.Actions.Create do
|
||||||
def run(api, resource, action, params) do
|
def run(api, resource, action, params) do
|
||||||
if Keyword.get(params, :side_load, []) in [[], nil] do
|
if Keyword.get(params, :side_load, []) in [[], nil] do
|
||||||
with %{valid?: true} = changeset <- prepare_create_params(api, resource, params),
|
with %{valid?: true} = changeset <- prepare_create_params(api, resource, params),
|
||||||
{:ok, %{data: created}} <- do_authorized(changeset, params, action, resource, api) do
|
{:ok, %{data: created} = state} <-
|
||||||
ChangesetHelpers.run_after_changes(changeset, created)
|
do_authorized(changeset, params, action, resource, api) do
|
||||||
|
{:ok, Ash.Actions.Relationships.add_relationships_to_result(resource, created, state)}
|
||||||
else
|
else
|
||||||
%Ecto.Changeset{} = changeset ->
|
%Ecto.Changeset{} = changeset ->
|
||||||
{:error, changeset}
|
{:error, changeset}
|
||||||
|
@ -22,60 +23,75 @@ defmodule Ash.Actions.Create do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp do_authorized(changeset, params, action, resource, api) do
|
defp do_authorized(changeset, params, action, resource, api) do
|
||||||
if params[:authorization] do
|
create_authorization_request =
|
||||||
create_authorization_request =
|
Ash.Authorization.Request.new(
|
||||||
|
api: api,
|
||||||
|
authorization_steps: action.authorization_steps,
|
||||||
|
resource: resource,
|
||||||
|
changeset: changeset,
|
||||||
|
action_type: action.type,
|
||||||
|
fetcher: fn ->
|
||||||
|
do_create(resource, changeset)
|
||||||
|
end,
|
||||||
|
state_key: :data,
|
||||||
|
must_fetch?: true,
|
||||||
|
relationship: [],
|
||||||
|
source: "#{action.type} - `#{action.name}`"
|
||||||
|
)
|
||||||
|
|
||||||
|
attribute_requests =
|
||||||
|
resource
|
||||||
|
|> Ash.attributes()
|
||||||
|
|> Enum.reject(fn attribute ->
|
||||||
|
attribute.primary_key?
|
||||||
|
end)
|
||||||
|
|> Enum.reject(fn attribute ->
|
||||||
|
attribute.name in Map.get(changeset, :__ash_skip_authorization_fields__, [])
|
||||||
|
end)
|
||||||
|
|> Enum.filter(fn attribute ->
|
||||||
|
attribute.authorization_steps != false && Map.has_key?(changeset.changes, attribute.name)
|
||||||
|
end)
|
||||||
|
|> Enum.map(fn attribute ->
|
||||||
Ash.Authorization.Request.new(
|
Ash.Authorization.Request.new(
|
||||||
api: api,
|
api: api,
|
||||||
authorization_steps: action.authorization_steps,
|
authorization_steps: attribute.authorization_steps,
|
||||||
resource: resource,
|
resource: resource,
|
||||||
changeset: changeset,
|
changeset: changeset,
|
||||||
action_type: action.type,
|
action_type: action.type,
|
||||||
fetcher: fn -> do_create(resource, changeset) end,
|
fetcher: fn -> :ok end,
|
||||||
state_key: :data,
|
state_key: :data,
|
||||||
relationship: [],
|
relationship: [],
|
||||||
source: "#{action.type} - `#{action.name}`"
|
source: "change on `#{attribute.name}`"
|
||||||
)
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
attribute_requests =
|
case Ash.Actions.Relationships.relationship_change_authorizations(api, resource, changeset) do
|
||||||
resource
|
{:ok, relationship_auths} ->
|
||||||
|> Ash.attributes()
|
if params[:authorization] do
|
||||||
|> Enum.reject(fn attribute ->
|
strict_access? =
|
||||||
attribute.primary_key?
|
case Keyword.fetch(params[:authorization], :strict_access?) do
|
||||||
end)
|
{:ok, value} -> value
|
||||||
|> Enum.filter(fn attribute ->
|
:error -> true
|
||||||
attribute.authorization_steps && Map.has_key?(changeset.changes, attribute.name)
|
end
|
||||||
end)
|
|
||||||
|> Enum.map(fn attribute ->
|
Authorizer.authorize(
|
||||||
Ash.Authorization.Request.new(
|
params[:authorization][:user],
|
||||||
api: api,
|
[create_authorization_request | attribute_requests] ++ relationship_auths,
|
||||||
authorization_steps: attribute.authorization_steps,
|
strict_access?: strict_access?,
|
||||||
resource: resource,
|
log_final_report?: params[:authorization][:log_final_report?] || false
|
||||||
changeset: changeset,
|
|
||||||
action_type: action.type,
|
|
||||||
fetcher: fn -> :ok end,
|
|
||||||
state_key: :data,
|
|
||||||
relationship: [],
|
|
||||||
source: "change on `#{attribute.name}`"
|
|
||||||
)
|
)
|
||||||
end)
|
else
|
||||||
|
authorization = params[:authorization] || []
|
||||||
|
|
||||||
strict_access? =
|
Authorizer.authorize(
|
||||||
case Keyword.fetch(params[:authorization], :strict_access?) do
|
authorization[:user],
|
||||||
{:ok, value} -> value
|
[create_authorization_request | attribute_requests],
|
||||||
:error -> true
|
fetch_only?: true
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
Authorizer.authorize(
|
{:error, error} ->
|
||||||
params[:authorization][:user],
|
{:error, error}
|
||||||
[create_authorization_request | attribute_requests],
|
|
||||||
strict_access?: strict_access?,
|
|
||||||
log_final_report?: params[:authorization][:log_final_report?] || false
|
|
||||||
)
|
|
||||||
else
|
|
||||||
case do_create(resource, changeset) do
|
|
||||||
{:ok, result} -> {:ok, %{data: result}}
|
|
||||||
{:error, error} -> {:error, error}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -98,16 +114,84 @@ defmodule Ash.Actions.Create do
|
||||||
defp prepare_create_params(api, resource, params) do
|
defp prepare_create_params(api, resource, params) do
|
||||||
attributes = Keyword.get(params, :attributes, %{})
|
attributes = Keyword.get(params, :attributes, %{})
|
||||||
relationships = Keyword.get(params, :relationships, %{})
|
relationships = Keyword.get(params, :relationships, %{})
|
||||||
|
|
||||||
|
old_relationships =
|
||||||
|
Enum.reduce(relationships, %{}, fn {key, value}, acc ->
|
||||||
|
if Ash.relationship(resource, key).type == :has_many do
|
||||||
|
acc
|
||||||
|
else
|
||||||
|
Map.put(acc, key, value)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
authorization = Keyword.get(params, :authorization, false)
|
authorization = Keyword.get(params, :authorization, false)
|
||||||
|
|
||||||
case prepare_create_attributes(resource, attributes) do
|
case prepare_create_attributes(resource, attributes) do
|
||||||
%{valid?: true} = changeset ->
|
%{valid?: true} = changeset ->
|
||||||
|
# TODO: __ash_api__ should be unnecessary in the new way.
|
||||||
|
# TODO: If you are saying to `add` somethign to a to_one relationship
|
||||||
|
# but not removing the old thing, that should be a validation error
|
||||||
|
# assuming you were authorized to read the original data.
|
||||||
|
# If you are changing a foreign key, that needs to map to a relationship update
|
||||||
changeset = Map.put(changeset, :__ash_api__, api)
|
changeset = Map.put(changeset, :__ash_api__, api)
|
||||||
|
|
||||||
|
changeset =
|
||||||
|
relationships
|
||||||
|
|> Enum.reduce(changeset, fn {key, value}, changeset ->
|
||||||
|
case Ash.relationship(resource, key) do
|
||||||
|
# TODO remove the `type` checks here
|
||||||
|
# TODO: ew
|
||||||
|
%{cardinality: :many, type: :has_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
|
||||||
|
|
||||||
|
%{
|
||||||
|
cardinality: :one,
|
||||||
|
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)
|
||||||
|
|
||||||
ChangesetHelpers.prepare_relationship_changes(
|
ChangesetHelpers.prepare_relationship_changes(
|
||||||
changeset,
|
changeset,
|
||||||
resource,
|
resource,
|
||||||
relationships,
|
old_relationships,
|
||||||
authorization
|
authorization
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -122,10 +206,11 @@ defmodule Ash.Actions.Create do
|
||||||
|> Ash.attributes()
|
|> Ash.attributes()
|
||||||
|> Enum.map(& &1.name)
|
|> Enum.map(& &1.name)
|
||||||
|
|
||||||
|
# TODO: Reject any changes for attributes that are the source field of any `belongs_to` relationships!
|
||||||
attributes_with_defaults =
|
attributes_with_defaults =
|
||||||
resource
|
resource
|
||||||
|> Ash.attributes()
|
|> Ash.attributes()
|
||||||
|> Stream.filter(&(not is_nil(&1.default)))
|
|> Enum.filter(&(not is_nil(&1.default)))
|
||||||
|> Enum.reduce(attributes, fn attr, attributes ->
|
|> Enum.reduce(attributes, fn attr, attributes ->
|
||||||
if Map.has_key?(attributes, attr.name) do
|
if Map.has_key?(attributes, attr.name) do
|
||||||
attributes
|
attributes
|
||||||
|
@ -139,6 +224,7 @@ defmodule Ash.Actions.Create do
|
||||||
|> struct()
|
|> struct()
|
||||||
|> Ecto.Changeset.cast(attributes_with_defaults, allowed_keys)
|
|> Ecto.Changeset.cast(attributes_with_defaults, allowed_keys)
|
||||||
|> Map.put(:action, :create)
|
|> Map.put(:action, :create)
|
||||||
|
|> Map.put(:__ash_relationships__, %{})
|
||||||
|
|
||||||
resource
|
resource
|
||||||
|> Ash.attributes()
|
|> Ash.attributes()
|
||||||
|
|
|
@ -38,8 +38,11 @@ defmodule Ash.Actions.PrimaryKeyHelpers do
|
||||||
attr = Ash.attribute(resource, key)
|
attr = Ash.attribute(resource, key)
|
||||||
|
|
||||||
case Ash.Type.cast_input(attr.type, val) do
|
case Ash.Type.cast_input(attr.type, val) do
|
||||||
{:ok, casted} -> {:ok, Keyword.put(filter, attr.name, casted)}
|
{:ok, casted} ->
|
||||||
:error -> {:error, {key, "is invalid"}}
|
{:ok, Keyword.put(filter, attr.name, casted)}
|
||||||
|
|
||||||
|
:error ->
|
||||||
|
{:error, "#{key} is invalid"}
|
||||||
end
|
end
|
||||||
|
|
||||||
_, {:error, error} ->
|
_, {:error, error} ->
|
||||||
|
|
|
@ -41,20 +41,21 @@ defmodule Ash.Actions.Read do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp do_authorized(query, params, filter, resource, api, action, auths) do
|
defp do_authorized(query, params, filter, resource, api, action, auths) do
|
||||||
if params[:authorization] do
|
filter_authorization_request =
|
||||||
filter_authorization_request =
|
Ash.Authorization.Request.new(
|
||||||
Ash.Authorization.Request.new(
|
api: api,
|
||||||
api: api,
|
resource: resource,
|
||||||
resource: resource,
|
authorization_steps: action.authorization_steps,
|
||||||
authorization_steps: action.authorization_steps,
|
filter: filter,
|
||||||
filter: filter,
|
action_type: action.type,
|
||||||
action_type: action.type,
|
fetcher: fn -> Ash.DataLayer.run_query(query, resource) end,
|
||||||
fetcher: fn -> Ash.DataLayer.run_query(query, resource) end,
|
must_fetch?: true,
|
||||||
state_key: :data,
|
state_key: :data,
|
||||||
relationship: [],
|
relationship: [],
|
||||||
source: "#{action.type} - `#{action.name}`"
|
source: "#{action.type} - `#{action.name}`"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if params[:authorization] do
|
||||||
strict_access? =
|
strict_access? =
|
||||||
case Keyword.fetch(params[:authorization], :strict_access?) do
|
case Keyword.fetch(params[:authorization], :strict_access?) do
|
||||||
{:ok, value} -> value
|
{:ok, value} -> value
|
||||||
|
@ -66,10 +67,11 @@ defmodule Ash.Actions.Read do
|
||||||
log_final_report?: params[:authorization][:log_final_report?] || false
|
log_final_report?: params[:authorization][:log_final_report?] || false
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
case Ash.DataLayer.run_query(query, resource) do
|
authorization = params[:authorization] || []
|
||||||
{:ok, found} -> {:ok, %{data: found}}
|
|
||||||
{:error, error} -> {:error, error}
|
Authorizer.authorize(authorization[:user], [filter_authorization_request | auths],
|
||||||
end
|
fetch_only?: true
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
182
lib/ash/actions/relationships.ex
Normal file
182
lib/ash/actions/relationships.ex
Normal file
|
@ -0,0 +1,182 @@
|
||||||
|
defmodule Ash.Actions.Relationships do
|
||||||
|
alias Ash.Actions.PrimaryKeyHelpers
|
||||||
|
|
||||||
|
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.reduce_while({:ok, []}, fn relationship, {:ok, authorizations} ->
|
||||||
|
case add_related_authorizations(resource, api, relationship, changeset) do
|
||||||
|
{:ok, new_authorizations} -> {:cont, {:ok, authorizations ++ new_authorizations}}
|
||||||
|
{:error, error} -> {:halt, {:error, error}}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_relationships_to_result(resource, result, state) do
|
||||||
|
state
|
||||||
|
|> Map.get(:relationships, %{})
|
||||||
|
|> Enum.reduce(result, fn {name, value}, result ->
|
||||||
|
# TODO: Figure out `to_remove`
|
||||||
|
# how does that look for has_one?
|
||||||
|
case Map.fetch(value, :to_add) do
|
||||||
|
{:ok, to_add} ->
|
||||||
|
case Ash.relationship(resource, name) do
|
||||||
|
%{cardinality: :many} ->
|
||||||
|
Map.put(result, name, Map.keys(to_add))
|
||||||
|
|
||||||
|
%{cardinality: :one} ->
|
||||||
|
Map.put(result, name, to_add)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp wrap_in_list(list) do
|
||||||
|
if Keyword.keyword?(list) do
|
||||||
|
[list]
|
||||||
|
else
|
||||||
|
List.wrap(list)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp add_related_authorizations(
|
||||||
|
resource,
|
||||||
|
api,
|
||||||
|
%{destination: destination} = relationship,
|
||||||
|
changeset
|
||||||
|
) do
|
||||||
|
default_read = Ash.primary_action(resource, :read) || raise "Need a default read action for #{resource}"
|
||||||
|
relationship_name = relationship.name
|
||||||
|
|
||||||
|
changeset.__ash_relationships__
|
||||||
|
|> Map.get(relationship_name)
|
||||||
|
|> Map.get(:add, [])
|
||||||
|
|> wrap_in_list()
|
||||||
|
|> Enum.reduce_while({:ok, []}, fn related_read, {:ok, authorizations} ->
|
||||||
|
with {:ok, filters} <-
|
||||||
|
PrimaryKeyHelpers.values_to_primary_key_filters(
|
||||||
|
destination,
|
||||||
|
wrap_in_list(related_read)
|
||||||
|
),
|
||||||
|
%{errors: []} = filter <- Ash.Filter.parse(destination, [or: filters], api) do
|
||||||
|
read_request =
|
||||||
|
Ash.Authorization.Request.new(
|
||||||
|
api: api,
|
||||||
|
authorization_steps: default_read.authorization_steps,
|
||||||
|
resource: relationship.destination,
|
||||||
|
action_type: :read,
|
||||||
|
filter: filter,
|
||||||
|
state_key: [:relationships, relationship_name, :to_add],
|
||||||
|
fetcher: fn ->
|
||||||
|
api.read(destination, filter: filter)
|
||||||
|
end,
|
||||||
|
relationship: [relationship.name],
|
||||||
|
source: "read prior to write related #{relationship.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
related_requests =
|
||||||
|
related_add_authorization_requests(api, related_read, relationship, changeset)
|
||||||
|
|
||||||
|
{:cont, {:ok, [read_request | authorizations] ++ related_requests}}
|
||||||
|
else
|
||||||
|
{:error, error} -> {:halt, {:error, error}}
|
||||||
|
%{errors: errors} -> {:halt, {:error, errors}}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp related_add_authorization_requests(
|
||||||
|
api,
|
||||||
|
identifier,
|
||||||
|
%{destination: destination, name: name, type: :has_many} = relationship,
|
||||||
|
changeset
|
||||||
|
) do
|
||||||
|
pkey = Ash.primary_key(destination)
|
||||||
|
default_update = Ash.primary_action(destination, :update)
|
||||||
|
|
||||||
|
[
|
||||||
|
Ash.Authorization.Request.new(
|
||||||
|
api: api,
|
||||||
|
authorization_steps: relationship.authorization_steps,
|
||||||
|
resource: relationship.source,
|
||||||
|
changeset: changeset,
|
||||||
|
action_type: :create,
|
||||||
|
state_key: [:data],
|
||||||
|
depends_on: [:data],
|
||||||
|
fetcher: fn %{data: data} -> data end,
|
||||||
|
relationship: [],
|
||||||
|
bypass_strict_access?: true,
|
||||||
|
source: "Update relationship #{name}"
|
||||||
|
),
|
||||||
|
Ash.Authorization.Request.new(
|
||||||
|
api: api,
|
||||||
|
authorization_steps: default_update.authorization_steps,
|
||||||
|
resource: relationship.destinion,
|
||||||
|
action_type: :update,
|
||||||
|
state_key: [:relationships, relationship.name, Map.take(identifier, pkey)],
|
||||||
|
bypass_strict_access?: true,
|
||||||
|
dependencies: [[:relationships, name, :to_add], :data],
|
||||||
|
changeset: fn %{data: data, relationships: %{^name => %{:to_add => to_add}}} ->
|
||||||
|
related =
|
||||||
|
Enum.find(to_add, fn to_relate ->
|
||||||
|
Map.take(to_relate, pkey) == Map.take(identifier, pkey)
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
Ecto.Changeset.cast(
|
||||||
|
related,
|
||||||
|
%{
|
||||||
|
relationship.destination_field => Map.get(data, relationship.source_field)
|
||||||
|
},
|
||||||
|
[relationship.destination_field]
|
||||||
|
)}
|
||||||
|
end,
|
||||||
|
fetcher: fn %{data: data, relationships: %{^name => %{:to_add => to_add}}} ->
|
||||||
|
related =
|
||||||
|
Enum.find(to_add, fn to_relate ->
|
||||||
|
Map.take(to_relate, pkey) == Map.take(identifier, pkey)
|
||||||
|
end)
|
||||||
|
|
||||||
|
api.update(related, %{
|
||||||
|
relationship.destination_field => Map.get(data, relationship.source_field)
|
||||||
|
})
|
||||||
|
end,
|
||||||
|
relationship: [relationship.name],
|
||||||
|
source: "Update related #{name} from create"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp related_add_authorization_requests(
|
||||||
|
api,
|
||||||
|
identifier,
|
||||||
|
%{type: :belongs_to} = relationship,
|
||||||
|
changeset
|
||||||
|
) do
|
||||||
|
[
|
||||||
|
Ash.Authorization.Request.new(
|
||||||
|
api: api,
|
||||||
|
authorization_steps: relationship.authorization_steps,
|
||||||
|
resource: relationship.source,
|
||||||
|
action_type: :update,
|
||||||
|
state_key: :data,
|
||||||
|
dependencies: [:data],
|
||||||
|
bypass_strict_access?: true,
|
||||||
|
changeset:
|
||||||
|
Ecto.Changeset.put_change(
|
||||||
|
changeset,
|
||||||
|
relationship.source_field,
|
||||||
|
Keyword.get(identifier, relationship.destination_field)
|
||||||
|
),
|
||||||
|
fetcher: fn %{data: data} ->
|
||||||
|
data
|
||||||
|
end,
|
||||||
|
relationship: [],
|
||||||
|
source: "Set relationship #{relationship.name}"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
|
@ -22,24 +22,30 @@ defmodule Ash.Authorization.Authorizer do
|
||||||
def authorize(user, requests, opts \\ []) do
|
def authorize(user, requests, opts \\ []) do
|
||||||
strict_access? = Keyword.get(opts, :strict_access?, true)
|
strict_access? = Keyword.get(opts, :strict_access?, true)
|
||||||
|
|
||||||
if Enum.any?(requests, fn request -> Enum.empty?(request.authorization_steps) end) do
|
if opts[:fetch_only?] do
|
||||||
{:error,
|
fetch_must_fetch(requests, %{})
|
||||||
Ash.Error.Forbidden.exception(
|
|
||||||
no_steps_configured?: true,
|
|
||||||
log_final_report?: opts[:log_final_report?] || false
|
|
||||||
)}
|
|
||||||
else
|
else
|
||||||
facts = strict_check_facts(user, requests, strict_access?)
|
case Enum.find(requests, fn request -> Enum.empty?(request.authorization_steps) end) do
|
||||||
|
nil ->
|
||||||
|
{new_requests, facts} = strict_check_facts(user, requests, strict_access?)
|
||||||
|
|
||||||
solve(
|
solve(
|
||||||
requests,
|
new_requests,
|
||||||
user,
|
user,
|
||||||
facts,
|
facts,
|
||||||
facts,
|
facts,
|
||||||
%{user: user},
|
%{user: user},
|
||||||
strict_access?,
|
strict_access?,
|
||||||
opts[:log_final_report?] || false
|
opts[:log_final_report?] || false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
request ->
|
||||||
|
{:error,
|
||||||
|
Ash.Error.Forbidden.exception(
|
||||||
|
no_steps_configured: request,
|
||||||
|
log_final_report?: opts[:log_final_report?] || false
|
||||||
|
)}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -175,7 +181,8 @@ defmodule Ash.Authorization.Authorizer do
|
||||||
defp sat_solver(requests, facts, negations, state) do
|
defp sat_solver(requests, facts, negations, state) do
|
||||||
case state do
|
case state do
|
||||||
%{data: [%resource{} | _] = data} ->
|
%{data: [%resource{} | _] = data} ->
|
||||||
# TODO: Needs primary key
|
# TODO: Needs primary key, looks like some kind of primary key is necessary for
|
||||||
|
# almost everything ash does :/
|
||||||
pkey = Ash.primary_key(resource)
|
pkey = Ash.primary_key(resource)
|
||||||
|
|
||||||
ids = Enum.map(data, &Map.take(&1, pkey))
|
ids = Enum.map(data, &Map.take(&1, pkey))
|
||||||
|
@ -241,36 +248,75 @@ defmodule Ash.Authorization.Authorizer do
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
{:error, error}
|
{:error, error}
|
||||||
|
|
||||||
{:ok, new_facts, state} ->
|
{:ok, new_requests, new_facts, new_state} ->
|
||||||
solve(
|
if new_requests == requests && new_facts == new_facts && state == new_state do
|
||||||
requests,
|
exception =
|
||||||
user,
|
Ash.Error.Forbidden.exception(
|
||||||
new_facts,
|
scenarios: scenarios,
|
||||||
strict_check_facts,
|
requests: requests,
|
||||||
state,
|
facts: facts,
|
||||||
strict_access?,
|
strict_check_facts: strict_check_facts,
|
||||||
log_final_report?
|
state: state,
|
||||||
)
|
strict_access?: strict_access?
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp fetch_must_fetch(requests, state) do
|
defp fetch_must_fetch(requests, state) do
|
||||||
Enum.reduce_while(requests, {:ok, state}, fn request, {:ok, state} ->
|
unfetched = Enum.reject(requests, &Request.fetched?(state, &1))
|
||||||
case Request.fetch_request_state(state, request) do
|
|
||||||
{:ok, _state} ->
|
|
||||||
{:cont, {:ok, state}}
|
|
||||||
|
|
||||||
:error ->
|
{safe_to_fetch, unmet} =
|
||||||
case request.fetcher.() do
|
Enum.split_with(unfetched, fn request -> Request.dependencies_met?(state, request) end)
|
||||||
{:ok, value} ->
|
|
||||||
{:cont, {:ok, Request.put_request_state(state, request, value)}}
|
|
||||||
|
|
||||||
{:error, error} ->
|
case Enum.filter(safe_to_fetch, &Map.get(&1, :must_fetch?)) do
|
||||||
{:halt, {:error, error}}
|
[] ->
|
||||||
end
|
if unmet == [] do
|
||||||
end
|
{:ok, state}
|
||||||
end)
|
else
|
||||||
|
{:error,
|
||||||
|
"Could not fetch all required data due to data dependency issues, unmet dependencies existed"}
|
||||||
|
end
|
||||||
|
|
||||||
|
must_fetch ->
|
||||||
|
new_state =
|
||||||
|
Enum.reduce_while(must_fetch, {:ok, state}, fn request, {:ok, state} ->
|
||||||
|
case Request.fetch(state, request) do
|
||||||
|
{:ok, new_state} -> {:cont, {:ok, new_state}}
|
||||||
|
{:error, error} -> {:halt, {:error, error}}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
case new_state do
|
||||||
|
{:ok, new_state} ->
|
||||||
|
if new_state == state do
|
||||||
|
{:error,
|
||||||
|
"Could not fetch all required data due to data dependency issues, no step affected state"}
|
||||||
|
else
|
||||||
|
fetch_must_fetch(unfetched, new_state)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:error, error}
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp any_scenarios_reality?(scenarios, facts) do
|
defp any_scenarios_reality?(scenarios, facts) do
|
||||||
|
@ -306,8 +352,11 @@ defmodule Ash.Authorization.Authorizer do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp strict_check_facts(user, requests, strict_access?) do
|
defp strict_check_facts(user, requests, strict_access?) do
|
||||||
Enum.reduce(requests, %{true: true, false: false}, fn request, facts ->
|
Enum.reduce(requests, {[], %{true: true, false: false}}, fn request, {requests, facts} ->
|
||||||
Ash.Authorization.Checker.strict_check(user, request, facts, strict_access?)
|
{new_request, new_facts} =
|
||||||
|
Ash.Authorization.Checker.strict_check(user, request, facts, strict_access?)
|
||||||
|
|
||||||
|
{[new_request | requests], new_facts}
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -13,11 +13,12 @@ defmodule Ash.Authorization.Check.BuiltInChecks do
|
||||||
{Ash.Authorization.Check.AttributeEquals, field: field, value: value}
|
{Ash.Authorization.Check.AttributeEquals, field: field, value: value}
|
||||||
end
|
end
|
||||||
|
|
||||||
defmacro related_to_user_via(relationship) do
|
def related_to_user_via(relationship) do
|
||||||
quote do
|
{Ash.Authorization.Check.RelatedToUserVia, relationship: List.wrap(relationship)}
|
||||||
{Ash.Authorization.Check.RelatedToUserVia,
|
end
|
||||||
relationship: List.wrap(unquote(relationship)), source: __MODULE__}
|
|
||||||
end
|
def setting_relationship(relationship) do
|
||||||
|
{Ash.Authorization.Check.SettingRelationship, relationship_name: relationship}
|
||||||
end
|
end
|
||||||
|
|
||||||
def setting_attribute(name, opts) do
|
def setting_attribute(name, opts) do
|
||||||
|
@ -37,4 +38,13 @@ defmodule Ash.Authorization.Check.BuiltInChecks do
|
||||||
{Ash.Authorization.Check.UserAttributeMatchesRecord,
|
{Ash.Authorization.Check.UserAttributeMatchesRecord,
|
||||||
user_field: user_field, record_field: record_field}
|
user_field: user_field, record_field: record_field}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def relating_to_user(relationship_name, opts) do
|
||||||
|
{Ash.Authorization.Check.RelatingToUser,
|
||||||
|
Keyword.put(opts, :relationship_name, relationship_name)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def relationship_set(relationship_name) do
|
||||||
|
{Ash.Authorization.Check.RelationshipSet, [relationship_name: relationship_name]}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,13 +3,13 @@ defmodule Ash.Authorization.Check.RelatedToUserVia do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def describe(opts) do
|
def describe(opts) do
|
||||||
description = describe_relationship(opts[:source], opts[:relationship])
|
description = describe_relationship(opts[:resource], opts[:relationship])
|
||||||
|
|
||||||
description <> "this_record is the user"
|
description <> "this_record is the user"
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def strict_check(%user_resource{} = user, request, opts) do
|
def strict_check(%user_resource{} = user, request = %{action_type: :read}, opts) do
|
||||||
full_relationship_path = request.relationship ++ opts[:relationship]
|
full_relationship_path = request.relationship ++ opts[:relationship]
|
||||||
|
|
||||||
pkey_filter = user |> Map.take(Ash.primary_key(user_resource)) |> Map.to_list()
|
pkey_filter = user |> Map.take(Ash.primary_key(user_resource)) |> Map.to_list()
|
||||||
|
@ -29,6 +29,8 @@ defmodule Ash.Authorization.Check.RelatedToUserVia do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def strict_check(_, _, _), do: {:ok, :unknown}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def prepare(opts) do
|
def prepare(opts) do
|
||||||
[side_load: put_into_relationship_path(opts[:relationship], [])]
|
[side_load: put_into_relationship_path(opts[:relationship], [])]
|
||||||
|
|
71
lib/ash/authorization/check/relating_to_user.ex
Normal file
71
lib/ash/authorization/check/relating_to_user.ex
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
defmodule Ash.Authorization.Check.RelatingToUser do
|
||||||
|
use Ash.Authorization.Check, action_types: [:update, :delete]
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def describe(opts) do
|
||||||
|
"relating #{opts[:relationship_name]} to the user"
|
||||||
|
end
|
||||||
|
|
||||||
|
# TODO: Maybe we should check to see if the pkey of the destination is less fields
|
||||||
|
# and as such we'd need to fetch it before we could determine the answer to this check.
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def strict_check(%user_resource{} = user, %{changeset: changeset}, opts) do
|
||||||
|
pkey = Ash.primary_key(user_resource)
|
||||||
|
pkey_value = Map.take(user, pkey) |> Map.to_list()
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
strict_check_relating_via_attribute?(pkey, pkey_value, opts) ||
|
||||||
|
strict_check_relating?(pkey, pkey_value, changeset, opts)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def strict_check_relating?(pkey, pkey_value, changeset, opts) do
|
||||||
|
case Map.fetch(changeset.__ash_relationships__, opts[:relationship_name]) do
|
||||||
|
{:ok, %{add: relationship_change}} when is_list(relationship_change) ->
|
||||||
|
op =
|
||||||
|
if opts[:allow_additional?] do
|
||||||
|
:any?
|
||||||
|
else
|
||||||
|
:all?
|
||||||
|
end
|
||||||
|
|
||||||
|
relationship_change =
|
||||||
|
if Keyword.keyword?(relationship_change) do
|
||||||
|
[relationship_change]
|
||||||
|
else
|
||||||
|
relationship_change
|
||||||
|
end
|
||||||
|
|
||||||
|
found? =
|
||||||
|
apply(Enum, op, [
|
||||||
|
relationship_change,
|
||||||
|
fn relationship_change ->
|
||||||
|
Keyword.take(relationship_change, pkey) == pkey_value
|
||||||
|
end
|
||||||
|
])
|
||||||
|
|
||||||
|
found?
|
||||||
|
|
||||||
|
%{add: nil} ->
|
||||||
|
false
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def strict_check_relating_via_attribute?([pkey_field], pkey_value, changeset, opts) do
|
||||||
|
relationship = Ash.relationship(opts[:resource], opts[:relationship_name])
|
||||||
|
|
||||||
|
case relationship do
|
||||||
|
%{cardinality: :one, source_field: source_field, destination_field: destination_field} ->
|
||||||
|
destination_field == pkey_field &&
|
||||||
|
Map.get(pkey_value, pkey_field) == Ecto.Changeset.get_change(changeset, source_field)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def strict_check_relating_via_attribute?(_, _, _), do: false
|
||||||
|
end
|
11
lib/ash/authorization/check/relationship_built_in_checks.ex
Normal file
11
lib/ash/authorization/check/relationship_built_in_checks.ex
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
defmodule Ash.Authorization.Check.RelationshipBuiltInChecks do
|
||||||
|
@moduledoc "The relationship specific authorization checks built into ash"
|
||||||
|
|
||||||
|
def relating_to_user(opts \\ []) do
|
||||||
|
{Ash.Authorization.Check.RelatingToUser, opts}
|
||||||
|
end
|
||||||
|
|
||||||
|
def relationship_set() do
|
||||||
|
{Ash.Authorization.Check.RelationshipSet, []}
|
||||||
|
end
|
||||||
|
end
|
27
lib/ash/authorization/check/relationship_set.ex
Normal file
27
lib/ash/authorization/check/relationship_set.ex
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
defmodule Ash.Authorization.Check.RelationshipSet do
|
||||||
|
use Ash.Authorization.Check, action_types: [:create, :update, :read, :delete]
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def describe(opts) do
|
||||||
|
"#{opts[:relationship_name]} is already set"
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
# TODO: Add a filter for "has_something_related", and then check for that here for read actions
|
||||||
|
# TODO: Make this support a nested relationship path?
|
||||||
|
def strict_check(_user, _request, _options) do
|
||||||
|
{:ok, :unknown}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def prepare(opts) do
|
||||||
|
[side_load: opts[:relationship_name]]
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def check(_user, records, _state, opts) do
|
||||||
|
Enum.reject(records, fn record ->
|
||||||
|
Map.get(record, opts[:relationship_name]) in [nil, []]
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
|
@ -3,14 +3,29 @@ defmodule Ash.Authorization.Check.SettingAttribute do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def describe(opts) do
|
def describe(opts) do
|
||||||
"setting #{opts[:attribute_name]} to #{inspect(opts[:to])}"
|
case Keyword.fetch(opts, :to) do
|
||||||
|
{:ok, should_equal} ->
|
||||||
|
"setting #{opts[:attribute_name]} to #{inspect(should_equal)}"
|
||||||
|
|
||||||
|
:error ->
|
||||||
|
"setting #{opts[:attribute_name]}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def strict_check(_user, %{changeset: %Ecto.Changeset{} = changeset}, opts) do
|
def strict_check(_user, %{changeset: %Ecto.Changeset{} = changeset}, opts) do
|
||||||
case Ecto.Changeset.fetch_change(changeset, opts[:attribute_name]) do
|
case Ecto.Changeset.fetch_change(changeset, opts[:attribute_name]) do
|
||||||
{:ok, value} -> {:ok, value == opts[:to]}
|
{:ok, value} ->
|
||||||
:error -> {:ok, false}
|
case Keyword.fetch(opts, :to) do
|
||||||
|
{:ok, should_equal} ->
|
||||||
|
{:ok, value == should_equal}
|
||||||
|
|
||||||
|
:error ->
|
||||||
|
{:ok, true}
|
||||||
|
end
|
||||||
|
|
||||||
|
:error ->
|
||||||
|
{:ok, false}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
13
lib/ash/authorization/check/setting_relationship.ex
Normal file
13
lib/ash/authorization/check/setting_relationship.ex
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
defmodule Ash.Authorization.Check.SettingRelationship do
|
||||||
|
use Ash.Authorization.Check, action_types: [:create, :update]
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def describe(opts) do
|
||||||
|
"setting #{opts[:relationship_name]}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def strict_check(_user, %{changeset: changeset}, options) do
|
||||||
|
{:ok, Map.has_key?(changeset.__ash_relationships__, options[:relationship_name])}
|
||||||
|
end
|
||||||
|
end
|
|
@ -2,29 +2,46 @@ defmodule Ash.Authorization.Checker do
|
||||||
alias Ash.Authorization.Request
|
alias Ash.Authorization.Request
|
||||||
alias Ash.Actions.SideLoad
|
alias Ash.Actions.SideLoad
|
||||||
|
|
||||||
def strict_check(user, request, facts, strict_access?) do
|
# TODO: strict_check can't do things with dependencies. Meaning,
|
||||||
request.authorization_steps
|
# we need to run strict check for things with dependencies in the
|
||||||
|> Enum.reduce(facts, fn {_step, clause}, facts ->
|
# second phase. So we should prioritize things in this way:
|
||||||
case Map.fetch(facts, {request.relationship, clause}) do
|
# 1.) Things who's dependencies unlock strict checks
|
||||||
{:ok, _boolean_result} ->
|
# 2.) things who's strict checks were never run
|
||||||
facts
|
# 3.) Generate the changeset for those
|
||||||
|
# 3.5) probably make it invalid to have an auth request with a changeset function
|
||||||
|
# but no dependencies.
|
||||||
|
# 4.) run strict checks
|
||||||
|
|
||||||
:error ->
|
def strict_check(user, request, facts, strict_access?) do
|
||||||
case do_strict_check(clause, user, request, strict_access?) do
|
if Request.can_strict_check?(request) do
|
||||||
:unknown ->
|
new_facts =
|
||||||
|
request.authorization_steps
|
||||||
|
|> Enum.reduce(facts, fn {_step, clause}, facts ->
|
||||||
|
case Map.fetch(facts, {request.relationship, clause}) do
|
||||||
|
{:ok, _boolean_result} ->
|
||||||
facts
|
facts
|
||||||
|
|
||||||
:unknowable ->
|
:error ->
|
||||||
Map.put(facts, clause, :unknowable)
|
case do_strict_check(clause, user, request, strict_access?) do
|
||||||
|
:unknown ->
|
||||||
|
facts
|
||||||
|
|
||||||
:irrelevant ->
|
:unknowable ->
|
||||||
Map.put(facts, clause, :irrelevant)
|
Map.put(facts, clause, :unknowable)
|
||||||
|
|
||||||
boolean ->
|
:irrelevant ->
|
||||||
Map.put(facts, clause, boolean)
|
Map.put(facts, clause, :irrelevant)
|
||||||
|
|
||||||
|
boolean ->
|
||||||
|
Map.put(facts, clause, boolean)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end)
|
||||||
end)
|
|
||||||
|
{Map.put(request, :strict_check_completed?, true), new_facts}
|
||||||
|
else
|
||||||
|
{request, facts}
|
||||||
|
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
|
||||||
|
@ -60,39 +77,48 @@ defmodule Ash.Authorization.Checker do
|
||||||
|
|
||||||
# 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
|
||||||
unfetched_requests =
|
fetchable_requests =
|
||||||
Enum.reject(requests, fn request ->
|
requests
|
||||||
|
|> Enum.reject(fn request ->
|
||||||
Request.fetched?(state, request)
|
Request.fetched?(state, request)
|
||||||
end)
|
end)
|
||||||
|
|> Enum.filter(fn request ->
|
||||||
|
Request.dependencies_met?(state, request)
|
||||||
|
end)
|
||||||
|
|
||||||
requests_without_strict_access =
|
requests_without_strict_access =
|
||||||
if strict_access? do
|
if strict_access? do
|
||||||
Enum.filter(unfetched_requests, fn request ->
|
Enum.filter(fetchable_requests, fn request ->
|
||||||
request.bypass_strict_access?
|
request.bypass_strict_access?
|
||||||
end)
|
end)
|
||||||
else
|
else
|
||||||
unfetched_requests
|
fetchable_requests
|
||||||
end
|
end
|
||||||
|
|
||||||
requests_without_strict_access
|
requests_without_strict_access
|
||||||
|
|> Enum.filter(fn request ->
|
||||||
|
Request.dependencies_met?(state, request) && request.strict_check_completed?
|
||||||
|
end)
|
||||||
|
|> Enum.map(fn request ->
|
||||||
|
Request.fetch_changeset(state, request)
|
||||||
|
end)
|
||||||
|> 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
|
||||||
{Enum.count(request.relationship), not request.bypass_strict_access?, request.relationship}
|
{Enum.count(request.relationship), not request.bypass_strict_access?, request.relationship}
|
||||||
end)
|
end)
|
||||||
|> Enum.at(0)
|
|
||||||
|> case do
|
|> case do
|
||||||
nil ->
|
[request | _] = requests ->
|
||||||
:all_scenarios_known
|
case Request.fetch_request_state(state, request) do
|
||||||
|
{:ok, new_state} ->
|
||||||
|
{:ok, {requests, new_state}}
|
||||||
|
|
||||||
request ->
|
:error ->
|
||||||
case request.fetcher.() do
|
{:ok, {requests, state}}
|
||||||
{:ok, value} ->
|
|
||||||
{:ok, Request.put_request_state(state, request, value)}
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
{:error, error}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:all_scenarios_known
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -143,7 +169,8 @@ defmodule Ash.Authorization.Checker 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)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
|
@ -10,11 +10,12 @@ defmodule Ash.Authorization.Report do
|
||||||
:strict_access?,
|
:strict_access?,
|
||||||
:header,
|
:header,
|
||||||
:authorized?,
|
:authorized?,
|
||||||
no_steps_configured?: false
|
no_steps_configured: false
|
||||||
]
|
]
|
||||||
|
|
||||||
def report(%{no_steps_configured?: true}) do
|
def report(%{no_steps_configured: %Ash.Authorization.Request{} = request}) do
|
||||||
"One of the authorizations required had no authorization steps configured."
|
"forbidden:\n" <>
|
||||||
|
request.source <> ": no authorization steps configured. Resource: #{request.resource}"
|
||||||
end
|
end
|
||||||
|
|
||||||
# We know that each group of authorization steps shares the same relationship
|
# We know that each group of authorization steps shares the same relationship
|
||||||
|
|
|
@ -4,12 +4,14 @@ defmodule Ash.Authorization.Request do
|
||||||
:authorization_steps,
|
:authorization_steps,
|
||||||
:filter,
|
:filter,
|
||||||
:action_type,
|
:action_type,
|
||||||
|
:dependencies,
|
||||||
:bypass_strict_access?,
|
:bypass_strict_access?,
|
||||||
:relationship,
|
:relationship,
|
||||||
:fetcher,
|
:fetcher,
|
||||||
:source,
|
:source,
|
||||||
:must_fetch?,
|
:must_fetch?,
|
||||||
:state_key,
|
:state_key,
|
||||||
|
:strict_check_completed?,
|
||||||
:api,
|
:api,
|
||||||
:changeset
|
:changeset
|
||||||
]
|
]
|
||||||
|
@ -20,10 +22,12 @@ defmodule Ash.Authorization.Request do
|
||||||
authorization_steps: list(term),
|
authorization_steps: list(term),
|
||||||
filter: Ash.Filter.t(),
|
filter: Ash.Filter.t(),
|
||||||
changeset: Ecto.Changeset.t(),
|
changeset: Ecto.Changeset.t(),
|
||||||
|
dependencies: list(term),
|
||||||
# TODO: fetcher is a function
|
# TODO: fetcher is a function
|
||||||
fetcher: term,
|
fetcher: term,
|
||||||
relationship: list(atom),
|
relationship: list(atom),
|
||||||
bypass_strict_access?: boolean,
|
bypass_strict_access?: boolean,
|
||||||
|
strict_check_completed?: boolean,
|
||||||
source: String.t(),
|
source: String.t(),
|
||||||
must_fetch?: boolean,
|
must_fetch?: boolean,
|
||||||
state_key: term,
|
state_key: term,
|
||||||
|
@ -36,6 +40,8 @@ defmodule Ash.Authorization.Request do
|
||||||
|> Keyword.put_new(:relationship, [])
|
|> Keyword.put_new(:relationship, [])
|
||||||
|> Keyword.put_new(:authorization_steps, [])
|
|> Keyword.put_new(:authorization_steps, [])
|
||||||
|> Keyword.put_new(:bypass_strict_access?, false)
|
|> Keyword.put_new(:bypass_strict_access?, false)
|
||||||
|
|> Keyword.put_new(:dependencies, [])
|
||||||
|
|> Keyword.put_new(:strict_check_completed?, false)
|
||||||
|> Keyword.update!(:authorization_steps, fn steps ->
|
|> Keyword.update!(:authorization_steps, fn steps ->
|
||||||
Enum.map(steps, fn {step, fact} ->
|
Enum.map(steps, fn {step, fact} ->
|
||||||
{step, Ash.Authorization.Clause.new(opts[:relationship] || [], opts[:resource], fact)}
|
{step, Ash.Authorization.Clause.new(opts[:relationship] || [], opts[:resource], fact)}
|
||||||
|
@ -45,6 +51,20 @@ defmodule Ash.Authorization.Request do
|
||||||
struct!(__MODULE__, opts)
|
struct!(__MODULE__, opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def can_strict_check?(%{changeset: changeset}) when is_function(changeset), do: false
|
||||||
|
def can_strict_check?(_), do: true
|
||||||
|
|
||||||
|
def dependencies_met?(_state, %{dependencies: []}), do: true
|
||||||
|
|
||||||
|
def dependencies_met?(state, %{dependencies: dependencies}) do
|
||||||
|
Enum.all?(dependencies, fn dependency ->
|
||||||
|
case fetch_nested_value(state, dependency) do
|
||||||
|
{:ok, _} -> true
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
def contains_clause?(request, clause) do
|
def contains_clause?(request, clause) do
|
||||||
Enum.any?(request.authorization_steps, fn {_step, request_clause} ->
|
Enum.any?(request.authorization_steps, fn {_step, request_clause} ->
|
||||||
clause == request_clause
|
clause == request_clause
|
||||||
|
@ -60,12 +80,95 @@ defmodule Ash.Authorization.Request do
|
||||||
|
|
||||||
def put_request_state(state, %{state_key: state_key} = request, value) do
|
def put_request_state(state, %{state_key: state_key} = request, value) do
|
||||||
state_key = state_key || request
|
state_key = state_key || request
|
||||||
Map.put(state, state_key, value)
|
|
||||||
|
key =
|
||||||
|
state_key
|
||||||
|
|> Kernel.||(request)
|
||||||
|
|> List.wrap()
|
||||||
|
|
||||||
|
put_nested_key(state, key, value)
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_request_state(state, %{state_key: state_key} = request) do
|
def fetch_request_state(state, %{state_key: state_key} = request) do
|
||||||
state_key = state_key || request
|
state_key = state_key || request
|
||||||
|
|
||||||
Map.fetch(state, state_key)
|
key =
|
||||||
|
state_key
|
||||||
|
|> Kernel.||(request)
|
||||||
|
|> List.wrap()
|
||||||
|
|
||||||
|
fetch_nested_value(state, key)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch(state, %{fetcher: fetcher, dependencies: []} = request) do
|
||||||
|
case fetcher.() do
|
||||||
|
{:ok, value} ->
|
||||||
|
{:ok, put_request_state(state, request, value)}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:error, error}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch(state, %{fetcher: fetcher, dependencies: dependencies} = request) do
|
||||||
|
arg =
|
||||||
|
Enum.reduce(dependencies, %{}, fn dependency, acc ->
|
||||||
|
{:ok, value} = fetch_nested_value(state, dependency)
|
||||||
|
put_nested_key(acc, dependency, value)
|
||||||
|
end)
|
||||||
|
|
||||||
|
case fetcher.(arg) do
|
||||||
|
{:ok, value} ->
|
||||||
|
{:ok, put_request_state(state, request, value)}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:error, error}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def 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
|
||||||
|
{:ok, new_changeset} ->
|
||||||
|
{:ok, %{request | changeset: new_changeset}}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:error, error}
|
||||||
|
end
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -10,7 +10,7 @@ defmodule Ash.Error.Forbidden do
|
||||||
:strict_check_facts,
|
:strict_check_facts,
|
||||||
:state,
|
:state,
|
||||||
:strict_access?,
|
:strict_access?,
|
||||||
no_steps_configured?: false
|
no_steps_configured: false
|
||||||
]
|
]
|
||||||
|
|
||||||
def message(error) do
|
def message(error) do
|
||||||
|
@ -21,7 +21,7 @@ defmodule Ash.Error.Forbidden do
|
||||||
strict_check_facts: error.strict_check_facts,
|
strict_check_facts: error.strict_check_facts,
|
||||||
state: error.state,
|
state: error.state,
|
||||||
strict_access?: error.strict_access?,
|
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,7 +37,7 @@ defmodule Ash.Error.Forbidden do
|
||||||
strict_check_facts: error.strict_check_facts,
|
strict_check_facts: error.strict_check_facts,
|
||||||
state: error.state,
|
state: error.state,
|
||||||
strict_access?: error.strict_access?,
|
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
|
||||||
}
|
}
|
||||||
|
|
|
@ -99,6 +99,7 @@ defmodule Ash.Filter do
|
||||||
def strict_subset_of?(_, nil), do: false
|
def strict_subset_of?(_, nil), do: false
|
||||||
|
|
||||||
def strict_subset_of?(filter, candidate) do
|
def strict_subset_of?(filter, candidate) do
|
||||||
|
# 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")
|
||||||
unless filter.not in [[], nil], do: raise("Can't do not contains yet")
|
unless filter.not in [[], nil], do: raise("Can't do not contains yet")
|
||||||
unless candidate.ors in [[], nil], do: raise("Can't do ors contains yet")
|
unless candidate.ors in [[], nil], do: raise("Can't do ors contains yet")
|
||||||
|
|
|
@ -125,7 +125,7 @@ defmodule Ash.Resource do
|
||||||
case opts[:primary_key] do
|
case opts[:primary_key] do
|
||||||
true ->
|
true ->
|
||||||
{:ok, attribute} =
|
{:ok, attribute} =
|
||||||
Ash.Resource.Attributes.Attribute.new(:id, :uuid,
|
Ash.Resource.Attributes.Attribute.new(mod, :id, :uuid,
|
||||||
primary_key?: true,
|
primary_key?: true,
|
||||||
default: &Ecto.UUID.generate/0
|
default: &Ecto.UUID.generate/0
|
||||||
)
|
)
|
||||||
|
@ -137,7 +137,7 @@ defmodule Ash.Resource do
|
||||||
|
|
||||||
opts ->
|
opts ->
|
||||||
{:ok, attribute} =
|
{:ok, attribute} =
|
||||||
Ash.Resource.Attributes.Attribute.new(opts[:field], opts[:type], primary_key?: true)
|
Ash.Resource.Attributes.Attribute.new(mod, opts[:field], opts[:type], primary_key?: true)
|
||||||
|
|
||||||
Module.put_attribute(mod, :attributes, attribute)
|
Module.put_attribute(mod, :attributes, attribute)
|
||||||
end
|
end
|
||||||
|
|
|
@ -49,7 +49,7 @@ defmodule Ash.Resource.Actions do
|
||||||
path: [:actions, :create]
|
path: [:actions, :create]
|
||||||
end
|
end
|
||||||
|
|
||||||
case Ash.Resource.Actions.Create.new(name, opts) do
|
case Ash.Resource.Actions.Create.new(__MODULE__, name, opts) do
|
||||||
{:ok, action} ->
|
{:ok, action} ->
|
||||||
@actions action
|
@actions action
|
||||||
|
|
||||||
|
@ -80,7 +80,7 @@ defmodule Ash.Resource.Actions do
|
||||||
path: [:actions, :read]
|
path: [:actions, :read]
|
||||||
end
|
end
|
||||||
|
|
||||||
case Ash.Resource.Actions.Read.new(name, opts) do
|
case Ash.Resource.Actions.Read.new(__MODULE__, name, opts) do
|
||||||
{:ok, action} ->
|
{:ok, action} ->
|
||||||
@actions action
|
@actions action
|
||||||
|
|
||||||
|
@ -111,7 +111,7 @@ defmodule Ash.Resource.Actions do
|
||||||
path: [:actions, :update]
|
path: [:actions, :update]
|
||||||
end
|
end
|
||||||
|
|
||||||
case Ash.Resource.Actions.Update.new(name, opts) do
|
case Ash.Resource.Actions.Update.new(__MODULE__, name, opts) do
|
||||||
{:ok, action} ->
|
{:ok, action} ->
|
||||||
@actions action
|
@actions action
|
||||||
|
|
||||||
|
@ -142,7 +142,7 @@ defmodule Ash.Resource.Actions do
|
||||||
path: [:actions, :destroy]
|
path: [:actions, :destroy]
|
||||||
end
|
end
|
||||||
|
|
||||||
case Ash.Resource.Actions.Destroy.new(name, opts) do
|
case Ash.Resource.Actions.Destroy.new(__MODULE__, name, opts) do
|
||||||
{:ok, action} ->
|
{:ok, action} ->
|
||||||
@actions action
|
@actions action
|
||||||
|
|
||||||
|
|
|
@ -29,16 +29,31 @@ defmodule Ash.Resource.Actions.Create do
|
||||||
@doc false
|
@doc false
|
||||||
def opt_schema(), do: @opt_schema
|
def opt_schema(), do: @opt_schema
|
||||||
|
|
||||||
@spec new(atom, Keyword.t()) :: {:ok, t()} | {:error, term}
|
@spec new(Ash.resource(), atom, Keyword.t()) :: {:ok, t()} | {:error, term}
|
||||||
def new(name, opts \\ []) do
|
def new(resource, name, opts \\ []) do
|
||||||
case Ashton.validate(opts, @opt_schema) do
|
case Ashton.validate(opts, @opt_schema) do
|
||||||
{:ok, opts} ->
|
{:ok, opts} ->
|
||||||
|
authorization_steps =
|
||||||
|
case opts[:authorization_steps] do
|
||||||
|
false ->
|
||||||
|
false
|
||||||
|
|
||||||
|
steps ->
|
||||||
|
base_attribute_opts = [
|
||||||
|
resource: resource
|
||||||
|
]
|
||||||
|
|
||||||
|
Enum.map(steps, fn {step, {mod, opts}} ->
|
||||||
|
{step, {mod, Keyword.merge(base_attribute_opts, opts)}}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
%__MODULE__{
|
%__MODULE__{
|
||||||
name: name,
|
name: name,
|
||||||
type: :create,
|
type: :create,
|
||||||
primary?: opts[:primary?],
|
primary?: opts[:primary?],
|
||||||
authorization_steps: opts[:authorization_steps]
|
authorization_steps: authorization_steps
|
||||||
}}
|
}}
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
|
|
|
@ -30,16 +30,32 @@ defmodule Ash.Resource.Actions.Destroy do
|
||||||
@doc false
|
@doc false
|
||||||
def opt_schema(), do: @opt_schema
|
def opt_schema(), do: @opt_schema
|
||||||
|
|
||||||
@spec new(atom, Keyword.t()) :: {:ok, t()} | {:error, term}
|
@spec new(Ash.resource(), atom, Keyword.t()) :: {:ok, t()} | {:error, term}
|
||||||
def new(name, opts \\ []) do
|
def new(resource, name, opts \\ []) do
|
||||||
|
# Don't call functions on the resource! We don't want it to compile here
|
||||||
case Ashton.validate(opts, @opt_schema) do
|
case Ashton.validate(opts, @opt_schema) do
|
||||||
{:ok, opts} ->
|
{:ok, opts} ->
|
||||||
|
authorization_steps =
|
||||||
|
case opts[:authorization_steps] do
|
||||||
|
false ->
|
||||||
|
false
|
||||||
|
|
||||||
|
steps ->
|
||||||
|
base_attribute_opts = [
|
||||||
|
resource: resource
|
||||||
|
]
|
||||||
|
|
||||||
|
Enum.map(steps, fn {step, {mod, opts}} ->
|
||||||
|
{step, {mod, Keyword.merge(base_attribute_opts, opts)}}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
%__MODULE__{
|
%__MODULE__{
|
||||||
name: name,
|
name: name,
|
||||||
type: :destroy,
|
type: :destroy,
|
||||||
primary?: opts[:primary?],
|
primary?: opts[:primary?],
|
||||||
authorization_steps: opts[:authorization_steps]
|
authorization_steps: authorization_steps
|
||||||
}}
|
}}
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
|
|
|
@ -35,16 +35,32 @@ defmodule Ash.Resource.Actions.Read do
|
||||||
@doc false
|
@doc false
|
||||||
def opt_schema(), do: @opt_schema
|
def opt_schema(), do: @opt_schema
|
||||||
|
|
||||||
@spec new(atom, Keyword.t()) :: {:ok, t()} | {:error, term}
|
@spec new(Ash.resource(), atom, Keyword.t()) :: {:ok, t()} | {:error, term}
|
||||||
def new(name, opts \\ []) do
|
def new(resource, name, opts \\ []) do
|
||||||
|
# Don't call functions on the resource! We don't want it to compile here
|
||||||
case Ashton.validate(opts, @opt_schema) do
|
case Ashton.validate(opts, @opt_schema) do
|
||||||
{:ok, opts} ->
|
{:ok, opts} ->
|
||||||
|
authorization_steps =
|
||||||
|
case opts[:authorization_steps] do
|
||||||
|
false ->
|
||||||
|
false
|
||||||
|
|
||||||
|
steps ->
|
||||||
|
base_attribute_opts = [
|
||||||
|
resource: resource
|
||||||
|
]
|
||||||
|
|
||||||
|
Enum.map(steps, fn {step, {mod, opts}} ->
|
||||||
|
{step, {mod, Keyword.merge(base_attribute_opts, opts)}}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
%__MODULE__{
|
%__MODULE__{
|
||||||
name: name,
|
name: name,
|
||||||
type: :read,
|
type: :read,
|
||||||
primary?: opts[:primary?],
|
primary?: opts[:primary?],
|
||||||
authorization_steps: opts[:authorization_steps],
|
authorization_steps: authorization_steps,
|
||||||
paginate?: opts[:paginate?]
|
paginate?: opts[:paginate?]
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
|
|
@ -30,16 +30,32 @@ defmodule Ash.Resource.Actions.Update do
|
||||||
@doc false
|
@doc false
|
||||||
def opt_schema(), do: @opt_schema
|
def opt_schema(), do: @opt_schema
|
||||||
|
|
||||||
@spec new(atom, Keyword.t()) :: {:ok, t()} | {:error, term}
|
@spec new(Ash.resource(), atom, Keyword.t()) :: {:ok, t()} | {:error, term}
|
||||||
def new(name, opts \\ []) do
|
def new(resource, name, opts \\ []) do
|
||||||
|
# Don't call functions on the resource! We don't want it to compile here
|
||||||
case Ashton.validate(opts, @opt_schema) do
|
case Ashton.validate(opts, @opt_schema) do
|
||||||
{:ok, opts} ->
|
{:ok, opts} ->
|
||||||
|
authorization_steps =
|
||||||
|
case opts[:authorization_steps] do
|
||||||
|
false ->
|
||||||
|
false
|
||||||
|
|
||||||
|
steps ->
|
||||||
|
base_attribute_opts = [
|
||||||
|
resource: resource
|
||||||
|
]
|
||||||
|
|
||||||
|
Enum.map(steps, fn {step, {mod, opts}} ->
|
||||||
|
{step, {mod, Keyword.merge(base_attribute_opts, opts)}}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
%__MODULE__{
|
%__MODULE__{
|
||||||
name: name,
|
name: name,
|
||||||
type: :update,
|
type: :update,
|
||||||
primary?: opts[:primary?],
|
primary?: opts[:primary?],
|
||||||
authorization_steps: opts[:authorization_steps]
|
authorization_steps: authorization_steps
|
||||||
}}
|
}}
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
|
|
|
@ -28,10 +28,6 @@ defmodule Ash.Resource.Attributes.Attribute do
|
||||||
authorization_steps: []
|
authorization_steps: []
|
||||||
],
|
],
|
||||||
describe: [
|
describe: [
|
||||||
authorization_steps: """
|
|
||||||
Rules applied on an attribute during create or update. If no rules are defined, authorization to change will fail.
|
|
||||||
If set to false, no rules are applied and any changes are allowed (assuming the action was authorized as a whole)
|
|
||||||
""",
|
|
||||||
allow_nil?: """
|
allow_nil?: """
|
||||||
Whether or not to allow `null` values. Ash can perform optimizations with this information, so if you do not
|
Whether or not to allow `null` values. Ash can perform optimizations with this information, so if you do not
|
||||||
expect any null values, make sure to set this switch.
|
expect any null values, make sure to set this switch.
|
||||||
|
@ -39,15 +35,20 @@ defmodule Ash.Resource.Attributes.Attribute do
|
||||||
primary_key?:
|
primary_key?:
|
||||||
"Whether this field is, or is part of, the primary key of a resource.",
|
"Whether this field is, or is part of, the primary key of a resource.",
|
||||||
default:
|
default:
|
||||||
"A one argument function that returns a default value, an mfa that does the same, or a raw value via specifying `{:constant, value}`."
|
"A one argument function that returns a default value, an mfa that does the same, or a raw value via specifying `{:constant, value}`.",
|
||||||
|
authorization_steps: """
|
||||||
|
Rules applied on an attribute during create or update. If no rules are defined, authorization to change will fail.
|
||||||
|
If set to false, no rules are applied and any changes are allowed (assuming the action was authorized as a whole)
|
||||||
|
"""
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
def attribute_schema(), do: @schema
|
def attribute_schema(), do: @schema
|
||||||
|
|
||||||
@spec new(atom, Ash.Type.t(), Keyword.t()) :: {:ok, t()} | {:error, term}
|
@spec new(Ash.resource(), atom, Ash.Type.t(), Keyword.t()) :: {:ok, t()} | {:error, term}
|
||||||
def new(name, type, opts) do
|
def new(resource, name, type, opts) do
|
||||||
|
# Don't call functions on the resource! We don't want it to compile here
|
||||||
with {:ok, opts} <- Ashton.validate(opts, @schema),
|
with {:ok, opts} <- Ashton.validate(opts, @schema),
|
||||||
{:default, {:ok, default}} <- {:default, cast_default(type, opts)} do
|
{:default, {:ok, default}} <- {:default, cast_default(type, opts)} do
|
||||||
authorization_steps =
|
authorization_steps =
|
||||||
|
@ -58,7 +59,8 @@ defmodule Ash.Resource.Attributes.Attribute do
|
||||||
steps ->
|
steps ->
|
||||||
base_attribute_opts = [
|
base_attribute_opts = [
|
||||||
attribute_name: name,
|
attribute_name: name,
|
||||||
attribute_type: type
|
attribute_type: type,
|
||||||
|
resource: resource
|
||||||
]
|
]
|
||||||
|
|
||||||
Enum.map(steps, fn {step, {mod, opts}} ->
|
Enum.map(steps, fn {step, {mod, opts}} ->
|
||||||
|
|
|
@ -58,7 +58,7 @@ defmodule Ash.Resource.Attributes do
|
||||||
path: [:attributes, :attribute, name]
|
path: [:attributes, :attribute, name]
|
||||||
end
|
end
|
||||||
|
|
||||||
case Ash.Resource.Attributes.Attribute.new(name, type, opts) do
|
case Ash.Resource.Attributes.Attribute.new(__MODULE__, name, type, opts) do
|
||||||
{:ok, attribute} ->
|
{:ok, attribute} ->
|
||||||
@attributes attribute
|
@attributes attribute
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,8 @@ defmodule Ash.Resource.Relationships.BelongsTo do
|
||||||
:destination_field,
|
:destination_field,
|
||||||
:source_field,
|
:source_field,
|
||||||
:source,
|
:source,
|
||||||
:reverse_relationship
|
:reverse_relationship,
|
||||||
|
:authorization_steps
|
||||||
]
|
]
|
||||||
|
|
||||||
@type t :: %__MODULE__{
|
@type t :: %__MODULE__{
|
||||||
|
@ -24,7 +25,8 @@ defmodule Ash.Resource.Relationships.BelongsTo do
|
||||||
define_field?: boolean,
|
define_field?: boolean,
|
||||||
field_type: Ash.Type.t(),
|
field_type: Ash.Type.t(),
|
||||||
destination_field: atom,
|
destination_field: atom,
|
||||||
source_field: atom | nil
|
source_field: atom | nil,
|
||||||
|
authorization_steps: Keyword.t()
|
||||||
}
|
}
|
||||||
|
|
||||||
@opt_schema Ashton.schema(
|
@opt_schema Ashton.schema(
|
||||||
|
@ -34,13 +36,15 @@ defmodule Ash.Resource.Relationships.BelongsTo do
|
||||||
primary_key?: :boolean,
|
primary_key?: :boolean,
|
||||||
define_field?: :boolean,
|
define_field?: :boolean,
|
||||||
field_type: :atom,
|
field_type: :atom,
|
||||||
reverse_relationship: :atom
|
reverse_relationship: :atom,
|
||||||
|
authorization_steps: :keyword
|
||||||
],
|
],
|
||||||
defaults: [
|
defaults: [
|
||||||
destination_field: :id,
|
destination_field: :id,
|
||||||
primary_key?: false,
|
primary_key?: false,
|
||||||
define_field?: true,
|
define_field?: true,
|
||||||
field_type: :uuid
|
field_type: :uuid,
|
||||||
|
authorization_steps: []
|
||||||
],
|
],
|
||||||
describe: [
|
describe: [
|
||||||
reverse_relationship:
|
reverse_relationship:
|
||||||
|
@ -53,7 +57,12 @@ defmodule Ash.Resource.Relationships.BelongsTo do
|
||||||
source_field:
|
source_field:
|
||||||
"The field on this resource that should match the `destination_field` on the related resource. Default: [relationship_name]_id",
|
"The field on this resource that should match the `destination_field` on the related resource. Default: [relationship_name]_id",
|
||||||
primary_key?:
|
primary_key?:
|
||||||
"Whether this field is, or is part of, the primary key of a resource."
|
"Whether this field is, or is part of, the primary key of a resource.",
|
||||||
|
authorization_steps: """
|
||||||
|
Steps applied on an relationship during create or update. If no steps are defined, authorization to change will fail.
|
||||||
|
If set to false, no steps are applied and any changes are allowed (assuming the action was authorized as a whole)
|
||||||
|
Remember that any changes against the destination records *will* still be authorized regardless of this setting.
|
||||||
|
"""
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -70,9 +79,27 @@ defmodule Ash.Resource.Relationships.BelongsTo do
|
||||||
# Don't call functions on the resource! We don't want it to compile here
|
# Don't call functions on the resource! We don't want it to compile here
|
||||||
case Ashton.validate(opts, @opt_schema) do
|
case Ashton.validate(opts, @opt_schema) do
|
||||||
{:ok, opts} ->
|
{:ok, opts} ->
|
||||||
|
authorization_steps =
|
||||||
|
case opts[:authorization_steps] do
|
||||||
|
false ->
|
||||||
|
false
|
||||||
|
|
||||||
|
steps ->
|
||||||
|
base_attribute_opts = [
|
||||||
|
relationship_name: name,
|
||||||
|
destination: related_resource,
|
||||||
|
resource: resource
|
||||||
|
]
|
||||||
|
|
||||||
|
Enum.map(steps, fn {step, {mod, opts}} ->
|
||||||
|
{step, {mod, Keyword.merge(base_attribute_opts, opts)}}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
%__MODULE__{
|
%__MODULE__{
|
||||||
name: name,
|
name: name,
|
||||||
|
authorization_steps: authorization_steps,
|
||||||
source: resource,
|
source: resource,
|
||||||
type: :belongs_to,
|
type: :belongs_to,
|
||||||
cardinality: :one,
|
cardinality: :one,
|
||||||
|
|
|
@ -6,14 +6,17 @@ defmodule Ash.Resource.Relationships.HasMany do
|
||||||
:destination,
|
:destination,
|
||||||
:destination_field,
|
:destination_field,
|
||||||
:source_field,
|
:source_field,
|
||||||
|
:authorization_steps,
|
||||||
:source,
|
:source,
|
||||||
:reverse_relationship
|
:reverse_relationship,
|
||||||
|
:authorization_steps
|
||||||
]
|
]
|
||||||
|
|
||||||
@type t :: %__MODULE__{
|
@type t :: %__MODULE__{
|
||||||
type: :has_many,
|
type: :has_many,
|
||||||
cardinality: :many,
|
cardinality: :many,
|
||||||
source: Ash.resource(),
|
source: Ash.resource(),
|
||||||
|
authorization_steps: Keyword.t(),
|
||||||
name: atom,
|
name: atom,
|
||||||
type: Ash.Type.t(),
|
type: Ash.Type.t(),
|
||||||
destination: Ash.resource(),
|
destination: Ash.resource(),
|
||||||
|
@ -26,10 +29,12 @@ defmodule Ash.Resource.Relationships.HasMany do
|
||||||
opts: [
|
opts: [
|
||||||
destination_field: :atom,
|
destination_field: :atom,
|
||||||
source_field: :atom,
|
source_field: :atom,
|
||||||
|
authorization_steps: :keyword,
|
||||||
reverse_relationship: :atom
|
reverse_relationship: :atom
|
||||||
],
|
],
|
||||||
defaults: [
|
defaults: [
|
||||||
source_field: :id
|
source_field: :id,
|
||||||
|
authorization_steps: []
|
||||||
],
|
],
|
||||||
describe: [
|
describe: [
|
||||||
reverse_relationship:
|
reverse_relationship:
|
||||||
|
@ -37,7 +42,12 @@ defmodule Ash.Resource.Relationships.HasMany do
|
||||||
destination_field:
|
destination_field:
|
||||||
"The field on the related resource that should match the `source_field` on this resource. Default: [resource.name]_id",
|
"The field on the related resource that should match the `source_field` on this resource. Default: [resource.name]_id",
|
||||||
source_field:
|
source_field:
|
||||||
"The field on this resource that should match the `destination_field` on the related resource."
|
"The field on this resource that should match the `destination_field` on the related resource.",
|
||||||
|
authorization_steps: """
|
||||||
|
Steps applied on an relationship during create or update. If no steps are defined, authorization to change will fail.
|
||||||
|
If set to false, no steps are applied and any changes are allowed (assuming the action was authorized as a whole)
|
||||||
|
Remember that any changes against the destination records *will* still be authorized regardless of this setting.
|
||||||
|
"""
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -54,9 +64,27 @@ defmodule Ash.Resource.Relationships.HasMany do
|
||||||
# Don't call functions on the resource! We don't want it to compile here
|
# Don't call functions on the resource! We don't want it to compile here
|
||||||
case Ashton.validate(opts, @opt_schema) do
|
case Ashton.validate(opts, @opt_schema) do
|
||||||
{:ok, opts} ->
|
{:ok, opts} ->
|
||||||
|
authorization_steps =
|
||||||
|
case opts[:authorization_steps] do
|
||||||
|
false ->
|
||||||
|
false
|
||||||
|
|
||||||
|
steps ->
|
||||||
|
base_attribute_opts = [
|
||||||
|
relationship_name: name,
|
||||||
|
destination: related_resource,
|
||||||
|
resource: resource
|
||||||
|
]
|
||||||
|
|
||||||
|
Enum.map(steps, fn {step, {mod, opts}} ->
|
||||||
|
{step, {mod, Keyword.merge(base_attribute_opts, opts)}}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
%__MODULE__{
|
%__MODULE__{
|
||||||
name: name,
|
name: name,
|
||||||
|
authorization_steps: authorization_steps,
|
||||||
source: resource,
|
source: resource,
|
||||||
type: :has_many,
|
type: :has_many,
|
||||||
cardinality: :many,
|
cardinality: :many,
|
||||||
|
|
|
@ -8,7 +8,8 @@ defmodule Ash.Resource.Relationships.HasOne do
|
||||||
:destination,
|
:destination,
|
||||||
:destination_field,
|
:destination_field,
|
||||||
:source_field,
|
:source_field,
|
||||||
:reverse_relationship
|
:reverse_relationship,
|
||||||
|
:authorization_steps
|
||||||
]
|
]
|
||||||
|
|
||||||
@type t :: %__MODULE__{
|
@type t :: %__MODULE__{
|
||||||
|
@ -17,6 +18,7 @@ defmodule Ash.Resource.Relationships.HasOne do
|
||||||
source: Ash.resource(),
|
source: Ash.resource(),
|
||||||
name: atom,
|
name: atom,
|
||||||
type: Ash.Type.t(),
|
type: Ash.Type.t(),
|
||||||
|
authorization_steps: Keyword.t(),
|
||||||
destination: Ash.resource(),
|
destination: Ash.resource(),
|
||||||
destination_field: atom,
|
destination_field: atom,
|
||||||
source_field: atom,
|
source_field: atom,
|
||||||
|
@ -27,10 +29,12 @@ defmodule Ash.Resource.Relationships.HasOne do
|
||||||
opts: [
|
opts: [
|
||||||
destination_field: :atom,
|
destination_field: :atom,
|
||||||
source_field: :atom,
|
source_field: :atom,
|
||||||
reverse_relationship: :atom
|
reverse_relationship: :atom,
|
||||||
|
authorization_steps: :keyword
|
||||||
],
|
],
|
||||||
defaults: [
|
defaults: [
|
||||||
source_field: :id
|
source_field: :id,
|
||||||
|
authorization_steps: []
|
||||||
],
|
],
|
||||||
describe: [
|
describe: [
|
||||||
reverse_relationship:
|
reverse_relationship:
|
||||||
|
@ -38,7 +42,12 @@ defmodule Ash.Resource.Relationships.HasOne do
|
||||||
destination_field:
|
destination_field:
|
||||||
"The field on the related resource that should match the `source_field` on this resource. Default: [resource.name]_id",
|
"The field on the related resource that should match the `source_field` on this resource. Default: [resource.name]_id",
|
||||||
source_field:
|
source_field:
|
||||||
"The field on this resource that should match the `destination_field` on the related resource."
|
"The field on this resource that should match the `destination_field` on the related resource.",
|
||||||
|
authorization_steps: """
|
||||||
|
Steps applied on an relationship during create or update. If no steps are defined, authorization to change will fail.
|
||||||
|
If set to false, no steps are applied and any changes are allowed (assuming the action was authorized as a whole)
|
||||||
|
Remember that any changes against the destination records *will* still be authorized regardless of this setting.
|
||||||
|
"""
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -57,6 +66,23 @@ defmodule Ash.Resource.Relationships.HasOne do
|
||||||
# Don't call functions on the resource! We don't want it to compile here
|
# Don't call functions on the resource! We don't want it to compile here
|
||||||
case Ashton.validate(opts, @opt_schema) do
|
case Ashton.validate(opts, @opt_schema) do
|
||||||
{:ok, opts} ->
|
{:ok, opts} ->
|
||||||
|
authorization_steps =
|
||||||
|
case opts[:authorization_steps] do
|
||||||
|
false ->
|
||||||
|
false
|
||||||
|
|
||||||
|
steps ->
|
||||||
|
base_attribute_opts = [
|
||||||
|
relationship_name: name,
|
||||||
|
destination: related_resource,
|
||||||
|
resource: resource
|
||||||
|
]
|
||||||
|
|
||||||
|
Enum.map(steps, fn {step, {mod, opts}} ->
|
||||||
|
{step, {mod, Keyword.merge(base_attribute_opts, opts)}}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
%__MODULE__{
|
%__MODULE__{
|
||||||
name: name,
|
name: name,
|
||||||
|
@ -66,7 +92,8 @@ defmodule Ash.Resource.Relationships.HasOne do
|
||||||
destination: related_resource,
|
destination: related_resource,
|
||||||
destination_field: opts[:destination_field] || :"#{resource_type}_id",
|
destination_field: opts[:destination_field] || :"#{resource_type}_id",
|
||||||
source_field: opts[:source_field],
|
source_field: opts[:source_field],
|
||||||
reverse_relationship: opts[:reverse_relationship]
|
reverse_relationship: opts[:reverse_relationship],
|
||||||
|
authorization_steps: authorization_steps
|
||||||
}}
|
}}
|
||||||
|
|
||||||
{:error, errors} ->
|
{:error, errors} ->
|
||||||
|
|
|
@ -10,7 +10,8 @@ defmodule Ash.Resource.Relationships.ManyToMany do
|
||||||
:destination_field,
|
:destination_field,
|
||||||
:source_field_on_join_table,
|
:source_field_on_join_table,
|
||||||
:destination_field_on_join_table,
|
:destination_field_on_join_table,
|
||||||
:reverse_relationship
|
:reverse_relationship,
|
||||||
|
:authorization_steps
|
||||||
]
|
]
|
||||||
|
|
||||||
@type t :: %__MODULE__{
|
@type t :: %__MODULE__{
|
||||||
|
@ -24,7 +25,8 @@ defmodule Ash.Resource.Relationships.ManyToMany do
|
||||||
destination_field: atom,
|
destination_field: atom,
|
||||||
source_field_on_join_table: atom,
|
source_field_on_join_table: atom,
|
||||||
destination_field_on_join_table: atom,
|
destination_field_on_join_table: atom,
|
||||||
reverse_relationship: atom
|
reverse_relationship: atom,
|
||||||
|
authorization_steps: Keyword.t()
|
||||||
}
|
}
|
||||||
|
|
||||||
@opt_schema Ashton.schema(
|
@opt_schema Ashton.schema(
|
||||||
|
@ -33,12 +35,15 @@ defmodule Ash.Resource.Relationships.ManyToMany do
|
||||||
destination_field_on_join_table: :atom,
|
destination_field_on_join_table: :atom,
|
||||||
source_field: :atom,
|
source_field: :atom,
|
||||||
destination_field: :atom,
|
destination_field: :atom,
|
||||||
|
authorization_steps: :keyword,
|
||||||
through: :atom,
|
through: :atom,
|
||||||
reverse_relationship: :atom
|
reverse_relationship: :atom,
|
||||||
|
authorization_steps: :keyword
|
||||||
],
|
],
|
||||||
defaults: [
|
defaults: [
|
||||||
source_field: :id,
|
source_field: :id,
|
||||||
destination_field: :id
|
destination_field: :id,
|
||||||
|
authorization_steps: []
|
||||||
],
|
],
|
||||||
required: [
|
required: [
|
||||||
:through
|
:through
|
||||||
|
@ -54,7 +59,12 @@ defmodule Ash.Resource.Relationships.ManyToMany do
|
||||||
source_field:
|
source_field:
|
||||||
"The field on this resource that should line up with `source_field_on_join_table` on the join table.",
|
"The field on this resource that should line up with `source_field_on_join_table` on the join table.",
|
||||||
destination_field:
|
destination_field:
|
||||||
"The field on the related resource that should line up with `destination_field_on_join_table` on the join table."
|
"The field on the related resource that should line up with `destination_field_on_join_table` on the join table.",
|
||||||
|
authorization_steps: """
|
||||||
|
Steps applied on an relationship during create or update. If no steps are defined, authorization to change will fail.
|
||||||
|
If set to false, no steps are applied and any changes are allowed (assuming the action was authorized as a whole)
|
||||||
|
Remember that any changes against the destination records *will* still be authorized regardless of this setting.
|
||||||
|
"""
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -72,6 +82,23 @@ defmodule Ash.Resource.Relationships.ManyToMany do
|
||||||
# Don't call functions on the resource! We don't want it to compile here
|
# Don't call functions on the resource! We don't want it to compile here
|
||||||
case Ashton.validate(opts, @opt_schema) do
|
case Ashton.validate(opts, @opt_schema) do
|
||||||
{:ok, opts} ->
|
{:ok, opts} ->
|
||||||
|
authorization_steps =
|
||||||
|
case opts[:authorization_steps] do
|
||||||
|
false ->
|
||||||
|
false
|
||||||
|
|
||||||
|
steps ->
|
||||||
|
base_attribute_opts = [
|
||||||
|
relationship_name: name,
|
||||||
|
destination: related_resource,
|
||||||
|
resource: resource
|
||||||
|
]
|
||||||
|
|
||||||
|
Enum.map(steps, fn {step, {mod, opts}} ->
|
||||||
|
{step, {mod, Keyword.merge(base_attribute_opts, opts)}}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
%__MODULE__{
|
%__MODULE__{
|
||||||
name: name,
|
name: name,
|
||||||
|
@ -83,6 +110,7 @@ defmodule Ash.Resource.Relationships.ManyToMany do
|
||||||
reverse_relationship: opts[:reverse_relationship],
|
reverse_relationship: opts[:reverse_relationship],
|
||||||
source_field: opts[:source_field],
|
source_field: opts[:source_field],
|
||||||
destination_field: opts[:destination_field],
|
destination_field: opts[:destination_field],
|
||||||
|
authorization_steps: authorization_steps,
|
||||||
source_field_on_join_table:
|
source_field_on_join_table:
|
||||||
opts[:source_field_on_join_table] || :"#{resource_name}_id",
|
opts[:source_field_on_join_table] || :"#{resource_name}_id",
|
||||||
destination_field_on_join_table:
|
destination_field_on_join_table:
|
||||||
|
|
|
@ -12,7 +12,13 @@ defmodule Ash.Resource.Relationships do
|
||||||
defmacro relationships(do: block) do
|
defmacro relationships(do: block) do
|
||||||
quote do
|
quote do
|
||||||
import Ash.Resource.Relationships
|
import Ash.Resource.Relationships
|
||||||
|
import Ash.Authorization.Check.BuiltInChecks
|
||||||
|
import Ash.Authorization.Check.RelationshipBuiltInChecks
|
||||||
|
|
||||||
unquote(block)
|
unquote(block)
|
||||||
|
|
||||||
|
import Ash.Authorization.Check.BuiltInChecks, only: []
|
||||||
|
import Ash.Authorization.Check.RelationshipBuiltInChecks, only: []
|
||||||
import Ash.Resource.Relationships, only: [relationships: 1]
|
import Ash.Resource.Relationships, only: [relationships: 1]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -124,6 +130,7 @@ defmodule Ash.Resource.Relationships do
|
||||||
if relationship.define_field? do
|
if relationship.define_field? do
|
||||||
{:ok, attribute} =
|
{:ok, attribute} =
|
||||||
Ash.Resource.Attributes.Attribute.new(
|
Ash.Resource.Attributes.Attribute.new(
|
||||||
|
__MODULE__,
|
||||||
relationship.source_field,
|
relationship.source_field,
|
||||||
relationship.field_type,
|
relationship.field_type,
|
||||||
primary_key?: relationship.primary_key?
|
primary_key?: relationship.primary_key?
|
||||||
|
|
|
@ -1,6 +1,35 @@
|
||||||
defmodule Ash.Test.Authorization.CreateAuthorizationTest do
|
defmodule Ash.Test.Authorization.CreateAuthorizationTest do
|
||||||
use ExUnit.Case, async: true
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
defmodule Draft do
|
||||||
|
use Ash.Resource, name: "drafts", type: "draft"
|
||||||
|
use Ash.DataLayer.Ets, private?: true
|
||||||
|
|
||||||
|
actions do
|
||||||
|
read :default,
|
||||||
|
authorization_steps: [
|
||||||
|
authorize_if: always()
|
||||||
|
]
|
||||||
|
|
||||||
|
create :default,
|
||||||
|
authorization_steps: [
|
||||||
|
forbid_unless: setting_relationship(:author),
|
||||||
|
authorize_if: user_attribute(:author, true)
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
attributes do
|
||||||
|
attribute :contents, :string, authorization_steps: false
|
||||||
|
end
|
||||||
|
|
||||||
|
relationships do
|
||||||
|
belongs_to :author, Ash.Test.Authorization.CreateAuthorizationTest.Author,
|
||||||
|
authorization_steps: [
|
||||||
|
authorize_if: relating_to_user()
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defmodule Author do
|
defmodule Author do
|
||||||
use Ash.Resource, name: "authors", type: "author"
|
use Ash.Resource, name: "authors", type: "author"
|
||||||
use Ash.DataLayer.Ets, private?: true
|
use Ash.DataLayer.Ets, private?: true
|
||||||
|
@ -44,6 +73,28 @@ defmodule Ash.Test.Authorization.CreateAuthorizationTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defmodule Bio do
|
||||||
|
use Ash.Resource, name: "bios", type: "bio"
|
||||||
|
use Ash.DataLayer.Ets, private?: true
|
||||||
|
|
||||||
|
actions do
|
||||||
|
read :default
|
||||||
|
|
||||||
|
create :default,
|
||||||
|
authorization_steps: [
|
||||||
|
forbid_unless: setting_relationship(:author),
|
||||||
|
authorize_if: user_attribute(:author, true)
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
relationships do
|
||||||
|
belongs_to :author, Author,
|
||||||
|
authorization_steps: [
|
||||||
|
authorize_if: relating_to_user()
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defmodule User do
|
defmodule User do
|
||||||
use Ash.Resource, name: "users", type: "user"
|
use Ash.Resource, name: "users", type: "user"
|
||||||
use Ash.DataLayer.Ets, private?: true
|
use Ash.DataLayer.Ets, private?: true
|
||||||
|
@ -57,6 +108,7 @@ defmodule Ash.Test.Authorization.CreateAuthorizationTest do
|
||||||
attribute :name, :string
|
attribute :name, :string
|
||||||
attribute :manager, :boolean, default: {:constant, false}
|
attribute :manager, :boolean, default: {:constant, false}
|
||||||
attribute :admin, :boolean, default: {:constant, false}
|
attribute :admin, :boolean, default: {:constant, false}
|
||||||
|
attribute :author, :boolean, default: {:constant, false}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -102,15 +154,14 @@ defmodule Ash.Test.Authorization.CreateAuthorizationTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
relationships do
|
relationships do
|
||||||
has_many :author_posts, AuthorPost
|
many_to_many :authors, Author, through: AuthorPost
|
||||||
many_to_many :authors, Author, through: :author_posts
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defmodule Api do
|
defmodule Api do
|
||||||
use Ash.Api
|
use Ash.Api
|
||||||
|
|
||||||
resources [Post, Author, AuthorPost, User]
|
resources [Post, Author, AuthorPost, User, Draft]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should fail if a user does not match the action requirements" do
|
test "should fail if a user does not match the action requirements" do
|
||||||
|
@ -137,4 +188,28 @@ defmodule Ash.Test.Authorization.CreateAuthorizationTest do
|
||||||
|
|
||||||
Api.create!(Author, attributes: %{name: "foo", state: "open"}, authorization: [user: user])
|
Api.create!(Author, attributes: %{name: "foo", state: "open"}, authorization: [user: user])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "forbids belongs_to relationships properly" do
|
||||||
|
user = Api.create!(User, attributes: %{name: "foo", author: true})
|
||||||
|
author = Api.create!(Author, attributes: %{name: "someone else"})
|
||||||
|
|
||||||
|
assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||||
|
Api.create!(Draft,
|
||||||
|
attributes: %{contents: "best ever"},
|
||||||
|
relationships: %{author: author.id},
|
||||||
|
authorization: [user: user]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "allows belongs_to relationships properly" do
|
||||||
|
user = Api.create!(User, attributes: %{name: "foo", author: true})
|
||||||
|
author = Api.create!(Author, attributes: %{name: "someone else", id: user.id})
|
||||||
|
|
||||||
|
Api.create!(Draft,
|
||||||
|
attributes: %{contents: "best ever"},
|
||||||
|
relationships: %{author: author.id},
|
||||||
|
authorization: [user: user]
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -30,6 +30,8 @@ defmodule Ash.Test.Authorization.GetAuthorizationTest do
|
||||||
relationships do
|
relationships do
|
||||||
many_to_many :posts, Ash.Test.Authorization.AuthorizationTest.Post,
|
many_to_many :posts, Ash.Test.Authorization.AuthorizationTest.Post,
|
||||||
through: Ash.Test.Authorization.AuthorizationTest.AuthorPost
|
through: Ash.Test.Authorization.AuthorizationTest.AuthorPost
|
||||||
|
|
||||||
|
has_many :drafts, Draft
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -89,7 +91,7 @@ defmodule Ash.Test.Authorization.GetAuthorizationTest do
|
||||||
|
|
||||||
relationships do
|
relationships do
|
||||||
has_many :author_posts, AuthorPost
|
has_many :author_posts, AuthorPost
|
||||||
many_to_many :authors, Author, through: :author_posts
|
many_to_many :authors, Author, through: AuthorPost
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -89,7 +89,7 @@ defmodule Ash.Test.Authorization.ReadAuthorizationTest do
|
||||||
|
|
||||||
relationships do
|
relationships do
|
||||||
has_many :author_posts, AuthorPost
|
has_many :author_posts, AuthorPost
|
||||||
many_to_many :authors, Author, through: :author_posts
|
many_to_many :authors, Author, through: AuthorPost
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue