File: multi_tenant.md

package info (click to toggle)
ruby-graphql 2.2.17-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 9,584 kB
  • sloc: ruby: 67,505; ansic: 1,753; yacc: 831; javascript: 331; makefile: 6
file content (128 lines) | stat: -rw-r--r-- 4,711 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
---
layout: guide
doc_stub: false
search: true
section: Subscriptions
title: Multi-Tenant
desc: Switching tenants in GraphQL Subscription execution
index: 8
---

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](https://github.com/influitive/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:

```ruby
context = {
  viewer: current_user,
  tenant: current_user.tenant,
  # ...
}

MySchema.execute(query_str, context: context, ...)
```

## Tenant-based `subscription_scope`

When subscriptions are delivered, {% internal_link "`subscription_scope`",  "subscriptions/subscription_classes#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:

```ruby
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:

```ruby
class BudgetWasApproved < GraphQL::Schema::Subcription
  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:

- When building the payload for the subscription (fetching data to prepare the result)
- `ActionCableSubscriptions`: when deserializing the JSON string broadcasted by `ActionCable`
- `PusherSubscriptions` and `AblySubscriptions`: when deserializing query context

Each of these operations will need to select the right tenant in order to load data properly.

For __building the payload__, use a {% internal_link "Trace module", "queries/tracing" %}:

```ruby
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)`:

```ruby
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:

```ruby
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.