mirror of
https://github.com/ash-project/ash.git
synced 2024-09-20 13:33:20 +12:00
e4980d55ba
the attribute strategy allowed for overwriting the multitenant attribute on update. In practice, this can't really happen using any standard pattern because any record to be updated is read with the tenant context, but it still represents a small risk (and `schema` based multitenancy would enforce it in this way anyway, so this is more consistent).
533 lines
14 KiB
Elixir
533 lines
14 KiB
Elixir
defmodule Ash.Actions.MultitenancyTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
require Ash.Query
|
|
|
|
alias Ash.Test.Domain, as: Domain
|
|
|
|
defmodule User do
|
|
@moduledoc false
|
|
use Ash.Resource, domain: Domain, data_layer: Ash.DataLayer.Ets
|
|
|
|
ets do
|
|
private?(true)
|
|
end
|
|
|
|
multitenancy do
|
|
strategy(:attribute)
|
|
attribute(:org_id)
|
|
end
|
|
|
|
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
|
|
uuid_primary_key :id
|
|
|
|
attribute :name, :string do
|
|
public?(true)
|
|
end
|
|
|
|
attribute :org_id, :uuid do
|
|
public?(true)
|
|
end
|
|
end
|
|
|
|
relationships do
|
|
has_many :posts, Ash.Actions.MultitenancyTest.Post,
|
|
destination_attribute: :author_id,
|
|
public?: true
|
|
|
|
has_many :comments, Ash.Actions.MultitenancyTest.Comment,
|
|
destination_attribute: :commenter_id,
|
|
public?: true
|
|
end
|
|
end
|
|
|
|
defmodule Post do
|
|
@moduledoc false
|
|
use Ash.Resource, domain: Domain, data_layer: Ash.DataLayer.Ets
|
|
|
|
ets do
|
|
private?(true)
|
|
end
|
|
|
|
attributes do
|
|
uuid_primary_key :id
|
|
|
|
attribute :name, :string do
|
|
public?(true)
|
|
end
|
|
|
|
attribute :org_id, :uuid do
|
|
public?(true)
|
|
end
|
|
end
|
|
|
|
actions do
|
|
default_accept :*
|
|
defaults [:read, :destroy, create: :*, update: :*]
|
|
end
|
|
|
|
relationships do
|
|
has_many :comments, Ash.Actions.MultitenancyTest.Comment,
|
|
destination_attribute: :post_id,
|
|
public?: true
|
|
|
|
belongs_to :author, User do
|
|
public?(true)
|
|
end
|
|
end
|
|
end
|
|
|
|
defmodule Comment do
|
|
@doc false
|
|
|
|
use Ash.Resource, domain: Domain, data_layer: Ash.DataLayer.Ets
|
|
|
|
ets do
|
|
private?(true)
|
|
end
|
|
|
|
multitenancy do
|
|
strategy(:context)
|
|
global?(false)
|
|
end
|
|
|
|
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
|
|
uuid_primary_key :id
|
|
|
|
attribute :name, :string do
|
|
public?(true)
|
|
end
|
|
|
|
attribute :org_id, :uuid do
|
|
public?(true)
|
|
end
|
|
end
|
|
|
|
relationships do
|
|
belongs_to :commenter, User do
|
|
public?(true)
|
|
end
|
|
|
|
belongs_to :post, Post do
|
|
public?(true)
|
|
end
|
|
end
|
|
end
|
|
|
|
defmodule Tenant do
|
|
@doc false
|
|
|
|
use Ash.Resource, domain: Domain, data_layer: Ash.DataLayer.Ets
|
|
|
|
ets do
|
|
private?(true)
|
|
end
|
|
|
|
actions do
|
|
default_accept :*
|
|
defaults [:read, :destroy, create: :*, update: :*]
|
|
end
|
|
|
|
attributes do
|
|
integer_primary_key :id, writable?: true
|
|
end
|
|
|
|
defimpl Ash.ToTenant do
|
|
def to_tenant(tenant, _resource), do: tenant.id
|
|
end
|
|
end
|
|
|
|
defmodule OtherThingName do
|
|
use Ash.Resource.Calculation
|
|
|
|
def load(_, _, _) do
|
|
[other_thing: [:name]]
|
|
end
|
|
|
|
def calculate(records, _, _) do
|
|
Enum.map(records, &(&1.other_thing && &1.other_thing.name))
|
|
end
|
|
end
|
|
|
|
defmodule OtherThingNameReversed do
|
|
use Ash.Resource.Calculation
|
|
|
|
def calculate(records, _, _) do
|
|
# Normally you would just load :other_thing in the load callback
|
|
# This simulates cases where you're conditionally loading based on
|
|
# runtime conditions so you do it manually in the calculation
|
|
records
|
|
|> Ash.load!(:other_thing_name)
|
|
|> Enum.map(&(&1.other_thing_name && String.reverse(&1.other_thing_name)))
|
|
end
|
|
end
|
|
|
|
defmodule MultitenantThing do
|
|
@doc false
|
|
|
|
use Ash.Resource, domain: Domain, data_layer: Ash.DataLayer.Ets
|
|
|
|
ets do
|
|
private?(true)
|
|
end
|
|
|
|
multitenancy do
|
|
strategy(:attribute)
|
|
attribute(:tenant_id)
|
|
parse_attribute {MultitenantThing, :parse_tenant, []}
|
|
end
|
|
|
|
actions do
|
|
default_accept :*
|
|
defaults [:read, :destroy, create: :*, update: :*]
|
|
end
|
|
|
|
attributes do
|
|
uuid_primary_key :id
|
|
|
|
attribute :tenant_id, :string do
|
|
public?(true)
|
|
end
|
|
|
|
attribute :name, :string do
|
|
public?(true)
|
|
end
|
|
end
|
|
|
|
relationships do
|
|
belongs_to :other_thing, MultitenantThing, public?: true
|
|
end
|
|
|
|
calculations do
|
|
calculate :other_thing_name, :string, OtherThingName, public?: true
|
|
calculate :other_thing_name_reversed, :string, OtherThingNameReversed, public?: true
|
|
end
|
|
|
|
def parse_tenant(id), do: "tenant_#{id}"
|
|
end
|
|
|
|
describe "ToTenant and parse_attribute are applied in the correct order" do
|
|
setup do
|
|
tenant1 =
|
|
Tenant
|
|
|> Ash.Changeset.for_create(:create, %{id: 1})
|
|
|> Ash.create!()
|
|
|
|
tenant2 =
|
|
Tenant
|
|
|> Ash.Changeset.for_create(:create, %{id: 2})
|
|
|> Ash.create!()
|
|
|
|
%{tenant1: tenant1, tenant2: tenant2}
|
|
end
|
|
|
|
test "with tenant on changeset and query", %{tenant1: tenant1, tenant2: tenant2} do
|
|
thing =
|
|
MultitenantThing
|
|
|> Ash.Changeset.for_create(:create, %{name: "foo"}, tenant: tenant1)
|
|
|> Ash.create!()
|
|
|
|
thing
|
|
|> Ash.Changeset.for_update(:update, %{name: "bar"}, tenant: tenant1)
|
|
|> Ash.update!()
|
|
|
|
assert [%{tenant_id: "tenant_1", name: "bar"}] =
|
|
MultitenantThing
|
|
|> Ash.Query.set_tenant(tenant1)
|
|
|> Ash.read!()
|
|
|
|
assert MultitenantThing |> Ash.Query.set_tenant(tenant2) |> Ash.read!() == []
|
|
end
|
|
|
|
test "with tenant in options", %{tenant1: tenant1, tenant2: tenant2} do
|
|
thing =
|
|
MultitenantThing
|
|
|> Ash.Changeset.for_create(:create, %{name: "foo"})
|
|
|> Ash.create!(tenant: tenant1)
|
|
|
|
thing
|
|
|> Ash.Changeset.for_update(:update, %{name: "bar"})
|
|
|> Ash.update!(tenant: tenant1)
|
|
|
|
assert [%{tenant_id: "tenant_1", name: "bar"}] =
|
|
MultitenantThing
|
|
|> Ash.read!(tenant: tenant1)
|
|
|
|
assert MultitenantThing |> Ash.read!(tenant: tenant2) == []
|
|
end
|
|
end
|
|
|
|
describe "attribute multitenancy" do
|
|
setup do
|
|
%{tenant1: Ash.UUID.generate(), tenant2: Ash.UUID.generate()}
|
|
end
|
|
|
|
test "a simple write works when a tenant is specified", %{tenant1: tenant1} do
|
|
User
|
|
|> Ash.Changeset.for_create(:create, %{}, tenant: tenant1)
|
|
|> Ash.create!()
|
|
end
|
|
|
|
test "a record written to one tenant cannot be read from another", %{
|
|
tenant1: tenant1,
|
|
tenant2: tenant2
|
|
} do
|
|
User
|
|
|> Ash.Changeset.for_create(:create, %{}, tenant: tenant1)
|
|
|> Ash.create!()
|
|
|
|
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
|
|
} do
|
|
User
|
|
|> Ash.Changeset.new()
|
|
|> Ash.Changeset.set_tenant(tenant1)
|
|
|> Ash.create!()
|
|
|
|
assert User |> Ash.Query.set_tenant(tenant2) |> Ash.list!(:name) == []
|
|
end
|
|
|
|
test "a record can be updated in a tenant", %{tenant1: tenant1, tenant2: tenant2} do
|
|
User
|
|
|> Ash.Changeset.for_create(:create, %{}, tenant: tenant1)
|
|
|> Ash.create!()
|
|
|> Ash.Changeset.for_update(:update, %{}, tenant: tenant1)
|
|
|> Ash.update!()
|
|
|
|
assert User |> Ash.Query.set_tenant(tenant2) |> Ash.read!() == []
|
|
end
|
|
|
|
test "a record for a different tenant cant be updated from the other one", %{
|
|
tenant1: tenant1,
|
|
tenant2: tenant2
|
|
} do
|
|
assert_raise Ash.Error.Invalid, ~r/Attempted to update stale record/, fn ->
|
|
User
|
|
|> Ash.Changeset.for_create(:create, %{}, tenant: tenant1)
|
|
|> Ash.create!()
|
|
|> Ash.Changeset.for_update(:update, %{name: "new name"}, tenant: tenant2)
|
|
|> Ash.update!()
|
|
end
|
|
end
|
|
|
|
test "a record can be destroyed in a tenant", %{tenant1: tenant1} do
|
|
User
|
|
|> Ash.Changeset.for_create(:create, %{}, tenant: tenant1)
|
|
|> Ash.create!()
|
|
|> Ash.Changeset.for_update(:update, %{}, tenant: tenant1)
|
|
|> Ash.destroy!()
|
|
end
|
|
|
|
test "tenant is set on data in calculations", %{tenant1: tenant1} do
|
|
thing1 =
|
|
MultitenantThing
|
|
|> Ash.Changeset.for_create(:create, %{name: "foo"}, tenant: tenant1)
|
|
|> Ash.create!()
|
|
|
|
thing2 =
|
|
MultitenantThing
|
|
|> Ash.Changeset.for_create(:create, %{name: "bar", other_thing_id: thing1.id},
|
|
tenant: tenant1
|
|
)
|
|
|> Ash.create!()
|
|
|
|
thing2 =
|
|
thing2
|
|
|> Ash.Changeset.for_update(:update, %{name: "bar updated"})
|
|
|> Ash.update!()
|
|
|
|
%{other_thing_name_reversed: "oof"} = Ash.load!(thing2, :other_thing_name_reversed)
|
|
|
|
%{other_thing_name_reversed: "oof"} =
|
|
MultitenantThing
|
|
|> Ash.get!(thing2.id, tenant: tenant1, load: :other_thing_name_reversed)
|
|
end
|
|
end
|
|
|
|
describe "contextual multitenancy" do
|
|
setup do
|
|
%{tenant1: Ash.UUID.generate(), tenant2: Ash.UUID.generate()}
|
|
end
|
|
|
|
test "a simple write works when a tenant is specified", %{tenant1: tenant1} do
|
|
Comment
|
|
|> Ash.Changeset.for_create(:create, %{}, tenant: tenant1)
|
|
|> Ash.create!()
|
|
end
|
|
|
|
test "a record written to one tenant cannot be read from another", %{
|
|
tenant1: tenant1,
|
|
tenant2: tenant2
|
|
} do
|
|
Comment
|
|
|> Ash.Changeset.for_create(:create, %{}, tenant: tenant1)
|
|
|> Ash.create!()
|
|
|
|
assert Comment |> Ash.Query.set_tenant(tenant2) |> Ash.read!() == []
|
|
end
|
|
|
|
test "a record can be updated in a tenant", %{tenant1: tenant1, tenant2: tenant2} do
|
|
Comment
|
|
|> Ash.Changeset.for_create(:create, %{}, tenant: tenant1)
|
|
|> Ash.Changeset.set_tenant(tenant1)
|
|
|> Ash.create!()
|
|
|> Ash.Changeset.for_update(:update, %{}, tenant: tenant1)
|
|
|> Ash.update!()
|
|
|
|
assert Comment |> Ash.Query.set_tenant(tenant2) |> Ash.read!() == []
|
|
end
|
|
|
|
test "a record can be destroyed in a tenant", %{tenant1: tenant1} do
|
|
Comment
|
|
|> Ash.Changeset.for_create(:create, %{}, tenant: tenant1)
|
|
|> Ash.create!()
|
|
|> Ash.Changeset.for_update(:update, %{}, tenant: tenant1)
|
|
|> Ash.destroy!()
|
|
end
|
|
|
|
test "a record cannot be read without tenant specified", %{
|
|
tenant1: tenant1
|
|
} do
|
|
Comment
|
|
|> Ash.Changeset.for_create(:create, %{}, tenant: tenant1)
|
|
|> Ash.create!()
|
|
|
|
result = Comment |> Ash.read()
|
|
assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Invalid.TenantRequired{}]}} = result
|
|
end
|
|
|
|
test "an aggregate cannot be used without tenant specified", %{
|
|
tenant1: tenant1
|
|
} do
|
|
Comment
|
|
|> Ash.Changeset.new()
|
|
|> Ash.Changeset.set_tenant(tenant1)
|
|
|> Ash.create!()
|
|
|
|
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
|