Multi-Tenant

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.

Add Tenant to 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, ...)

Tenant-based 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.

Choosing a tenant for execution

There are a few places where subscriptions might need to load data:

Each 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.