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

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
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
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?
28 Replies
moxley
moxleyOP3y ago
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.
Ash HQ
Guide: Get Started
Read the "Get Started" guide on Ash HQ
ZachDaniel
ZachDaniel3y ago
Yikes. If you don't mind creating an issue in Ash for those links being broken that would be great Well, just the one link. 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 So the idea is that tenancy is something that can be derived from something like a subdomain or a header or something like that. oh I should have read your message more 🤔 the set_tenant should be all you need How are you calling the mutation in your test?
moxley
moxleyOP3y ago
In Ash.PlugHelpers.set_tenant(conn, "tenant_string"), what is "tenant_string"? Is that the tenant ID?
ZachDaniel
ZachDaniel3y ago
WIth tenancy in Ash a tenant is just a string For attribute multitenancy, it is the value that the attribute must equal
moxley
moxleyOP3y ago
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. What is "tenant_string" for me?
ZachDaniel
ZachDaniel3y ago
If you are using attribute multitenancy, and the attribute is org_id, then the tenant string would be the organisation id
moxley
moxleyOP3y ago
Yes, I"m using attribute base multitenancy
ZachDaniel
ZachDaniel3y ago
attribute multitenancy ultimately boils down to filter(org_id == ^tenant_string)
moxley
moxleyOP3y ago
Okay, great. I've been passing the Org struct to Ash.PlugHelpers.set_tenant/2, so that's my problem. Hmm, the problem still remains. I added an IO.inspect to AshGraphql.Graphql.Resolver.mutate/3, and the tenant is nil. I don't know why.
ZachDaniel
ZachDaniel3y ago
So what does your test code look like?
moxley
moxleyOP3y ago
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
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
ZachDaniel3y ago
oh, are you getting the error from the actual post_gql call or just your call to for_create?
Ash.Changeset.for_create(:create, %{}, tenant:
ctx.org.id)
Ash.Changeset.for_create(:create, %{}, tenant:
ctx.org.id)
moxley
moxleyOP3y ago
In the resp_body of the GraphQL call
ZachDaniel
ZachDaniel3y ago
what does post_gql do
moxley
moxleyOP3y ago
It calls post(), but it also passes the org_id to the request first. In my plug, it does this:
conn |> Ash.PlugHelpers.set_tenant(org.id)
conn |> Ash.PlugHelpers.set_tenant(org.id)
I added an IO.inspect there, and it's executing correctly there.
ZachDaniel
ZachDaniel3y ago
Okay...maybe there is a bug there? Does it work in real life? like if you load up the playground?
moxley
moxleyOP3y ago
I haven't tried it outside of tests. I'll try... Same thing in Graphiql. In the server output, I see my debug statements:
org.id (tenant): 1
tenant: nil
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 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
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
That plug is used inside a pipeline:
pipeline :absinthe do
plug GfWeb.AbsintheValues
end
pipeline :absinthe do
plug GfWeb.AbsintheValues
end
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
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
Somehow, the set_tenant() call isn't getting the tenant value to the context inside of AshGraphql.Graphql.Resolver.mutate/3
ZachDaniel
ZachDaniel3y ago
lemme take a look at that code in a bit, I'll see if I can figure out what is going wrong hopefully I haven't just been telling you a lie 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
moxleyOP3y ago
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
ZachDaniel3y ago
Actually...try setting tenant and actor keys in the context there that was the old way
moxley
moxleyOP3y ago
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
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
I realized that there was nothing passing the tenant from the conn to the Absinthe context.
ZachDaniel
ZachDaniel3y ago
...is that in our guides? I completely forgot about that plug, sorry 😦
moxley
moxleyOP3y ago
No, it's not
ZachDaniel
ZachDaniel3y ago
woof okay, well, an issue for that would be great. I'll fix it in the morning, sorry about that 😢
moxley
moxleyOP3y ago
oh wait, there it is
ZachDaniel
ZachDaniel3y ago
Hmm...yeah we should just put taht in the initial set up guide 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
moxleyOP3y ago
Yeah, good idea

Did you find this page helpful?