How to Auto-Set Tenant in Ash Multitenancy Based on Actor in Resource Queries

I am working on a multi-tenant Ash project. Each user always has a current_tenant. I am finding myself having to pass actor: user and tenant: user.current_tenant.domain on each query.

Since the current_tenant is directly accessible via user A.K.A actor, is there a way to automatically set the tenant in the resource definition based on the actor so that instead of having to pass tenant like the following:

Contacts.get_contact(contact_id, actor: user, tenant: user.current_tenant.domain)

I will just pass the actor like the following and the tenant resolution will automatically happen based on the actor?

Contacts.get_contact(contact_id, actor: user)

Hm…it’s an interesting question. We could potentially add another protocol called Ash.TenantFromActor or something like that to handle that, but it’s a bit complex to do that automatically. There are resources that could have a tenant or not have a tenant, so we can’t assume providing an actor is also providing a tenant. You could try this on your resources:

preparations do
  prepare MyApp.Preparations.SetTenant
end

changes do
  prepare MyApp.Changes.SetTenant
end

and the definitions

defmodule MyApp.Preparations.SetTenant do
  use Ash.Resource.Preparation

  def prepare(query, _, context) do
    if context.actor do
      Ash.Query.set_tenant(query, context.actor.current_tenant.domain)
    else
      query
    end 
  end
end
defmodule MyApp.Changes.SetTenant do
  use Ash.Resource.Change

  def change(changeset, _, context) do
    if context.actor do
      Ash.Changeset.set_tenant(changeset, context.actor.current_tenant.domain)
    else
      changeset
    end 
  end

  def atomic(changeset, opts, context), do: {:ok, change(changeset, opts, context)}
end
1 Like

I have tried it but Ash still expects a tenant to be passed in while querying. I was thinking that the preparations section would be executed before validating the presence of the tenant but it does not seem to be the case.

I am getting the following error.

   ** (Ash.Error.Invalid) Invalid Error
     
     * Queries against the Kamaro.Contacts.Resources.Contact resource require a tenant to be specified
       (elixir 1.15.7) lib/process.ex:860: Process.info/2
       (ash 3.2.4) lib/ash/error/invalid/tenant_required.ex:5: Ash.Error.Invalid.TenantRequired.exception/1
       (ash 3.2.4) lib/ash/actions/read/read.ex:1551: Ash.Actions.Read.validate_multitenancy/1
       (ash 3.2.4) lib/ash/actions/read/read.ex:1493: Ash.Actions.Read.handle_multitenancy/1
       (ash 3.2.4) lib/ash/actions/read/read.ex:331: anonymous fn/5 in Ash.Actions.Read.do_read/4
       (ash 3.2.4) lib/ash/actions/read/read.ex:786: Ash.Actions.Read.maybe_in_transaction/3
       (ash 3.2.4) lib/ash/actions/read/read.ex:249: Ash.Actions.Read.do_run/3
       (ash 3.2.4) lib/ash/actions/read/read.ex:66: anonymous fn/3 in Ash.Actions.Read.run/3
       (ash 3.2.4) lib/ash/actions/read/read.ex:65: Ash.Actions.Read.run/3
       (ash 3.2.4) lib/ash.ex:1855: Ash.read/2
       (ash 3.2.4) lib/ash.ex:1814: Ash.read!/2

I resolved the issue. Ignore my reply. It is working as expected. I had not prefixed line 14 with prepare and 18 with change. Thanks a ton!

See my tests are green now!

I think this should be enough. It will sort many out if added to the official document.

@zachdaniel the provided solution works well expect when it comes to ash_double_entry Balance resource. Is there extra work to do with ash_double_entry package?

When I pass a tenant at the time of transfer like the following, it works:

Kamaro.Ledger.transfer(transfer_attrs, actor: user, tenant: user.current_tenant.domain)

But when I remove the tenant, the preparation and the change are not seeing the actor. Thus, failing with the error:

 ** (Ash.Error.Unknown) Unknown Error
     
     * %KeyError{key: :current_tenant, term: nil, message: "key :current_tenant not found in: nil\n\nIf you are using the dot syntax, such as map.field, make sure the left-hand side of the dot is a map"}
       (kamaro 0.1.0) lib/kamaro/preparations/set_tenant_from_actor.ex:5: Kamaro.Preparations.SetTenantFromActor.prepare/3
       (ash 3.2.4) lib/ash/query/query.ex:704: anonymous fn/6 in Ash.Query.run_preparations/6

Here is the balance and the transfer resources.

Balance Resource

defmodule Kamaro.Ledger.Resources.Balance do
  use Ash.Resource,
    domain: Kamaro.Ledger,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshDoubleEntry.Balance],
    authorizers: [Ash.Policy.Authorizer]

  postgres do
    table "ledger_balances"
    repo Kamaro.Repo
  end

  preparations do
    prepare Kamaro.Preparations.SetTenantFromActor
  end

  changes do
    change Kamaro.Changes.SetTenantFromActor
  end

  multitenancy do
    strategy :context
  end

  balance do
    # configure the other resources it will interact with
    transfer_resource(Kamaro.Ledger.Resources.Transfer)
    account_resource(Kamaro.Ledger.Resources.Account)
  end

  actions do
    read :read do
      primary? true
      # configure keyset pagination for streaming
      pagination keyset?: true, required?: false
    end
  end

  policies do
    policy action_type(:create), authorize_if: Kamaro.Checks.Can
    policy action_type(:read), authorize_if: Kamaro.Checks.Can
    policy action_type(:update), authorize_if: Kamaro.Checks.Can
    policy action_type(:destroy), authorize_if: Kamaro.Checks.Can
  end
end

Transfer Resource

defmodule Kamaro.Ledger.Resources.Transfer do
  use Ash.Resource,
    domain: Kamaro.Ledger,
    data_layer: AshPostgres.DataLayer,
    authorizers: [Ash.Policy.Authorizer],
    extensions: [AshDoubleEntry.Transfer]

  postgres do
    table "ledger_transfers"
    repo Kamaro.Repo
  end

  preparations do
    prepare Kamaro.Preparations.SetTenantFromActor
  end

  changes do
    change Kamaro.Changes.SetTenantFromActor
  end

  multitenancy do
    strategy :context
  end

  actions do
    defaults [:read]
  end

  transfer do
    account_resource(Kamaro.Ledger.Resources.Account)
    balance_resource(Kamaro.Ledger.Resources.Balance)
  end

  policies do
    policy action_type(:create), authorize_if: Kamaro.Checks.Can
    policy action_type(:read), authorize_if: Kamaro.Checks.Can
    policy action_type(:update), authorize_if: Kamaro.Checks.Can
    policy action_type(:destroy), authorize_if: Kamaro.Checks.Can
  end
end

There must be a case where we are not passing the actor to an underlying actor that we call. Please open an issue in ash_double_entry.

1 Like

I have opened the issue on the repo.