mirror of
https://github.com/ash-project/ash.git
synced 2024-09-19 21:13:10 +12:00
WIP
This commit is contained in:
parent
b876f87e23
commit
1ed9d3c5fa
65 changed files with 968 additions and 4566 deletions
18
README.md
18
README.md
|
@ -29,16 +29,9 @@ defmodule Post do
|
|||
use Ash.DataLayer.Postgres
|
||||
|
||||
actions do
|
||||
read :default,
|
||||
rules: [
|
||||
authorize_if: user_is(:admin)
|
||||
]
|
||||
|
||||
create :default,
|
||||
rules: [
|
||||
authorize_if: user_is(:admin)
|
||||
]
|
||||
read :default
|
||||
|
||||
create :default
|
||||
end
|
||||
|
||||
attributes do
|
||||
|
@ -66,7 +59,6 @@ Validations
|
|||
- DSL level validations! Things like includes validating that their chain exists. All DSL structs should be strictly validated when they are created.
|
||||
- Especially at compile time, we should _never_ ignore or skip invalid options. If an option is present and invalid, an error is raised.
|
||||
- Validate that the user resource has a get action
|
||||
- Validate rules at creation
|
||||
- Maybe fix the crappy parts of optimal and bring it in for opts validation?
|
||||
- We need to validate incoming attributes/relationships better.
|
||||
- Validate `dependencies` and `must_fetch` (all `must_fetch` with dependencies must have those dependencies as `must_fetch` also)
|
||||
|
@ -92,7 +84,6 @@ Filters
|
|||
- Validate filters, now that there can be duplicates. Doesn't make sense to provide two "exact equals" filters
|
||||
- Clean up and test filter inspecting code.
|
||||
- When checking for filter inclusion, we should allow for `and` filters to each contain _part_ of the filter, requiring that the whole thing is covered by all of the `and`s at least
|
||||
- Support filtering side loads. Especially useful in authorization code?
|
||||
- Add `filter: [relationship_name: [all: [...filter...]]]` so you can assert that _all_ related items match a filter (for to many relationships, may perform poorly)
|
||||
- really need to figure out the distinction between filter impossibility for things like authorization plays into filter impossibility as an optimization. I think they are actually two different things. The fact that you consider nothing to be a subset of an impossible filter may not be correct.
|
||||
- Figure out how to handle cross data layer filters for boolean.
|
||||
|
@ -103,7 +94,6 @@ Filters
|
|||
Actions
|
||||
|
||||
- all actions need to be performed in a transaction
|
||||
- Since actions contain rules now, consider making it possible to list each action as its own `do` block, with an internal DSL for configuring the action. (overkill?)
|
||||
- Since actions can return multiple errors, we need a testing utility to unwrap/assert on them
|
||||
- Validate that checks have the correct action type when compiling an action
|
||||
- Handle related values on delete
|
||||
|
@ -222,10 +212,8 @@ Framework
|
|||
- Framework internals need to stop using `api.foo`, because the code interface is supposed to be optional
|
||||
- support accepting a _resource and a filter_ in `api.update` and `api.destroy`, and doing those as bulk actions
|
||||
- support something similar to the older interface we had with ash, like `Api.read(resource, filter: [...], sort: [...])`, as the `Ash.Query` method is a bit long form in some cases
|
||||
- Add a mixin compatibility checker framework, to allow for mix_ins to declare what features they do/don't support.
|
||||
- Add a mixin compatibility checker framework, to allow for extensions to declare what features they do/don't support.
|
||||
- Have ecto types ask the data layer about the kinds of filtering they can do, and that kind of thing.
|
||||
- Make it `rules: :none` (or something better) than `rules: false`
|
||||
- Support `read_rules`, `create_rules`, `update_rules` for attributes/relationships
|
||||
- Make an `Ash.Changeset` that is a superset of an ecto changeset
|
||||
- consider, just for the sake of good old fashion fun/cool factor, a parser that can parse a string into a query at compile time, so that queries can look nice in code.
|
||||
- Make `Ash.Type` that is a superset of things like `Ecto.Type`. If we bring in ecto database-less(looking like more and more of a good idea to me) that kind of thing gets easier and we can potentially lean on ecto for type validations well.
|
||||
|
|
27
lib/ash.ex
27
lib/ash.ex
|
@ -9,7 +9,6 @@ defmodule Ash do
|
|||
alias Ash.Resource.Relationships.{BelongsTo, HasOne, HasMany, ManyToMany}
|
||||
alias Ash.Resource.Actions.{Create, Read, Update, Destroy}
|
||||
|
||||
@type authorization :: Keyword.t()
|
||||
@type record :: struct
|
||||
@type cardinality_one_relationship() :: HasOne.t() | BelongsTo.t()
|
||||
@type cardinality_many_relationship() :: HasMany.t() | ManyToMany.t()
|
||||
|
@ -29,6 +28,19 @@ defmodule Ash do
|
|||
@type attribute :: Ash.Attributes.Attribute.t()
|
||||
@type action :: Create.t() | Read.t() | Update.t() | Destroy.t()
|
||||
@type query :: Ash.Query.t()
|
||||
@type actor :: Ash.record()
|
||||
|
||||
defmacro partial_resource(do: body) do
|
||||
quote do
|
||||
defmacro __using__(_) do
|
||||
body = unquote(body)
|
||||
|
||||
quote do
|
||||
unquote(body)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def ash_error?(value) do
|
||||
!!Ash.Error.impl_for(value)
|
||||
|
@ -55,6 +67,10 @@ defmodule Ash do
|
|||
resource.describe()
|
||||
end
|
||||
|
||||
def authorizers(resource) do
|
||||
resource.authorizers()
|
||||
end
|
||||
|
||||
@spec resource_module?(module) :: boolean
|
||||
def resource_module?(module) do
|
||||
:attributes
|
||||
|
@ -67,7 +83,7 @@ defmodule Ash do
|
|||
def data_layer_can?(resource, feature) do
|
||||
data_layer = data_layer(resource)
|
||||
|
||||
data_layer && data_layer.can?(feature)
|
||||
data_layer && data_layer.can?(resource, feature)
|
||||
end
|
||||
|
||||
@spec resources(api) :: list(resource())
|
||||
|
@ -94,6 +110,13 @@ defmodule Ash do
|
|||
resource.relationships()
|
||||
end
|
||||
|
||||
def primary_action!(resource, type) do
|
||||
case primary_action(resource, type) do
|
||||
nil -> raise "Required primary #{type} action for #{inspect(resource)}"
|
||||
action -> action
|
||||
end
|
||||
end
|
||||
|
||||
@spec primary_action(resource(), atom()) :: action() | nil
|
||||
def primary_action(resource, type) do
|
||||
resource
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
defmodule Ash.Actions.Attributes do
|
||||
def attribute_change_requests(changeset, api, resource, action) do
|
||||
resource
|
||||
|> Ash.attributes()
|
||||
|> Enum.reject(fn attribute ->
|
||||
attribute.name in Map.get(changeset, :__ash_skip_authorization_fields__, [])
|
||||
end)
|
||||
|> Enum.filter(fn attribute ->
|
||||
attribute.write_rules != false && Map.has_key?(changeset.changes, attribute.name)
|
||||
end)
|
||||
|> Enum.map(fn attribute ->
|
||||
Ash.Engine.Request.new(
|
||||
api: api,
|
||||
rules: attribute.write_rules,
|
||||
resource: resource,
|
||||
changeset: changeset,
|
||||
action_type: action.type,
|
||||
data:
|
||||
Ash.Engine.Request.resolve(
|
||||
[[:data, :data]],
|
||||
fn %{data: %{data: data}} ->
|
||||
{:ok, data}
|
||||
end
|
||||
),
|
||||
path: :data,
|
||||
name: "change on `#{attribute.name}`",
|
||||
write_to_data?: false,
|
||||
strict_access?: false
|
||||
)
|
||||
end)
|
||||
end
|
||||
end
|
|
@ -1,6 +1,6 @@
|
|||
defmodule Ash.Actions.Create do
|
||||
alias Ash.Engine
|
||||
alias Ash.Actions.{Attributes, Relationships, SideLoad}
|
||||
alias Ash.Actions.{Relationships, SideLoad}
|
||||
require Logger
|
||||
|
||||
def run(api, resource, action, params) do
|
||||
|
@ -33,10 +33,10 @@ defmodule Ash.Actions.Create do
|
|||
{:ok, side_load_requests} <-
|
||||
SideLoad.requests(side_load_query, api.query(resource)),
|
||||
%{
|
||||
data: %{data: %{data: %^resource{} = created}} = state,
|
||||
data: %{data: %^resource{} = created} = state,
|
||||
errors: []
|
||||
} <-
|
||||
do_authorized(changeset, params, action, resource, api, side_load_requests) do
|
||||
do_run_requests(changeset, params, action, resource, api, side_load_requests) do
|
||||
{:ok, SideLoad.attach_side_loads(created, state)}
|
||||
else
|
||||
%Ecto.Changeset{} = changeset ->
|
||||
|
@ -59,7 +59,7 @@ defmodule Ash.Actions.Create do
|
|||
|> Relationships.handle_relationship_changes(api, relationships, :create)
|
||||
end
|
||||
|
||||
defp do_authorized(changeset, params, action, resource, api, side_load_requests) do
|
||||
defp do_run_requests(changeset, params, action, resource, api, side_load_requests) do
|
||||
case resolve_data(resource, params) do
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
|
@ -70,7 +70,6 @@ defmodule Ash.Actions.Create do
|
|||
create_request =
|
||||
Ash.Engine.Request.new(
|
||||
api: api,
|
||||
rules: action.rules,
|
||||
resource: resource,
|
||||
changeset:
|
||||
Relationships.changeset(
|
||||
|
@ -78,46 +77,20 @@ defmodule Ash.Actions.Create do
|
|||
api,
|
||||
relationships
|
||||
),
|
||||
action_type: action.type,
|
||||
strict_access?: false,
|
||||
action: action,
|
||||
data: data_resolver,
|
||||
resolve_when_skip_authorization?: true,
|
||||
request_id: :change,
|
||||
path: [:data],
|
||||
name: "#{action.type} - `#{action.name}`"
|
||||
)
|
||||
|
||||
attribute_requests =
|
||||
Attributes.attribute_change_requests(changeset, api, resource, action)
|
||||
|
||||
relationship_read_requests = Map.get(changeset, :__requests__, [])
|
||||
|
||||
relationship_change_requests =
|
||||
Relationships.relationship_change_requests(
|
||||
changeset,
|
||||
api,
|
||||
resource,
|
||||
action,
|
||||
relationships
|
||||
)
|
||||
|
||||
if params[:authorization] do
|
||||
Engine.run(
|
||||
[create_request | attribute_requests] ++
|
||||
relationship_read_requests ++ relationship_change_requests ++ side_load_requests,
|
||||
api,
|
||||
user: params[:authorization][:user],
|
||||
bypass_strict_access?: params[:authorization][:bypass_strict_access?],
|
||||
verbose?: params[:verbose?]
|
||||
)
|
||||
else
|
||||
Engine.run(
|
||||
[create_request | attribute_requests] ++
|
||||
relationship_read_requests ++ relationship_change_requests ++ side_load_requests,
|
||||
api,
|
||||
skip_authorization?: true,
|
||||
verbose?: params[:verbose?]
|
||||
)
|
||||
end
|
||||
Engine.run(
|
||||
[create_request | relationship_read_requests] ++ side_load_requests,
|
||||
api,
|
||||
verbose?: params[:verbose?]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -3,16 +3,7 @@ defmodule Ash.Actions.Destroy do
|
|||
|
||||
@spec run(Ash.api(), Ash.record(), Ash.action(), Ash.params()) ::
|
||||
{:ok, Ash.record()} | {:error, Ecto.Changeset.t()} | {:error, Ash.error()}
|
||||
def run(api, %resource{} = record, action, params) do
|
||||
if params[:authorization][:bypass_strict_access?] do
|
||||
# This one is a bit weird. Essentially, we can't *fetch* the data without *deleting*
|
||||
# it, as there is only one data resolution step. Specifically to support deletes,
|
||||
# we may need to add a final step, like a "commit" or "operation" that happens
|
||||
# after data is fetched and authorization is complete. That would let us support
|
||||
# bypassing strict access while deleting
|
||||
raise "bypassing strict access while deleting is not currently supported"
|
||||
end
|
||||
|
||||
def run(api, %resource{} = record, action, _params) do
|
||||
action =
|
||||
if is_atom(action) and not is_nil(action) do
|
||||
Ash.action(resource, action, :read)
|
||||
|
@ -20,13 +11,13 @@ defmodule Ash.Actions.Destroy do
|
|||
action
|
||||
end
|
||||
|
||||
auth_request =
|
||||
request =
|
||||
Ash.Engine.Request.new(
|
||||
resource: resource,
|
||||
rules: action.rules,
|
||||
api: api,
|
||||
strict_access: true,
|
||||
path: [:data],
|
||||
action: action,
|
||||
request_id: :change,
|
||||
data:
|
||||
Ash.Engine.Request.resolve(fn _ ->
|
||||
case Ash.data_layer(resource).destroy(record) do
|
||||
|
@ -34,24 +25,10 @@ defmodule Ash.Actions.Destroy do
|
|||
{:error, error} -> {:error, error}
|
||||
end
|
||||
end),
|
||||
name: "destroy request",
|
||||
resolve_when_skip_authorization?: true
|
||||
name: "destroy request"
|
||||
)
|
||||
|
||||
result =
|
||||
if params[:authorization] do
|
||||
Engine.run(
|
||||
[auth_request],
|
||||
api,
|
||||
user: params[:authorization][:user],
|
||||
bypass_strict_access?: params[:authorization][:bypass_strict_access?],
|
||||
verbose?: params[:verbose?]
|
||||
)
|
||||
else
|
||||
Engine.run([auth_request], api, skip_authorization?: true, verbose?: params[:verbose?])
|
||||
end
|
||||
|
||||
case result do
|
||||
case Engine.run([request], api) do
|
||||
%{errors: []} ->
|
||||
:ok
|
||||
|
||||
|
|
|
@ -9,8 +9,8 @@ defmodule Ash.Actions.Read do
|
|||
{:action, action} when not is_nil(action) <- {:action, action(query, opts)},
|
||||
requests <- requests(query, action, opts),
|
||||
{:ok, side_load_requests} <- Ash.Actions.SideLoad.requests(query),
|
||||
%{data: %{data: %{data: data}} = all_data, errors: [], authorized?: true} <-
|
||||
run_requests(requests ++ side_load_requests, query.api, opts),
|
||||
%{data: %{data: data} = all_data, errors: []} <-
|
||||
Engine.run(requests ++ side_load_requests, query.api, opts),
|
||||
data_with_side_loads <- SideLoad.attach_side_loads(data, all_data) do
|
||||
{:ok, data_with_side_loads}
|
||||
else
|
||||
|
@ -42,31 +42,14 @@ defmodule Ash.Actions.Read do
|
|||
end
|
||||
end
|
||||
|
||||
def run_requests(requests, api, opts) do
|
||||
if opts[:authorization] do
|
||||
Ash.Engine.Engine2.run(
|
||||
requests,
|
||||
api,
|
||||
user: opts[:authorization][:user],
|
||||
bypass_strict_access?: opts[:authorization][:bypass_strict_access?],
|
||||
verbose?: opts[:verbose?]
|
||||
)
|
||||
else
|
||||
Ash.Engine.Engine2.run(requests, api, skip_authorization?: true, verbose?: opts[:verbose?])
|
||||
end
|
||||
end
|
||||
|
||||
defp requests(query, action, opts) do
|
||||
request =
|
||||
Request.new(
|
||||
resource: query.resource,
|
||||
rules: action.rules,
|
||||
api: query.api,
|
||||
query: query,
|
||||
action_type: action.type,
|
||||
strict_access?: !Ash.Filter.primary_key_filter?(query.filter),
|
||||
action: action,
|
||||
data: data_field(opts, query.filter, query.resource, query.data_layer_query),
|
||||
resolve_when_skip_authorization?: true,
|
||||
path: [:data],
|
||||
name: "#{action.type} - `#{action.name}`"
|
||||
)
|
||||
|
|
|
@ -67,38 +67,6 @@ defmodule Ash.Actions.Relationships do
|
|||
end)
|
||||
end
|
||||
|
||||
def relationship_change_requests(changeset, api, resource, action, relationships) do
|
||||
Enum.flat_map(relationships, fn {relationship_name, _data} ->
|
||||
case Ash.relationship(resource, relationship_name) do
|
||||
nil ->
|
||||
[]
|
||||
|
||||
relationship ->
|
||||
dependencies = [[:data, :data] | Map.get(changeset, :__changes_depend_on__, [])]
|
||||
|
||||
request =
|
||||
Ash.Engine.Request.new(
|
||||
api: api,
|
||||
rules: relationship.write_rules,
|
||||
resource: resource,
|
||||
changeset: changeset(changeset, api, relationships),
|
||||
action_type: action.type,
|
||||
data:
|
||||
Ash.Engine.Request.resolve(dependencies, fn
|
||||
%{data: %{data: data}} ->
|
||||
{:ok, data}
|
||||
end),
|
||||
path: :data,
|
||||
name: "#{relationship_name} edit",
|
||||
strict_access?: false,
|
||||
write_to_data?: false
|
||||
)
|
||||
|
||||
[request]
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp add_relationship_read_requests(changeset, api, relationship, input, :update) do
|
||||
changeset
|
||||
|> add_replace_requests(api, relationship, input)
|
||||
|
@ -127,12 +95,7 @@ defmodule Ash.Actions.Relationships do
|
|||
%{type: :belongs_to, source_field: source_field, destination_field: destination_field} ->
|
||||
case Keyword.fetch(identifiers, destination_field) do
|
||||
{:ok, field_value} ->
|
||||
changeset
|
||||
|> Ecto.Changeset.put_change(source_field, field_value)
|
||||
|> Map.put_new(:__ash_skip_authorization_fields__, [])
|
||||
|> Map.update!(:__ash_skip_authorization_fields__, fn fields ->
|
||||
[source_field | fields]
|
||||
end)
|
||||
Ecto.Changeset.put_change(changeset, source_field, field_value)
|
||||
|
||||
_ ->
|
||||
changeset
|
||||
|
@ -167,12 +130,7 @@ defmodule Ash.Actions.Relationships do
|
|||
changeset =
|
||||
case relationship do
|
||||
%{type: :belongs_to, source_field: source_field} ->
|
||||
changeset
|
||||
|> Ecto.Changeset.put_change(source_field, nil)
|
||||
|> Map.put_new(:__ash_skip_authorization_fields__, [])
|
||||
|> Map.update!(:__ash_skip_authorization_fields__, fn fields ->
|
||||
[source_field | fields]
|
||||
end)
|
||||
Ecto.Changeset.put_change(changeset, source_field, nil)
|
||||
|
||||
_ ->
|
||||
changeset
|
||||
|
@ -198,10 +156,6 @@ defmodule Ash.Actions.Relationships do
|
|||
identifiers,
|
||||
type
|
||||
) do
|
||||
default_read =
|
||||
Ash.primary_action(destination, :read) ||
|
||||
raise "Need a default read action for #{destination}"
|
||||
|
||||
relationship_name = relationship.name
|
||||
|
||||
filter =
|
||||
|
@ -228,11 +182,9 @@ defmodule Ash.Actions.Relationships do
|
|||
request =
|
||||
Ash.Engine.Request.new(
|
||||
api: api,
|
||||
rules: default_read.rules,
|
||||
resource: relationship.destination,
|
||||
action_type: :read,
|
||||
action: Ash.primary_action!(relationship.destination, :read),
|
||||
query: query,
|
||||
resolve_when_skip_authorization?: true,
|
||||
path: [:relationships, relationship_name, type],
|
||||
data:
|
||||
Ash.Engine.Request.resolve(fn _data ->
|
||||
|
@ -584,9 +536,6 @@ defmodule Ash.Actions.Relationships do
|
|||
Enum.reduce(add, changeset, fn to_relate_record, changeset ->
|
||||
case find_pkey_match(current, to_relate_record, pkey) do
|
||||
nil ->
|
||||
# If they want to change fields here, I think we could support it by authorizing
|
||||
# a *create* and *update* with those attributes, and then, if it already exists we don't
|
||||
# fail, we just feed that into the authorizer.
|
||||
add_after_changes(changeset, fn _changeset, record ->
|
||||
join_attrs = %{
|
||||
relationship.source_field_on_join_table() =>
|
||||
|
@ -885,10 +834,6 @@ defmodule Ash.Actions.Relationships do
|
|||
api,
|
||||
%{destination: destination} = relationship
|
||||
) do
|
||||
default_read =
|
||||
Ash.primary_action(destination, :read) ||
|
||||
raise "Must have a default read for #{destination}"
|
||||
|
||||
value = Ecto.Changeset.get_field(changeset, relationship.source_field)
|
||||
filter_statement = [{relationship.destination_field, value}]
|
||||
|
||||
|
@ -900,11 +845,9 @@ defmodule Ash.Actions.Relationships do
|
|||
request =
|
||||
Ash.Engine.Request.new(
|
||||
api: api,
|
||||
rules: default_read.rules,
|
||||
resource: destination,
|
||||
action_type: :read,
|
||||
action: Ash.primary_action!(relationship.destination, :read),
|
||||
path: [:relationships, relationship.name, :current],
|
||||
resolve_when_skip_authorization?: true,
|
||||
query: query,
|
||||
data:
|
||||
Ash.Engine.Request.resolve(fn _data ->
|
||||
|
@ -923,9 +866,6 @@ defmodule Ash.Actions.Relationships do
|
|||
changeset,
|
||||
%{through: through} = relationship
|
||||
) do
|
||||
default_read =
|
||||
Ash.primary_action(through, :read) || raise "Must have default read for #{inspect(through)}"
|
||||
|
||||
value = Ecto.Changeset.get_field(changeset, relationship.source_field)
|
||||
filter_statement = [{relationship.source_field_on_join_table, value}]
|
||||
|
||||
|
@ -936,17 +876,14 @@ defmodule Ash.Actions.Relationships do
|
|||
|
||||
Ash.Engine.Request.new(
|
||||
api: api,
|
||||
rules: default_read.rules,
|
||||
resource: through,
|
||||
action_type: :read,
|
||||
action: Ash.primary_action!(relationship.destination, :read),
|
||||
path: [:relationships, relationship.name, :current_join],
|
||||
query: query,
|
||||
resolve_when_skip_authorization?: true,
|
||||
data:
|
||||
Ash.Engine.Request.resolve(fn _data ->
|
||||
api.read(query)
|
||||
end),
|
||||
strict_access?: false,
|
||||
name: "Read related join for #{relationship.name} before replace"
|
||||
)
|
||||
end
|
||||
|
@ -955,16 +892,10 @@ defmodule Ash.Actions.Relationships do
|
|||
api,
|
||||
%{destination: destination, name: name} = relationship
|
||||
) do
|
||||
default_read =
|
||||
Ash.primary_action(destination, :read) ||
|
||||
raise "Must have default read for #{inspect(destination)}"
|
||||
|
||||
Ash.Engine.Request.new(
|
||||
api: api,
|
||||
rules: default_read.rules,
|
||||
resource: destination,
|
||||
action_type: :read,
|
||||
resolve_when_skip_authorization?: true,
|
||||
action: Ash.primary_action!(relationship.destination, :read),
|
||||
path: [:relationships, name, :current],
|
||||
query:
|
||||
Ash.Engine.Request.resolve(
|
||||
|
@ -992,7 +923,6 @@ defmodule Ash.Actions.Relationships do
|
|||
api.read(query)
|
||||
end
|
||||
),
|
||||
strict_access?: false,
|
||||
name: "Read related join for #{name} before replace"
|
||||
)
|
||||
end
|
||||
|
|
|
@ -87,18 +87,7 @@ defmodule Ash.Actions.SideLoad do
|
|||
|
||||
case requests(new_query, false, data) do
|
||||
{:ok, requests} ->
|
||||
result =
|
||||
if opts[:authorization] do
|
||||
Ash.Engine.run(
|
||||
requests,
|
||||
api,
|
||||
user: opts[:authorization][:user],
|
||||
bypass_strict_access?: opts[:authorization][:bypass_strict_access?],
|
||||
verbose?: opts[:verbose?]
|
||||
)
|
||||
else
|
||||
Ash.Engine.run(requests, api, skip_authorization?: true, verbose?: opts[:verbose?])
|
||||
end
|
||||
result = Ash.Engine.run(requests, api, opts)
|
||||
|
||||
case result do
|
||||
%{data: %{include: _} = state, errors: errors} when errors == [] ->
|
||||
|
@ -258,10 +247,6 @@ defmodule Ash.Actions.SideLoad do
|
|||
root_data,
|
||||
use_data_for_filter?
|
||||
) do
|
||||
default_read =
|
||||
Ash.primary_action(relationship.destination, :read) ||
|
||||
raise "Must set default read for #{inspect(relationship.destination)}"
|
||||
|
||||
dependencies =
|
||||
case path do
|
||||
[] ->
|
||||
|
@ -297,13 +282,11 @@ defmodule Ash.Actions.SideLoad do
|
|||
|> Enum.map_join(".", &Map.get(&1, :name))
|
||||
|
||||
Ash.Engine.Request.new(
|
||||
action_type: :read,
|
||||
action: Ash.primary_action!(relationship.destination, :read),
|
||||
resource: relationship.destination,
|
||||
rules: default_read.rules,
|
||||
name: "side_load #{source}",
|
||||
api: related_query.api,
|
||||
path: request_path,
|
||||
resolve_when_skip_authorization?: true,
|
||||
query:
|
||||
side_load_query(
|
||||
relationship,
|
||||
|
@ -312,7 +295,6 @@ defmodule Ash.Actions.SideLoad do
|
|||
root_query,
|
||||
use_data_for_filter?
|
||||
),
|
||||
strict_access?: true,
|
||||
data:
|
||||
Ash.Engine.Request.resolve(dependencies, fn data ->
|
||||
# Because we have the records, we can optimize the filter by nillifying the reverse relationship,
|
||||
|
@ -371,10 +353,6 @@ defmodule Ash.Actions.SideLoad do
|
|||
path ->
|
||||
join_relationship_path = join_relationship_path(path, join_relationship)
|
||||
|
||||
default_read =
|
||||
Ash.primary_action(join_relationship.source, :read) ||
|
||||
raise "Must set default read for #{inspect(relationship.destination)}"
|
||||
|
||||
dependencies =
|
||||
cond do
|
||||
path == [] ->
|
||||
|
@ -394,14 +372,11 @@ defmodule Ash.Actions.SideLoad do
|
|||
related_query = related_query.api.query(join_relationship.destination)
|
||||
|
||||
Ash.Engine.Request.new(
|
||||
action_type: :read,
|
||||
action: Ash.primary_action!(relationship.destination, :read),
|
||||
resource: relationship.through,
|
||||
rules: default_read.rules,
|
||||
name: "side_load join #{join_relationship.name}",
|
||||
api: related_query.api,
|
||||
path: [:include, join_relationship_path],
|
||||
strict_access?: true,
|
||||
resolve_when_skip_authorization?: true,
|
||||
query:
|
||||
side_load_query(
|
||||
join_relationship,
|
||||
|
@ -410,7 +385,6 @@ defmodule Ash.Actions.SideLoad do
|
|||
root_query,
|
||||
use_data_for_filter?
|
||||
),
|
||||
strict_access?: true,
|
||||
data:
|
||||
Ash.Engine.Request.resolve(dependencies, fn data ->
|
||||
new_query =
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
defmodule Ash.Actions.Update do
|
||||
alias Ash.Engine
|
||||
alias Ash.Actions.{Attributes, Relationships, SideLoad}
|
||||
alias Ash.Actions.{Relationships, SideLoad}
|
||||
require Logger
|
||||
|
||||
@spec run(Ash.api(), Ash.record(), Ash.action(), Ash.params()) ::
|
||||
|
@ -47,7 +47,7 @@ defmodule Ash.Actions.Update do
|
|||
{:ok, side_load_requests} <-
|
||||
SideLoad.requests(side_load_query, api.query(resource)),
|
||||
%{data: %{data: %{data: updated}}, errors: []} = state <-
|
||||
do_authorized(changeset, params, action, resource, api, side_load_requests) do
|
||||
do_run_requests(changeset, params, action, resource, api, side_load_requests) do
|
||||
{:ok, SideLoad.attach_side_loads(updated, state)}
|
||||
else
|
||||
%Ecto.Changeset{} = changeset ->
|
||||
|
@ -70,20 +70,19 @@ defmodule Ash.Actions.Update do
|
|||
|> Relationships.handle_relationship_changes(api, relationships, :update)
|
||||
end
|
||||
|
||||
defp do_authorized(changeset, params, action, resource, api, side_load_requests) do
|
||||
defp do_run_requests(changeset, params, action, resource, api, side_load_requests) do
|
||||
relationships = Keyword.get(params, :relationships)
|
||||
|
||||
update_request =
|
||||
Ash.Engine.Request.new(
|
||||
api: api,
|
||||
rules: action.rules,
|
||||
changeset:
|
||||
Ash.Actions.Relationships.changeset(
|
||||
changeset,
|
||||
api,
|
||||
relationships
|
||||
),
|
||||
action_type: action.type,
|
||||
action: Ash.primary_action!(resource, :read),
|
||||
resource: resource,
|
||||
data:
|
||||
Ash.Engine.Request.resolve(
|
||||
|
@ -105,31 +104,16 @@ defmodule Ash.Actions.Update do
|
|||
end
|
||||
),
|
||||
path: :data,
|
||||
resolve_when_skip_authorization?: true,
|
||||
name: "#{action.type} - `#{action.name}`"
|
||||
)
|
||||
|
||||
attribute_requests = Attributes.attribute_change_requests(changeset, api, resource, action)
|
||||
|
||||
relationship_requests = Map.get(changeset, :__requests__, [])
|
||||
|
||||
if params[:authorization] do
|
||||
Engine.run(
|
||||
[update_request | attribute_requests] ++ relationship_requests ++ side_load_requests,
|
||||
api,
|
||||
strict_access?: false,
|
||||
user: params[:authorization][:user],
|
||||
bypass_strict_access?: params[:authorization][:bypass_strict_access?],
|
||||
verbose?: params[:verbose?]
|
||||
)
|
||||
else
|
||||
Engine.run(
|
||||
[update_request | attribute_requests] ++ relationship_requests ++ side_load_requests,
|
||||
api,
|
||||
skip_authorization?: true,
|
||||
verbose?: params[:verbose?]
|
||||
)
|
||||
end
|
||||
Engine.run(
|
||||
[update_request | relationship_requests] ++ side_load_requests,
|
||||
api,
|
||||
verbose?: params[:verbose?]
|
||||
)
|
||||
end
|
||||
|
||||
defp side_loads_as_query(_api, _resource, nil), do: {:ok, nil}
|
||||
|
|
|
@ -1,17 +1,8 @@
|
|||
defmodule Ash.Api do
|
||||
@using_schema Ashton.schema(
|
||||
opts: [
|
||||
pubsub_adapter: :atom,
|
||||
# TODO: Support configuring this from env variables
|
||||
authorization_explanations: [:boolean]
|
||||
],
|
||||
defaults: [
|
||||
authorization_explanations: false
|
||||
],
|
||||
describe: [
|
||||
authorization_explanations:
|
||||
"A setting that determines whether or not verbose authorization errors should be returned."
|
||||
]
|
||||
opts: [],
|
||||
defaults: [],
|
||||
describe: []
|
||||
)
|
||||
|
||||
@moduledoc """
|
||||
|
@ -51,10 +42,8 @@ defmodule Ash.Api do
|
|||
end
|
||||
|
||||
@side_load_type :simple
|
||||
@authorization_explanations opts[:authorization_explanations] || false
|
||||
@pubsub_adapter opts[:pubsub_adapter]
|
||||
|
||||
Module.register_attribute(__MODULE__, :mix_ins, accumulate: true)
|
||||
Module.register_attribute(__MODULE__, :extensions, accumulate: true)
|
||||
Module.register_attribute(__MODULE__, :resources, accumulate: true)
|
||||
Module.register_attribute(__MODULE__, :named_resources, accumulate: true)
|
||||
|
||||
|
@ -85,9 +74,8 @@ defmodule Ash.Api do
|
|||
|
||||
defmacro __before_compile__(env) do
|
||||
quote generated: true do
|
||||
def mix_ins(), do: @mix_ins
|
||||
def extensions(), do: @extensions
|
||||
def resources(), do: @resources
|
||||
def authorization_explanations(), do: @authorization_explanations
|
||||
|
||||
def get_resource(mod) when mod in @resources, do: {:ok, mod}
|
||||
|
||||
|
@ -97,7 +85,7 @@ defmodule Ash.Api do
|
|||
|
||||
use Ash.Api.Interface
|
||||
|
||||
Enum.map(@mix_ins, fn hook_module ->
|
||||
Enum.map(@extensions, fn hook_module ->
|
||||
code = hook_module.before_compile_hook(unquote(Macro.escape(env)))
|
||||
Module.eval_quoted(__MODULE__, code)
|
||||
end)
|
||||
|
|
|
@ -7,30 +7,14 @@ defmodule Ash.Api.Interface do
|
|||
|
||||
alias Ash.Error.Interface.NoSuchResource
|
||||
|
||||
@authorization_schema Ashton.schema(
|
||||
opts: [
|
||||
user: :any,
|
||||
strict_access?: :boolean
|
||||
],
|
||||
defaults: [strict_access?: true],
|
||||
describe: [
|
||||
user: "# TODO describe",
|
||||
strict_access?:
|
||||
"only applies to `read` actions, so maybe belongs somewhere else"
|
||||
]
|
||||
)
|
||||
|
||||
@global_opts Ashton.schema(
|
||||
opts: [
|
||||
authorization: [{:const, false}, @authorization_schema],
|
||||
verbose?: :boolean
|
||||
],
|
||||
defaults: [
|
||||
authorization: false,
|
||||
verbose?: false
|
||||
],
|
||||
describe: [
|
||||
authorization: "# TODO describe",
|
||||
verbose?: "Debug log engine operation"
|
||||
]
|
||||
)
|
||||
|
@ -348,7 +332,6 @@ defmodule Ash.Api.Interface do
|
|||
|
||||
case filter do
|
||||
{:ok, filter} ->
|
||||
|
||||
resource
|
||||
|> api.query()
|
||||
|> Ash.Query.filter(filter)
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
defmodule Ash.Authorization do
|
||||
@moduledoc """
|
||||
#TODO: Explain authorization
|
||||
|
||||
Authorization in Ash is done via declaring `rules` for actions,
|
||||
and in the case of stateful actions, via declaring `authoriation_steps` on attributes
|
||||
and relationships.
|
||||
|
||||
|
||||
# TODO: consider this coverage metric when building the test framework
|
||||
https://en.wikipedia.org/wiki/Modified_condition/decision_coverage
|
||||
"""
|
||||
|
||||
@type request :: Ash.Engine.Request.t()
|
||||
|
||||
@type side_load :: {:side_load, Keyword.t()}
|
||||
@type prepare_instruction :: side_load
|
||||
end
|
|
@ -1,7 +0,0 @@
|
|||
defmodule Ash.Authorization.Check.AttributeBuiltInChecks do
|
||||
def setting(opts) do
|
||||
{Ash.Authorization.Check.SettingAttribute, Keyword.take(opts, [:to])}
|
||||
end
|
||||
|
||||
def logged_in(), do: {Ash.Authorization.Check.LoggedIn, []}
|
||||
end
|
|
@ -1,54 +0,0 @@
|
|||
defmodule Ash.Authorization.Check.AttributeEquals do
|
||||
use Ash.Authorization.Check, action_types: [:read, :create, :update, :delete]
|
||||
|
||||
@impl true
|
||||
def describe(opts) do
|
||||
"this_record.#{opts[:field]} == #{inspect(opts[:value])}"
|
||||
end
|
||||
|
||||
@impl true
|
||||
def strict_check(_user, request = %{action_type: :read}, options) do
|
||||
field = options[:field]
|
||||
value = options[:value]
|
||||
|
||||
case Ash.Filter.parse(request.resource, [{field, eq: value}], request.query.api) do
|
||||
%{errors: []} = parsed ->
|
||||
if Ash.Filter.strict_subset_of?(parsed, request.query.filter) do
|
||||
{:ok, true}
|
||||
else
|
||||
case Ash.Filter.parse(request.resource, [{field, not_eq: value}], request.query.api) do
|
||||
%{errors: []} = parsed ->
|
||||
if Ash.Filter.strict_subset_of?(parsed, request.query.filter) do
|
||||
{:ok, false}
|
||||
else
|
||||
{:ok, :unknown}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
%{errors: errors} ->
|
||||
{:error, errors}
|
||||
end
|
||||
end
|
||||
|
||||
def strict_check(_user, %{action_type: :create, changeset: changeset}, options) do
|
||||
case Ecto.Changeset.fetch_field(changeset, options[:field]) do
|
||||
{_, value} -> {:ok, options[:value] == value}
|
||||
_ -> {:ok, false}
|
||||
end
|
||||
end
|
||||
|
||||
def strict_check(_user, %{action_type: :update, changeset: changeset}, options) do
|
||||
{:ok, {:ok, options[:value]} == Map.fetch(changeset.data, options[:field])}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def check(_user, records, _state, options) do
|
||||
matches =
|
||||
Enum.filter(records, fn record ->
|
||||
Map.fetch(record, options[:field]) == {:ok, options[:value]}
|
||||
end)
|
||||
|
||||
{:ok, matches}
|
||||
end
|
||||
end
|
|
@ -1,52 +0,0 @@
|
|||
defmodule Ash.Authorization.Check.BuiltInChecks do
|
||||
@moduledoc "The global authorization checks built into ash"
|
||||
|
||||
def always() do
|
||||
{Ash.Authorization.Check.Static, [result: true]}
|
||||
end
|
||||
|
||||
def never() do
|
||||
{Ash.Authorization.Check.Static, [result: false]}
|
||||
end
|
||||
|
||||
def attribute_equals(field, value) do
|
||||
{Ash.Authorization.Check.AttributeEquals, field: field, value: value}
|
||||
end
|
||||
|
||||
def related_to_user_via(relationship) do
|
||||
{Ash.Authorization.Check.RelatedToUserVia, relationship: List.wrap(relationship)}
|
||||
end
|
||||
|
||||
def setting_relationship(relationship) do
|
||||
{Ash.Authorization.Check.SettingRelationship, relationship_name: relationship}
|
||||
end
|
||||
|
||||
def setting_attribute(name, opts) do
|
||||
opts =
|
||||
opts
|
||||
|> Keyword.take([:to])
|
||||
|> Keyword.put(:attribute_name, name)
|
||||
|
||||
Ash.Authorization.Check.AttributeBuiltInChecks.setting(opts)
|
||||
end
|
||||
|
||||
def user_attribute(field, value) do
|
||||
{Ash.Authorization.Check.UserAttribute, field: field, value: value}
|
||||
end
|
||||
|
||||
def user_attribute_matches_record(user_field, record_field) do
|
||||
{Ash.Authorization.Check.UserAttributeMatchesRecord,
|
||||
user_field: user_field, record_field: record_field}
|
||||
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
|
||||
|
||||
def logged_in(), do: {Ash.Authorization.Check.LoggedIn, []}
|
||||
end
|
|
@ -1,71 +0,0 @@
|
|||
defmodule Ash.Authorization.Check do
|
||||
@moduledoc """
|
||||
A behaviour for declaring checks, which can be used to easily construct
|
||||
authorization rules.
|
||||
"""
|
||||
|
||||
@type options :: Keyword.t()
|
||||
|
||||
@callback strict_check(Ash.user(), Ash.Authorization.request(), options) :: boolean | :unknown
|
||||
@callback prepare(options) ::
|
||||
list(Ash.Authorization.prepare_instruction()) | {:error, Ash.error()}
|
||||
@callback check(Ash.user(), list(Ash.record()), map, options) ::
|
||||
{:ok, list(Ash.record()) | boolean} | {:error, Ash.error()}
|
||||
@callback describe(options()) :: String.t()
|
||||
@callback action_types() :: list(Ash.action_type())
|
||||
@callback pure?() :: boolean
|
||||
|
||||
@optional_callbacks check: 4, prepare: 1
|
||||
|
||||
def defines_check?(module) do
|
||||
:erlang.function_exported(module, :check, 4)
|
||||
end
|
||||
|
||||
defmacro __using__(opts) do
|
||||
quote do
|
||||
@behaviour Ash.Authorization.Check
|
||||
|
||||
@impl true
|
||||
def prepare(_), do: []
|
||||
|
||||
@impl true
|
||||
def action_types(), do: unquote(opts[:action_types])
|
||||
|
||||
@impl true
|
||||
def pure?(), do: unquote(opts[:pure?] || false)
|
||||
|
||||
defoverridable prepare: 1, action_types: 0
|
||||
end
|
||||
end
|
||||
|
||||
defmacro import_default_checks(opts) do
|
||||
quote do
|
||||
import Ash.Authorization.Check.Static, only: [always: 0, never: 0]
|
||||
import Ash.Authorization.Check.RelatedToUserVia, only: [related_to_user_via: 1]
|
||||
import Ash.Authorization.Check.SettingAttribute, only: [setting_attribute: 2]
|
||||
|
||||
import Ash.Authorization.Check.UserAttributeMatchesRecord,
|
||||
only: [user_attribute_matches_record: 2]
|
||||
|
||||
import Ash.Authorization.Check.UserAttribute, only: [user_attribute: 2]
|
||||
|
||||
if unquote(opts[:attributes]) do
|
||||
import Ash.Authorization.Check.SettingAttribute,
|
||||
only: [setting_attribute: 2, setting_attribute: 1]
|
||||
else
|
||||
import Ash.Authorization.Check.AttributeEquals, only: [attribute_equals: 2]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defmacro unimport_checks() do
|
||||
quote do
|
||||
import Ash.Authorization.Check.Static, only: []
|
||||
import Ash.Authorization.Check.RelatedToUserVia, only: []
|
||||
import Ash.Authorization.Check.SettingAttribute, only: []
|
||||
import Ash.Authorization.Check.UserAttributeMatchesRecord, only: []
|
||||
import Ash.Authorization.Check.UserAttribute, only: []
|
||||
import Ash.Authorization.Check.SettingAttribute, only: []
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,12 +0,0 @@
|
|||
defmodule Ash.Authorization.Check.LoggedIn do
|
||||
use Ash.Authorization.Check, action_types: [:read, :update, :delete, :create], pure?: true
|
||||
|
||||
@impl true
|
||||
def describe(_opts) do
|
||||
"user is logged in"
|
||||
end
|
||||
|
||||
@impl true
|
||||
def strict_check(nil, _request, _options), do: {:ok, false}
|
||||
def strict_check(_, _request, _options), do: {:ok, true}
|
||||
end
|
|
@ -1,99 +0,0 @@
|
|||
defmodule Ash.Authorization.Check.RelatedToUserVia do
|
||||
use Ash.Authorization.Check, action_types: [:read, :update, :delete]
|
||||
|
||||
@impl true
|
||||
def describe(opts) do
|
||||
description = describe_relationship(opts[:resource], opts[:relationship])
|
||||
|
||||
description <> "this_record is the user"
|
||||
end
|
||||
|
||||
# TODO: If they aren't filtering on the "user equaling this", but are
|
||||
# filtering based on field values and we can trace those field value
|
||||
# filters from the record all the way to the user, then we can
|
||||
# allow this at strict check time
|
||||
|
||||
@impl true
|
||||
def strict_check(%user_resource{} = user, request = %{action_type: :read}, opts) do
|
||||
full_relationship_path = opts[:relationship]
|
||||
|
||||
pkey_filter = user |> Map.take(Ash.primary_key(user_resource)) |> Map.to_list()
|
||||
|
||||
candidate_filter = put_into_relationship_path(full_relationship_path, pkey_filter)
|
||||
|
||||
case Ash.Filter.parse(request.resource, candidate_filter, request.query.api) do
|
||||
%{errors: []} = parsed ->
|
||||
if Ash.Filter.strict_subset_of?(parsed, request.query.filter) do
|
||||
{:ok, true}
|
||||
else
|
||||
{:ok, :unknown}
|
||||
end
|
||||
|
||||
%{errors: errors} ->
|
||||
{:error, errors}
|
||||
end
|
||||
end
|
||||
|
||||
def strict_check(_, _, _), do: {:ok, :unknown}
|
||||
|
||||
@impl true
|
||||
def prepare(opts) do
|
||||
[side_load: put_into_relationship_path(opts[:relationship], [])]
|
||||
end
|
||||
|
||||
@impl true
|
||||
def check(user, records, _request, options) do
|
||||
matches =
|
||||
Enum.filter(records, fn record ->
|
||||
related_records = get_related(record, options[:relationship])
|
||||
|
||||
Enum.any?(related_records, fn related ->
|
||||
primary_key = Ash.primary_key(user)
|
||||
Map.take(related, primary_key) == Map.take(user, primary_key)
|
||||
end)
|
||||
end)
|
||||
|
||||
{:ok, matches}
|
||||
end
|
||||
|
||||
defp describe_relationship(resource, relationships) do
|
||||
reversed_relationships =
|
||||
relationships
|
||||
|> Enum.reduce({resource, []}, fn relationship_name, {resource, acc} ->
|
||||
relationship = Ash.relationship(resource, relationship_name)
|
||||
{relationship.destination, [relationship | acc]}
|
||||
end)
|
||||
|> elem(1)
|
||||
|
||||
do_describe_relationship(reversed_relationships)
|
||||
end
|
||||
|
||||
defp do_describe_relationship([]), do: ""
|
||||
|
||||
defp do_describe_relationship([%{name: name, cardinality: :many} | rest]) do
|
||||
"one of the #{name} of " <> do_describe_relationship(rest)
|
||||
end
|
||||
|
||||
defp do_describe_relationship([%{name: name, cardinality: :one} | rest]) do
|
||||
"the #{name} of " <> do_describe_relationship(rest)
|
||||
end
|
||||
|
||||
defp get_related(record, []), do: record
|
||||
|
||||
defp get_related(record, [relationship | rest]) do
|
||||
record
|
||||
|> List.wrap()
|
||||
|> Enum.flat_map(fn record ->
|
||||
record
|
||||
|> Map.get(relationship)
|
||||
|> List.wrap()
|
||||
|> Enum.map(&get_related(&1, rest))
|
||||
end)
|
||||
end
|
||||
|
||||
defp put_into_relationship_path([], value), do: value
|
||||
|
||||
defp put_into_relationship_path([item | rest], value) do
|
||||
[{item, put_into_relationship_path(rest, value)}]
|
||||
end
|
||||
end
|
|
@ -1,61 +0,0 @@
|
|||
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: adding}} ->
|
||||
op =
|
||||
if opts[:allow_additional?] do
|
||||
:any?
|
||||
else
|
||||
:all?
|
||||
end
|
||||
|
||||
found? =
|
||||
apply(Enum, op, [
|
||||
adding,
|
||||
fn relationship_change ->
|
||||
Map.take(relationship_change, pkey) == Enum.into(pkey_value, %{})
|
||||
end
|
||||
])
|
||||
|
||||
found?
|
||||
|
||||
_ ->
|
||||
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
|
|
@ -1,13 +0,0 @@
|
|||
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
|
||||
|
||||
def logged_in(), do: {Ash.Authorization.Check.LoggedIn, []}
|
||||
end
|
|
@ -1,27 +0,0 @@
|
|||
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
|
|
@ -1,31 +0,0 @@
|
|||
defmodule Ash.Authorization.Check.SettingAttribute do
|
||||
use Ash.Authorization.Check, action_types: [:create, :update]
|
||||
|
||||
@impl true
|
||||
def describe(opts) do
|
||||
case Keyword.fetch(opts, :to) do
|
||||
{:ok, should_equal} ->
|
||||
"setting #{opts[:attribute_name]} to #{inspect(should_equal)}"
|
||||
|
||||
:error ->
|
||||
"setting #{opts[:attribute_name]}"
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def strict_check(_user, %{changeset: %Ecto.Changeset{} = changeset}, opts) do
|
||||
case Ecto.Changeset.fetch_change(changeset, opts[:attribute_name]) do
|
||||
{:ok, value} ->
|
||||
case Keyword.fetch(opts, :to) do
|
||||
{:ok, should_equal} ->
|
||||
{:ok, value == should_equal}
|
||||
|
||||
:error ->
|
||||
{:ok, true}
|
||||
end
|
||||
|
||||
:error ->
|
||||
{:ok, false}
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,13 +0,0 @@
|
|||
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
|
|
@ -1,13 +0,0 @@
|
|||
defmodule Ash.Authorization.Check.Static do
|
||||
use Ash.Authorization.Check, pure?: true
|
||||
|
||||
@impl true
|
||||
def describe(options) do
|
||||
"always #{inspect(options[:result])}"
|
||||
end
|
||||
|
||||
@impl true
|
||||
def strict_check(_user, _request, options) do
|
||||
{:ok, options[:result]}
|
||||
end
|
||||
end
|
|
@ -1,13 +0,0 @@
|
|||
defmodule Ash.Authorization.Check.UserAttribute do
|
||||
use Ash.Authorization.Check, action_types: [:read, :update, :delete, :create], pure?: true
|
||||
|
||||
@impl true
|
||||
def describe(opts) do
|
||||
"user.#{opts[:field]} == #{inspect(opts[:value])}"
|
||||
end
|
||||
|
||||
@impl true
|
||||
def strict_check(user, _request, options) do
|
||||
{:ok, Map.fetch(user || %{}, options[:field]) == {:ok, options[:value]}}
|
||||
end
|
||||
end
|
|
@ -1,53 +0,0 @@
|
|||
defmodule Ash.Authorization.Check.UserAttributeMatchesRecord do
|
||||
use Ash.Authorization.Check, action_types: [:read, :update, :delete]
|
||||
|
||||
@impl true
|
||||
def describe(opts) do
|
||||
"user.#{opts[:user_field]} == this_record.#{opts[:record_field]}"
|
||||
end
|
||||
|
||||
@impl true
|
||||
def strict_check(nil, _, _), do: {:ok, false}
|
||||
|
||||
def strict_check(user, request, options) do
|
||||
user_field = options[:user_field]
|
||||
record_field = options[:record_field]
|
||||
|
||||
value = Map.get(user, user_field)
|
||||
|
||||
case Ash.Filter.parse(request.resource, [{record_field, eq: value}], request.query.api) do
|
||||
%{errors: []} = parsed ->
|
||||
if Ash.Filter.strict_subset_of?(parsed, request.query.filter) do
|
||||
{:ok, true}
|
||||
else
|
||||
case Ash.Filter.parse(
|
||||
request.resource,
|
||||
[{record_field, not_eq: value}],
|
||||
request.query.api
|
||||
) do
|
||||
%{errors: []} = parsed ->
|
||||
if Ash.Filter.strict_subset_of?(parsed, request.query.filter) do
|
||||
{:ok, false}
|
||||
else
|
||||
{:ok, :unknown}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
%{errors: errors} ->
|
||||
{:error, errors}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def check(user, records, _state, options) do
|
||||
user_value = Map.fetch(user, options[:user_field])
|
||||
|
||||
matches =
|
||||
Enum.filter(records, fn record ->
|
||||
user_value == Map.fetch(record, options[:record_field])
|
||||
end)
|
||||
|
||||
{:ok, matches}
|
||||
end
|
||||
end
|
|
@ -1,136 +0,0 @@
|
|||
defmodule Ash.Authorization.Checker do
|
||||
@moduledoc """
|
||||
Determines if a set of authorization requests can be met or not.
|
||||
|
||||
To read more about boolean satisfiability, see this page:
|
||||
https://en.wikipedia.org/wiki/Boolean_satisfiability_problem. At the end of
|
||||
the day, however, it is not necessary to understand exactly how Ash takes your
|
||||
authorization requirements and determines if a request is allowed. The
|
||||
important thing to understand is that Ash may or may not run any/all of your
|
||||
authorization rules as they may be deemed unnecessary. As such, authorization
|
||||
checks should have no side effects. Ideally, the checks built-in to ash should
|
||||
cover the bulk of your needs.
|
||||
|
||||
If you need to write your own checks see #TODO: Link to a guide about writing checks here.
|
||||
"""
|
||||
|
||||
alias Ash.Authorization.Clause
|
||||
|
||||
def strict_check(user, request, facts) do
|
||||
request.rules
|
||||
|> Enum.reduce(facts, fn {_step, clause}, facts ->
|
||||
case Clause.find(facts, clause) do
|
||||
{:ok, _boolean_result} ->
|
||||
facts
|
||||
|
||||
:error ->
|
||||
case do_strict_check(clause, user, request) do
|
||||
{:error, _error} ->
|
||||
# TODO: Surface this error
|
||||
facts
|
||||
|
||||
:unknown ->
|
||||
facts
|
||||
|
||||
:unknowable ->
|
||||
Map.put(facts, clause, :unknowable)
|
||||
|
||||
:irrelevant ->
|
||||
Map.put(facts, clause, :irrelevant)
|
||||
|
||||
boolean ->
|
||||
Map.put(facts, clause, boolean)
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def run_checks(engine, %{data: []}, _clause) do
|
||||
{:ok, engine}
|
||||
end
|
||||
|
||||
def run_checks(engine, request, clause) do
|
||||
case clause.check_module().check(engine.user, request.data, %{}, clause.check_opts) do
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
|
||||
{:ok, check_result} ->
|
||||
pkey = Ash.primary_key(request.resource)
|
||||
|
||||
{authorized, unauthorized} =
|
||||
Enum.split_with(request.data, fn data ->
|
||||
data_pkey = Map.take(data, pkey)
|
||||
|
||||
Enum.find(check_result, fn authorized ->
|
||||
Map.take(authorized, pkey) == data_pkey
|
||||
end)
|
||||
end)
|
||||
|
||||
case {authorized, unauthorized} do
|
||||
{_, []} ->
|
||||
{:ok, %{engine | facts: Map.put(engine.facts, clause, true)}}
|
||||
|
||||
{[], _} ->
|
||||
{:ok, %{engine | facts: Map.put(engine.facts, clause, false)}}
|
||||
|
||||
{authorized, unauthorized} ->
|
||||
# TODO: Handle this error
|
||||
{:ok, authorized_values} =
|
||||
Ash.Actions.PrimaryKeyHelpers.values_to_primary_key_filters(
|
||||
request.resource,
|
||||
authorized
|
||||
)
|
||||
|
||||
authorized_filter =
|
||||
Ash.Filter.parse(request.resource, [or: authorized_values], engine.api)
|
||||
|
||||
{:ok, unauthorized_values} =
|
||||
Ash.Actions.PrimaryKeyHelpers.values_to_primary_key_filters(
|
||||
request.resource,
|
||||
unauthorized
|
||||
)
|
||||
|
||||
unauthorized_filter =
|
||||
Ash.Filter.parse(request.resource, [or: unauthorized_values], engine.api)
|
||||
|
||||
authorized_clause = %{clause | filter: authorized_filter}
|
||||
unauthorized_clause = %{clause | filter: unauthorized_filter}
|
||||
|
||||
new_facts =
|
||||
engine.facts
|
||||
|> Map.delete(clause)
|
||||
|> Map.put(authorized_clause, true)
|
||||
|> Map.put(unauthorized_clause, false)
|
||||
|
||||
{:ok, %{engine | facts: new_facts}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp do_strict_check(%{check_module: module, check_opts: opts}, user, request) do
|
||||
case module.strict_check(user, request, opts) do
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
|
||||
{:ok, boolean} when is_boolean(boolean) ->
|
||||
boolean
|
||||
|
||||
{:ok, :irrelevant} ->
|
||||
:irrelevant
|
||||
|
||||
{:ok, :unknown} ->
|
||||
cond do
|
||||
request.strict_access? ->
|
||||
# This means "we needed a fact that we have no way of getting"
|
||||
# Because the fact was needed in the `strict_check` step
|
||||
:unknowable
|
||||
|
||||
Ash.Authorization.Check.defines_check?(module) ->
|
||||
:unknown
|
||||
|
||||
true ->
|
||||
:unknowable
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,112 +0,0 @@
|
|||
defmodule Ash.Authorization.Clause do
|
||||
defstruct [:path, :resource, :request_id, :check_module, :check_opts, :action, :filter]
|
||||
|
||||
def new(resource, {mod, opts}, action, filter, request_id \\ nil) do
|
||||
# Read actions should pass in `read` here,
|
||||
# once we have custom actions
|
||||
%__MODULE__{
|
||||
resource: resource,
|
||||
check_module: mod,
|
||||
check_opts: opts,
|
||||
action: action,
|
||||
request_id: request_id,
|
||||
filter: filter
|
||||
}
|
||||
end
|
||||
|
||||
def find(_clauses, %{check_module: Ash.Authorization.Check.Static, check_opts: check_opts}) do
|
||||
{:ok, check_opts[:result]}
|
||||
end
|
||||
|
||||
def find(clauses, clause) do
|
||||
Enum.find_value(clauses, fn {key, value} ->
|
||||
if is_matching_clause?(key, clause) do
|
||||
{:ok, value}
|
||||
end
|
||||
end) || :error
|
||||
end
|
||||
|
||||
def prune_facts(facts) do
|
||||
new_facts = do_prune_facts(facts)
|
||||
|
||||
if new_facts == facts do
|
||||
new_facts
|
||||
else
|
||||
do_prune_facts(new_facts)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_prune_facts(facts) do
|
||||
Enum.reduce(facts, facts, fn {clause, _value}, facts ->
|
||||
without_clause = Map.delete(facts, clause)
|
||||
|
||||
case find(without_clause, clause) do
|
||||
nil ->
|
||||
without_clause
|
||||
|
||||
_ ->
|
||||
facts
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp is_matching_clause?(clause, clause), do: true
|
||||
|
||||
defp is_matching_clause?(clause, other_clause)
|
||||
when is_boolean(clause) or is_boolean(other_clause),
|
||||
do: false
|
||||
|
||||
defp is_matching_clause?(clause, potential_matching) do
|
||||
cond do
|
||||
clause.check_module.pure? ->
|
||||
match_keys = [:check_module, :check_opts]
|
||||
|
||||
Map.take(clause, match_keys) ==
|
||||
Map.take(potential_matching, match_keys)
|
||||
|
||||
clause.request_id ->
|
||||
match_keys = [:resource, :check_module, :check_opts, :request_id]
|
||||
|
||||
Map.take(clause, match_keys) == Map.take(potential_matching, match_keys)
|
||||
|
||||
potential_matching.request_id ->
|
||||
false
|
||||
|
||||
true ->
|
||||
match_keys = [:resource, :check_module, :check_opts]
|
||||
|
||||
Map.take(clause, match_keys) == Map.take(potential_matching, match_keys) and
|
||||
Ash.Filter.strict_subset_of?(clause.filter, potential_matching.filter)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Inspect, for: Ash.Authorization.Clause do
|
||||
import Inspect.Algebra
|
||||
|
||||
def inspect(clause, opts) do
|
||||
filter =
|
||||
if clause.filter do
|
||||
concat(["(", to_doc(clause.filter, opts), ")"])
|
||||
else
|
||||
""
|
||||
end
|
||||
|
||||
terminator =
|
||||
if filter != "" do
|
||||
": "
|
||||
else
|
||||
""
|
||||
end
|
||||
|
||||
concat([
|
||||
"#Clause<",
|
||||
inspect(clause.resource),
|
||||
": ",
|
||||
filter,
|
||||
terminator,
|
||||
to_doc(clause.check_module.describe(clause.check_opts), opts),
|
||||
">"
|
||||
])
|
||||
end
|
||||
end
|
|
@ -1,232 +0,0 @@
|
|||
defmodule Ash.Authorization.Report do
|
||||
alias Ash.Authorization.Clause
|
||||
alias Ash.Engine.Request
|
||||
|
||||
defstruct [
|
||||
:scenarios,
|
||||
:requests,
|
||||
:facts,
|
||||
:state,
|
||||
:header,
|
||||
:authorized?,
|
||||
:api,
|
||||
:reason,
|
||||
path: [],
|
||||
no_steps_configured: false
|
||||
]
|
||||
|
||||
def report_from_engine(engine) do
|
||||
report(%__MODULE__{
|
||||
scenarios: engine.scenarios,
|
||||
requests: engine.requests,
|
||||
facts: engine.facts,
|
||||
authorized?: engine.authorized?,
|
||||
state: engine.data,
|
||||
api: engine.api
|
||||
})
|
||||
end
|
||||
|
||||
def report(%{no_steps_configured: %Ash.Engine.Request{} = request}) do
|
||||
"forbidden:\n" <>
|
||||
request.name <> ": no authorization steps configured. Resource: #{request.resource}"
|
||||
end
|
||||
|
||||
# We know that each group of authorization steps shares the same relationship
|
||||
def report(report) do
|
||||
header = (report.header || "Authorization Report") <> "\n"
|
||||
|
||||
header =
|
||||
if report.path do
|
||||
Enum.join(report.path, ".") <> " " <> header
|
||||
else
|
||||
header
|
||||
end
|
||||
|
||||
facts = Ash.Authorization.Clause.prune_facts(report.facts)
|
||||
|
||||
explained_facts = explain_facts(facts)
|
||||
|
||||
reason =
|
||||
if report.reason do
|
||||
"\n" <> report.reason <> "\n"
|
||||
else
|
||||
""
|
||||
end
|
||||
|
||||
explained_steps =
|
||||
Enum.map_join(report.requests, "\n", fn request ->
|
||||
header =
|
||||
if request.strict_access? do
|
||||
request.name <> "(strict access)"
|
||||
else
|
||||
request.name
|
||||
end
|
||||
|
||||
header <> "\n" <> indent(explain_steps([request], report.api, facts)) <> "\n"
|
||||
end)
|
||||
|
||||
main_message =
|
||||
header <>
|
||||
reason <>
|
||||
indent("Facts Gathered\n" <> indent(explained_facts) <> "\n\n" <> explained_steps)
|
||||
|
||||
if report.authorized? do
|
||||
main_message
|
||||
else
|
||||
main_message <> indent("\n\nScenarios:\n" <> indent(explain_scenarios(report.scenarios)))
|
||||
end
|
||||
end
|
||||
|
||||
defp explain_scenarios(scenarios) when scenarios in [nil, []] do
|
||||
"""
|
||||
No scenarios found. Under construction.
|
||||
Eventually, scenarios will explain what data you could change to make the request possible.
|
||||
"""
|
||||
end
|
||||
|
||||
defp explain_scenarios(scenarios) do
|
||||
"""
|
||||
#{Enum.count(scenarios)} found. Under construction.
|
||||
Eventually, scenarios will explain what data you could change to make the request possible.
|
||||
"""
|
||||
end
|
||||
|
||||
defp explain_facts(facts) when facts == %{true => true, false => false},
|
||||
do: "No facts gathered."
|
||||
|
||||
defp explain_facts(facts) do
|
||||
facts
|
||||
|> Map.drop([true, false])
|
||||
|> Enum.map(fn {%{filter: filter} = key, value} ->
|
||||
{key, value, Ash.Filter.count_of_clauses(filter)}
|
||||
end)
|
||||
# TODO: nest child filters under parent filters?
|
||||
|> Enum.group_by(fn {clause, _value, _count_of_clauses} ->
|
||||
if clause.filter do
|
||||
clause.filter.resource
|
||||
else
|
||||
nil
|
||||
end
|
||||
end)
|
||||
|> Enum.map_join("\n", fn {resource, clauses} ->
|
||||
clauses =
|
||||
Enum.sort_by(clauses, fn {_, _, count_of_clauses} ->
|
||||
count_of_clauses
|
||||
end)
|
||||
|
||||
explained =
|
||||
Enum.map_join(clauses, "\n", fn {clause, value, count_of_clauses} ->
|
||||
cond do
|
||||
clause.request_id ->
|
||||
inspect(clause.request_id) <>
|
||||
": " <>
|
||||
clause.check_module.describe(clause.check_opts) <> " " <> status_to_mark(value)
|
||||
|
||||
count_of_clauses == 0 ->
|
||||
clause.check_module.describe(clause.check_opts) <> " " <> status_to_mark(value)
|
||||
|
||||
true ->
|
||||
inspect(clause.filter) <>
|
||||
": " <>
|
||||
clause.check_module.describe(clause.check_opts) <> " " <> status_to_mark(value)
|
||||
end
|
||||
end)
|
||||
|
||||
if resource do
|
||||
"#{inspect(resource)}: \n" <> indent(explained) <> "\n"
|
||||
else
|
||||
explained <> "\n"
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp status_to_mark(true), do: "✓"
|
||||
defp status_to_mark(false), do: "✗"
|
||||
defp status_to_mark(:unknowable), do: "?"
|
||||
defp status_to_mark(:irrelevant), do: "⊘"
|
||||
defp status_to_mark(nil), do: "-"
|
||||
|
||||
defp indent(string) do
|
||||
string
|
||||
|> String.split("\n")
|
||||
|> Enum.map(fn line -> " " <> line end)
|
||||
|> Enum.join("\n")
|
||||
end
|
||||
|
||||
defp explain_steps(requests, api, facts) do
|
||||
requests_with_data_filter =
|
||||
Enum.flat_map(requests, fn request ->
|
||||
if Request.data_resolved?(request) && request.data not in [nil, []] &&
|
||||
match?(%Ash.Query{}, request.query) do
|
||||
request.data
|
||||
|> List.wrap()
|
||||
|> Enum.map(fn item ->
|
||||
%{request | query: Ash.Engine.get_pkeys(request, api, item)}
|
||||
end)
|
||||
else
|
||||
[request]
|
||||
end
|
||||
end)
|
||||
|
||||
contents =
|
||||
requests_with_data_filter
|
||||
|> Enum.sort_by(fn request -> Enum.count(request.path) end)
|
||||
|> Enum.map_join("\n------\n", fn request ->
|
||||
contents =
|
||||
request.rules
|
||||
|> Enum.map(fn {step, clause} ->
|
||||
status =
|
||||
with %Ash.Query{filter: filter} <- request.query,
|
||||
{:ok, value} <- Clause.find(facts, Map.put(clause, :filter, filter)) do
|
||||
value
|
||||
else
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
|
||||
status_mark = status_to_mark(status)
|
||||
step_mark = step_to_mark(step, status)
|
||||
|
||||
mod = clause.check_module
|
||||
opts = clause.check_opts
|
||||
relationship = clause.path
|
||||
|
||||
if relationship == [] do
|
||||
step_mark <>
|
||||
" | " <> to_string(step) <> ": " <> mod.describe(opts) <> " " <> status_mark
|
||||
else
|
||||
step_mark <>
|
||||
" | " <>
|
||||
to_string(step) <>
|
||||
": #{Enum.join(relationship || [], ".")}: " <>
|
||||
mod.describe(opts) <> " " <> status_mark
|
||||
end
|
||||
end)
|
||||
|> Enum.join("\n")
|
||||
|
||||
if request.action_type == :read && match?(%Ash.Query{}, request.query) do
|
||||
inspect(request.query.filter) <> "\n\n" <> contents
|
||||
else
|
||||
inspect(request.id) <> "\n\n" <> contents
|
||||
end
|
||||
end)
|
||||
|
||||
contents
|
||||
end
|
||||
|
||||
defp step_to_mark(:authorize_if, true), do: "✓"
|
||||
defp step_to_mark(:authorize_if, false), do: "↓"
|
||||
defp step_to_mark(:authorize_if, _), do: "↓"
|
||||
|
||||
defp step_to_mark(:forbid_if, true), do: "✗"
|
||||
defp step_to_mark(:forbid_if, false), do: "↓"
|
||||
defp step_to_mark(:forbid_if, _), do: "✗"
|
||||
|
||||
defp step_to_mark(:authorize_unless, true), do: "↓"
|
||||
defp step_to_mark(:authorize_unless, false), do: "✓"
|
||||
defp step_to_mark(:authorize_unless, _), do: "↓"
|
||||
|
||||
defp step_to_mark(:forbid_unless, true), do: "↓"
|
||||
defp step_to_mark(:forbid_unless, false), do: "✗"
|
||||
defp step_to_mark(:forbid_unless, _), do: "✗"
|
||||
end
|
|
@ -1,585 +0,0 @@
|
|||
defmodule Ash.Authorization.SatSolver do
|
||||
alias Ash.Authorization.Clause
|
||||
|
||||
@dialyzer {:no_return, :"picosat_solve/1"}
|
||||
|
||||
def solve(requests, facts) do
|
||||
expression =
|
||||
Enum.reduce(requests, nil, fn request, expr ->
|
||||
requirements_expression =
|
||||
build_requirements_expression([request.rules], facts, request.query.filter)
|
||||
|
||||
if expr do
|
||||
{:and, expr, requirements_expression}
|
||||
else
|
||||
requirements_expression
|
||||
end
|
||||
end)
|
||||
|
||||
expression
|
||||
|> add_negations_and_solve()
|
||||
|> get_all_scenarios(expression)
|
||||
|> case do
|
||||
[] ->
|
||||
{:error, :unsatisfiable}
|
||||
|
||||
scenarios ->
|
||||
{:ok,
|
||||
scenarios
|
||||
|> Enum.uniq()
|
||||
|> remove_irrelevant_clauses()}
|
||||
end
|
||||
end
|
||||
|
||||
def strict_filter_subset(filter, candidate) do
|
||||
filter_expr = filter_to_expr(filter)
|
||||
candidate_expr = filter_to_expr(candidate)
|
||||
|
||||
together = join_expr(filter_expr, candidate_expr, :and)
|
||||
|
||||
separate = join_expr(negate(filter_expr), candidate_expr, :and)
|
||||
|
||||
case solve_expression(together) do
|
||||
{:error, :unsatisfiable} ->
|
||||
false
|
||||
|
||||
{:ok, _} ->
|
||||
case solve_expression(separate) do
|
||||
{:error, :unsatisfiable} ->
|
||||
true
|
||||
|
||||
_ ->
|
||||
:maybe
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp negate(nil), do: nil
|
||||
defp negate(expr), do: {:not, expr}
|
||||
|
||||
defp filter_to_expr(nil), do: nil
|
||||
defp filter_to_expr(%{impossible?: true}), do: false
|
||||
|
||||
defp filter_to_expr(%{
|
||||
attributes: attributes,
|
||||
relationships: relationships,
|
||||
not: not_filter,
|
||||
ors: ors,
|
||||
ands: ands,
|
||||
path: path
|
||||
}) do
|
||||
expr =
|
||||
Enum.reduce(attributes, nil, fn {attr, statement}, expr ->
|
||||
join_expr(
|
||||
expr,
|
||||
tag_statement(statement_to_expr(statement), %{path: path, attr: attr}),
|
||||
:and
|
||||
)
|
||||
end)
|
||||
|
||||
expr =
|
||||
Enum.reduce(relationships, expr, fn {relationship, relationship_filter}, expr ->
|
||||
join_expr(expr, {relationship, filter_to_expr(relationship_filter)}, :and)
|
||||
end)
|
||||
|
||||
expr = join_expr(negate(filter_to_expr(not_filter)), expr, :and)
|
||||
|
||||
expr =
|
||||
Enum.reduce(ors, expr, fn or_filter, expr ->
|
||||
join_expr(filter_to_expr(or_filter), expr, :or)
|
||||
end)
|
||||
|
||||
Enum.reduce(ands, expr, fn and_filter, expr ->
|
||||
join_expr(filter_to_expr(and_filter), expr, :and)
|
||||
end)
|
||||
end
|
||||
|
||||
defp statement_to_expr(%Ash.Filter.NotIn{values: values}) do
|
||||
{:not, %Ash.Filter.In{values: values}}
|
||||
end
|
||||
|
||||
defp statement_to_expr(%Ash.Filter.NotEq{value: value}) do
|
||||
{:not, %Ash.Filter.Eq{value: value}}
|
||||
end
|
||||
|
||||
defp statement_to_expr(%Ash.Filter.And{left: left, right: right}) do
|
||||
{:and, statement_to_expr(left), statement_to_expr(right)}
|
||||
end
|
||||
|
||||
defp statement_to_expr(%Ash.Filter.Or{left: left, right: right}) do
|
||||
{:or, statement_to_expr(left), statement_to_expr(right)}
|
||||
end
|
||||
|
||||
defp statement_to_expr(statement), do: statement
|
||||
|
||||
defp tag_statement({:not, value}, tag), do: {:not, tag_statement(value, tag)}
|
||||
|
||||
defp tag_statement({joiner, left_value, right_value}, tag) when joiner in [:and, :or],
|
||||
do: {joiner, tag_statement(left_value, tag), tag_statement(right_value, tag)}
|
||||
|
||||
defp tag_statement(statement, tag), do: {statement, tag}
|
||||
|
||||
defp join_expr(nil, right, _joiner), do: right
|
||||
defp join_expr(left, nil, _joiner), do: left
|
||||
defp join_expr(left, right, joiner), do: {joiner, left, right}
|
||||
|
||||
defp get_all_scenarios(scenario_result, expression, scenarios \\ [])
|
||||
defp get_all_scenarios({:error, :unsatisfiable}, _, scenarios), do: scenarios
|
||||
|
||||
defp get_all_scenarios({:ok, scenario}, expression, scenarios) do
|
||||
expression
|
||||
|> add_negations_and_solve([Map.drop(scenario, [true, false]) | scenarios])
|
||||
|> get_all_scenarios(expression, [Map.drop(scenario, [true, false]) | scenarios])
|
||||
end
|
||||
|
||||
defp remove_irrelevant_clauses([scenario]), do: [scenario]
|
||||
|
||||
defp remove_irrelevant_clauses(scenarios) do
|
||||
new_scenarios =
|
||||
scenarios
|
||||
|> Enum.uniq()
|
||||
|> Enum.map(fn scenario ->
|
||||
unnecessary_fact =
|
||||
Enum.find_value(scenario, fn
|
||||
{_fact, :unknowable} ->
|
||||
false
|
||||
|
||||
# TODO: Is this acceptable?
|
||||
# If the check refers to empty data, and its meant to bypass strict checks
|
||||
# Then we consider that fact an irrelevant fact? Probably.
|
||||
{_fact, :irrelevant} ->
|
||||
true
|
||||
|
||||
{fact, value_in_this_scenario} ->
|
||||
matching =
|
||||
Enum.find(scenarios, fn potential_irrelevant_maker ->
|
||||
potential_irrelevant_maker != scenario &&
|
||||
Map.delete(scenario, fact) == Map.delete(potential_irrelevant_maker, fact)
|
||||
end)
|
||||
|
||||
case matching do
|
||||
%{^fact => value} when is_boolean(value) and value != value_in_this_scenario ->
|
||||
fact
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end)
|
||||
|
||||
Map.delete(scenario, unnecessary_fact)
|
||||
end)
|
||||
|> Enum.uniq()
|
||||
|
||||
if new_scenarios == scenarios do
|
||||
new_scenarios
|
||||
else
|
||||
remove_irrelevant_clauses(new_scenarios)
|
||||
end
|
||||
end
|
||||
|
||||
defp add_negations_and_solve(requirements_expression, negations \\ []) do
|
||||
negations =
|
||||
Enum.reduce(negations, nil, fn negation, expr ->
|
||||
negation_statement =
|
||||
negation
|
||||
|> Map.drop([true, false])
|
||||
|> facts_to_statement()
|
||||
|
||||
if expr do
|
||||
{:and, expr, {:not, negation_statement}}
|
||||
else
|
||||
{:not, negation_statement}
|
||||
end
|
||||
end)
|
||||
|
||||
full_expression =
|
||||
if negations do
|
||||
{:and, requirements_expression, negations}
|
||||
else
|
||||
requirements_expression
|
||||
end
|
||||
|
||||
solve_expression(full_expression)
|
||||
end
|
||||
|
||||
def satsolver_solve(input) do
|
||||
Picosat.solve(input)
|
||||
end
|
||||
|
||||
defp solve_expression(expression) do
|
||||
expression_with_constants = {:and, true, {:and, {:not, false}, expression}}
|
||||
|
||||
{bindings, expression} = extract_bindings(expression_with_constants)
|
||||
|
||||
expression
|
||||
|> to_conjunctive_normal_form()
|
||||
|> lift_clauses()
|
||||
|> negations_to_negative_numbers()
|
||||
|> satsolver_solve()
|
||||
|> solutions_to_predicate_values(bindings)
|
||||
end
|
||||
|
||||
defp facts_to_statement(facts) do
|
||||
Enum.reduce(facts, nil, fn {fact, true?}, expr ->
|
||||
expr_component =
|
||||
if true? do
|
||||
fact
|
||||
else
|
||||
{:not, fact}
|
||||
end
|
||||
|
||||
if expr do
|
||||
{:and, expr, expr_component}
|
||||
else
|
||||
expr_component
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp build_requirements_expression(sets_of_rules, facts, filter) do
|
||||
rules_expression =
|
||||
Enum.reduce(sets_of_rules, nil, fn rules, acc ->
|
||||
case acc do
|
||||
nil ->
|
||||
compile_rules_expression(rules, facts, filter)
|
||||
|
||||
expr ->
|
||||
{:and, expr, compile_rules_expression(rules, facts, filter)}
|
||||
end
|
||||
end)
|
||||
|
||||
facts =
|
||||
Enum.reduce(facts, %{}, fn {key, value}, acc ->
|
||||
if value == :unknowable do
|
||||
acc
|
||||
else
|
||||
Map.put(acc, key, value)
|
||||
end
|
||||
end)
|
||||
|
||||
facts_expression = facts_to_statement(Map.drop(facts, [true, false]))
|
||||
|
||||
if facts_expression do
|
||||
{:and, facts_expression, rules_expression}
|
||||
else
|
||||
rules_expression
|
||||
end
|
||||
end
|
||||
|
||||
defp solutions_to_predicate_values({:ok, solution}, bindings) do
|
||||
scenario =
|
||||
Enum.reduce(solution, %{true: [], false: []}, fn var, state ->
|
||||
fact = Map.get(bindings, abs(var))
|
||||
|
||||
Map.put(state, fact, var > 0)
|
||||
end)
|
||||
|
||||
{:ok, scenario}
|
||||
end
|
||||
|
||||
defp solutions_to_predicate_values({:error, error}, _), do: {:error, error}
|
||||
|
||||
defp compile_rules_expression([], _facts, _filter) do
|
||||
true
|
||||
end
|
||||
|
||||
defp compile_rules_expression([{:authorize_if, clause}], facts, filter) do
|
||||
clause = %{clause | filter: filter}
|
||||
|
||||
case Clause.find(facts, clause) do
|
||||
{:ok, true} -> true
|
||||
{:ok, false} -> false
|
||||
{:ok, :unknowable} -> false
|
||||
{:ok, :irrelevant} -> true
|
||||
:error -> clause
|
||||
end
|
||||
end
|
||||
|
||||
defp compile_rules_expression([{:authorize_if, clause} | rest], facts, filter) do
|
||||
clause = %{clause | filter: filter}
|
||||
|
||||
case Clause.find(facts, clause) do
|
||||
{:ok, true} ->
|
||||
true
|
||||
|
||||
{:ok, false} ->
|
||||
compile_rules_expression(rest, facts, filter)
|
||||
|
||||
{:ok, :irrelevant} ->
|
||||
true
|
||||
|
||||
{:ok, :unknowable} ->
|
||||
compile_rules_expression(rest, facts, filter)
|
||||
|
||||
:error ->
|
||||
{:or, clause, compile_rules_expression(rest, facts, filter)}
|
||||
end
|
||||
end
|
||||
|
||||
defp compile_rules_expression([{:authorize_unless, clause}], facts, filter) do
|
||||
clause = %{clause | filter: filter}
|
||||
|
||||
case Clause.find(facts, clause) do
|
||||
{:ok, true} ->
|
||||
false
|
||||
|
||||
{:ok, false} ->
|
||||
true
|
||||
|
||||
{:ok, :irrelevant} ->
|
||||
true
|
||||
|
||||
{:ok, :unknowable} ->
|
||||
false
|
||||
|
||||
:error ->
|
||||
{:not, clause}
|
||||
end
|
||||
end
|
||||
|
||||
defp compile_rules_expression([{:authorize_unless, clause} | rest], facts, filter) do
|
||||
clause = %{clause | filter: filter}
|
||||
|
||||
case Clause.find(facts, clause) do
|
||||
{:ok, true} ->
|
||||
compile_rules_expression(rest, facts, filter)
|
||||
|
||||
{:ok, false} ->
|
||||
true
|
||||
|
||||
{:ok, :irrelevant} ->
|
||||
true
|
||||
|
||||
{:ok, :unknowable} ->
|
||||
compile_rules_expression(rest, facts, filter)
|
||||
|
||||
:error ->
|
||||
{:or, {:not, clause}, compile_rules_expression(rest, facts, filter)}
|
||||
end
|
||||
end
|
||||
|
||||
defp compile_rules_expression([{:forbid_if, _clause}], _facts, _) do
|
||||
false
|
||||
end
|
||||
|
||||
defp compile_rules_expression([{:forbid_if, clause} | rest], facts, filter) do
|
||||
clause = %{clause | filter: filter}
|
||||
|
||||
case Clause.find(facts, clause) do
|
||||
{:ok, true} ->
|
||||
false
|
||||
|
||||
{:ok, :irrelevant} ->
|
||||
compile_rules_expression(rest, facts, filter)
|
||||
|
||||
{:ok, :unknowable} ->
|
||||
false
|
||||
|
||||
{:ok, false} ->
|
||||
compile_rules_expression(rest, facts, filter)
|
||||
|
||||
:error ->
|
||||
{:and, {:not, clause}, compile_rules_expression(rest, facts, filter)}
|
||||
end
|
||||
end
|
||||
|
||||
defp compile_rules_expression([{:forbid_unless, _clause}], _facts, _id) do
|
||||
false
|
||||
end
|
||||
|
||||
defp compile_rules_expression([{:forbid_unless, clause} | rest], facts, filter) do
|
||||
clause = %{clause | filter: filter}
|
||||
|
||||
case Clause.find(facts, clause) do
|
||||
{:ok, true} ->
|
||||
compile_rules_expression(rest, facts, filter)
|
||||
|
||||
{:ok, false} ->
|
||||
false
|
||||
|
||||
{:ok, :irrelevant} ->
|
||||
false
|
||||
|
||||
{:ok, :unknowable} ->
|
||||
false
|
||||
|
||||
:error ->
|
||||
{:and, clause, compile_rules_expression(rest, facts, filter)}
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_bindings(expr, bindings \\ %{current: 1})
|
||||
|
||||
defp extract_bindings({operator, left, right}, bindings) do
|
||||
{bindings, left_extracted} = extract_bindings(left, bindings)
|
||||
{bindings, right_extracted} = extract_bindings(right, bindings)
|
||||
|
||||
{bindings, {operator, left_extracted, right_extracted}}
|
||||
end
|
||||
|
||||
defp extract_bindings({:not, value}, bindings) do
|
||||
{bindings, extracted} = extract_bindings(value, bindings)
|
||||
|
||||
{bindings, {:not, extracted}}
|
||||
end
|
||||
|
||||
defp extract_bindings(value, %{current: current} = bindings) do
|
||||
current_binding =
|
||||
Enum.find(bindings, fn {key, binding_value} ->
|
||||
key != :current && binding_value == value
|
||||
end)
|
||||
|
||||
case current_binding do
|
||||
nil ->
|
||||
new_bindings =
|
||||
bindings
|
||||
|> Map.put(:current, current + 1)
|
||||
|> Map.put(current, value)
|
||||
|
||||
{new_bindings, current}
|
||||
|
||||
{binding, _} ->
|
||||
{bindings, binding}
|
||||
end
|
||||
end
|
||||
|
||||
# A helper function for formatting to the same output we'd give to picosat
|
||||
@doc false
|
||||
def to_picosat(clauses, variable_count) do
|
||||
clause_count = Enum.count(clauses)
|
||||
|
||||
formatted_input =
|
||||
Enum.map_join(clauses, "\n", fn clause ->
|
||||
format_clause(clause) <> " 0"
|
||||
end)
|
||||
|
||||
"p cnf #{variable_count} #{clause_count}\n" <> formatted_input
|
||||
end
|
||||
|
||||
defp negations_to_negative_numbers(clauses) do
|
||||
Enum.map(
|
||||
clauses,
|
||||
fn
|
||||
{:not, var} when is_integer(var) ->
|
||||
[negate_var(var)]
|
||||
|
||||
var when is_integer(var) ->
|
||||
[var]
|
||||
|
||||
clause ->
|
||||
Enum.map(clause, fn
|
||||
{:not, var} -> negate_var(var)
|
||||
var -> var
|
||||
end)
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
defp negate_var(var, multiplier \\ -1)
|
||||
|
||||
defp negate_var({:not, value}, multiplier) do
|
||||
negate_var(value, multiplier * -1)
|
||||
end
|
||||
|
||||
defp negate_var(value, multiplier), do: value * multiplier
|
||||
|
||||
defp format_clause(clause) do
|
||||
Enum.map_join(clause, " ", fn
|
||||
{:not, var} -> "-#{var}"
|
||||
var -> "#{var}"
|
||||
end)
|
||||
end
|
||||
|
||||
# {:and, {:or, 1, 2}, {:and, {:or, 3, 4}, {:or, 5, 6}}}
|
||||
|
||||
# [[1, 2], [3]]
|
||||
|
||||
# TODO: Is this so simple?
|
||||
defp lift_clauses({:and, left, right}) do
|
||||
lift_clauses(left) ++ lift_clauses(right)
|
||||
end
|
||||
|
||||
defp lift_clauses({:or, left, right}) do
|
||||
[lift_or_clauses(left) ++ lift_or_clauses(right)]
|
||||
end
|
||||
|
||||
defp lift_clauses(value), do: [[value]]
|
||||
|
||||
defp lift_or_clauses({:or, left, right}) do
|
||||
lift_or_clauses(left) ++ lift_or_clauses(right)
|
||||
end
|
||||
|
||||
defp lift_or_clauses(value), do: [value]
|
||||
|
||||
defp to_conjunctive_normal_form(expression) do
|
||||
expression
|
||||
|> demorgans_law()
|
||||
|> distributive_law()
|
||||
end
|
||||
|
||||
defp distributive_law(expression) do
|
||||
distributive_law_applied = apply_distributive_law(expression)
|
||||
|
||||
if expression == distributive_law_applied do
|
||||
expression
|
||||
else
|
||||
distributive_law(distributive_law_applied)
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_distributive_law({:or, left, {:and, right1, right2}}) do
|
||||
left_distributed = apply_distributive_law(left)
|
||||
|
||||
{:and, {:or, left_distributed, apply_distributive_law(right1)},
|
||||
{:or, left_distributed, apply_distributive_law(right2)}}
|
||||
end
|
||||
|
||||
defp apply_distributive_law({:or, {:and, left1, left2}, right}) do
|
||||
right_distributed = apply_distributive_law(right)
|
||||
|
||||
{:and, {:or, apply_distributive_law(left1), right_distributed},
|
||||
{:or, apply_distributive_law(left2), right_distributed}}
|
||||
end
|
||||
|
||||
defp apply_distributive_law({:not, expression}) do
|
||||
{:not, apply_distributive_law(expression)}
|
||||
end
|
||||
|
||||
defp apply_distributive_law({operator, left, right}) when operator in [:and, :or] do
|
||||
{operator, apply_distributive_law(left), apply_distributive_law(right)}
|
||||
end
|
||||
|
||||
defp apply_distributive_law(var) when is_integer(var) do
|
||||
var
|
||||
end
|
||||
|
||||
defp demorgans_law(expression) do
|
||||
demorgans_law_applied = apply_demorgans_law(expression)
|
||||
|
||||
if expression == demorgans_law_applied do
|
||||
expression
|
||||
else
|
||||
demorgans_law(demorgans_law_applied)
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_demorgans_law({:not, {:and, left, right}}) do
|
||||
{:or, {:not, apply_demorgans_law(left)}, {:not, apply_demorgans_law(right)}}
|
||||
end
|
||||
|
||||
defp apply_demorgans_law({:not, {:or, left, right}}) do
|
||||
{:and, {:not, left}, {:not, right}}
|
||||
end
|
||||
|
||||
defp apply_demorgans_law({operator, left, right}) when operator in [:or, :and] do
|
||||
{operator, apply_demorgans_law(left), apply_demorgans_law(right)}
|
||||
end
|
||||
|
||||
defp apply_demorgans_law({:not, expression}) do
|
||||
{:not, apply_demorgans_law(expression)}
|
||||
end
|
||||
|
||||
defp apply_demorgans_law(var) when is_integer(var) do
|
||||
var
|
||||
end
|
||||
end
|
|
@ -1,6 +1,6 @@
|
|||
defmodule Ash.DataLayer do
|
||||
@type filter_type :: :eq | :in
|
||||
@type feature() :: :transact | :query_async | {:filter, filter_type}
|
||||
@type feature() :: :transact | :query_async | {:filter, filter_type} | :upsert
|
||||
|
||||
@callback filter(Ash.data_layer_query(), Ash.filter(), resource :: Ash.resource()) ::
|
||||
{:ok, Ash.data_layer_query()} | {:error, Ash.error()}
|
||||
|
@ -25,7 +25,7 @@ defmodule Ash.DataLayer do
|
|||
{:ok, Ash.resource()} | {:error, Ash.error()}
|
||||
@callback destroy(record :: Ash.record()) :: :ok | {:error, Ash.error()}
|
||||
@callback transaction(Ash.resource(), (() -> term)) :: {:ok, term} | {:error, Ash.error()}
|
||||
@callback can?(feature()) :: boolean
|
||||
@callback can?(Ash.resource(), feature()) :: boolean
|
||||
|
||||
@optional_callbacks transaction: 2
|
||||
@optional_callbacks upsert: 2
|
||||
|
@ -90,7 +90,7 @@ defmodule Ash.DataLayer do
|
|||
@spec can?(feature, Ash.resource()) :: boolean
|
||||
def can?(feature, resource) do
|
||||
data_layer = Ash.data_layer(resource)
|
||||
data_layer.can?(feature)
|
||||
data_layer.can?(resource, feature)
|
||||
end
|
||||
|
||||
@spec run_query(Ash.data_layer_query(), central_resource :: Ash.resource()) ::
|
||||
|
|
|
@ -30,19 +30,24 @@ defmodule Ash.DataLayer.Ets do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def can?(:query_async), do: false
|
||||
def can?(:transact), do: false
|
||||
def can?(:composite_primary_key), do: true
|
||||
def can?(:upsert), do: true
|
||||
def can?({:filter, :in}), do: true
|
||||
def can?({:filter, :not_in}), do: true
|
||||
def can?({:filter, :not_eq}), do: true
|
||||
def can?({:filter, :eq}), do: true
|
||||
def can?({:filter, :and}), do: true
|
||||
def can?({:filter, :or}), do: true
|
||||
def can?({:filter, :not}), do: true
|
||||
def can?({:filter_related, _}), do: true
|
||||
def can?(_), do: false
|
||||
|
||||
def can?(resource, :query_async) do
|
||||
!Ash.DataLayer.Ets.private?(resource)
|
||||
end
|
||||
|
||||
def can?(_, :transact), do: false
|
||||
|
||||
def can?(_, :composite_primary_key), do: true
|
||||
def can?(_, :upsert), do: true
|
||||
def can?(_, {:filter, :in}), do: true
|
||||
def can?(_, {:filter, :not_in}), do: true
|
||||
def can?(_, {:filter, :not_eq}), do: true
|
||||
def can?(_, {:filter, :eq}), do: true
|
||||
def can?(_, {:filter, :and}), do: true
|
||||
def can?(_, {:filter, :or}), do: true
|
||||
def can?(_, {:filter, :not}), do: true
|
||||
def can?(_, {:filter_related, _}), do: true
|
||||
def can?(_, _), do: false
|
||||
|
||||
@impl true
|
||||
def resource_to_query(resource) do
|
||||
|
|
29
lib/ash/engine/authorizer.ex
Normal file
29
lib/ash/engine/authorizer.ex
Normal file
|
@ -0,0 +1,29 @@
|
|||
defmodule Ash.Engine.Authorizer do
|
||||
@type state :: map
|
||||
@callback initial_state(Ash.resource(), Ash.actor(), Ash.action(), boolean) :: state
|
||||
@callback strict_check_context(state) :: [atom]
|
||||
@callback strict_check(state, map) :: :authorized | {:continue, map} | {:error, Ash.error()}
|
||||
@callback check_context(state) :: [atom]
|
||||
@callback check(state, map) :: :authorized | {:error, Ash.error()}
|
||||
|
||||
def initial_state(module, actor, resource, action, verbose?) do
|
||||
module.initial_state(actor, resource, action, verbose?)
|
||||
end
|
||||
|
||||
def strict_check_context(module, state) do
|
||||
module.strict_check_context(state)
|
||||
end
|
||||
|
||||
@spec strict_check(atom, any, any) :: any
|
||||
def strict_check(module, state, context) do
|
||||
module.strict_check(state, context)
|
||||
end
|
||||
|
||||
def check_context(module, state) do
|
||||
module.check_context(state)
|
||||
end
|
||||
|
||||
def check(module, state, context) do
|
||||
module.check(state, context)
|
||||
end
|
||||
end
|
|
@ -1,23 +0,0 @@
|
|||
defmodule Ash.Engine.Data do
|
||||
alias ETS.Set
|
||||
|
||||
def init(requests) do
|
||||
{:ok, set} = Set.new(read_concurrency: true)
|
||||
|
||||
init_requests(set, requests)
|
||||
|
||||
set
|
||||
end
|
||||
|
||||
defp init_requests(set, requests) do
|
||||
values = Enum.map(requests, &{&1.id, &1})
|
||||
|
||||
case Set.put(set, values) do
|
||||
{:ok, _new_set} ->
|
||||
:ok
|
||||
|
||||
other ->
|
||||
raise "Encountered an error replacing request: #{inspect(other)}"
|
||||
end
|
||||
end
|
||||
end
|
File diff suppressed because it is too large
Load diff
|
@ -1,239 +0,0 @@
|
|||
defmodule Ash.Engine.Engine2 do
|
||||
defstruct [
|
||||
:api,
|
||||
:user,
|
||||
:requests,
|
||||
:verbose?,
|
||||
:skip_authorization?,
|
||||
:bypass_strict_access?,
|
||||
request_handlers: %{},
|
||||
errored_requests: [],
|
||||
data: %{},
|
||||
errors: [],
|
||||
facts: %{true => true, false => false}
|
||||
]
|
||||
|
||||
alias Ash.Authorization.SatSolver
|
||||
alias Ash.Authorization.Checker
|
||||
alias Ash.Engine.Request
|
||||
|
||||
use GenServer
|
||||
|
||||
require Logger
|
||||
|
||||
def run([], _api, _opts), do: {:error, :no_requests_provided}
|
||||
|
||||
def run(requests, api, opts) do
|
||||
opts =
|
||||
opts
|
||||
|> Keyword.put(:requests, requests)
|
||||
|> Keyword.put(:api, api)
|
||||
|
||||
Process.flag(:trap_exit, true)
|
||||
{:ok, pid} = GenServer.start_link(__MODULE__, opts)
|
||||
ref = Process.monitor(pid)
|
||||
|
||||
receive do
|
||||
{:EXIT, ^pid, {:shutdown, state}} ->
|
||||
log(state, "Engine complete, graceful shutdown")
|
||||
|
||||
{:ok, state}
|
||||
|
||||
{:EXIT, ^pid, reason} ->
|
||||
if opts[:verbose?] do
|
||||
Logger.warn("Engine complete, error shutdown: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
{:error, reason}
|
||||
|
||||
{:DOWN, ^ref, _thing1, _thing2, _reason} = message ->
|
||||
IO.inspect(message)
|
||||
|
||||
raise "unreachable DOWN message"
|
||||
end
|
||||
end
|
||||
|
||||
def init(opts) do
|
||||
state = %__MODULE__{
|
||||
requests: opts[:requests] || [],
|
||||
verbose?: opts[:verbose?] || false,
|
||||
api: opts[:api],
|
||||
skip_authorization?: opts[:skip_authorization?] || false,
|
||||
user: opts[:user],
|
||||
bypass_strict_access?: opts[:bypass_strict_access] || false
|
||||
}
|
||||
|
||||
new_state =
|
||||
state
|
||||
|> log_engine_init()
|
||||
|> validate_unique_paths()
|
||||
|> bypass_strict_access(opts)
|
||||
|> validate_dependencies()
|
||||
|> validate_has_rules()
|
||||
|
||||
{:ok, new_state, {:continue, :spawn_requests}}
|
||||
end
|
||||
|
||||
def handle_continue(:spawn_requests, state) do
|
||||
log(state, "Spawning request processes", :debug)
|
||||
|
||||
Process.flag(:trap_exit, true)
|
||||
|
||||
state =
|
||||
Enum.reduce(state.requests, state, fn request, state ->
|
||||
{:ok, pid} =
|
||||
GenServer.start_link(Ash.Engine.RequestHandler,
|
||||
request: request,
|
||||
verbose?: state.verbose?,
|
||||
engine_pid: self()
|
||||
)
|
||||
|
||||
%{state | request_handlers: Map.put(state.request_handlers, pid, request)}
|
||||
end)
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
# Change this to `update_request`
|
||||
def handle_cast({:update_request, new_request}, state) do
|
||||
new_request_handlers =
|
||||
Enum.reduce(state.request_handlers, state.request_handlers, fn {pid, request},
|
||||
request_handlers ->
|
||||
if request.id == new_request.id do
|
||||
Map.put(request_handlers, pid, new_request)
|
||||
else
|
||||
request_handlers
|
||||
end
|
||||
end)
|
||||
|
||||
if new_request.write_to_data? do
|
||||
new_data = Request.put_request(state.data, new_request)
|
||||
{:noreply, %{state | data: new_data, request_handlers: new_request_handlers}}
|
||||
else
|
||||
{:noreply, %{state | new_request_handlers: new_request_handlers}}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_call({:strict_check, request}, _, state) do
|
||||
log(state, "Strict checking #{request.name}", :debug)
|
||||
new_facts = Checker.strict_check(state.user, request, state.facts)
|
||||
|
||||
case SatSolver.solve([request], new_facts) do
|
||||
{:ok, _scenarios} ->
|
||||
{:reply, :ok, %{state | facts: new_facts}}
|
||||
|
||||
{:error, :unsatisfiable} ->
|
||||
{:reply, {:error, :unsatisfiable}, state}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info({:EXIT, pid, {:shutdown, request_handler_state}}, state) do
|
||||
state
|
||||
|> log("Successfully completed request #{request_handler_state.request.name}")
|
||||
|> remove_request_handler(pid)
|
||||
|> maybe_shutdown()
|
||||
end
|
||||
|
||||
def handle_info({:EXIT, pid, reason}, state) do
|
||||
request = Map.get(state.request_handlers, pid)
|
||||
|
||||
state
|
||||
|> log("Request exited in failure #{request.name}: #{inspect(reason)}")
|
||||
|> add_errored_request(request)
|
||||
|> remove_request_handler(pid)
|
||||
|> maybe_shutdown()
|
||||
end
|
||||
|
||||
defp maybe_shutdown(%{request_handlers: request_handlers} = state)
|
||||
when request_handlers == %{} do
|
||||
{:stop, {:shutdown, state}, state}
|
||||
end
|
||||
|
||||
defp maybe_shutdown(state) do
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
defp remove_request_handler(state, pid) do
|
||||
Map.update!(state, :request_handlers, &Map.delete(&1, pid))
|
||||
end
|
||||
|
||||
defp add_errored_request(state, request) do
|
||||
Map.update!(state, :errored_requests, fn errored_requests ->
|
||||
[request | errored_requests]
|
||||
end)
|
||||
end
|
||||
|
||||
defp log_engine_init(state) do
|
||||
log(state, "Initializing Engine with #{Enum.count(state.requests)} requests.")
|
||||
log(state, "Initial engine state: #{inspect(Map.delete(state, :request))}", :debug)
|
||||
end
|
||||
|
||||
defp log(state, message, level \\ :info)
|
||||
|
||||
defp log(%{verbose?: true} = state, message, level) do
|
||||
Logger.log(level, message)
|
||||
|
||||
state
|
||||
end
|
||||
|
||||
defp log(state, _, _) do
|
||||
state
|
||||
end
|
||||
|
||||
defp bypass_strict_access(engine, opts) do
|
||||
if opts[:bypass_strict_access?] do
|
||||
%{engine | requests: Enum.map(engine.requests, &Map.put(&1, :strict_access?, false))}
|
||||
else
|
||||
engine
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_dependencies(state) do
|
||||
case Request.build_dependencies(state.requests) do
|
||||
# TODO: Have `build_dependencies/1` return an ash error
|
||||
{:error, {:impossible, path}} ->
|
||||
add_error(state, path, "Impossible path: #{inspect(path)} required by request.")
|
||||
|
||||
{:ok, _requests} ->
|
||||
# TODO: no need to aggregate the full dependencies of
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_has_rules(%{skip_authorization?: true} = state), do: state
|
||||
|
||||
defp validate_has_rules(state) do
|
||||
case Enum.find(state.requests, &Enum.empty?(&1.rules)) do
|
||||
nil ->
|
||||
state
|
||||
|
||||
request ->
|
||||
add_error(state, request.path, "No authorization steps configured")
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_unique_paths(state) do
|
||||
case Request.validate_unique_paths(state.requests) do
|
||||
:ok ->
|
||||
state
|
||||
|
||||
{:error, paths} ->
|
||||
Enum.reduce(paths, state, &add_error(&2, &1, "Duplicate requests at path"))
|
||||
end
|
||||
end
|
||||
|
||||
defp add_error(state, path, error) do
|
||||
path = List.wrap(path)
|
||||
error = to_ash_error(error)
|
||||
|
||||
%{state | errors: [Map.put(error, :path, path) | state.errors]}
|
||||
end
|
||||
|
||||
defp to_ash_error(error) do
|
||||
if Ash.ash_error?(error) do
|
||||
error
|
||||
else
|
||||
Ash.Error.Unknown.exception(error: error)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,6 +1,4 @@
|
|||
defmodule Ash.Engine.Request do
|
||||
alias Ash.Authorization.{Check, Clause}
|
||||
|
||||
defmodule UnresolvedField do
|
||||
# TODO: Add some kind of optional dependency?
|
||||
defstruct [:resolver, deps: [], optional_deps: [], data?: false]
|
||||
|
@ -39,24 +37,25 @@ defmodule Ash.Engine.Request do
|
|||
defstruct [
|
||||
:id,
|
||||
:error?,
|
||||
:rules,
|
||||
:strict_access?,
|
||||
:resource,
|
||||
:changeset,
|
||||
:path,
|
||||
:action_type,
|
||||
:action,
|
||||
:data,
|
||||
:resolve_when_skip_authorization?,
|
||||
:name,
|
||||
:api,
|
||||
:query,
|
||||
:context,
|
||||
:write_to_data?,
|
||||
strict_check_complete?: false,
|
||||
check_complete?: false,
|
||||
prepared?: false
|
||||
:verbose?,
|
||||
:state,
|
||||
authorizer_state: %{},
|
||||
dependencies_request: [],
|
||||
dependencies_to_send: %{}
|
||||
]
|
||||
|
||||
require Logger
|
||||
|
||||
def resolve(dependencies \\ [], optional_dependencies \\ [], func) do
|
||||
UnresolvedField.new(dependencies, optional_dependencies, func)
|
||||
end
|
||||
|
@ -79,25 +78,6 @@ defmodule Ash.Engine.Request do
|
|||
|
||||
id = Ecto.UUID.generate()
|
||||
|
||||
clause_id =
|
||||
if opts[:action_type] == :read do
|
||||
nil
|
||||
else
|
||||
id
|
||||
end
|
||||
|
||||
rules =
|
||||
Enum.map(opts[:rules] || [], fn {rule, fact} ->
|
||||
{rule,
|
||||
Ash.Authorization.Clause.new(
|
||||
opts[:resource],
|
||||
fact,
|
||||
opts[:action],
|
||||
Map.get(query || %{}, :filter),
|
||||
clause_id
|
||||
)}
|
||||
end)
|
||||
|
||||
data =
|
||||
case opts[:data] do
|
||||
%UnresolvedField{} = unresolved ->
|
||||
|
@ -109,181 +89,162 @@ defmodule Ash.Engine.Request do
|
|||
|
||||
%__MODULE__{
|
||||
id: id,
|
||||
rules: rules,
|
||||
strict_access?: Keyword.get(opts, :strict_access?, true),
|
||||
resource: opts[:resource],
|
||||
changeset: opts[:changeset],
|
||||
path: List.wrap(opts[:path]),
|
||||
action_type: opts[:action_type],
|
||||
action: opts[:action],
|
||||
data: data,
|
||||
resolve_when_skip_authorization?: opts[:resolve_when_skip_authorization?],
|
||||
query: query,
|
||||
api: opts[:api],
|
||||
name: opts[:name],
|
||||
context: opts[:context] || %{},
|
||||
state: :strict_check,
|
||||
verbose?: opts[:verbose?] || false,
|
||||
write_to_data?: Keyword.get(opts, :write_to_data?, true)
|
||||
}
|
||||
end
|
||||
|
||||
def can_strict_check(%__MODULE__{strict_check_complete?: true}, _state), do: false
|
||||
def next(%{state: :strict_check} = request) do
|
||||
case Ash.authorizers(request.resource) do
|
||||
[] ->
|
||||
log(request, "No authorizers found, skipping strict check")
|
||||
{:continue, %{request | state: :fetch_data}}
|
||||
|
||||
def can_strict_check(request, state) do
|
||||
all_dependencies_met?(request, state, false)
|
||||
end
|
||||
authorizers ->
|
||||
case strict_check(authorizers, request) do
|
||||
{:ok, new_request, []} ->
|
||||
log(new_request, "Strict check complete")
|
||||
{:continue, %{new_request | state: :fetch_data}}
|
||||
|
||||
def authorize_always(request) do
|
||||
filter =
|
||||
case request.query do
|
||||
%UnresolvedField{} ->
|
||||
nil
|
||||
{:ok, new_request, dependencies} ->
|
||||
log(new_request, "Strict check incomplete, waiting on dependencies")
|
||||
{:waiting, new_request, dependencies}
|
||||
|
||||
%Ash.Query{filter: filter} ->
|
||||
filter
|
||||
|
||||
nil ->
|
||||
nil
|
||||
end
|
||||
|
||||
clause = Clause.new(request.resource, {Check.Static, result: true}, request.action, filter)
|
||||
|
||||
%{request | rules: [authorize_if: clause]}
|
||||
end
|
||||
|
||||
def errors(request) do
|
||||
request
|
||||
|> Map.from_struct()
|
||||
|> Enum.filter(fn {_key, value} ->
|
||||
match?(%ResolveError{}, value)
|
||||
end)
|
||||
|> Enum.into(%{})
|
||||
end
|
||||
|
||||
def data_resolved?(%__MODULE__{data: %UnresolvedField{}}), do: false
|
||||
def data_resolved?(_), do: true
|
||||
|
||||
def resolve_field(data, %UnresolvedField{resolver: resolver} = unresolved) do
|
||||
context = resolver_context(data, unresolved)
|
||||
|
||||
resolver.(context)
|
||||
end
|
||||
|
||||
def resolve_data(data, %{data: %UnresolvedField{resolver: resolver} = unresolved} = request) do
|
||||
context = resolver_context(data, unresolved)
|
||||
|
||||
case resolver.(context) do
|
||||
{:ok, resolved} -> {:ok, Map.put(request, :data, resolved)}
|
||||
{:error, error} -> {:error, error}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
if is_map(e) do
|
||||
{:error, Map.put(e, :__stacktrace__, __STACKTRACE__)}
|
||||
else
|
||||
{:error, e}
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_data(_, request), do: {:ok, request}
|
||||
|
||||
def contains_clause?(request, clause) do
|
||||
Enum.any?(request.rules, fn {_step, request_clause} ->
|
||||
clause == request_clause
|
||||
end)
|
||||
end
|
||||
|
||||
def put_request(state, request) do
|
||||
put_nested_key(state, request.path, request)
|
||||
end
|
||||
|
||||
def fetch_request_state(state, request) do
|
||||
fetch_nested_value(state, request.path)
|
||||
end
|
||||
|
||||
defp resolver_context(state, %{deps: depends_on, optional_deps: optional_deps}) do
|
||||
with_dependencies =
|
||||
Enum.reduce(depends_on, %{}, fn dependency, acc ->
|
||||
{:ok, value} = fetch_nested_value(state, dependency)
|
||||
|
||||
put_nested_key(acc, dependency, value)
|
||||
end)
|
||||
|
||||
Enum.reduce(optional_deps, with_dependencies, fn optional_dep, acc ->
|
||||
case fetch_nested_value(state, optional_dep) do
|
||||
{:ok, value} -> put_nested_key(acc, optional_dep, value)
|
||||
_ -> acc
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def all_dependencies_met?(request, state, data? \\ true) do
|
||||
dependencies_met?(state, get_dependencies(request, data?), data?)
|
||||
end
|
||||
|
||||
def dependencies_met?(state, deps, data? \\ true)
|
||||
def dependencies_met?(_state, [], _), do: true
|
||||
def dependencies_met?(_state, nil, _), do: true
|
||||
|
||||
def dependencies_met?(state, dependencies, data?) do
|
||||
Enum.all?(dependencies, fn dependency ->
|
||||
case fetch_nested_value(state, dependency) do
|
||||
{:ok, %UnresolvedField{deps: nested_dependencies, data?: dep_is_data?}} ->
|
||||
if dep_is_data? and not data? do
|
||||
false
|
||||
else
|
||||
dependencies_met?(state, nested_dependencies, data?)
|
||||
end
|
||||
|
||||
{:ok, _} ->
|
||||
true
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def depends_on?(request, other_request) do
|
||||
dependencies = get_dependencies(request)
|
||||
|
||||
Enum.any?(dependencies, fn dep ->
|
||||
List.starts_with?(dep, other_request.path)
|
||||
end)
|
||||
end
|
||||
|
||||
def fetch_nested_value(state, [key]) when is_map(state) do
|
||||
Map.fetch(state, key)
|
||||
end
|
||||
|
||||
def fetch_nested_value(%UnresolvedField{}, _), do: :error
|
||||
|
||||
def fetch_nested_value(state, [key | rest]) when is_map(state) do
|
||||
case Map.fetch(state, key) do
|
||||
{:ok, value} -> fetch_nested_value(value, rest)
|
||||
:error -> :error
|
||||
{:error, error} ->
|
||||
log(request, "Strict checking failed")
|
||||
{:error, error, request}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_nested_value(state, key) when is_map(state) do
|
||||
Map.fetch(state, key)
|
||||
def next(%{state: :strict_check} = request) do
|
||||
case try_resolve_local(request, [:data], false) do
|
||||
{:skipped, _, _} ->
|
||||
raise "unreachable!"
|
||||
|
||||
{:ok, state, []} ->
|
||||
{:continue, %{request | state: :check}}
|
||||
|
||||
{:ok, state, _} ->
|
||||
{:noreply, state}
|
||||
|
||||
{:error, error} ->
|
||||
{:stop, {:error, error, state.request}, state}
|
||||
end
|
||||
end
|
||||
|
||||
# Debugging utility
|
||||
def deps_report(requests) when is_list(requests) do
|
||||
Enum.map_join(requests, &deps_report/1)
|
||||
defp strict_check(authorizers, request) do
|
||||
Enum.reduce_while(authorizers, {:ok, request, true}, fn authorizer,
|
||||
{:ok, request, waiting_for} ->
|
||||
case do_strict_check(authorizer, request) do
|
||||
{:ok, new_request} ->
|
||||
log(new_request, "strict check succeeded for #{inspect(authorizer)}")
|
||||
{:cont, {:ok, new_request, waiting_for}}
|
||||
|
||||
{:waiting, new_request, new_deps} ->
|
||||
log(
|
||||
new_request,
|
||||
"waiting on dependencies: #{inspect(new_deps)} for #{inspect(authorizer)}"
|
||||
)
|
||||
|
||||
{:cont, {:ok, new_request, new_deps ++ waiting_for}}
|
||||
|
||||
{:error, error} ->
|
||||
log(request, "strict check failed for #{inspect(authorizer)}: #{inspect(error)}")
|
||||
|
||||
{:halt, {:error, error}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def deps_report(request) do
|
||||
header = "#{request.name}: \n"
|
||||
defp do_strict_check(authorizer, request) do
|
||||
case missing_strict_check_dependencies?(authorizer, request) do
|
||||
[] ->
|
||||
case strict_check_authorizer(authorizer, request) do
|
||||
:authorized ->
|
||||
{:ok, set_authorizer_state(request, authorizer, :authorized)}
|
||||
|
||||
body =
|
||||
request
|
||||
|> Map.from_struct()
|
||||
|> Enum.filter(&match?({_, %UnresolvedField{}}, &1))
|
||||
|> Enum.map_join("\n", fn {key, value} ->
|
||||
" #{key}: #{inspect(value)}"
|
||||
end)
|
||||
{:continue, authorizer_state} ->
|
||||
{:ok, set_authorizer_state(request, authorizer, authorizer_state)}
|
||||
|
||||
header <> body <> "\n"
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
|
||||
deps ->
|
||||
deps =
|
||||
Enum.map(deps, fn dep ->
|
||||
request.path ++ [dep]
|
||||
end)
|
||||
|
||||
case try_resolve(request, deps) do
|
||||
{:ok, new_request, []} ->
|
||||
do_strict_check(authorizer, new_request)
|
||||
|
||||
{:ok, new_request, waiting_for} ->
|
||||
{:waiting, new_request, waiting_for}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp missing_strict_check_dependencies?(authorizer, request) do
|
||||
authorizer
|
||||
|> Authorizer.strict_check_context(authorizer_state(request, authorizer))
|
||||
|> Enum.filter(fn dependency ->
|
||||
match?(%UnresolvedField{}, Map.get(request, dependency))
|
||||
end)
|
||||
end
|
||||
|
||||
defp missing_check_dependencies(authorizer, request) do
|
||||
authorizer
|
||||
|> Authorizer.check_context(authorizer_state(request, authorizer))
|
||||
|> Enum.filter(fn dependency ->
|
||||
match?(%UnresolvedField{}, Map.get(request, dependency))
|
||||
end)
|
||||
end
|
||||
|
||||
defp strict_check_authorizer(authorizer, state) do
|
||||
log(state, "strict checking for #{inspect(authorizer)}")
|
||||
|
||||
authorizer_state = authorizer_state(state, authorizer)
|
||||
|
||||
keys = Authorizer.strict_check_context(authorizer, authorizer_state)
|
||||
|
||||
Authorizer.strict_check(authorizer, authorizer_state, Map.take(state.request, keys))
|
||||
end
|
||||
|
||||
defp check_authorizer(authorizer, state) do
|
||||
log(state, "checking for #{inspect(authorizer)}")
|
||||
|
||||
authorizer_state = authorizer_state(state, authorizer)
|
||||
|
||||
keys = Authorizer.check_context(authorizer, authorizer_state)
|
||||
|
||||
Authorizer.check(authorizer, authorizer_state, Map.take(state.request, keys))
|
||||
end
|
||||
|
||||
defp set_authorizer_state(state, authorizer, authorizer_state) do
|
||||
%{
|
||||
state
|
||||
| authorizer_state: Map.put(state.authorizer_state, authorizer, authorizer_state)
|
||||
}
|
||||
end
|
||||
|
||||
defp authorizer_state(state, authorizer) do
|
||||
Map.get(state.authorizer_state, authorizer) || %{}
|
||||
end
|
||||
|
||||
def validate_unique_paths(requests) do
|
||||
|
@ -371,42 +332,13 @@ defmodule Ash.Engine.Request do
|
|||
end
|
||||
end
|
||||
|
||||
defp get_dependencies(request, data? \\ true) do
|
||||
keys_to_drop =
|
||||
if data? do
|
||||
[]
|
||||
else
|
||||
[:data]
|
||||
end
|
||||
defp log(state, message, level \\ :debug)
|
||||
|
||||
request
|
||||
|> Map.from_struct()
|
||||
|> Map.drop(keys_to_drop)
|
||||
|> Enum.flat_map(fn
|
||||
{_key, %UnresolvedField{deps: values}} ->
|
||||
values
|
||||
|
||||
_ ->
|
||||
[]
|
||||
end)
|
||||
|> Enum.uniq()
|
||||
defp log(%{verbose?: true, name: name}, message, level) do
|
||||
Logger.log(level, "#{name}: #{message}")
|
||||
end
|
||||
|
||||
defp put_nested_key(state, [key], value) do
|
||||
Map.put(state, key, value)
|
||||
end
|
||||
|
||||
defp put_nested_key(state, [key | rest], value) do
|
||||
case Map.fetch(state, key) do
|
||||
{:ok, nested_state} when is_map(nested_state) ->
|
||||
Map.put(state, key, put_nested_key(nested_state, rest, value))
|
||||
|
||||
:error ->
|
||||
Map.put(state, key, put_nested_key(%{}, rest, value))
|
||||
end
|
||||
end
|
||||
|
||||
defp put_nested_key(state, key, value) do
|
||||
Map.put(state, key, value)
|
||||
defp log(_, _, _) do
|
||||
false
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,9 +1,25 @@
|
|||
defmodule Ash.Engine.RequestHandler do
|
||||
defstruct [:request, :engine_pid, :verbose?, :skip_authorization?]
|
||||
defstruct [
|
||||
:request,
|
||||
:engine_pid,
|
||||
:authorizer_state,
|
||||
:verbose?,
|
||||
:authorize?,
|
||||
:actor,
|
||||
:dependency_data,
|
||||
:strict_check_complete?,
|
||||
:state,
|
||||
dependencies_requested: [],
|
||||
dependencies_to_send: %{}
|
||||
]
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Ash.Engine.Request
|
||||
# TODO: as an optimization, make the authorizer_state global
|
||||
# to all request_handlers (using an agent or something)
|
||||
|
||||
alias Ash.Engine.{Authorizer, Request}
|
||||
|
||||
## If not bypass strict check, then the engine needs to ensure
|
||||
# that a scenario is reality *at strict check time*
|
||||
|
@ -12,92 +28,410 @@ defmodule Ash.Engine.RequestHandler do
|
|||
def init(opts) do
|
||||
state = %__MODULE__{
|
||||
engine_pid: opts[:engine_pid],
|
||||
request: opts[:request],
|
||||
request: %{opts[:request] | verbose?: opts[:verbose?] || false},
|
||||
verbose?: opts[:verbose?] || false,
|
||||
skip_authorization?: opts[:skip_authorization?] || false
|
||||
authorizer_state: %{},
|
||||
dependency_data: %{},
|
||||
actor: opts[:actor],
|
||||
authorize?: opts[:authorize?],
|
||||
strict_check_complete?: false,
|
||||
state: :init
|
||||
}
|
||||
|
||||
state = add_initial_authorizer_state(state)
|
||||
|
||||
log(state, "Starting request")
|
||||
|
||||
{:ok, state, {:continue, :strict_check}}
|
||||
{:ok, %{state | state: :strict_check}, {:continue, :next}}
|
||||
end
|
||||
|
||||
def handle_continue(:strict_check, %{skip_authorization?: false} = state) do
|
||||
log(state, "Skipping strict check due to `skip_authorization?` flag")
|
||||
{:stop, {:shutdown, state}, state}
|
||||
def handle_continue(:next, %{request: %{authorize?: false}, state: :strict_check} = state) do
|
||||
log(state, "Skipping strict check due to `authorize?: false`")
|
||||
{:noreply, %{state | state: :fetch_data, strict_check_complete?: true}, {:continue, :next}}
|
||||
end
|
||||
|
||||
def handle_continue(:strict_check, state) do
|
||||
if can_strict_check?(state.request) do
|
||||
log(state, "strict checking")
|
||||
|
||||
case GenServer.call(state.engine_pid, {:strict_check, state.request}) do
|
||||
:ok ->
|
||||
log(state, "strict_check succeeded")
|
||||
new_request = Map.put(state.request, :strict_check_complete?, true)
|
||||
GenServer.cast(state.engine_pid, {:update_request, new_request})
|
||||
new_state = Map.put(state, :request, new_request)
|
||||
{:stop, {:shutdown, new_state}, new_state}
|
||||
|
||||
{:error, :unsatisfiable} ->
|
||||
log(state, "strict_check failed")
|
||||
{:stop, {:error, :unsatisfiable}, state}
|
||||
end
|
||||
else
|
||||
{:noreply, state, {:continue, :resolve_non_data_dependencies}}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_continue(:resolve_non_data_dependencies, state) do
|
||||
log(state, "resolving non data dependencies")
|
||||
|
||||
case unresolved_non_data_fields(state.request) do
|
||||
def handle_continue(:next, %{state: :strict_check} = state) do
|
||||
case Ash.authorizers(state.request.resource) do
|
||||
[] ->
|
||||
raise "unreachable that none are unresolved"
|
||||
log(state, "No authorizers found, skipping strict check")
|
||||
|
||||
[{key, %{deps: []} = unresolved} | rest] ->
|
||||
log(state, "unresolved field #{inspect(key)} had no dependencies, resolving in place")
|
||||
{:noreply, %{state | state: :fetch_data, strict_check_complete?: true},
|
||||
{:continue, :next}}
|
||||
|
||||
case Request.resolve_field(%{}, unresolved) do
|
||||
{:ok, resolved} ->
|
||||
log(state, "#{key} successfully resolved")
|
||||
new_request = Map.put(state.request, key, resolved)
|
||||
new_state = Map.put(state, :request, new_request)
|
||||
GenServer.cast(state.engine_pid, {:updated_request, new_request})
|
||||
authorizers ->
|
||||
case strict_check(authorizers, state) do
|
||||
{:ok, new_state, true} ->
|
||||
log(state, "Strict check complete")
|
||||
new_state = %{new_state | strict_check_complete?: true, state: :fetch_data}
|
||||
{:noreply, new_state, {:continue, :next}}
|
||||
|
||||
if Enum.empty?(rest) do
|
||||
log(state, "no more unresolved non data dependencies, moving on to strict check")
|
||||
{:noreply, new_state, {:continue, :strict_check}}
|
||||
else
|
||||
{:noreply, state, {:continue, :resolve_non_data_dependencies}}
|
||||
end
|
||||
{:ok, new_state, false} ->
|
||||
log(state, "Strict check incomplete, waiting on dependencies")
|
||||
{:noreply, new_state}
|
||||
|
||||
{:error, error} ->
|
||||
log(state, "error when resolving #{key} #{inspect(error)}")
|
||||
{:stop, {:error, error}, state}
|
||||
log(state, "Strict checking failed")
|
||||
{:stop, {:error, error, state.request}, state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp unresolved_non_data_fields(request) do
|
||||
request
|
||||
|> Map.delete(:data)
|
||||
|> Map.to_list()
|
||||
|> Enum.filter(fn {_, value} ->
|
||||
case value do
|
||||
%Request.UnresolvedField{} ->
|
||||
true
|
||||
def handle_continue(:next, %{state: :fetch_data} = state) do
|
||||
case try_resolve_local(state, [:data], false) do
|
||||
{:skipped, _, _} ->
|
||||
raise "unreachable!"
|
||||
|
||||
_ ->
|
||||
false
|
||||
{:ok, state, []} ->
|
||||
{:noreply, %{state | state: :check}, {:continue, :next}}
|
||||
|
||||
{:ok, state, _} ->
|
||||
{:noreply, state}
|
||||
|
||||
{:error, error} ->
|
||||
{:stop, {:error, error, state.request}, state}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_continue(:next, %{request: %{authorize?: false}, state: :check} = state) do
|
||||
log(state, "Skipping check due to `authorize?: false`")
|
||||
complete(state)
|
||||
{:noreply, %{state | state: :complete}, {:continue, :next}}
|
||||
end
|
||||
|
||||
def handle_continue(:next, %{state: :check} = state) do
|
||||
case Ash.authorizers(state.request.resource) do
|
||||
[] ->
|
||||
log(state, "No authorizers found, skipping check")
|
||||
complete(state)
|
||||
{:noreply, %{state | state: :complete}, {:continue, :next}}
|
||||
|
||||
authorizers ->
|
||||
case check(authorizers, state) do
|
||||
{:ok, new_state, true} ->
|
||||
log(new_state, "Check complete")
|
||||
complete(new_state)
|
||||
|
||||
{:noreply, %{state | state: :complete}, {:continue, :next}}
|
||||
|
||||
{:ok, new_state, false} ->
|
||||
log(state, "Check incomplete, waiting on dependencies")
|
||||
{:noreply, new_state}
|
||||
|
||||
{:error, error} ->
|
||||
log(state, "Check failed")
|
||||
{:stop, {:error, error, state.request}, state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_continue(:next, %{state: :complete} = state) do
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_cast({:wont_receive, path, field}, state) do
|
||||
log(state, "Quitting due to never receiving dependency #{inspect(path ++ [field])}")
|
||||
|
||||
{:stop, {:error, "Dependency failed to resolve: #{inspect(path ++ [field])}"}, state}
|
||||
end
|
||||
|
||||
def handle_cast({:send_field, pid, field}, state) do
|
||||
log(state, "Attempting to send #{field} to #{inspect(pid)}")
|
||||
|
||||
case try_resolve_local(state, [field], false) do
|
||||
{:skipped, _, _} ->
|
||||
log(state, "Field could not be resolved #{field}, registering dependency")
|
||||
{:noreply, store_dependency(state, field, pid)}
|
||||
|
||||
{:ok, new_state, _} ->
|
||||
case Map.get(new_state.request, field) do
|
||||
%Request.UnresolvedField{} ->
|
||||
log(state, "Field could not be resolved #{field}, registering dependency")
|
||||
{:noreply, store_dependency(state, field, pid)}
|
||||
|
||||
value ->
|
||||
log(state, "Field value for #{field} sent")
|
||||
GenServer.cast(pid, {:field_value, state.request.path, field, value})
|
||||
|
||||
{:noreply, new_state}
|
||||
end
|
||||
|
||||
{:error, error} ->
|
||||
log(state, "Error resolving #{field}")
|
||||
GenServer.cast(pid, {:wont_receive, state.request.path, field})
|
||||
|
||||
{:stop, {:error, error, state.request}, state}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_cast({:field_value, path, field, value}, state) do
|
||||
put_dependency_data(state, path ++ [field], value)
|
||||
|
||||
{:noreply, state, {:continue, :next}}
|
||||
end
|
||||
|
||||
defp store_dependency(state, field, pid) do
|
||||
new_deps_to_send =
|
||||
Map.update(state.dependencies_to_send, field, [pid], fn pids -> [pid | pids] end)
|
||||
|
||||
%{state | dependencies_to_send: new_deps_to_send}
|
||||
end
|
||||
|
||||
defp complete(state) do
|
||||
log(state, "Request complete")
|
||||
GenServer.cast(state.engine_pid, {:complete, self(), state})
|
||||
end
|
||||
|
||||
defp check(authorizers, state) do
|
||||
Enum.reduce_while(authorizers, {:ok, state, true}, fn authorizer, {:ok, state, all_passed?} ->
|
||||
case do_check(authorizer, state) do
|
||||
{:ok, new_state} ->
|
||||
log(state, "check succeeded for #{inspect(authorizer)}")
|
||||
{:cont, {:ok, new_state, all_passed?}}
|
||||
|
||||
{:waiting, new_state, waiting_for} ->
|
||||
log(
|
||||
state,
|
||||
"waiting on dependencies: #{inspect(waiting_for)} for #{inspect(authorizer)}"
|
||||
)
|
||||
|
||||
{:cont, {:ok, new_state, false}}
|
||||
|
||||
{:error, error} ->
|
||||
log(state, "check failed for #{inspect(authorizer)}: #{inspect(error)}")
|
||||
|
||||
{:halt, {:error, error}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp can_strict_check?(request) do
|
||||
request
|
||||
|> unresolved_non_data_fields()
|
||||
|> Enum.empty?()
|
||||
defp do_check(authorizer, state) do
|
||||
case authorizer_state(state, authorizer) do
|
||||
:authorized ->
|
||||
{:ok, state}
|
||||
|
||||
_authorizer_state ->
|
||||
case missing_check_dependencies(authorizer, state) do
|
||||
[] ->
|
||||
case check_authorizer(authorizer, state) do
|
||||
:authorized ->
|
||||
{:ok, set_authorizer_state(state, authorizer, :authorized)}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
|
||||
deps ->
|
||||
deps =
|
||||
Enum.map(deps, fn dep ->
|
||||
state.request.path ++ [dep]
|
||||
end)
|
||||
|
||||
case try_resolve(state, deps) do
|
||||
{:ok, new_state, []} ->
|
||||
do_check(authorizer, new_state)
|
||||
|
||||
{:ok, new_state, waiting_for} ->
|
||||
{:waiting, new_state, waiting_for}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp strict_check(authorizers, state) do
|
||||
Enum.reduce_while(authorizers, {:ok, state, true}, fn authorizer, {:ok, state, all_passed?} ->
|
||||
case do_strict_check(authorizer, state) do
|
||||
{:ok, new_state} ->
|
||||
log(state, "strict check succeeded for #{inspect(authorizer)}")
|
||||
{:cont, {:ok, new_state, all_passed?}}
|
||||
|
||||
{:waiting, new_state, waiting_for} ->
|
||||
log(
|
||||
state,
|
||||
"waiting on dependencies: #{inspect(waiting_for)} for #{inspect(authorizer)}"
|
||||
)
|
||||
|
||||
{:cont, {:ok, new_state, false}}
|
||||
|
||||
{:error, error} ->
|
||||
log(state, "strict check failed for #{inspect(authorizer)}: #{inspect(error)}")
|
||||
|
||||
{:halt, {:error, error}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_strict_check(authorizer, state) do
|
||||
case missing_strict_check_dependencies?(authorizer, state) do
|
||||
[] ->
|
||||
case strict_check_authorizer(authorizer, state) do
|
||||
:authorized ->
|
||||
{:ok, set_authorizer_state(state, authorizer, :authorized)}
|
||||
|
||||
{:continue, authorizer_state} ->
|
||||
{:ok, set_authorizer_state(state, authorizer, authorizer_state)}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
|
||||
deps ->
|
||||
deps =
|
||||
Enum.map(deps, fn dep ->
|
||||
state.request.path ++ [dep]
|
||||
end)
|
||||
|
||||
case try_resolve(state, deps) do
|
||||
{:ok, new_state, []} ->
|
||||
do_strict_check(authorizer, new_state)
|
||||
|
||||
{:ok, new_state, waiting_for} ->
|
||||
{:waiting, new_state, waiting_for}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp try_resolve(state, dep_or_deps, optional? \\ false) do
|
||||
dep_or_deps
|
||||
|> List.wrap()
|
||||
|> Enum.reduce_while({:ok, state, []}, fn dep, {:ok, state, skipped} ->
|
||||
if local_dep?(state, dep) do
|
||||
case try_resolve_local(state, dep, optional?) do
|
||||
{:skipped, state, other_deps} -> {:cont, {:ok, state, [dep | skipped] ++ other_deps}}
|
||||
{:ok, state, other_deps} -> {:cont, {:ok, state, skipped ++ other_deps}}
|
||||
{:error, error} -> {:halt, {:error, error}}
|
||||
end
|
||||
else
|
||||
case get_dependency_data(state, dep) do
|
||||
{:ok, _value} ->
|
||||
{:cont, {:ok, state, skipped}}
|
||||
|
||||
:error ->
|
||||
new_state = register_dependency(state, dep, optional?)
|
||||
|
||||
{:cont, {:ok, new_state, [dep | skipped]}}
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp register_dependency(state, dep, optional?) do
|
||||
GenServer.cast(state.engine_pid, {:register_dependency, self(), dep, optional?})
|
||||
|
||||
%{state | dependencies_requested: [dep | state.dependencies_requested]}
|
||||
end
|
||||
|
||||
defp try_resolve_local(state, dep, optional?) do
|
||||
field = List.last(dep)
|
||||
|
||||
# Don't fetch request data if strict_check is not complete
|
||||
if field == :data && not state.strict_check_complete? do
|
||||
case state.request.data do
|
||||
%Request.UnresolvedField{deps: deps, optional_deps: optional_deps} ->
|
||||
with {:ok, new_state, _remaining_optional} <- try_resolve(state, optional_deps, true),
|
||||
{:ok, new_state, remaining_deps} <- try_resolve(new_state, deps, optional?) do
|
||||
{:skipped, new_state, remaining_deps}
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:skipped, state, []}
|
||||
end
|
||||
else
|
||||
case state.request do
|
||||
%{^field => %Request.UnresolvedField{} = unresolved} ->
|
||||
%{deps: deps, optional_deps: optional_deps, resolver: resolver} = unresolved
|
||||
|
||||
with {:ok, new_state, _remaining_optional} <- try_resolve(state, optional_deps, true),
|
||||
{:ok, new_state, remaining_deps} <- try_resolve(new_state, deps, optional?) do
|
||||
resolver_context = resolver_context(new_state, deps ++ optional_deps)
|
||||
|
||||
case resolver.(resolver_context) do
|
||||
{:ok, value} ->
|
||||
new_state = notify_dependents(new_state, field, value)
|
||||
new_state = %{new_state | request: Map.put(new_state.request, field, value)}
|
||||
|
||||
new_state =
|
||||
put_dependency_data(new_state, new_state.request.path ++ [field], value)
|
||||
|
||||
{:ok, new_state, remaining_deps}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
%{^field => value} ->
|
||||
{:ok, put_dependency_data(state, dep, value), []}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp notify_dependents(state, field, value) do
|
||||
case Map.fetch(state.dependencies_to_send, field) do
|
||||
{:ok, pids} ->
|
||||
Enum.each(pids, &GenServer.cast(&1, {:field_value, state.request.path, field, value}))
|
||||
%{state | dependencies_to_send: Map.delete(state.dependencies_to_send, field)}
|
||||
|
||||
:error ->
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
defp get_dependency_data(state, dep) do
|
||||
Map.fetch(state.dependency_data, dep)
|
||||
end
|
||||
|
||||
defp put_dependency_data(state, dep, value) do
|
||||
%{state | dependency_data: Map.put(state.dependency_data, dep, value)}
|
||||
end
|
||||
|
||||
defp resolver_context(state, deps) do
|
||||
Enum.reduce(deps, %{}, fn dep, resolver_context ->
|
||||
case get_dependency_data(state, dep) do
|
||||
{:ok, value} ->
|
||||
Ash.Engine.put_nested_key(resolver_context, dep, value)
|
||||
|
||||
:error ->
|
||||
resolver_context
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp local_dep?(state, dep) do
|
||||
:lists.droplast(dep) == state.request.path
|
||||
end
|
||||
|
||||
defp add_initial_authorizer_state(state) do
|
||||
state.request.resource
|
||||
|> Ash.authorizers()
|
||||
|> Enum.reduce(state, fn authorizer, state ->
|
||||
initial_state =
|
||||
Ash.Engine.Authorizer.initial_state(
|
||||
authorizer,
|
||||
state.actor,
|
||||
state.request.resource,
|
||||
state.request.action,
|
||||
state.verbose?
|
||||
)
|
||||
|
||||
set_authorizer_state(state, authorizer, initial_state)
|
||||
end)
|
||||
end
|
||||
|
||||
defp set_authorizer_state(state, authorizer, authorizer_state) do
|
||||
%{
|
||||
state
|
||||
| authorizer_state: Map.put(state.authorizer_state, authorizer, authorizer_state)
|
||||
}
|
||||
end
|
||||
|
||||
defp authorizer_state(state, authorizer) do
|
||||
Map.get(state.authorizer_state, authorizer) || %{}
|
||||
end
|
||||
|
||||
defp log(state, message, level \\ :debug)
|
||||
|
|
|
@ -3,36 +3,17 @@ defmodule Ash.Error.Forbidden do
|
|||
|
||||
use Ash.Error
|
||||
|
||||
def_ash_error(
|
||||
[
|
||||
:errors,
|
||||
:scenarios,
|
||||
:requests,
|
||||
:facts,
|
||||
:state,
|
||||
:strict_access?,
|
||||
:api,
|
||||
verbose?: false,
|
||||
no_steps_configured: false
|
||||
],
|
||||
class: :forbidden
|
||||
)
|
||||
def_ash_error([], class: :forbidden)
|
||||
|
||||
defimpl Ash.ErrorKind do
|
||||
alias Ash.Authorization.Report
|
||||
|
||||
def id(_), do: Ecto.UUID.generate()
|
||||
|
||||
def message(%{errors: errors}) when not is_nil(errors) do
|
||||
Ash.Error.error_messages(errors)
|
||||
end
|
||||
|
||||
def message(error) do
|
||||
if error.verbose? do
|
||||
description(error)
|
||||
else
|
||||
"forbidden"
|
||||
end
|
||||
def message(_error) do
|
||||
"forbidden"
|
||||
end
|
||||
|
||||
def code(_), do: "Forbidden"
|
||||
|
@ -41,19 +22,8 @@ defmodule Ash.Error.Forbidden do
|
|||
Ash.Error.error_descriptions(errors)
|
||||
end
|
||||
|
||||
def description(error) do
|
||||
report = %Report{
|
||||
api: error.api,
|
||||
scenarios: error.scenarios,
|
||||
requests: error.requests,
|
||||
facts: error.facts,
|
||||
state: error.state,
|
||||
no_steps_configured: error.no_steps_configured,
|
||||
header: "forbidden:",
|
||||
authorized?: false
|
||||
}
|
||||
|
||||
Report.report(report)
|
||||
def description(_error) do
|
||||
"Forbidden"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
# defmodule Ash.Filter.Error do
|
||||
# defexception [:errors]
|
||||
|
||||
# def exception(opts) do
|
||||
# engine = opts[:filter]
|
||||
|
||||
# %__MODULE__{errors: filter.errors}
|
||||
# end
|
||||
|
||||
# def message(error) do
|
||||
# header = "Authorized?: #{error.authorized?}\n\n"
|
||||
|
||||
# body =
|
||||
# Enum.map_join(error.errors, fn {path, value} ->
|
||||
# "path: #{inspect(path)}\n" <> Ash.ErrorKind.message(value)
|
||||
# end)
|
||||
|
||||
# header <> body
|
||||
# end
|
||||
# end
|
|
@ -31,6 +31,7 @@ defmodule Ash.Filter do
|
|||
impossible?: false
|
||||
]
|
||||
|
||||
alias Ash.Engine
|
||||
alias Ash.Engine.Request
|
||||
alias Ash.Filter.Merge
|
||||
|
||||
|
@ -44,7 +45,7 @@ defmodule Ash.Filter do
|
|||
path: list(atom),
|
||||
impossible?: boolean,
|
||||
errors: list(String.t()),
|
||||
requests: list(Ash.Engine.Request.t())
|
||||
requests: list(Request.t())
|
||||
}
|
||||
|
||||
@predicates %{
|
||||
|
@ -104,15 +105,13 @@ defmodule Ash.Filter do
|
|||
|> Ash.Query.filter(parsed_filter)
|
||||
|
||||
request =
|
||||
Ash.Engine.Request.new(
|
||||
Request.new(
|
||||
resource: resource,
|
||||
api: api,
|
||||
rules: Ash.primary_action(resource, :read).rules,
|
||||
query: query,
|
||||
path: [:filter, path],
|
||||
resolve_when_skip_authorization?: false,
|
||||
data:
|
||||
Ash.Engine.Request.resolve(
|
||||
Request.resolve(
|
||||
[[:filter, path, :query]],
|
||||
fn %{filter: %{^path => %{query: query}}} ->
|
||||
data_layer_query = Ash.DataLayer.resource_to_query(resource)
|
||||
|
@ -126,8 +125,7 @@ defmodule Ash.Filter do
|
|||
end
|
||||
end
|
||||
),
|
||||
action_type: :read,
|
||||
strict_access?: !primary_key_filter?(query.filter),
|
||||
action: Ash.primary_action!(resource, :read),
|
||||
relationship: path,
|
||||
name: source
|
||||
)
|
||||
|
@ -139,27 +137,6 @@ defmodule Ash.Filter do
|
|||
end
|
||||
end
|
||||
|
||||
def primary_key_filter?(nil), do: false
|
||||
|
||||
def primary_key_filter?(filter) do
|
||||
cleared_pkey_filter =
|
||||
filter.resource
|
||||
|> Ash.primary_key()
|
||||
|> Enum.map(fn key -> {key, nil} end)
|
||||
|
||||
case cleared_pkey_filter do
|
||||
[] ->
|
||||
false
|
||||
|
||||
cleared_pkey_filter ->
|
||||
parsed_cleared_pkey_filter = parse(filter.resource, cleared_pkey_filter, filter.api)
|
||||
|
||||
cleared_candidate_filter = clear_equality_values(filter)
|
||||
|
||||
strict_subset_of?(parsed_cleared_pkey_filter, cleared_candidate_filter)
|
||||
end
|
||||
end
|
||||
|
||||
def optional_paths(filter) do
|
||||
filter
|
||||
|> do_optional_paths()
|
||||
|
@ -221,328 +198,16 @@ defmodule Ash.Filter do
|
|||
|
||||
defp paths_and_data(paths, data) do
|
||||
Enum.flat_map(paths, fn path ->
|
||||
case Request.fetch_nested_value(data, path) do
|
||||
case Engine.fetch_nested_value(data, path) do
|
||||
{:ok, related_data} -> [{path, related_data}]
|
||||
:error -> []
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# The story here:
|
||||
# we don't really need to fully simplify every value statement, e.g `in: [1, 2, 3]` -> `== 1 or == 2 or == 3`
|
||||
# We could instead just simplify *only as much as we need to*, for instance if the filter contains
|
||||
# `in: [1, 2, 3]` and `in: [2, 3, 4]`, we could translate the first to `in: [2, 3] or == 1` and the
|
||||
# second one to `in: [2, 3] or == 4`. We should then be able to go about expressing the fact that none
|
||||
# of `== 1` and `== 2` are mutually exclusive terms by exchanging them for `== 1 and != 2` and `== 2 and != 1`
|
||||
# respectively. This is the methodology behind translating a *value* based filter into a boolean expression.
|
||||
#
|
||||
# However for now for simplicity's sake, I'm turning all `in: [1, 2]` into `== 1 or == 2` and all `not_in: [1, 2]`
|
||||
# into `!= 1 and !=2` for the sole reason that its not worth figuring it out right now. Cosimplification is, at the
|
||||
# and of the day, really just an optimization to keep the expression simple. Its not so important with lists and equality
|
||||
# but when we add substring filters/greater than filters, we're going to need to improve this logic
|
||||
def cosimplify(left, right) do
|
||||
{new_left, new_right} = simplify_lists(left, right)
|
||||
|
||||
express_mutual_exclusion(new_left, new_right)
|
||||
end
|
||||
|
||||
defp simplify_lists(left, right) do
|
||||
values = get_all_values(left, get_all_values(right, %{}))
|
||||
|
||||
substitutions =
|
||||
Enum.reduce(values, %{}, fn {key, values}, substitutions ->
|
||||
value_substitutions =
|
||||
Enum.reduce(values, %{}, fn value, substitutions ->
|
||||
case do_simplify_list(value) do
|
||||
{:ok, substitution} ->
|
||||
Map.put(substitutions, value, substitution)
|
||||
|
||||
:error ->
|
||||
substitutions
|
||||
end
|
||||
end)
|
||||
|
||||
Map.put(substitutions, key, value_substitutions)
|
||||
end)
|
||||
|
||||
{replace_values(left, substitutions), replace_values(right, substitutions)}
|
||||
end
|
||||
|
||||
defp do_simplify_list(%Ash.Filter.In{values: []}), do: :error
|
||||
|
||||
defp do_simplify_list(%Ash.Filter.In{values: [value]}) do
|
||||
{:ok, %Ash.Filter.Eq{value: value}}
|
||||
end
|
||||
|
||||
defp do_simplify_list(%Ash.Filter.In{values: [value | rest]}) do
|
||||
{:ok,
|
||||
Enum.reduce(rest, %Ash.Filter.Eq{value: value}, fn value, other_values ->
|
||||
Ash.Filter.Or.prebuilt_new(%Ash.Filter.Eq{value: value}, other_values)
|
||||
end)}
|
||||
end
|
||||
|
||||
defp do_simplify_list(%Ash.Filter.NotIn{values: []}), do: :error
|
||||
|
||||
defp do_simplify_list(%Ash.Filter.NotIn{values: [value]}) do
|
||||
{:ok, %Ash.Filter.NotEq{value: value}}
|
||||
end
|
||||
|
||||
defp do_simplify_list(%Ash.Filter.NotIn{values: [value | rest]}) do
|
||||
{:ok,
|
||||
Enum.reduce(rest, %Ash.Filter.Eq{value: value}, fn value, other_values ->
|
||||
Ash.Filter.And.prebuilt_new(%Ash.Filter.NotEq{value: value}, other_values)
|
||||
end)}
|
||||
end
|
||||
|
||||
defp do_simplify_list(_), do: :error
|
||||
|
||||
defp express_mutual_exclusion(left, right) do
|
||||
values = get_all_values(left, get_all_values(right, %{}))
|
||||
|
||||
substitutions =
|
||||
Enum.reduce(values, %{}, fn {key, values}, substitutions ->
|
||||
value_substitutions =
|
||||
Enum.reduce(values, %{}, fn value, substitutions ->
|
||||
case do_express_mutual_exclusion(value, values) do
|
||||
{:ok, substitution} ->
|
||||
Map.put(substitutions, value, substitution)
|
||||
|
||||
:error ->
|
||||
substitutions
|
||||
end
|
||||
end)
|
||||
|
||||
Map.put(substitutions, key, value_substitutions)
|
||||
end)
|
||||
|
||||
{replace_values(left, substitutions), replace_values(right, substitutions)}
|
||||
end
|
||||
|
||||
defp do_express_mutual_exclusion(%Ash.Filter.Eq{value: value} = eq_filter, values) do
|
||||
values
|
||||
|> Enum.filter(fn
|
||||
%Ash.Filter.Eq{value: other_value} -> value != other_value
|
||||
_ -> false
|
||||
end)
|
||||
|> case do
|
||||
[] ->
|
||||
:error
|
||||
|
||||
[%{value: other_value}] ->
|
||||
{:ok, Ash.Filter.And.prebuilt_new(eq_filter, %Ash.Filter.NotEq{value: other_value})}
|
||||
|
||||
values ->
|
||||
{:ok,
|
||||
Enum.reduce(values, eq_filter, fn %{value: other_value}, expr ->
|
||||
Ash.Filter.And.prebuilt_new(expr, %Ash.Filter.NotEq{value: other_value})
|
||||
end)}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_express_mutual_exclusion(_, _), do: :error
|
||||
|
||||
defp get_all_values(filter, state) do
|
||||
state =
|
||||
filter.attributes
|
||||
# TODO
|
||||
|> Enum.reduce(state, fn {field, value}, state ->
|
||||
state
|
||||
|> Map.put_new([filter.path, field], [])
|
||||
|> Map.update!([filter.path, field], fn values ->
|
||||
value
|
||||
|> do_get_values()
|
||||
|> Enum.reduce(values, fn value, values ->
|
||||
if value in values do
|
||||
values
|
||||
else
|
||||
[value | values]
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
state =
|
||||
Enum.reduce(filter.relationships, state, fn {_, relationship_filter}, new_state ->
|
||||
get_all_values(relationship_filter, new_state)
|
||||
end)
|
||||
|
||||
state =
|
||||
if filter.not do
|
||||
get_all_values(filter, state)
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
state =
|
||||
Enum.reduce(filter.ors, state, fn or_filter, new_state ->
|
||||
get_all_values(or_filter, new_state)
|
||||
end)
|
||||
|
||||
Enum.reduce(filter.ands, state, fn and_filter, new_state ->
|
||||
get_all_values(and_filter, new_state)
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_get_values(%struct{left: left, right: right})
|
||||
when struct in [Ash.Filter.And, Ash.Filter.Or] do
|
||||
do_get_values(left) ++ do_get_values(right)
|
||||
end
|
||||
|
||||
defp do_get_values(other), do: [other]
|
||||
|
||||
defp replace_values(filter, substitutions) do
|
||||
new_attrs =
|
||||
Enum.reduce(filter.attributes, %{}, fn {field, value}, attributes ->
|
||||
substitutions = Map.get(substitutions, [filter.path, field]) || %{}
|
||||
|
||||
Map.put(attributes, field, do_replace_value(value, substitutions))
|
||||
end)
|
||||
|
||||
new_relationships =
|
||||
Enum.reduce(filter.relationships, %{}, fn {relationship, related_filter}, relationships ->
|
||||
new_relationship_filter = replace_values(related_filter, substitutions)
|
||||
|
||||
Map.put(relationships, relationship, new_relationship_filter)
|
||||
end)
|
||||
|
||||
new_not =
|
||||
if filter.not do
|
||||
replace_values(filter, substitutions)
|
||||
else
|
||||
filter.not
|
||||
end
|
||||
|
||||
new_ors =
|
||||
Enum.reduce(filter.ors, [], fn or_filter, ors ->
|
||||
new_or = replace_values(or_filter, substitutions)
|
||||
|
||||
[new_or | ors]
|
||||
end)
|
||||
|
||||
new_ands =
|
||||
Enum.reduce(filter.ands, [], fn and_filter, ands ->
|
||||
new_and = replace_values(and_filter, substitutions)
|
||||
|
||||
[new_and | ands]
|
||||
end)
|
||||
|
||||
%{
|
||||
filter
|
||||
| attributes: new_attrs,
|
||||
relationships: new_relationships,
|
||||
not: new_not,
|
||||
ors: Enum.reverse(new_ors),
|
||||
ands: Enum.reverse(new_ands)
|
||||
}
|
||||
end
|
||||
|
||||
defp do_replace_value(%struct{left: left, right: right} = compound, substitutions)
|
||||
when struct in [Ash.Filter.And, Ash.Filter.Or] do
|
||||
%{
|
||||
compound
|
||||
| left: do_replace_value(left, substitutions),
|
||||
right: do_replace_value(right, substitutions)
|
||||
}
|
||||
end
|
||||
|
||||
defp do_replace_value(value, substitutions) do
|
||||
case Map.fetch(substitutions, value) do
|
||||
{:ok, new_value} ->
|
||||
new_value
|
||||
|
||||
_ ->
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
defp clear_equality_values(filter) do
|
||||
new_attrs =
|
||||
Enum.reduce(filter.attributes, %{}, fn {field, value}, attributes ->
|
||||
Map.put(attributes, field, do_clear_equality_value(value))
|
||||
end)
|
||||
|
||||
new_relationships =
|
||||
Enum.reduce(filter.relationships, %{}, fn {relationship, related_filter}, relationships ->
|
||||
new_relationship_filter = clear_equality_values(related_filter)
|
||||
|
||||
Map.put(relationships, relationship, new_relationship_filter)
|
||||
end)
|
||||
|
||||
new_not =
|
||||
if filter.not do
|
||||
clear_equality_values(filter)
|
||||
else
|
||||
filter.not
|
||||
end
|
||||
|
||||
new_ors =
|
||||
Enum.reduce(filter.ors, [], fn or_filter, ors ->
|
||||
new_or = clear_equality_values(or_filter)
|
||||
|
||||
[new_or | ors]
|
||||
end)
|
||||
|
||||
new_ands =
|
||||
Enum.reduce(filter.ands, [], fn and_filter, ands ->
|
||||
new_and = clear_equality_values(and_filter)
|
||||
|
||||
[new_and | ands]
|
||||
end)
|
||||
|
||||
%{
|
||||
filter
|
||||
| attributes: new_attrs,
|
||||
relationships: new_relationships,
|
||||
not: new_not,
|
||||
ors: Enum.reverse(new_ors),
|
||||
ands: Enum.reverse(new_ands)
|
||||
}
|
||||
end
|
||||
|
||||
defp do_clear_equality_value(%struct{left: left, right: right} = compound)
|
||||
when struct in [Ash.Filter.And, Ash.Filter.Or] do
|
||||
%{
|
||||
compound
|
||||
| left: do_clear_equality_value(left),
|
||||
right: do_clear_equality_value(right)
|
||||
}
|
||||
end
|
||||
|
||||
defp do_clear_equality_value(%Ash.Filter.Eq{value: _} = filter), do: %{filter | value: nil}
|
||||
defp do_clear_equality_value(%Ash.Filter.In{values: _}), do: %Ash.Filter.Eq{value: nil}
|
||||
defp do_clear_equality_value(other), do: other
|
||||
|
||||
@doc """
|
||||
Returns true if the second argument is a strict subset (always returns the same or less data) of the first
|
||||
"""
|
||||
def strict_subset_of(nil, _), do: true
|
||||
|
||||
def strict_subset_of(_, nil), do: false
|
||||
|
||||
def strict_subset_of(%{resource: resource}, %{resource: other_resource})
|
||||
when resource != other_resource,
|
||||
do: false
|
||||
|
||||
def strict_subset_of(filter, candidate) do
|
||||
if empty_filter?(filter) do
|
||||
true
|
||||
else
|
||||
if empty_filter?(candidate) do
|
||||
false
|
||||
else
|
||||
{filter, candidate} = cosimplify(filter, candidate)
|
||||
Ash.Authorization.SatSolver.strict_filter_subset(filter, candidate)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def strict_subset_of?(filter, candidate) do
|
||||
strict_subset_of(filter, candidate) == true
|
||||
end
|
||||
|
||||
defp empty_filter?(filter) do
|
||||
def empty_filter?(filter) do
|
||||
filter.attributes == %{} and filter.relationships == %{} and filter.not == nil and
|
||||
filter.ors in [[], nil]
|
||||
filter.ors in [[], nil] and filter.ands in [[], nil]
|
||||
end
|
||||
|
||||
defp add_records_to_relationship_filter(filter, [], records) do
|
||||
|
@ -852,9 +517,10 @@ defmodule Ash.Filter do
|
|||
{:predicate_type, Map.fetch(@predicates, predicate_name)},
|
||||
{:type_can?, _, true} <-
|
||||
{:type_can?, predicate_name,
|
||||
Ash.Type.supports_filter?(attr_type, predicate_name, data_layer)},
|
||||
Ash.Type.supports_filter?(resource, attr_type, predicate_name, data_layer)},
|
||||
{:data_layer_can?, _, true} <-
|
||||
{:data_layer_can?, predicate_name, data_layer.can?({:filter, predicate_name})},
|
||||
{:data_layer_can?, predicate_name,
|
||||
Ash.data_layer_can?(resource, {:filter, predicate_name})},
|
||||
{:predicate, _, {:ok, predicate}} <-
|
||||
{:predicate, attr_name, predicate_type.new(resource, attr_name, attr_type, value)} do
|
||||
{:ok, predicate}
|
||||
|
|
|
@ -106,7 +106,8 @@ defmodule Ash.Resource do
|
|||
Module.register_attribute(mod, :actions, accumulate: true)
|
||||
Module.register_attribute(mod, :attributes, accumulate: true)
|
||||
Module.register_attribute(mod, :relationships, accumulate: true)
|
||||
Module.register_attribute(mod, :mix_ins, accumulate: true)
|
||||
Module.register_attribute(mod, :extensions, accumulate: true)
|
||||
Module.register_attribute(mod, :authorizers, accumulate: true)
|
||||
|
||||
Module.put_attribute(mod, :name, opts[:name])
|
||||
Module.put_attribute(mod, :resource_type, opts[:type])
|
||||
|
@ -122,8 +123,7 @@ defmodule Ash.Resource do
|
|||
Ash.Resource.Attributes.Attribute.new(mod, :id, :uuid,
|
||||
primary_key?: true,
|
||||
default: &Ecto.UUID.generate/0,
|
||||
generated?: true,
|
||||
write_rules: false
|
||||
generated?: true
|
||||
)
|
||||
|
||||
Module.put_attribute(mod, :attributes, attribute)
|
||||
|
@ -135,8 +135,7 @@ defmodule Ash.Resource do
|
|||
{:ok, attribute} =
|
||||
Ash.Resource.Attributes.Attribute.new(mod, opts[:field], opts[:type],
|
||||
primary_key?: true,
|
||||
generated?: opts[:generated?] || true,
|
||||
write_rules: false
|
||||
generated?: opts[:generated?] || true
|
||||
)
|
||||
|
||||
Module.put_attribute(mod, :attributes, attribute)
|
||||
|
@ -197,8 +196,8 @@ defmodule Ash.Resource do
|
|||
@name
|
||||
end
|
||||
|
||||
def mix_ins() do
|
||||
@mix_ins
|
||||
def extensions() do
|
||||
@extensions
|
||||
end
|
||||
|
||||
def data_layer() do
|
||||
|
@ -209,7 +208,11 @@ defmodule Ash.Resource do
|
|||
@description
|
||||
end
|
||||
|
||||
Enum.map(@mix_ins || [], fn hook_module ->
|
||||
def authorizers() do
|
||||
@authorizers
|
||||
end
|
||||
|
||||
Enum.map(@extensions || [], fn hook_module ->
|
||||
code = hook_module.before_compile_hook(unquote(Macro.escape(env)))
|
||||
Module.eval_quoted(__MODULE__, code)
|
||||
end)
|
||||
|
|
|
@ -12,22 +12,15 @@ defmodule Ash.Resource.Actions do
|
|||
If you have multiple actions of the same type, one of them must be designated as the
|
||||
primary action for that type, via: `primary?: true`. This tells the ash what to do
|
||||
if an action of that type is requested, but no specific action name is given.
|
||||
|
||||
Authorization in ash is done via supplying a list of rules to actions in the
|
||||
`rules` option. To understand rules and authorization, see the documentation in `Ash.Authorization`
|
||||
"""
|
||||
|
||||
@doc false
|
||||
defmacro actions(do: block) do
|
||||
quote do
|
||||
import Ash.Resource.Actions
|
||||
require Ash.Authorization.Check
|
||||
|
||||
import Ash.Authorization.Check.BuiltInChecks
|
||||
|
||||
unquote(block)
|
||||
import Ash.Resource.Actions, only: [actions: 1]
|
||||
import Ash.Authorization.Check.BuiltInChecks, only: []
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -1,28 +1,23 @@
|
|||
defmodule Ash.Resource.Actions.Create do
|
||||
@moduledoc "The representation of a `create` action."
|
||||
defstruct [:type, :name, :primary?, :rules]
|
||||
defstruct [:type, :name, :primary?]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
type: :create,
|
||||
name: atom,
|
||||
primary?: boolean,
|
||||
rules: Engine.steps()
|
||||
primary?: boolean
|
||||
}
|
||||
|
||||
@opt_schema Ashton.schema(
|
||||
opts: [
|
||||
primary?: :boolean,
|
||||
rules: :keyword
|
||||
primary?: :boolean
|
||||
],
|
||||
defaults: [
|
||||
primary?: false,
|
||||
rules: []
|
||||
primary?: false
|
||||
],
|
||||
describe: [
|
||||
primary?:
|
||||
"Whether or not this action should be used when no action is specified by the caller.",
|
||||
# TODO: doc better
|
||||
rules: "A list of authorization steps"
|
||||
"Whether or not this action should be used when no action is specified by the caller."
|
||||
]
|
||||
)
|
||||
|
||||
|
@ -30,30 +25,14 @@ defmodule Ash.Resource.Actions.Create do
|
|||
def opt_schema(), do: @opt_schema
|
||||
|
||||
@spec new(Ash.resource(), atom, Keyword.t()) :: {:ok, t()} | {:error, term}
|
||||
def new(resource, name, opts \\ []) do
|
||||
def new(_resource, name, opts \\ []) do
|
||||
case Ashton.validate(opts, @opt_schema) do
|
||||
{:ok, opts} ->
|
||||
rules =
|
||||
case opts[:rules] 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,
|
||||
%__MODULE__{
|
||||
name: name,
|
||||
type: :create,
|
||||
primary?: opts[:primary?],
|
||||
rules: rules
|
||||
primary?: opts[:primary?]
|
||||
}}
|
||||
|
||||
{:error, error} ->
|
||||
|
|
|
@ -1,29 +1,24 @@
|
|||
defmodule Ash.Resource.Actions.Destroy do
|
||||
@moduledoc "The representation of a `destroy` action"
|
||||
|
||||
defstruct [:type, :name, :primary?, :rules]
|
||||
defstruct [:type, :name, :primary?]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
type: :destroy,
|
||||
name: atom,
|
||||
primary?: boolean,
|
||||
rules: Authorization.steps()
|
||||
primary?: boolean
|
||||
}
|
||||
|
||||
@opt_schema Ashton.schema(
|
||||
opts: [
|
||||
primary?: :boolean,
|
||||
rules: :keyword
|
||||
primary?: :boolean
|
||||
],
|
||||
defaults: [
|
||||
primary?: false,
|
||||
rules: []
|
||||
primary?: false
|
||||
],
|
||||
describe: [
|
||||
primary?:
|
||||
"Whether or not this action should be used when no action is specified by the caller.",
|
||||
# TODO: doc better
|
||||
rules: "A list of authorization steps"
|
||||
"Whether or not this action should be used when no action is specified by the caller."
|
||||
]
|
||||
)
|
||||
|
||||
|
@ -31,31 +26,15 @@ defmodule Ash.Resource.Actions.Destroy do
|
|||
def opt_schema(), do: @opt_schema
|
||||
|
||||
@spec new(Ash.resource(), atom, Keyword.t()) :: {:ok, t()} | {:error, term}
|
||||
def new(resource, 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
|
||||
{:ok, opts} ->
|
||||
rules =
|
||||
case opts[:rules] 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,
|
||||
%__MODULE__{
|
||||
name: name,
|
||||
type: :destroy,
|
||||
primary?: opts[:primary?],
|
||||
rules: rules
|
||||
primary?: opts[:primary?]
|
||||
}}
|
||||
|
||||
{:error, error} ->
|
||||
|
|
|
@ -1,29 +1,24 @@
|
|||
defmodule Ash.Resource.Actions.Read do
|
||||
@moduledoc "The representation of a `read` action"
|
||||
|
||||
defstruct [:type, :name, :primary?, :rules]
|
||||
defstruct [:type, :name, :primary?]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
type: :read,
|
||||
name: atom,
|
||||
primary?: boolean,
|
||||
rules: Authorization.steps()
|
||||
primary?: boolean
|
||||
}
|
||||
|
||||
@opt_schema Ashton.schema(
|
||||
opts: [
|
||||
primary?: :boolean,
|
||||
rules: :keyword
|
||||
primary?: :boolean
|
||||
],
|
||||
defaults: [
|
||||
primary?: false,
|
||||
rules: []
|
||||
primary?: false
|
||||
],
|
||||
describe: [
|
||||
primary?:
|
||||
"Whether or not this action should be used when no action is specified by the caller.",
|
||||
# TODO: doc better
|
||||
rules: "A list of authorization steps"
|
||||
"Whether or not this action should be used when no action is specified by the caller."
|
||||
]
|
||||
)
|
||||
|
||||
|
@ -31,31 +26,15 @@ defmodule Ash.Resource.Actions.Read do
|
|||
def opt_schema(), do: @opt_schema
|
||||
|
||||
@spec new(Ash.resource(), atom, Keyword.t()) :: {:ok, t()} | {:error, term}
|
||||
def new(resource, 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
|
||||
{:ok, opts} ->
|
||||
rules =
|
||||
case opts[:rules] 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,
|
||||
%__MODULE__{
|
||||
name: name,
|
||||
type: :read,
|
||||
primary?: opts[:primary?],
|
||||
rules: rules
|
||||
primary?: opts[:primary?]
|
||||
}}
|
||||
|
||||
{:error, error} ->
|
||||
|
|
|
@ -1,29 +1,24 @@
|
|||
defmodule Ash.Resource.Actions.Update do
|
||||
@moduledoc "The representation of a `update` action"
|
||||
|
||||
defstruct [:type, :name, :primary?, :rules]
|
||||
defstruct [:type, :name, :primary?]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
type: :update,
|
||||
name: atom,
|
||||
primary?: boolean,
|
||||
rules: Authorization.steps()
|
||||
primary?: boolean
|
||||
}
|
||||
|
||||
@opt_schema Ashton.schema(
|
||||
opts: [
|
||||
primary?: :boolean,
|
||||
rules: :keyword
|
||||
primary?: :boolean
|
||||
],
|
||||
defaults: [
|
||||
primary?: false,
|
||||
rules: []
|
||||
primary?: false
|
||||
],
|
||||
describe: [
|
||||
primary?:
|
||||
"Whether or not this action should be used when no action is specified by the caller.",
|
||||
# TODO: doc better
|
||||
rules: "A list of authorization steps"
|
||||
"Whether or not this action should be used when no action is specified by the caller."
|
||||
]
|
||||
)
|
||||
|
||||
|
@ -31,31 +26,15 @@ defmodule Ash.Resource.Actions.Update do
|
|||
def opt_schema(), do: @opt_schema
|
||||
|
||||
@spec new(Ash.resource(), atom, Keyword.t()) :: {:ok, t()} | {:error, term}
|
||||
def new(resource, 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
|
||||
{:ok, opts} ->
|
||||
rules =
|
||||
case opts[:rules] 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,
|
||||
%__MODULE__{
|
||||
name: name,
|
||||
type: :update,
|
||||
primary?: opts[:primary?],
|
||||
rules: rules
|
||||
primary?: opts[:primary?]
|
||||
}}
|
||||
|
||||
{:error, error} ->
|
||||
|
|
|
@ -9,8 +9,7 @@ defmodule Ash.Resource.Attributes.Attribute do
|
|||
:primary_key?,
|
||||
:writable?,
|
||||
:default,
|
||||
:update_default,
|
||||
:write_rules
|
||||
:update_default
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
|
@ -19,7 +18,6 @@ defmodule Ash.Resource.Attributes.Attribute do
|
|||
primary_key?: boolean(),
|
||||
default: (() -> term),
|
||||
update_default: (() -> term) | (Ash.record() -> term),
|
||||
write_rules: Keyword.t(),
|
||||
writable?: boolean
|
||||
}
|
||||
|
||||
|
@ -27,7 +25,6 @@ defmodule Ash.Resource.Attributes.Attribute do
|
|||
opts: [
|
||||
primary_key?: :boolean,
|
||||
allow_nil?: :boolean,
|
||||
write_rules: [{:const, false}, :keyword],
|
||||
generated?: :boolean,
|
||||
writable?: :boolean,
|
||||
update_default: [
|
||||
|
@ -46,16 +43,14 @@ defmodule Ash.Resource.Attributes.Attribute do
|
|||
primary_key?: false,
|
||||
generated?: false,
|
||||
allow_nil?: true,
|
||||
writable?: true,
|
||||
write_rules: []
|
||||
writable?: true
|
||||
],
|
||||
describe: [
|
||||
allow_nil?: "#TODO: doc this",
|
||||
generated?: "#TODO: doc this",
|
||||
primary_key?: "#TODO: doc this",
|
||||
writable?: "#TODO: doc this",
|
||||
default: "#TODO: doc this",
|
||||
write_rules: "#TODO: doc this"
|
||||
default: "#TODO: doc this"
|
||||
]
|
||||
)
|
||||
|
||||
|
@ -63,33 +58,15 @@ defmodule Ash.Resource.Attributes.Attribute do
|
|||
def attribute_schema(), do: @schema
|
||||
|
||||
@spec new(Ash.resource(), atom, Ash.Type.t(), Keyword.t()) :: {:ok, t()} | {:error, term}
|
||||
def new(resource, 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),
|
||||
{:default, {:ok, default}} <- {:default, cast_default(type, opts)} do
|
||||
write_rules =
|
||||
case opts[:write_rules] do
|
||||
false ->
|
||||
false
|
||||
|
||||
steps ->
|
||||
base_attribute_opts = [
|
||||
attribute_name: name,
|
||||
attribute_type: type,
|
||||
resource: resource
|
||||
]
|
||||
|
||||
Enum.map(steps, fn {step, {mod, opts}} ->
|
||||
{step, {mod, Keyword.merge(base_attribute_opts, opts)}}
|
||||
end)
|
||||
end
|
||||
|
||||
{:ok,
|
||||
%__MODULE__{
|
||||
name: name,
|
||||
type: type,
|
||||
generated?: opts[:generated?],
|
||||
write_rules: write_rules,
|
||||
writable?: opts[:writable?],
|
||||
allow_nil?: opts[:allow_nil?],
|
||||
primary_key?: opts[:primary_key?],
|
||||
|
|
|
@ -10,14 +10,10 @@ defmodule Ash.Resource.Attributes do
|
|||
defmacro attributes(do: block) do
|
||||
quote do
|
||||
import Ash.Resource.Attributes
|
||||
import Ash.Authorization.Check.BuiltInChecks
|
||||
import Ash.Authorization.Check.AttributeBuiltInChecks
|
||||
|
||||
unquote(block)
|
||||
|
||||
import Ash.Resource.Attributes, only: [attributes: 1]
|
||||
import Ash.Authorization.Check.BuiltInChecks, only: []
|
||||
import Ash.Authorization.Check.AttributeBuiltInChecks, only: []
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -11,8 +11,7 @@ defmodule Ash.Resource.Relationships.BelongsTo do
|
|||
:destination_field,
|
||||
:source_field,
|
||||
:source,
|
||||
:reverse_relationship,
|
||||
:write_rules
|
||||
:reverse_relationship
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
|
@ -25,8 +24,7 @@ defmodule Ash.Resource.Relationships.BelongsTo do
|
|||
define_field?: boolean,
|
||||
field_type: Ash.Type.t(),
|
||||
destination_field: atom,
|
||||
source_field: atom | nil,
|
||||
write_rules: Keyword.t()
|
||||
source_field: atom | nil
|
||||
}
|
||||
|
||||
@opt_schema Ashton.schema(
|
||||
|
@ -36,15 +34,13 @@ defmodule Ash.Resource.Relationships.BelongsTo do
|
|||
primary_key?: :boolean,
|
||||
define_field?: :boolean,
|
||||
field_type: :atom,
|
||||
reverse_relationship: :atom,
|
||||
write_rules: :keyword
|
||||
reverse_relationship: :atom
|
||||
],
|
||||
defaults: [
|
||||
destination_field: :id,
|
||||
primary_key?: false,
|
||||
define_field?: true,
|
||||
field_type: :uuid,
|
||||
write_rules: []
|
||||
field_type: :uuid
|
||||
],
|
||||
describe: [
|
||||
reverse_relationship:
|
||||
|
@ -57,12 +53,7 @@ defmodule Ash.Resource.Relationships.BelongsTo do
|
|||
source_field:
|
||||
"The field on this resource that should match the `destination_field` on the related resource. Default: [relationship_name]_id",
|
||||
primary_key?:
|
||||
"Whether this field is, or is part of, the primary key of a resource.",
|
||||
write_rules: """
|
||||
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.
|
||||
"""
|
||||
"Whether this field is, or is part of, the primary key of a resource."
|
||||
]
|
||||
)
|
||||
|
||||
|
@ -79,27 +70,9 @@ defmodule Ash.Resource.Relationships.BelongsTo do
|
|||
# Don't call functions on the resource! We don't want it to compile here
|
||||
case Ashton.validate(opts, @opt_schema) do
|
||||
{:ok, opts} ->
|
||||
write_rules =
|
||||
case opts[:write_rules] 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,
|
||||
%__MODULE__{
|
||||
name: name,
|
||||
write_rules: write_rules,
|
||||
source: resource,
|
||||
type: :belongs_to,
|
||||
cardinality: :one,
|
||||
|
|
|
@ -7,15 +7,13 @@ defmodule Ash.Resource.Relationships.HasMany do
|
|||
:destination_field,
|
||||
:source_field,
|
||||
:source,
|
||||
:reverse_relationship,
|
||||
:write_rules
|
||||
:reverse_relationship
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
type: :has_many,
|
||||
cardinality: :many,
|
||||
source: Ash.resource(),
|
||||
write_rules: Keyword.t(),
|
||||
name: atom,
|
||||
type: Ash.Type.t(),
|
||||
destination: Ash.resource(),
|
||||
|
@ -28,12 +26,10 @@ defmodule Ash.Resource.Relationships.HasMany do
|
|||
opts: [
|
||||
destination_field: :atom,
|
||||
source_field: :atom,
|
||||
write_rules: :keyword,
|
||||
reverse_relationship: :atom
|
||||
],
|
||||
defaults: [
|
||||
source_field: :id,
|
||||
write_rules: []
|
||||
source_field: :id
|
||||
],
|
||||
describe: [
|
||||
reverse_relationship:
|
||||
|
@ -41,12 +37,7 @@ defmodule Ash.Resource.Relationships.HasMany do
|
|||
destination_field:
|
||||
"The field on the related resource that should match the `source_field` on this resource. Default: [resource.name]_id",
|
||||
source_field:
|
||||
"The field on this resource that should match the `destination_field` on the related resource.",
|
||||
write_rules: """
|
||||
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.
|
||||
"""
|
||||
"The field on this resource that should match the `destination_field` on the related resource."
|
||||
]
|
||||
)
|
||||
|
||||
|
@ -63,27 +54,9 @@ defmodule Ash.Resource.Relationships.HasMany do
|
|||
# Don't call functions on the resource! We don't want it to compile here
|
||||
case Ashton.validate(opts, @opt_schema) do
|
||||
{:ok, opts} ->
|
||||
write_rules =
|
||||
case opts[:write_rules] 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,
|
||||
%__MODULE__{
|
||||
name: name,
|
||||
write_rules: write_rules,
|
||||
source: resource,
|
||||
type: :has_many,
|
||||
cardinality: :many,
|
||||
|
|
|
@ -9,7 +9,6 @@ defmodule Ash.Resource.Relationships.HasOne do
|
|||
:destination_field,
|
||||
:source_field,
|
||||
:reverse_relationship,
|
||||
:write_rules,
|
||||
:allow_orphans?
|
||||
]
|
||||
|
||||
|
@ -19,7 +18,6 @@ defmodule Ash.Resource.Relationships.HasOne do
|
|||
source: Ash.resource(),
|
||||
name: atom,
|
||||
type: Ash.Type.t(),
|
||||
write_rules: Keyword.t(),
|
||||
destination: Ash.resource(),
|
||||
destination_field: atom,
|
||||
source_field: atom,
|
||||
|
@ -32,12 +30,10 @@ defmodule Ash.Resource.Relationships.HasOne do
|
|||
destination_field: :atom,
|
||||
source_field: :atom,
|
||||
reverse_relationship: :atom,
|
||||
write_rules: :keyword,
|
||||
allow_orphans?: :boolean
|
||||
],
|
||||
defaults: [
|
||||
source_field: :id,
|
||||
write_rules: [],
|
||||
# TODO: When we add constraint expressions, we should validate this with that.
|
||||
allow_orphans?: true
|
||||
],
|
||||
|
@ -50,12 +46,7 @@ defmodule Ash.Resource.Relationships.HasOne do
|
|||
"The field on this resource that should match the `destination_field` on the related resource.",
|
||||
# TODO: Explain this better
|
||||
allow_orphans:
|
||||
"Whether or not to allow orphaned records that would result in replaced relationships.",
|
||||
write_rules: """
|
||||
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.
|
||||
"""
|
||||
"Whether or not to allow orphaned records that would result in replaced relationships."
|
||||
]
|
||||
)
|
||||
|
||||
|
@ -74,23 +65,6 @@ defmodule Ash.Resource.Relationships.HasOne do
|
|||
# Don't call functions on the resource! We don't want it to compile here
|
||||
case Ashton.validate(opts, @opt_schema) do
|
||||
{:ok, opts} ->
|
||||
write_rules =
|
||||
case opts[:write_rules] 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,
|
||||
%__MODULE__{
|
||||
name: name,
|
||||
|
@ -100,8 +74,7 @@ defmodule Ash.Resource.Relationships.HasOne do
|
|||
destination: related_resource,
|
||||
destination_field: opts[:destination_field] || :"#{resource_type}_id",
|
||||
source_field: opts[:source_field],
|
||||
reverse_relationship: opts[:reverse_relationship],
|
||||
write_rules: write_rules
|
||||
reverse_relationship: opts[:reverse_relationship]
|
||||
}}
|
||||
|
||||
{:error, errors} ->
|
||||
|
|
|
@ -10,8 +10,7 @@ defmodule Ash.Resource.Relationships.ManyToMany do
|
|||
:destination_field,
|
||||
:source_field_on_join_table,
|
||||
:destination_field_on_join_table,
|
||||
:reverse_relationship,
|
||||
:write_rules
|
||||
:reverse_relationship
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
|
@ -25,8 +24,7 @@ defmodule Ash.Resource.Relationships.ManyToMany do
|
|||
destination_field: atom,
|
||||
source_field_on_join_table: atom,
|
||||
destination_field_on_join_table: atom,
|
||||
reverse_relationship: atom,
|
||||
write_rules: Keyword.t()
|
||||
reverse_relationship: atom
|
||||
}
|
||||
|
||||
@opt_schema Ashton.schema(
|
||||
|
@ -35,14 +33,12 @@ defmodule Ash.Resource.Relationships.ManyToMany do
|
|||
destination_field_on_join_table: :atom,
|
||||
source_field: :atom,
|
||||
destination_field: :atom,
|
||||
write_rules: :keyword,
|
||||
through: :atom,
|
||||
reverse_relationship: :atom
|
||||
],
|
||||
defaults: [
|
||||
source_field: :id,
|
||||
destination_field: :id,
|
||||
write_rules: []
|
||||
destination_field: :id
|
||||
],
|
||||
required: [
|
||||
:through
|
||||
|
@ -58,12 +54,7 @@ defmodule Ash.Resource.Relationships.ManyToMany do
|
|||
source_field:
|
||||
"The field on this resource that should line up with `source_field_on_join_table` on the join table.",
|
||||
destination_field:
|
||||
"The field on the related resource that should line up with `destination_field_on_join_table` on the join table.",
|
||||
write_rules: """
|
||||
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.
|
||||
"""
|
||||
"The field on the related resource that should line up with `destination_field_on_join_table` on the join table."
|
||||
]
|
||||
)
|
||||
|
||||
|
@ -81,23 +72,6 @@ defmodule Ash.Resource.Relationships.ManyToMany do
|
|||
# Don't call functions on the resource! We don't want it to compile here
|
||||
case Ashton.validate(opts, @opt_schema) do
|
||||
{:ok, opts} ->
|
||||
write_rules =
|
||||
case opts[:write_rules] 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,
|
||||
%__MODULE__{
|
||||
name: name,
|
||||
|
@ -109,7 +83,6 @@ defmodule Ash.Resource.Relationships.ManyToMany do
|
|||
reverse_relationship: opts[:reverse_relationship],
|
||||
source_field: opts[:source_field],
|
||||
destination_field: opts[:destination_field],
|
||||
write_rules: write_rules,
|
||||
source_field_on_join_table:
|
||||
opts[:source_field_on_join_table] || :"#{resource_name}_id",
|
||||
destination_field_on_join_table:
|
||||
|
|
|
@ -4,21 +4,16 @@ defmodule Ash.Resource.Relationships do
|
|||
|
||||
Relationships are a core component of resource oriented design. Many components of Ash
|
||||
will use these relationships. A simple use case is side_loading (done via the `side_load`
|
||||
option, given to an api action). A more complex use case might be building authorization
|
||||
rules that grant access to a resource based on how the user is related to it.
|
||||
option, given to an api action).
|
||||
"""
|
||||
|
||||
@doc false
|
||||
defmacro relationships(do: block) do
|
||||
quote do
|
||||
import Ash.Resource.Relationships
|
||||
import Ash.Authorization.Check.BuiltInChecks
|
||||
import Ash.Authorization.Check.RelationshipBuiltInChecks
|
||||
|
||||
unquote(block)
|
||||
|
||||
import Ash.Authorization.Check.BuiltInChecks, only: []
|
||||
import Ash.Authorization.Check.RelationshipBuiltInChecks, only: []
|
||||
import Ash.Resource.Relationships, only: [relationships: 1]
|
||||
end
|
||||
end
|
||||
|
|
|
@ -47,13 +47,14 @@ defmodule Ash.Type do
|
|||
end
|
||||
end
|
||||
|
||||
@spec supports_filter?(t(), Ash.DataLayer.filter_type(), Ash.data_layer()) :: boolean
|
||||
def supports_filter?(type, filter_type, data_layer) when type in @builtin_names do
|
||||
data_layer.can?({:filter, filter_type}) and filter_type in @builtins[type][:filters]
|
||||
@spec supports_filter?(Ash.resource(), t(), Ash.DataLayer.filter_type(), Ash.data_layer()) ::
|
||||
boolean
|
||||
def supports_filter?(resource, type, filter_type, data_layer) when type in @builtin_names do
|
||||
data_layer.can?(resource, {:filter, filter_type}) and filter_type in @builtins[type][:filters]
|
||||
end
|
||||
|
||||
def supports_filter?(type, filter_type, data_layer) do
|
||||
data_layer.can?({:filter, filter_type}) and
|
||||
def supports_filter?(resource, type, filter_type, data_layer) do
|
||||
data_layer.can?(resource, {:filter, filter_type}) and
|
||||
filter_type in type.supported_filter_types(data_layer)
|
||||
end
|
||||
|
||||
|
|
4
mix.exs
4
mix.exs
|
@ -49,9 +49,7 @@ defmodule Ash.MixProject do
|
|||
{:ecto, "~> 3.0"},
|
||||
{:ets, "~> 0.8.0"},
|
||||
{:ex_doc, "~> 0.21", only: :dev, runtime: false},
|
||||
{:ashton, "~> 0.4.1"},
|
||||
{:picosat_elixir, "~> 0.1.1"},
|
||||
{:machinery, "~> 1.0.0"}
|
||||
{:ashton, "~> 0.4.1"}
|
||||
]
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,261 +0,0 @@
|
|||
defmodule Ash.Test.Authorization.CreateAuthorizationTest do
|
||||
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,
|
||||
rules: [
|
||||
authorize_if: always()
|
||||
]
|
||||
|
||||
create :default,
|
||||
rules: [
|
||||
forbid_unless: setting_relationship(:author),
|
||||
authorize_if: user_attribute(:author, true)
|
||||
]
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :contents, :string, write_rules: false
|
||||
attribute :color, :string, write_rules: false
|
||||
attribute :size, :string, write_rules: false
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :author, Ash.Test.Authorization.CreateAuthorizationTest.Author,
|
||||
write_rules: [
|
||||
authorize_if: relating_to_user()
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Author do
|
||||
use Ash.Resource, name: "authors", type: "author"
|
||||
use Ash.DataLayer.Ets, private?: true
|
||||
|
||||
actions do
|
||||
read :default, rules: [authorize_if: always()]
|
||||
|
||||
create :default,
|
||||
rules: [
|
||||
authorize_if: user_attribute(:admin, true),
|
||||
authorize_if: user_attribute(:manager, true)
|
||||
]
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :name, :string, write_rules: false
|
||||
|
||||
attribute :state, :string,
|
||||
write_rules: [
|
||||
authorize_if: user_attribute(:admin, true),
|
||||
forbid_if: setting(to: "closed"),
|
||||
authorize_if: always()
|
||||
]
|
||||
|
||||
attribute :bio_locked, :boolean,
|
||||
default: {:constant, false},
|
||||
write_rules: false
|
||||
|
||||
attribute :self_manager, :boolean, write_rules: false
|
||||
|
||||
attribute :fired, :boolean, write_rules: false
|
||||
end
|
||||
|
||||
relationships do
|
||||
many_to_many :posts, Ash.Test.Authorization.CreateAuthorizationTest.Post,
|
||||
through: Ash.Test.Authorization.CreateAuthorizationTest.AuthorPost
|
||||
|
||||
has_one :bio, Ash.Test.Authorization.CreateAuthorizationTest.Bio,
|
||||
write_rules: [
|
||||
forbid_if: attribute_equals(:bio_locked, true),
|
||||
authorize_if: always()
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Bio do
|
||||
use Ash.Resource, name: "bios", type: "bio"
|
||||
use Ash.DataLayer.Ets, private?: true
|
||||
|
||||
actions do
|
||||
read :default,
|
||||
rules: [
|
||||
authorize_if: always()
|
||||
]
|
||||
|
||||
create :default,
|
||||
rules: [
|
||||
forbid_unless: setting_relationship(:author),
|
||||
authorize_if: user_attribute(:author, true)
|
||||
]
|
||||
|
||||
update :default,
|
||||
rules: [
|
||||
authorize_if: always()
|
||||
]
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :admin_only?, :boolean,
|
||||
default: {:constant, false},
|
||||
write_rules: [
|
||||
authorize_if: always()
|
||||
]
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :author, Author,
|
||||
write_rules: [
|
||||
authorize_if: relating_to_user()
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
defmodule User do
|
||||
use Ash.Resource, name: "users", type: "user"
|
||||
use Ash.DataLayer.Ets, private?: true
|
||||
|
||||
actions do
|
||||
read :default
|
||||
create :default
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :name, :string
|
||||
attribute :manager, :boolean, default: {:constant, false}
|
||||
attribute :admin, :boolean, default: {:constant, false}
|
||||
attribute :author, :boolean, default: {:constant, false}
|
||||
end
|
||||
end
|
||||
|
||||
defmodule AuthorPost do
|
||||
use Ash.Resource, name: "author_posts", type: "author_post", primary_key: false
|
||||
use Ash.DataLayer.Ets, private?: true
|
||||
|
||||
actions do
|
||||
read :default
|
||||
create :default
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :name, :string
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :post, Ash.Test.Authorization.CreateAuthorizationTest.Post, primary_key?: true
|
||||
belongs_to :author, Author, primary_key?: true
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Post do
|
||||
use Ash.Resource, name: "posts", type: "post"
|
||||
use Ash.DataLayer.Ets, private?: true
|
||||
|
||||
actions do
|
||||
read :default
|
||||
|
||||
create :default,
|
||||
rules: [
|
||||
authorize_if: user_attribute(:admin, true),
|
||||
authorize_if: user_attribute(:manager, true)
|
||||
]
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :title, :string, write_rules: false
|
||||
|
||||
attribute :contents, :string, write_rules: false
|
||||
|
||||
attribute :published, :boolean, write_rules: false
|
||||
end
|
||||
|
||||
relationships do
|
||||
many_to_many :authors, Author, through: AuthorPost
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Api do
|
||||
use Ash.Api
|
||||
|
||||
resources [Post, Author, AuthorPost, User, Draft, Bio]
|
||||
end
|
||||
|
||||
test "should fail if a user does not match the action requirements" do
|
||||
user = Api.create!(User, attributes: %{name: "foo", admin: false, manager: false})
|
||||
|
||||
assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||
Api.create!(Author, attributes: %{name: "foo"}, authorization: [user: user])
|
||||
end
|
||||
end
|
||||
|
||||
test "should fail if a change is not authorized" do
|
||||
user = Api.create!(User, attributes: %{name: "foo", manager: true})
|
||||
|
||||
assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||
Api.create!(Author,
|
||||
attributes: %{name: "foo", state: "closed"},
|
||||
authorization: [user: user]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
test "should succeed if a change is authorized" do
|
||||
user = Api.create!(User, attributes: %{name: "foo", manager: true})
|
||||
|
||||
Api.create!(Author, attributes: %{name: "foo", state: "open"}, authorization: [user: user])
|
||||
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
|
||||
|
||||
test "it forbids has_one relationships properly" do
|
||||
user = Api.create!(User, attributes: %{name: "foo", author: true, manager: true})
|
||||
|
||||
bio = Api.create!(Bio, attributes: %{admin_only?: false})
|
||||
|
||||
assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||
Api.create!(Author,
|
||||
attributes: %{contents: "best ever", bio_locked: true},
|
||||
relationships: %{bio: bio.id},
|
||||
authorization: [user: user]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
test "it allows has_one relationships properly" do
|
||||
user = Api.create!(User, attributes: %{name: "foo", author: true, manager: true})
|
||||
|
||||
bio = Api.create!(Bio, attributes: %{admin_only?: false})
|
||||
|
||||
Api.create!(Author,
|
||||
attributes: %{contents: "best ever"},
|
||||
relationships: %{bio: bio.id},
|
||||
authorization: [user: user]
|
||||
)
|
||||
end
|
||||
end
|
|
@ -1,127 +0,0 @@
|
|||
defmodule Ash.Test.Authorization.GetAuthorizationTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
defmodule Author do
|
||||
use Ash.Resource, name: "authors", type: "author"
|
||||
use Ash.DataLayer.Ets, private?: true
|
||||
|
||||
actions do
|
||||
read :default,
|
||||
rules: [
|
||||
# You can see yourself
|
||||
authorize_if: user_attribute_matches_record(:id, :id),
|
||||
# You can't see anything else unless you're a manager
|
||||
forbid_unless: user_attribute(:manager, true),
|
||||
# No one can see a fired author
|
||||
forbid_if: attribute_equals(:fired, true),
|
||||
# Managers can't see `self_manager` authors
|
||||
authorize_unless: attribute_equals(:self_manager, true)
|
||||
]
|
||||
|
||||
create :default
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :name, :string
|
||||
attribute :self_manager, :boolean
|
||||
attribute :fired, :boolean
|
||||
end
|
||||
|
||||
relationships do
|
||||
many_to_many :posts, Ash.Test.Authorization.AuthorizationTest.Post,
|
||||
through: Ash.Test.Authorization.AuthorizationTest.AuthorPost
|
||||
|
||||
has_many :drafts, Draft
|
||||
end
|
||||
end
|
||||
|
||||
defmodule User do
|
||||
use Ash.Resource, name: "users", type: "user"
|
||||
use Ash.DataLayer.Ets, private?: true
|
||||
|
||||
actions do
|
||||
read :default
|
||||
create :default
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :name, :string
|
||||
attribute :manager, :boolean
|
||||
end
|
||||
end
|
||||
|
||||
defmodule AuthorPost do
|
||||
use Ash.Resource, name: "author_posts", type: "author_post", primary_key: false
|
||||
use Ash.DataLayer.Ets, private?: true
|
||||
|
||||
actions do
|
||||
read :default
|
||||
create :default
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :name, :string
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :post, Ash.Test.Authorization.AuthorizationTest.Post, primary_key?: true
|
||||
belongs_to :author, Author, primary_key?: true
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Post do
|
||||
use Ash.Resource, name: "posts", type: "post"
|
||||
use Ash.DataLayer.Ets, private?: true
|
||||
|
||||
actions do
|
||||
read :default,
|
||||
rules: [
|
||||
authorize_if: attribute_equals(:published, true),
|
||||
authorize_if: related_to_user_via(:authors)
|
||||
]
|
||||
|
||||
create :default
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :title, :string
|
||||
attribute :contents, :string
|
||||
attribute :published, :boolean
|
||||
end
|
||||
|
||||
relationships do
|
||||
has_many :author_posts, AuthorPost
|
||||
many_to_many :authors, Author, through: AuthorPost
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Api do
|
||||
use Ash.Api
|
||||
|
||||
resources [Post, Author, AuthorPost, User]
|
||||
end
|
||||
|
||||
test "it succeeds if you match a strict_check" do
|
||||
author = Api.create!(Author, attributes: %{name: "foo"})
|
||||
user = Api.create!(User, attributes: %{id: author.id})
|
||||
|
||||
Api.get!(Author, author.id, authorization: [user: user])
|
||||
end
|
||||
|
||||
test "it succeeds if you match the data checks" do
|
||||
author = Api.create!(Author, attributes: %{name: "foo", fired: false, self_manager: false})
|
||||
user = Api.create!(User, attributes: %{manager: true})
|
||||
|
||||
Api.get!(Author, author.id, authorization: [user: user])
|
||||
end
|
||||
|
||||
test "it fails if you dont match the data checks" do
|
||||
author = Api.create!(Author, attributes: %{name: "foo", fired: false, self_manager: true})
|
||||
|
||||
user = Api.create!(User, attributes: %{manager: true})
|
||||
|
||||
assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||
Api.get!(Author, author.id, authorization: [user: user])
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,226 +0,0 @@
|
|||
defmodule Ash.Test.Authorization.ReadAuthorizationTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
defmodule Author do
|
||||
use Ash.Resource, name: "authors", type: "author"
|
||||
use Ash.DataLayer.Ets, private?: true
|
||||
|
||||
actions do
|
||||
read :default,
|
||||
rules: [
|
||||
# You can see yourself
|
||||
authorize_if: user_attribute_matches_record(:id, :id),
|
||||
# You can't see anything else unless you're a manager
|
||||
forbid_unless: user_attribute(:manager, true),
|
||||
# No one can see a fired author
|
||||
forbid_if: attribute_equals(:fired, true),
|
||||
# Managers can't see `self_manager` authors
|
||||
authorize_unless: attribute_equals(:self_manager, true)
|
||||
]
|
||||
|
||||
create :default
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :name, :string
|
||||
attribute :self_manager, :boolean
|
||||
attribute :fired, :boolean
|
||||
end
|
||||
|
||||
relationships do
|
||||
many_to_many :posts, Ash.Test.Authorization.AuthorizationTest.Post,
|
||||
through: Ash.Test.Authorization.AuthorizationTest.AuthorPost
|
||||
end
|
||||
end
|
||||
|
||||
defmodule User do
|
||||
use Ash.Resource, name: "users", type: "user"
|
||||
use Ash.DataLayer.Ets, private?: true
|
||||
|
||||
actions do
|
||||
read :default
|
||||
create :default
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :name, :string
|
||||
attribute :manager, :boolean, default: {:constant, false}, allow_nil?: false
|
||||
end
|
||||
end
|
||||
|
||||
defmodule AuthorPost do
|
||||
use Ash.Resource, name: "author_posts", type: "author_post", primary_key: false
|
||||
use Ash.DataLayer.Ets, private?: true
|
||||
|
||||
actions do
|
||||
read :default
|
||||
create :default
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :name, :string
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :post, Ash.Test.Authorization.AuthorizationTest.Post, primary_key?: true
|
||||
belongs_to :author, Author, primary_key?: true
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Post do
|
||||
use Ash.Resource, name: "posts", type: "post"
|
||||
use Ash.DataLayer.Ets, private?: true
|
||||
|
||||
actions do
|
||||
read :default,
|
||||
rules: [
|
||||
authorize_if: attribute_equals(:published, true),
|
||||
authorize_if: related_to_user_via(:authors)
|
||||
]
|
||||
|
||||
create :default
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :title, :string
|
||||
attribute :contents, :string
|
||||
attribute :published, :boolean
|
||||
end
|
||||
|
||||
relationships do
|
||||
has_many :author_posts, AuthorPost
|
||||
many_to_many :authors, Author, through: AuthorPost
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Api do
|
||||
use Ash.Api
|
||||
|
||||
resources [Post, Author, AuthorPost, User]
|
||||
end
|
||||
|
||||
test "it succeeds if you match the first rule" do
|
||||
author = Api.create!(Author, attributes: %{name: "foo"})
|
||||
user = Api.create!(User, attributes: %{id: author.id})
|
||||
|
||||
Post
|
||||
|> Api.query()
|
||||
|> Ash.Query.filter(authors: [id: author.id])
|
||||
|> Api.read!(authorization: [user: user])
|
||||
end
|
||||
|
||||
test "it succeeds if you match the second rule" do
|
||||
user = Api.create!(User)
|
||||
|
||||
Post
|
||||
|> Api.query()
|
||||
|> Ash.Query.filter(published: true)
|
||||
|> Api.read!(authorization: [user: user])
|
||||
end
|
||||
|
||||
test "it succeeds if you match both rules" do
|
||||
author = Api.create!(Author, attributes: %{name: "foo"})
|
||||
user = Api.create!(User, attributes: %{id: author.id})
|
||||
|
||||
Post
|
||||
|> Api.query()
|
||||
|> Ash.Query.filter(published: true, authors: [id: author.id])
|
||||
|> Api.read!(authorization: [user: user])
|
||||
end
|
||||
|
||||
test "it fails if you don't match either" do
|
||||
user = Api.create!(User)
|
||||
|
||||
assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||
Post
|
||||
|> Api.query()
|
||||
|> Ash.Query.filter(published: false)
|
||||
|> Api.read!(authorization: [user: user])
|
||||
end
|
||||
end
|
||||
|
||||
test "it fails if it can't confirm that you match either" do
|
||||
user = Api.create!(User)
|
||||
|
||||
assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||
Api.read!(Post, authorization: [user: user])
|
||||
end
|
||||
end
|
||||
|
||||
test "authorize_if falls through properly" do
|
||||
user = Api.create!(User, attributes: %{manager: true})
|
||||
|
||||
Author
|
||||
|> Api.query()
|
||||
|> Ash.Query.filter(fired: [not_eq: true], self_manager: [not_eq: true])
|
||||
|> Api.read!(authorization: [user: user])
|
||||
end
|
||||
|
||||
test "authorize_unless doesn't trigger if its check is not true" do
|
||||
user = Api.create!(User, attributes: %{manager: true})
|
||||
|
||||
assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||
Author
|
||||
|> Api.query()
|
||||
|> Ash.Query.filter(fired: false, self_manager: true)
|
||||
|> Api.read!(authorization: [user: user])
|
||||
end
|
||||
end
|
||||
|
||||
test "forbid_if triggers if its check is true" do
|
||||
user = Api.create!(User, attributes: %{manager: true})
|
||||
|
||||
assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||
Author
|
||||
|> Api.query()
|
||||
|> Ash.Query.filter(fired: true, self_manager: false)
|
||||
|> Api.read!(authorization: [user: user])
|
||||
end
|
||||
end
|
||||
|
||||
test "forbid_unless doesn't trigger if its check is true" do
|
||||
user = Api.create!(User, attributes: %{manager: false})
|
||||
|
||||
assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||
Author
|
||||
|> Api.query()
|
||||
|> Ash.Query.filter(fired: false, self_manager: false)
|
||||
|> Api.read!(authorization: [user: user])
|
||||
end
|
||||
end
|
||||
|
||||
test "it can handle conflicting results" do
|
||||
author = Api.create!(Author, attributes: %{name: "foo", fired: false, self_manager: true})
|
||||
|
||||
Api.create!(Author, attributes: %{name: "foo", fired: false, self_manager: false})
|
||||
|
||||
user = Api.create!(User, attributes: %{manager: true, id: author.id})
|
||||
|
||||
Author
|
||||
|> Api.query()
|
||||
|> Ash.Query.filter(fired: false, self_manager: false)
|
||||
|> Api.read!(authorization: [user: user, bypass_strict_access?: true])
|
||||
end
|
||||
|
||||
test "it fails properly on conflicting results" do
|
||||
author = Api.create!(Author, attributes: %{name: "foo", fired: false, self_manager: true})
|
||||
Api.create!(Author, attributes: %{name: "foo", fired: false, self_manager: false})
|
||||
user = Api.create!(User, attributes: %{manager: false, id: author.id})
|
||||
|
||||
assert_raise Ash.Error.Forbidden, ~r/forbidden/, fn ->
|
||||
Api.read!(Author,
|
||||
authorization: [user: user, bypass_strict_access?: true]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
test "it handles authorizing destination records properly" do
|
||||
author = Api.create!(Author, attributes: %{name: "foo"})
|
||||
user = Api.create!(User, attributes: %{manager: true})
|
||||
|
||||
Post
|
||||
|> Api.query()
|
||||
|> Ash.Query.filter(published: true, authors: [id: author.id])
|
||||
|> Api.read!(authorization: [user: user])
|
||||
end
|
||||
end
|
|
@ -23,7 +23,6 @@ defmodule Ash.Test.Dsl.Resource.Actions.CreateTest do
|
|||
%Ash.Resource.Actions.Create{
|
||||
name: :default,
|
||||
primary?: true,
|
||||
rules: [],
|
||||
type: :create
|
||||
}
|
||||
] = Ash.actions(Post)
|
||||
|
@ -58,19 +57,5 @@ defmodule Ash.Test.Dsl.Resource.Actions.CreateTest do
|
|||
end
|
||||
)
|
||||
end
|
||||
|
||||
test "it fails if `rules` is not a list" do
|
||||
assert_raise(
|
||||
Ash.Error.ResourceDslError,
|
||||
"option rules at actions -> create -> default must be keyword",
|
||||
fn ->
|
||||
defposts do
|
||||
actions do
|
||||
create :default, rules: 10
|
||||
end
|
||||
end
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -23,7 +23,6 @@ defmodule Ash.Test.Dsl.Resource.Actions.DestroyTest do
|
|||
%Ash.Resource.Actions.Destroy{
|
||||
name: :default,
|
||||
primary?: true,
|
||||
rules: [],
|
||||
type: :destroy
|
||||
}
|
||||
] = Ash.actions(Post)
|
||||
|
@ -58,19 +57,5 @@ defmodule Ash.Test.Dsl.Resource.Actions.DestroyTest do
|
|||
end
|
||||
)
|
||||
end
|
||||
|
||||
test "it fails if `rules` is not a list" do
|
||||
assert_raise(
|
||||
Ash.Error.ResourceDslError,
|
||||
"option rules at actions -> destroy -> default must be keyword",
|
||||
fn ->
|
||||
defposts do
|
||||
actions do
|
||||
destroy :default, rules: 10
|
||||
end
|
||||
end
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -23,7 +23,6 @@ defmodule Ash.Test.Dsl.Resource.Actions.ReadTest do
|
|||
%Ash.Resource.Actions.Read{
|
||||
name: :default,
|
||||
primary?: true,
|
||||
rules: [],
|
||||
type: :read
|
||||
}
|
||||
] = Ash.actions(Post)
|
||||
|
@ -58,19 +57,5 @@ defmodule Ash.Test.Dsl.Resource.Actions.ReadTest do
|
|||
end
|
||||
)
|
||||
end
|
||||
|
||||
test "it fails if `rules` is not a list" do
|
||||
assert_raise(
|
||||
Ash.Error.ResourceDslError,
|
||||
"option rules at actions -> read -> default must be keyword",
|
||||
fn ->
|
||||
defposts do
|
||||
actions do
|
||||
read :default, rules: 10
|
||||
end
|
||||
end
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -23,7 +23,6 @@ defmodule Ash.Test.Dsl.Resource.Actions.UpdateTest do
|
|||
%Ash.Resource.Actions.Update{
|
||||
name: :default,
|
||||
primary?: true,
|
||||
rules: [],
|
||||
type: :update
|
||||
}
|
||||
] = Ash.actions(Post)
|
||||
|
@ -58,19 +57,5 @@ defmodule Ash.Test.Dsl.Resource.Actions.UpdateTest do
|
|||
end
|
||||
)
|
||||
end
|
||||
|
||||
test "it fails if `rules` is not a list" do
|
||||
assert_raise(
|
||||
Ash.Error.ResourceDslError,
|
||||
"option rules at actions -> update -> default must be keyword",
|
||||
fn ->
|
||||
defposts do
|
||||
actions do
|
||||
update :default, rules: 10
|
||||
end
|
||||
end
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -87,8 +87,7 @@ defmodule Ash.Test.Resource.AttributesTest do
|
|||
primary_key?: false,
|
||||
type: :utc_datetime,
|
||||
update_default: ^default,
|
||||
writable?: true,
|
||||
write_rules: []
|
||||
writable?: true
|
||||
},
|
||||
%Ash.Resource.Attributes.Attribute{
|
||||
allow_nil?: true,
|
||||
|
@ -98,8 +97,7 @@ defmodule Ash.Test.Resource.AttributesTest do
|
|||
primary_key?: false,
|
||||
type: :utc_datetime,
|
||||
update_default: nil,
|
||||
writable?: true,
|
||||
write_rules: []
|
||||
writable?: true
|
||||
}
|
||||
] = Ash.attributes(Post)
|
||||
end
|
||||
|
@ -122,8 +120,7 @@ defmodule Ash.Test.Resource.AttributesTest do
|
|||
primary_key?: false,
|
||||
type: :utc_datetime,
|
||||
update_default: ^default,
|
||||
writable?: true,
|
||||
write_rules: []
|
||||
writable?: true
|
||||
},
|
||||
%Ash.Resource.Attributes.Attribute{
|
||||
allow_nil?: true,
|
||||
|
@ -133,8 +130,7 @@ defmodule Ash.Test.Resource.AttributesTest do
|
|||
primary_key?: false,
|
||||
type: :utc_datetime,
|
||||
update_default: nil,
|
||||
writable?: true,
|
||||
write_rules: []
|
||||
writable?: true
|
||||
}
|
||||
] = Ash.attributes(Post)
|
||||
end
|
||||
|
|
|
@ -28,7 +28,6 @@ defmodule Ash.Test.Resource.Relationships.ManyToManyTest do
|
|||
source: Ash.Test.Resource.Relationships.ManyToManyTest.Post,
|
||||
source_field: :id,
|
||||
type: :has_many,
|
||||
write_rules: '',
|
||||
reverse_relationship: nil
|
||||
},
|
||||
%Ash.Resource.Relationships.ManyToMany{
|
||||
|
@ -42,7 +41,6 @@ defmodule Ash.Test.Resource.Relationships.ManyToManyTest do
|
|||
source_field_on_join_table: :posts_id,
|
||||
through: SomeResource,
|
||||
type: :many_to_many,
|
||||
write_rules: '',
|
||||
reverse_relationship: nil
|
||||
}
|
||||
] = Ash.relationships(Post)
|
||||
|
|
Loading…
Reference in a new issue