This commit is contained in:
Zach Daniel 2020-05-20 18:59:58 -04:00
parent b876f87e23
commit 1ed9d3c5fa
No known key found for this signature in database
GPG key ID: C377365383138D4B
65 changed files with 968 additions and 4566 deletions

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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}`"
)

View file

@ -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

View file

@ -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 =

View file

@ -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}

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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()) ::

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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}

View file

@ -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)

View file

@ -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

View file

@ -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} ->

View file

@ -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} ->

View file

@ -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} ->

View file

@ -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} ->

View file

@ -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?],

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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} ->

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)