ash/test/actions/multitenancy_test.exs
Zach Daniel e4980d55ba fix: don't update tenant on update, instead enforce it
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).
2024-06-05 10:43:38 -04:00

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