feat: configurable multitenancy on read actions (#1030)

Allow making specific read actions able to optionally or totally bypass
multitenancy
This commit is contained in:
Riccardo Binetti 2024-04-16 13:09:13 +02:00 committed by GitHub
parent 7c189ede32
commit 6d209e8836
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 219 additions and 89 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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