I'm trying to understand how to use attribute-based multitenancy with AshGraphql

moxley
2023-03-17

moxley:

  1. The documentation says to set up a multitenancy block in your resource module, and add a strategy and attribute . Check:

      multitenancy do
        strategy :attribute
        attribute :org_id
      end
  2. The documentation says to pass the tenant to the conn by calling Ash.PlugHelpers.set_tenant/2 . Check:

    def call(conn, _opts) do
      ...
      conn
      |> Ash.PlugHelpers.set_tenant(org)
      |> Ash.PlugHelpers.set_actor(session_resource)
    end

    Also, I inspected the above call to ensure it’s being called correctly in my test.

So now in my test, I call the “create” mutation for my resource, and it returns the error "GF.Ash.WebComponent changesets require a tenant to be specified"

What am I missing?

moxley:

In the Multitenancy Topic ( https://ash-hq.org/docs/guides/ash/latest/topics/multitenancy ), it says:

Setting the tenant when using the code API is done via Ash.Query.set_tenant/2 and Ash.Changeset.set_tenant/2 . If you are using an extension, such as AshJsonApi or AshGraphql the method of setting tenant context is explained in that extension’s documentation.

The words AshJsonApi and AshGraphql are linked. AshJsonApi goes to a “We couldn’t find that page.”. AshGraphql goes to the API doc for the AshGraphql module, with no information about setting the tenant context as claimed in the paragraph above.

ZachDaniel:

Yikes.

ZachDaniel:

If you don’t mind creating an issue in Ash for those links being broken that would be great

ZachDaniel:

Well, just the one link.

ZachDaniel:

Anyway, this ought to be documented in the multitenancy guide, but what you need to do is use Ash.PlugHelpers.set_tenant(conn, "tenant_string") in a plug

ZachDaniel:

So the idea is that tenancy is something that can be derived from something like a subdomain or a header or something like that.

ZachDaniel:

oh

ZachDaniel:

I should have read your message more

ZachDaniel:

🤔 the set_tenant should be all you need

ZachDaniel:

How are you calling the mutation in your test?

moxley:

In Ash.PlugHelpers.set_tenant(conn, "tenant_string") , what is "tenant_string" ? Is that the tenant ID?

ZachDaniel:

WIth tenancy in Ash a tenant is just a string

ZachDaniel:

For attribute multitenancy, it is the value that the attribute must equal

moxley:

I’m not sure I follow. My tenant is an Org record. It has a primary key. Other tables in the database have org_id foreign keys to the orgs table.

moxley:

What is "tenant_string" for me?

ZachDaniel:

If you are using attribute multitenancy, and the attribute is org_id , then the tenant string would be the organisation id

moxley:

Yes, I”m using attribute base multitenancy

ZachDaniel:

attribute multitenancy ultimately boils down to filter(org_id == ^tenant_string)

moxley:

Okay, great.

moxley:

I’ve been passing the Org struct to Ash.PlugHelpers.set_tenant/2 , so that’s my problem.

moxley:

Hmm, the problem still remains.

moxley:

I added an IO.inspect to AshGraphql.Graphql.Resolver.mutate/3 , and the tenant is nil. I don’t know why.

ZachDaniel:

So what does your test code look like?

moxley:

    test "success", ctx do
      web_site =
        WebSite
        |> Ash.Changeset.for_create(:create, %{})
        |> Ash.Changeset.set_tenant(ctx.org.id)
        |> GF.Ash.create!()

      input = %{
        title: "Test Component",
        attrs: [%{title: "Test Attr", key: "test-attr"}],
        type: "HTML",
        web_site_id: web_site.id
      }

      resp_body =
        post_gql(ctx, %{
          query: @create_web_component,
          variables: %{input: input}
        })

      assert %{
               "data" => %{
                 "createWebComponent" => %{
                   "result" => %{
                     "attrs" => [%{"key" => "test-attr", "title" => "Test Attr"}],
                     "id" => id,
                     "type" => "HTML"
                   }
                 }
               }
             } = resp_body

ZachDaniel:

oh, are you getting the error from the actual post_gql call or just your call to for_create ?

ZachDaniel:

Ash.Changeset.for_create(:create, %{}, tenant: 
ctx.org.id)

moxley:

In the resp_body of the GraphQL call

ZachDaniel:

what does post_gql do

moxley:

It calls post() , but it also passes the org_id to the request first.

moxley:

In my plug, it does this:

conn |> Ash.PlugHelpers.set_tenant(org.id)

moxley:

I added an IO.inspect there, and it’s executing correctly there.

ZachDaniel:

Okay…maybe there is a bug there? Does it work in real life?

ZachDaniel:

like if you load up the playground?

moxley:

I haven’t tried it outside of tests. I’ll try…

moxley:

Same thing in Graphiql. In the server output, I see my debug statements:

org.id (tenant): 1
tenant: nil

The first one is from my plug that calls set_tenant/2 . The second one is from inside of AshGraphql.Graphql.Resolver.mutate/3

moxley:

Here’s the call/2 function from the plug:

  def call(conn, _opts) do
    mobile_version =
      case get_req_header(conn, "gf-mobile-version") do
        [version | _] -> version
        _ -> nil
      end

    linked_account = GfWeb.Auth.get_session_linked_account(conn)

    record_mobile_version(linked_account, mobile_version)
    org = GfWeb.Auth.get_org(conn)
    session_resource = conn.assigns[:session_resource]

    context = %{
      org: org,
      session_resource: session_resource,
      session_member: GfWeb.Auth.get_member(conn),
      session_non_member: conn.assigns[:non_member],
      session_app: GfWeb.Auth.get_session_app(conn),
      session_linked_account: linked_account,
      mobile_version: mobile_version
    }

    IO.inspect(org && org.id, label: "org.id (tenant)")

    conn
    |> Absinthe.Plug.put_options(context: context)
    |> Ash.PlugHelpers.set_tenant(org.id)
    |> Ash.PlugHelpers.set_actor(session_resource)
  end

moxley:

That plug is used inside a pipeline:

  pipeline :absinthe do
    plug GfWeb.AbsintheValues
  end

moxley:

Then that pipeline is used in the graphql endpoints:

  scope "/api", as: :api do
    pipe_through @anonymous_pipelines ++ [:absinthe]

    forward "/graphiql", Absinthe.Plug.GraphiQL, schema: GfWeb.GraphQL.AbsintheSchema

    forward "/gql", Absinthe.Plug,
      schema: GfWeb.GraphQL.AbsintheSchema,
      before_send: {GfWeb.AbsintheLogging, :log_errors}
  end

moxley:

Somehow, the set_tenant() call isn’t getting the tenant value to the context inside of AshGraphql.Graphql.Resolver.mutate/3

ZachDaniel:

lemme take a look at that code in a bit, I’ll see if I can figure out what is going wrong

ZachDaniel:

hopefully I haven’t just been telling you a lie

ZachDaniel:

We did a change over from how it used to get the tenant to supporting getting it from plug helpers at some point, and maybe no one has set up a new multitenant graphql since then to spot the issue

moxley:

I also tried commenting out the line |> Absinthe.Plug.put_options(context: context) , and it didn’t make any difference. I thought maybe that might be interfering with things, but no.

ZachDaniel:

Actually…try setting tenant and actor keys in the context there

ZachDaniel:

that was the old way

moxley:

I think I figured it out. My test is passing:

diff --git a/lib/gf_web/router.ex b/lib/gf_web/router.ex
index b5ba0679..b27725b9 100644
--- a/lib/gf_web/router.ex
+++ b/lib/gf_web/router.ex
@@ -62,6 +62,7 @@ defmodule GFWeb.Router do
 
   pipeline :absinthe do
     plug GfWeb.AbsintheValues
+    plug AshGraphql.Plug
   end
 
   if Mix.env() == :dev do

moxley:

I realized that there was nothing passing the tenant from the conn to the Absinthe context.

ZachDaniel:

…is that in our guides? I completely forgot about that plug, sorry 😦

moxley:

No, it’s not

ZachDaniel:

woof

ZachDaniel:

okay, well, an issue for that would be great. I’ll fix it in the morning, sorry about that 😢

moxley:

oh wait, there it is

ZachDaniel:

Hmm…yeah we should just put taht in the initial set up guide

ZachDaniel:

even if you aren’t using users or tenants, it won’t hurt to have it, and then you don’t end up in this position

moxley:

Yeah, good idea