AF
Ash Framework•4w ago
LukV

Actor can create data in other tenant

I'm testing wether a member of TenantA can create a project in TenantB. This test is failing (the :create is succeeding). I thought that multitenancy would block this case, but perhaps I'm confusing things.
elixir
test "member_cannot_create_in_other_tenant" do
tenant_a = create_tenant!(%{name: "A Tenant"})
tenant_b = create_tenant!(%{name: "B Tenant"})
member_a = create_member!(tenant_a)

result = create_project(%{name: "Wrong"}, tenant_b.id, member_a)
assert match?({:error, %Ash.Error.Forbidden{}}, result)
end
elixir
test "member_cannot_create_in_other_tenant" do
tenant_a = create_tenant!(%{name: "A Tenant"})
tenant_b = create_tenant!(%{name: "B Tenant"})
member_a = create_member!(tenant_a)

result = create_project(%{name: "Wrong"}, tenant_b.id, member_a)
assert match?({:error, %Ash.Error.Forbidden{}}, result)
end
Following here are some snippets of the setup. the resource (skipped parts)
elixir
[...]
multitenancy do
strategy :attribute
attribute :tenant_id
end
[...]
relationships do
belongs_to :tenant, Api.Tenants.Tenant,
allow_nil?: false,
attribute_type: :uuid,
public?: true

belongs_to :image_file, Api.Files.File, allow_nil?: true, attribute_type: :uuid, public?: true
end

actions do
[...]
create :create do
accept([:name, :description, :address, :image_file_id])
end
[...]
end

policies do
[...]
policy action(:create) do
# does this override multitenancy?
authorize_if always()
# authorize_if Api.Policies.ActorInTenant
end
[...]
end
elixir
[...]
multitenancy do
strategy :attribute
attribute :tenant_id
end
[...]
relationships do
belongs_to :tenant, Api.Tenants.Tenant,
allow_nil?: false,
attribute_type: :uuid,
public?: true

belongs_to :image_file, Api.Files.File, allow_nil?: true, attribute_type: :uuid, public?: true
end

actions do
[...]
create :create do
accept([:name, :description, :address, :image_file_id])
end
[...]
end

policies do
[...]
policy action(:create) do
# does this override multitenancy?
authorize_if always()
# authorize_if Api.Policies.ActorInTenant
end
[...]
end
9 Replies
LukV
LukVOP•4w ago
Some more snippets for context create the tenants
elixir
defp create_tenant!(attrs \\ %{}) do
id = Map.get(attrs, :id, Ecto.UUID.generate())
name = Map.get(attrs, :name, "tenant-" <> binary_part(id, 0, 8))

{:ok, tenant} =
Tenant
|> Ash.Changeset.for_create(:create, %{id: id, name: name})
|> Ash.create(upsert?: true)

tenant
end
elixir
defp create_tenant!(attrs \\ %{}) do
id = Map.get(attrs, :id, Ecto.UUID.generate())
name = Map.get(attrs, :name, "tenant-" <> binary_part(id, 0, 8))

{:ok, tenant} =
Tenant
|> Ash.Changeset.for_create(:create, %{id: id, name: name})
|> Ash.create(upsert?: true)

tenant
end
create the member
elixir
defp create_user!(tenant, attrs \\ %{}) do
sub = Map.get(attrs, :sub, "sub-" <> Ecto.UUID.generate())

email =
Map.get(attrs, :email, "user-" <> binary_part(Ecto.UUID.generate(), 0, 8) <> "@example.com")

name = Map.get(attrs, :name, "Test User")
given_name = Map.get(attrs, :given_name, "Test")
family_name = Map.get(attrs, :family_name, "User")

{:ok, user} =
User
|> Ash.Changeset.for_create(:create, %{
sub: sub,
email: email,
name: name,
given_name: given_name,
family_name: family_name,
tenant_id: tenant.id
})
|> Ash.Changeset.set_tenant(tenant.id)
|> Ash.create(upsert?: true)

user
end
elixir
defp create_user!(tenant, attrs \\ %{}) do
sub = Map.get(attrs, :sub, "sub-" <> Ecto.UUID.generate())

email =
Map.get(attrs, :email, "user-" <> binary_part(Ecto.UUID.generate(), 0, 8) <> "@example.com")

name = Map.get(attrs, :name, "Test User")
given_name = Map.get(attrs, :given_name, "Test")
family_name = Map.get(attrs, :family_name, "User")

{:ok, user} =
User
|> Ash.Changeset.for_create(:create, %{
sub: sub,
email: email,
name: name,
given_name: given_name,
family_name: family_name,
tenant_id: tenant.id
})
|> Ash.Changeset.set_tenant(tenant.id)
|> Ash.create(upsert?: true)

user
end
sevenseacat
sevenseacat•4w ago
I think the most important snippet would be the create_project snippet
LukV
LukVOP•4w ago
Oh sorry, here it is:
defp create_project(attrs, tenant_id, actor) do
defaults = %{name: "Project-" <> binary_part(Ecto.UUID.generate(), 0, 6)}

Project
|> Ash.Changeset.for_create(:create, Map.merge(defaults, attrs))
|> Ash.Changeset.set_tenant(tenant_id)
|> Ash.create(actor: actor)
end
defp create_project(attrs, tenant_id, actor) do
defaults = %{name: "Project-" <> binary_part(Ecto.UUID.generate(), 0, 6)}

Project
|> Ash.Changeset.for_create(:create, Map.merge(defaults, attrs))
|> Ash.Changeset.set_tenant(tenant_id)
|> Ash.create(actor: actor)
end
barnabasj
barnabasj•4w ago
the tenant will only set the attribute to the tenant value, but you need to setup the policies yourself
LukV
LukVOP•4w ago
So if I understand correctly, without a :create policy, my multitenancy block only sets the field but does not prevent inserting anything? Regarding a simple "actor_in_tenant" policy, would this be correct? The concept isn't easy to grasp at first 👼
defmodule Api.Policies.ActorInTenant do
use Ash.Policy.SimpleCheck
alias Api.Tenants.Membership
require Ash.Query

@impl true
def describe(_opts), do: "actor is member of tenant"

@impl true
def match?(%{id: user_id}, %{changeset: %{tenant: tenant}}, _opts) when not is_nil(tenant) do
case Membership
|> Ash.Query.filter(Ash.Expr.expr(user_id == ^user_id and tenant_id == ^tenant))
|> Ash.Query.set_tenant(tenant)
|> Ash.read_one(actor: user_id) do
{:ok, %Membership{}} -> true
_ -> false
end
end

def match?(_actor, _context, _opts), do: false
end
defmodule Api.Policies.ActorInTenant do
use Ash.Policy.SimpleCheck
alias Api.Tenants.Membership
require Ash.Query

@impl true
def describe(_opts), do: "actor is member of tenant"

@impl true
def match?(%{id: user_id}, %{changeset: %{tenant: tenant}}, _opts) when not is_nil(tenant) do
case Membership
|> Ash.Query.filter(Ash.Expr.expr(user_id == ^user_id and tenant_id == ^tenant))
|> Ash.Query.set_tenant(tenant)
|> Ash.read_one(actor: user_id) do
{:ok, %Membership{}} -> true
_ -> false
end
end

def match?(_actor, _context, _opts), do: false
end
barnabasj
barnabasj•4w ago
you probably don't want to do a query in your policy, as this could lead to a lot of queries being fired. Most often all the necessary data on the actor is loaded in a plug before setting it, that way you only load it once and can just do a simple comparison
LukV
LukVOP•4w ago
I see, thanks for the hint. It appears my actor already contains the memberships:
actor: %Api.Accounts.User{
id: "...",
sub: "sub-...",
email: "user-...",
...
tenants_join_assoc: #Ash.NotLoaded<:relationship, field: :tenants_join_assoc>,
memberships: [
%Api.Tenants.Membership{
id: "c2e9830b-f080-4f61-b89f-1cce4dc6fafc",
role: :admin,
created_at: ~U[2025-09-09 15:40:42.173331Z],
updated_at: ~U[2025-09-09 15:40:42.173331Z],
tenant_id: "...",
user_id: "...",
tenant: #Ash.NotLoaded<:relationship, field: :tenant>,
user: #Ash.NotLoaded<:relationship, field: :user>,
__meta__: #Ecto.Schema.Metadata<:loaded, "memberships">
}
],
tenants: #Ash.NotLoaded<:relationship, field: :tenants>,
__meta__: #Ecto.Schema.Metadata<:loaded, "users">
}
actor: %Api.Accounts.User{
id: "...",
sub: "sub-...",
email: "user-...",
...
tenants_join_assoc: #Ash.NotLoaded<:relationship, field: :tenants_join_assoc>,
memberships: [
%Api.Tenants.Membership{
id: "c2e9830b-f080-4f61-b89f-1cce4dc6fafc",
role: :admin,
created_at: ~U[2025-09-09 15:40:42.173331Z],
updated_at: ~U[2025-09-09 15:40:42.173331Z],
tenant_id: "...",
user_id: "...",
tenant: #Ash.NotLoaded<:relationship, field: :tenant>,
user: #Ash.NotLoaded<:relationship, field: :user>,
__meta__: #Ecto.Schema.Metadata<:loaded, "memberships">
}
],
tenants: #Ash.NotLoaded<:relationship, field: :tenants>,
__meta__: #Ecto.Schema.Metadata<:loaded, "users">
}
So the following also works:
defmodule Api.Policies.ActorInTenant do
use Ash.Policy.SimpleCheck

@impl true
def describe(_opts), do: "actor is member of tenant"

@impl true
def match?(
%Api.Accounts.User{memberships: memberships},
%{changeset: %{tenant: tenant_id}},
_opts
)
when not is_nil(tenant_id) do
Enum.any?(memberships, &(&1.tenant_id == tenant_id))
end

def match?(_actor, _context, _opts), do: false
end
defmodule Api.Policies.ActorInTenant do
use Ash.Policy.SimpleCheck

@impl true
def describe(_opts), do: "actor is member of tenant"

@impl true
def match?(
%Api.Accounts.User{memberships: memberships},
%{changeset: %{tenant: tenant_id}},
_opts
)
when not is_nil(tenant_id) do
Enum.any?(memberships, &(&1.tenant_id == tenant_id))
end

def match?(_actor, _context, _opts), do: false
end
Is that correct? I wonder if the memberships will be present when used with AshJsonApi. I set the tenant and actor there like this:
with {:ok, t} <-
Api.Tenants.Tenant
|> Ash.Changeset.for_create(:create, tenant)
|> Ash.create(upsert?: true),
{:ok, u} <-
Api.Accounts.User
|> Ash.Changeset.for_create(:create, user)
|> Ash.Changeset.set_tenant(tenant.id)
|> Ash.create(upsert?: true) do

Logger.metadata(user_id: u.id, tenant_id: t.id)
conn
|> Ash.PlugHelpers.set_tenant(t.id)
|> Ash.PlugHelpers.set_actor(u)
with {:ok, t} <-
Api.Tenants.Tenant
|> Ash.Changeset.for_create(:create, tenant)
|> Ash.create(upsert?: true),
{:ok, u} <-
Api.Accounts.User
|> Ash.Changeset.for_create(:create, user)
|> Ash.Changeset.set_tenant(tenant.id)
|> Ash.create(upsert?: true) do

Logger.metadata(user_id: u.id, tenant_id: t.id)
conn
|> Ash.PlugHelpers.set_tenant(t.id)
|> Ash.PlugHelpers.set_actor(u)
I'm afraid of overlooking / overestimating some of the magic now that is provided 😊 Ah, I have a change manage_relationship(...) in my User.create action:
create :create do
accept([:sub, :email, :name, :given_name, :family_name])
upsert?(true)
upsert_identity(:unique_sub)

argument :tenant_id, :uuid, allow_nil?: false

change manage_relationship(:tenant_id, :memberships, type: :create)
end
create :create do
accept([:sub, :email, :name, :given_name, :family_name])
upsert?(true)
upsert_identity(:unique_sub)

argument :tenant_id, :uuid, allow_nil?: false

change manage_relationship(:tenant_id, :memberships, type: :create)
end
I guess that explains, why the memberships are present.
barnabasj
barnabasj•4w ago
you probably want to add a load there
with {:ok, t} <-
Api.Tenants.Tenant
|> Ash.Changeset.for_create(:create, tenant)
|> Ash.create(upsert?: true),
{:ok, u} <-
Api.Accounts.User
|> Ash.Changeset.for_create(:create, user)
|> Ash.Changeset.set_tenant(tenant.id)
|> Ash.create(upsert?: true, load: :memberships) do # <= change here

Logger.metadata(user_id: u.id, tenant_id: t.id)
conn
|> Ash.PlugHelpers.set_tenant(t.id)
|> Ash.PlugHelpers.set_actor(u)
with {:ok, t} <-
Api.Tenants.Tenant
|> Ash.Changeset.for_create(:create, tenant)
|> Ash.create(upsert?: true),
{:ok, u} <-
Api.Accounts.User
|> Ash.Changeset.for_create(:create, user)
|> Ash.Changeset.set_tenant(tenant.id)
|> Ash.create(upsert?: true, load: :memberships) do # <= change here

Logger.metadata(user_id: u.id, tenant_id: t.id)
conn
|> Ash.PlugHelpers.set_tenant(t.id)
|> Ash.PlugHelpers.set_actor(u)
that would do it, I would still add the load, just for completeness sake
LukV
LukVOP•4w ago
Thank you very much!

Did you find this page helpful?