mirror of
https://github.com/ash-project/ash.git
synced 2024-09-19 13:03:02 +12:00
feat: configurable multitenancy on read actions (#1030)
Allow making specific read actions able to optionally or totally bypass multitenancy
This commit is contained in:
parent
7c189ede32
commit
6d209e8836
6 changed files with 219 additions and 89 deletions
|
@ -135,6 +135,7 @@ spark_locals_without_parens = [
|
|||
min: 4,
|
||||
modify_query: 1,
|
||||
module: 1,
|
||||
multitenancy: 1,
|
||||
name: 1,
|
||||
no_attributes?: 1,
|
||||
not_found_error?: 1,
|
||||
|
|
|
@ -1291,6 +1291,7 @@ end
|
|||
| [`modify_query`](#actions-read-modify_query){: #actions-read-modify_query } | `mfa \| (any, any -> any)` | | Allows direct manipulation of the data layer query via an MFA. The ash query and the data layer query will be provided as additional arguments. The result must be `{:ok, new_data_layer_query} \| {:error, error}`. |
|
||||
| [`get_by`](#actions-read-get_by){: #actions-read-get_by } | `atom \| list(atom)` | | A helper to automatically generate a "get by X" action. Sets `get?` to true, add args for each of the specified fields, and adds a filter for each of the arguments. |
|
||||
| [`timeout`](#actions-read-timeout){: #actions-read-timeout } | `pos_integer` | | The maximum amount of time, in milliseconds, that the action is allowed to run for. Ignored if the data layer doesn't support transactions *and* async is disabled. |
|
||||
| [`multitenancy`](#actions-read-multitenancy){: #actions-read-multitenancy } | `:enforce \| :allow_global \| :bypass` | `:enforce` | This setting defines how this action handles multitenancy. `:enforce` requires a tenant to be set (the default behavior), `:allow_global` allows using this action both with and without a tenant, `:bypass` completely ignores the tenant even if it's set. This is useful to change the behaviour of selected read action without the need of marking the whole resource with `global? true`. |
|
||||
| [`primary?`](#actions-read-primary?){: #actions-read-primary? } | `boolean` | `false` | Whether or not this action should be used when no action is specified by the caller. |
|
||||
| [`description`](#actions-read-description){: #actions-read-description } | `String.t` | | An optional description for the action |
|
||||
| [`transaction?`](#actions-read-transaction?){: #actions-read-transaction? } | `boolean` | | Whether or not the action should be run in transactions. Reads default to false, while create/update/destroy actions default to `true`. |
|
||||
|
|
|
@ -6,101 +6,95 @@ defmodule Ash.Actions.Aggregate do
|
|||
query = Ash.Query.new(query)
|
||||
query = %{query | domain: domain}
|
||||
{query, opts} = Ash.Actions.Helpers.set_context_and_get_opts(query.domain, query, opts)
|
||||
query = Ash.Actions.Read.handle_attribute_multitenancy(query)
|
||||
|
||||
if query.valid? do
|
||||
with :ok <- Ash.Actions.Read.validate_multitenancy(query) do
|
||||
aggregates
|
||||
|> Enum.group_by(fn
|
||||
%Ash.Query.Aggregate{} = aggregate ->
|
||||
agg_authorize? = aggregate.authorize? && opts[:authorize?]
|
||||
aggregates
|
||||
|> Enum.group_by(fn
|
||||
%Ash.Query.Aggregate{} = aggregate ->
|
||||
agg_authorize? = aggregate.authorize? && opts[:authorize?]
|
||||
|
||||
read_action =
|
||||
aggregate.read_action || (query.action && query.action.name) ||
|
||||
Ash.Resource.Info.primary_action!(query.resource, :read).name
|
||||
read_action =
|
||||
aggregate.read_action || (query.action && query.action.name) ||
|
||||
Ash.Resource.Info.primary_action!(query.resource, :read).name
|
||||
|
||||
{agg_authorize?, read_action}
|
||||
{agg_authorize?, read_action}
|
||||
|
||||
{_name, _kind} ->
|
||||
{!!opts[:authorize?],
|
||||
opts[:read_action] || opts[:action] || (query.action && query.action.name) ||
|
||||
Ash.Resource.Info.primary_action!(query.resource, :read).name}
|
||||
{_name, _kind} ->
|
||||
{!!opts[:authorize?],
|
||||
opts[:read_action] || opts[:action] || (query.action && query.action.name) ||
|
||||
Ash.Resource.Info.primary_action!(query.resource, :read).name}
|
||||
|
||||
{_name, _kind, agg_opts} ->
|
||||
authorize? =
|
||||
Keyword.get(agg_opts, :authorize?, true) && opts[:authorize?]
|
||||
{_name, _kind, agg_opts} ->
|
||||
authorize? =
|
||||
Keyword.get(agg_opts, :authorize?, true) && opts[:authorize?]
|
||||
|
||||
{authorize?,
|
||||
agg_opts[:read_action] || opts[:read_action] || agg_opts[:action] || opts[:action] ||
|
||||
(query.action && query.action.name) ||
|
||||
Ash.Resource.Info.primary_action!(query.resource, :read).name}
|
||||
end)
|
||||
|> Enum.reduce_while({:ok, %{}}, fn
|
||||
{{agg_authorize?, read_action}, aggregates}, {:ok, acc} ->
|
||||
query =
|
||||
if query.__validated_for_action__ == read_action do
|
||||
query
|
||||
else
|
||||
Ash.Query.for_read(query, read_action, %{},
|
||||
tenant: opts[:tenant],
|
||||
actor: opts[:actor],
|
||||
authorize?: opts[:authorize?]
|
||||
)
|
||||
end
|
||||
{authorize?,
|
||||
agg_opts[:read_action] || opts[:read_action] || agg_opts[:action] || opts[:action] ||
|
||||
(query.action && query.action.name) ||
|
||||
Ash.Resource.Info.primary_action!(query.resource, :read).name}
|
||||
end)
|
||||
|> Enum.reduce_while({:ok, %{}}, fn
|
||||
{{agg_authorize?, read_action}, aggregates}, {:ok, acc} ->
|
||||
query =
|
||||
if query.__validated_for_action__ == read_action do
|
||||
query
|
||||
else
|
||||
Ash.Query.for_read(query, read_action, %{},
|
||||
tenant: opts[:tenant],
|
||||
actor: opts[:actor],
|
||||
authorize?: opts[:authorize?]
|
||||
)
|
||||
end
|
||||
|
||||
query = %{query | domain: domain}
|
||||
query = %{query | domain: domain}
|
||||
|
||||
Ash.Tracer.span :action,
|
||||
Ash.Domain.Info.span_name(query.domain, query.resource, :aggregate),
|
||||
opts[:tracer] do
|
||||
metadata = %{
|
||||
domain: query.domain,
|
||||
resource: query.resource,
|
||||
resource_short_name: Ash.Resource.Info.short_name(query.resource),
|
||||
aggregates: List.wrap(aggregates),
|
||||
actor: opts[:actor],
|
||||
tenant: opts[:tenant],
|
||||
action: read_action,
|
||||
authorize?: opts[:authorize?]
|
||||
}
|
||||
Ash.Tracer.span :action,
|
||||
Ash.Domain.Info.span_name(query.domain, query.resource, :aggregate),
|
||||
opts[:tracer] do
|
||||
metadata = %{
|
||||
domain: query.domain,
|
||||
resource: query.resource,
|
||||
resource_short_name: Ash.Resource.Info.short_name(query.resource),
|
||||
aggregates: List.wrap(aggregates),
|
||||
actor: opts[:actor],
|
||||
tenant: opts[:tenant],
|
||||
action: read_action,
|
||||
authorize?: opts[:authorize?]
|
||||
}
|
||||
|
||||
Ash.Tracer.telemetry_span [
|
||||
:ash,
|
||||
Ash.Domain.Info.short_name(query.domain),
|
||||
:aggregate
|
||||
],
|
||||
metadata do
|
||||
Ash.Tracer.set_metadata(opts[:tracer], :action, metadata)
|
||||
Ash.Tracer.telemetry_span [
|
||||
:ash,
|
||||
Ash.Domain.Info.short_name(query.domain),
|
||||
:aggregate
|
||||
],
|
||||
metadata do
|
||||
Ash.Tracer.set_metadata(opts[:tracer], :action, metadata)
|
||||
|
||||
with {:ok, query} <- authorize_query(query, opts, agg_authorize?),
|
||||
{:ok, aggregates} <- validate_aggregates(query, aggregates, opts),
|
||||
{:ok, data_layer_query} <-
|
||||
Ash.Query.data_layer_query(%Ash.Query{
|
||||
resource: query.resource,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
domain: query.domain,
|
||||
tenant: query.tenant,
|
||||
to_tenant: query.to_tenant
|
||||
}),
|
||||
{:ok, result} <-
|
||||
Ash.DataLayer.run_aggregate_query(
|
||||
data_layer_query,
|
||||
aggregates,
|
||||
query.resource
|
||||
) do
|
||||
{:cont, {:ok, Map.merge(acc, result)}}
|
||||
else
|
||||
{:error, error} ->
|
||||
{:halt, {:error, error}}
|
||||
end
|
||||
end
|
||||
with {:ok, query} <- Ash.Actions.Read.handle_multitenancy(query),
|
||||
{:ok, query} <- authorize_query(query, opts, agg_authorize?),
|
||||
{:ok, aggregates} <- validate_aggregates(query, aggregates, opts),
|
||||
{:ok, data_layer_query} <-
|
||||
Ash.Query.data_layer_query(%Ash.Query{
|
||||
resource: query.resource,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
domain: query.domain,
|
||||
tenant: query.tenant,
|
||||
to_tenant: query.to_tenant
|
||||
}),
|
||||
{:ok, result} <-
|
||||
Ash.DataLayer.run_aggregate_query(
|
||||
data_layer_query,
|
||||
aggregates,
|
||||
query.resource
|
||||
) do
|
||||
{:cont, {:ok, Map.merge(acc, result)}}
|
||||
else
|
||||
{:error, error} ->
|
||||
{:halt, {:error, error}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
else
|
||||
{:error, query}
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp merge_query(left, right) do
|
||||
|
|
|
@ -318,8 +318,7 @@ defmodule Ash.Actions.Read do
|
|||
|
||||
defp do_read(%{action: action} = query, calculations_at_runtime, calculations_in_query, opts) do
|
||||
maybe_in_transaction(query, opts, fn ->
|
||||
with %{valid?: true} = query <- handle_attribute_multitenancy(query),
|
||||
:ok <- validate_multitenancy(query),
|
||||
with {:ok, %{valid?: true} = query} <- handle_multitenancy(query),
|
||||
{:ok, sort} <-
|
||||
Ash.Actions.Sort.process(
|
||||
query.resource,
|
||||
|
@ -1143,7 +1142,26 @@ defmodule Ash.Actions.Read do
|
|||
end
|
||||
|
||||
@doc false
|
||||
def handle_attribute_multitenancy(query) do
|
||||
def handle_multitenancy(query) do
|
||||
action_multitenancy = get_action(query.resource, query.action).multitenancy
|
||||
|
||||
case action_multitenancy do
|
||||
:enforce ->
|
||||
query = handle_attribute_multitenancy(query)
|
||||
|
||||
with :ok <- validate_multitenancy(query) do
|
||||
{:ok, query}
|
||||
end
|
||||
|
||||
:allow_global ->
|
||||
{:ok, handle_attribute_multitenancy(query)}
|
||||
|
||||
:bypass ->
|
||||
{:ok, %{query | tenant: nil, to_tenant: nil}}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_attribute_multitenancy(query) do
|
||||
multitenancy_attribute = Ash.Resource.Info.multitenancy_attribute(query.resource)
|
||||
|
||||
if multitenancy_attribute && query.tenant do
|
||||
|
@ -1180,8 +1198,7 @@ defmodule Ash.Actions.Read do
|
|||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
def validate_multitenancy(query) do
|
||||
defp validate_multitenancy(query) do
|
||||
if is_nil(Ash.Resource.Info.multitenancy_strategy(query.resource)) ||
|
||||
Ash.Resource.Info.multitenancy_global?(query.resource) || query.tenant do
|
||||
:ok
|
||||
|
|
|
@ -10,6 +10,7 @@ defmodule Ash.Resource.Actions.Read do
|
|||
manual: nil,
|
||||
metadata: [],
|
||||
modify_query: nil,
|
||||
multitenancy: nil,
|
||||
name: nil,
|
||||
pagination: nil,
|
||||
preparations: [],
|
||||
|
@ -29,6 +30,7 @@ defmodule Ash.Resource.Actions.Read do
|
|||
manual: atom | {atom, Keyword.t()} | nil,
|
||||
metadata: [Ash.Resource.Actions.Metadata.t()],
|
||||
modify_query: nil | mfa,
|
||||
multitenancy: atom,
|
||||
name: atom,
|
||||
pagination: any,
|
||||
primary?: boolean,
|
||||
|
@ -77,6 +79,13 @@ defmodule Ash.Resource.Actions.Read do
|
|||
doc: """
|
||||
The maximum amount of time, in milliseconds, that the action is allowed to run for. Ignored if the data layer doesn't support transactions *and* async is disabled.
|
||||
"""
|
||||
],
|
||||
multitenancy: [
|
||||
type: {:in, [:enforce, :allow_global, :bypass]},
|
||||
default: :enforce,
|
||||
doc: """
|
||||
This setting defines how this action handles multitenancy. `:enforce` requires a tenant to be set (the default behavior), `:allow_global` allows using this action both with and without a tenant, `:bypass` completely ignores the tenant even if it's set. This is useful to change the behaviour of selected read action without the need of marking the whole resource with `global? true`.
|
||||
"""
|
||||
]
|
||||
],
|
||||
@global_opts,
|
||||
|
|
|
@ -21,6 +21,14 @@ defmodule Ash.Actions.MultitenancyTest do
|
|||
actions do
|
||||
default_accept :*
|
||||
defaults [:read, :destroy, create: :*, update: :*]
|
||||
|
||||
read :allow_global do
|
||||
multitenancy(:allow_global)
|
||||
end
|
||||
|
||||
read :bypass_tenant do
|
||||
multitenancy(:bypass)
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
|
@ -99,6 +107,14 @@ defmodule Ash.Actions.MultitenancyTest do
|
|||
actions do
|
||||
default_accept :*
|
||||
defaults [:read, :destroy, create: :*, update: :*]
|
||||
|
||||
read :allow_global do
|
||||
multitenancy(:allow_global)
|
||||
end
|
||||
|
||||
read :bypass_tenant do
|
||||
multitenancy(:bypass)
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
|
@ -255,6 +271,59 @@ defmodule Ash.Actions.MultitenancyTest do
|
|||
assert User |> Ash.Query.set_tenant(tenant2) |> Ash.read!() == []
|
||||
end
|
||||
|
||||
test "supports :allow_global multitenancy on the read action", %{
|
||||
tenant1: tenant1,
|
||||
tenant2: tenant2
|
||||
} do
|
||||
user1 =
|
||||
User
|
||||
|> Ash.Changeset.for_create(:create, %{}, tenant: tenant1)
|
||||
|> Ash.create!()
|
||||
|
||||
user2 =
|
||||
User
|
||||
|> Ash.Changeset.for_create(:create, %{}, tenant: tenant2)
|
||||
|> Ash.create!()
|
||||
|
||||
assert [fetched_user1, fetched_user2] =
|
||||
User
|
||||
|> Ash.Query.for_read(:allow_global)
|
||||
|> Ash.read!()
|
||||
|
||||
assert Enum.sort([fetched_user1.id, fetched_user2.id]) == Enum.sort([user1.id, user2.id])
|
||||
|
||||
assert [fetched_user1] =
|
||||
User
|
||||
|> Ash.Query.for_read(:allow_global)
|
||||
|> Ash.Query.set_tenant(tenant1)
|
||||
|> Ash.read!()
|
||||
|
||||
assert fetched_user1.id == user1.id
|
||||
end
|
||||
|
||||
test "supports :bypass multitenancy on the read action", %{
|
||||
tenant1: tenant1,
|
||||
tenant2: tenant2
|
||||
} do
|
||||
user1 =
|
||||
User
|
||||
|> Ash.Changeset.for_create(:create, %{}, tenant: tenant1)
|
||||
|> Ash.create!()
|
||||
|
||||
user2 =
|
||||
User
|
||||
|> Ash.Changeset.for_create(:create, %{}, tenant: tenant2)
|
||||
|> Ash.create!()
|
||||
|
||||
assert [fetched_user1, fetched_user2] =
|
||||
User
|
||||
|> Ash.Query.for_read(:bypass_tenant)
|
||||
|> Ash.Query.set_tenant(tenant1)
|
||||
|> Ash.read!()
|
||||
|
||||
assert Enum.sort([fetched_user1.id, fetched_user2.id]) == Enum.sort([user1.id, user2.id])
|
||||
end
|
||||
|
||||
test "a record written to one tenant cannot be read from another with aggregate queries", %{
|
||||
tenant1: tenant1,
|
||||
tenant2: tenant2
|
||||
|
@ -349,5 +418,44 @@ defmodule Ash.Actions.MultitenancyTest do
|
|||
result = User |> Ash.count()
|
||||
assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Invalid.TenantRequired{}]}} = result
|
||||
end
|
||||
|
||||
test "supports :allow_global multitenancy on the read action", %{
|
||||
tenant1: tenant1,
|
||||
tenant2: tenant2
|
||||
} do
|
||||
comment1 =
|
||||
Comment
|
||||
|> Ash.Changeset.for_create(:create, %{}, tenant: tenant1)
|
||||
|> Ash.create!()
|
||||
|
||||
_comment2 =
|
||||
Comment
|
||||
|> Ash.Changeset.for_create(:create, %{}, tenant: tenant2)
|
||||
|> Ash.create!()
|
||||
|
||||
# We can't actually read all the posts because the ETS data layer
|
||||
# can't query across contextual tenants, but the read action
|
||||
# doesn't raise Ash.Error.Invalid.TenantRequired
|
||||
Comment
|
||||
|> Ash.Query.for_read(:allow_global)
|
||||
|> Ash.read!()
|
||||
|
||||
assert [fetched_comment1] =
|
||||
Comment
|
||||
|> Ash.Query.for_read(:allow_global)
|
||||
|> Ash.Query.set_tenant(tenant1)
|
||||
|> Ash.read!()
|
||||
|
||||
assert fetched_comment1.id == comment1.id
|
||||
end
|
||||
|
||||
test "supports :bypass multitenancy on the read action" do
|
||||
# We can't actually read all the posts because the ETS data layer
|
||||
# can't query across contextual tenants, but the read action
|
||||
# doesn't raise Ash.Error.Invalid.TenantRequired
|
||||
Comment
|
||||
|> Ash.Query.for_read(:bypass_tenant)
|
||||
|> Ash.read!()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue