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