In a multi-tenant system, data from many different accounts is stored on the same server. (An account might be an organization, a customer, a namespace, a domain, etc – these are all tenants.) Gems like Apartment assist with this arrangement, but it can also be implemented in the application. Here are a few considerations for this architecture when using GraphQL subscriptions.
context
All the approaches below will use context[:tenant]
to identify the tenant during GraphQL execution, so make sure to assign it before executing a query:
context = {
viewer: current_user,
tenant: current_user.tenant,
# ...
}
MySchema.execute(query_str, context: context, ...)
subscription_scope
When subscriptions are delivered, subscription_scope
is one element used to route data to the right subscriber. In short, it’s the implicit identifier for the receiver. In a multi-tenant architecture, subscription_scope
should reference the context key that names the tenant, for example:
class BudgetWasApproved < GraphQL::Schema::Subscription
subscription_scope :tenant # This would work with `context[:tenant] => "acme-corp"`
# ...
end
# Include the scope when `.trigger`ing:
BudgetSchema.subscriptions.trigger(:budget_was_approved, {}, { ... }, scope: "acme-corp")
Alternatively, subscription_scope
might name something that belongs to the tenant:
class BudgetWasApproved < GraphQL::Schema::Subscription
subscription_scope :project_id # This would work with `context[:project_id] = 1234`
end
# Include the scope when `.trigger`ing:
BudgetSchema.subscriptions.trigger(:budget_was_approved, {}, { ... }, scope: 1234)
As long as project_id
is unique among all tenants, that would work fine too. But some scope is required so that subscriptions can be disambiguated between tenants.
There are a few places where subscriptions might need to load data:
ActionCableSubscriptions
: when deserializing the JSON string broadcasted by ActionCable
PusherSubscriptions
and AblySubscriptions
: when deserializing query contextEach of these operations will need to select the right tenant in order to load data properly.
For building the payload, use a Trace module:
module TenantSelectionTrace
def execute_multiplex(multiplex:) # this is the top-level, umbrella event
context = data[:multiplex].queries.first.context # This assumes that all queries in a multiplex have the same tenant
MultiTenancy.select_tenant(context[:tenant]) do
# ^^ your multi-tenancy implementation here
super # Call through to the rest of execution
end
end
end
# ...
class MySchema < GraphQL::Schema
trace_with(TenantSelectionTrace)
end
The tracer above will use context[:tenant]
to select a tenant for the duration of execution for all queries, mutations, and subscriptions.
For deserializing ActionCable messages, provide a serializer:
object that implements .dump(obj)
and .load(string, context)
:
class MultiTenantSerializer
def self.dump(obj)
GraphQL::Subscriptions::Serialize.dump(obj)
end
def self.load(string, context)
MultiTenancy.select_tenant(context[:tenant]) do
GraphQL::Subscriptions::Serialize.load(string)
end
end
end
# ...
class MySchema < GraphQL::Schema
# ...
use GraphQL::Subscriptions::ActionCableSubscriptions, serializer: MultiTenantSerializer
end
The implementation above will use the built-in serialization algorithms, but it will do so in the context of the selected tenant.
For loading query context in Pusher and Ably, add tenant selection to your load_context
method, if required:
class CustomSubscriptions < GraphQL::Pro::PusherSubscriptions # or `GraphQL::Pro::AblySubscriptions`
def dump_context(ctx)
JSON.dump(ctx.to_h)
end
def load_context(ctx_string)
ctx_data = JSON.parse(ctx_string)
MultiTenancy.select_tenant(ctx_data["tenant"]) do
# Build a symbol-keyed hash, loading objects from the database if necessary
# to use a `context: ...`
end
end
end
With that approach, the selected tenant will be active when building the context hash, in case any objects need to be loaded from the database.