tenanting 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +490 -0
- data/lib/generators/tenanting/USAGE +24 -0
- data/lib/generators/tenanting/templates/app/controllers/accounts_controller.rb.tt +8 -0
- data/lib/generators/tenanting/templates/app/controllers/concerns/tenanting.rb.tt +44 -0
- data/lib/generators/tenanting/templates/app/models/account.rb.tt +14 -0
- data/lib/generators/tenanting/templates/app/models/concerns/account_scoping.rb.tt +129 -0
- data/lib/generators/tenanting/templates/app/models/current.rb.tt +3 -0
- data/lib/generators/tenanting/templates/app/models/membership.rb.tt +6 -0
- data/lib/generators/tenanting/templates/app/views/accounts/index.html.erb.tt +7 -0
- data/lib/generators/tenanting/templates/config/initializers/tenanting.rb.tt +93 -0
- data/lib/generators/tenanting/templates/db/migrate/create_accounts.rb.tt +9 -0
- data/lib/generators/tenanting/templates/db/migrate/create_memberships.rb.tt +12 -0
- data/lib/generators/tenanting/templates/test/fixtures/accounts.yml.tt +5 -0
- data/lib/generators/tenanting/templates/test/fixtures/memberships.yml.tt +7 -0
- data/lib/generators/tenanting/templates/test/test_helpers/account_test_helper.rb.tt +29 -0
- data/lib/generators/tenanting/tenanting_generator.rb +116 -0
- data/lib/tenanting.rb +5 -0
- metadata +92 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7c494dc26510302cd4f4697c66378a0c889d8cd7d544acd941e8aa3b8ae46e12
|
|
4
|
+
data.tar.gz: 791f00f6e957ff10fe42c98496c2af5b039ab6ebd04d34520b63881c71be6756
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ca16cac52ab1f0661e29c98f4c1629ef4313a9551a16338db83a99c6d82a6722adf47b40ddbd0990fbc71f828d3d04d14b087a3387857ada49090f4d4a95112b
|
|
7
|
+
data.tar.gz: 453179c1d8227ed0f8f0843e77c3ed13a2ee88f51345e3d0661f2ed4870eacc99abff983707e7210dbed043df5437ae47d6500e9c46aedb08eaea7b29c0ef372
|
data/CHANGELOG.md
ADDED
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright (c) 2026 Chris Oliver
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
# Tenanting
|
|
2
|
+
|
|
3
|
+
Multitenancy for Rails, generated into your app the same way `bin/rails generate authentication`
|
|
4
|
+
generates authentication.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
bin/rails generate tenanting
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Tenanting isn't a library you call at runtime. The generator writes a small amount of plain
|
|
11
|
+
Rails code into your app: `Current.account`, a model concern, a controller concern, a
|
|
12
|
+
middleware, and test helpers. You own that code, can read all of it in a few minutes, and can
|
|
13
|
+
change it when your app needs something different.
|
|
14
|
+
|
|
15
|
+
It's built for row-level multitenancy: every account-owned table has an `account_id` column,
|
|
16
|
+
and every query on those tables is scoped to the current account.
|
|
17
|
+
|
|
18
|
+
## Contents
|
|
19
|
+
|
|
20
|
+
- [Installation](#installation)
|
|
21
|
+
- [What gets generated](#what-gets-generated)
|
|
22
|
+
- [Scoping models](#scoping-models)
|
|
23
|
+
- [The current account](#the-current-account)
|
|
24
|
+
- [Controllers and URLs](#controllers-and-urls)
|
|
25
|
+
- [Background jobs, mailers, and broadcasts](#background-jobs-mailers-and-broadcasts)
|
|
26
|
+
- [Console, seeds, and data migrations](#console-seeds-and-data-migrations)
|
|
27
|
+
- [Testing](#testing)
|
|
28
|
+
- [Customizing](#customizing)
|
|
29
|
+
- [Security model](#security-model)
|
|
30
|
+
- [Migrating from ActsAsTenant](#migrating-from-actsastenant)
|
|
31
|
+
- [Development](#development)
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
Tenanting requires Rails 8.0 or newer. The gem only contains the generator, so it only needs
|
|
36
|
+
to be in the development group:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
bundle add tenanting --group development
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
If you want users who can belong to accounts, run the Rails authentication generator first.
|
|
43
|
+
Tenanting detects it and also adds memberships and an account picker.
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
bin/rails generate authentication # optional
|
|
47
|
+
bin/rails generate tenanting
|
|
48
|
+
bin/rails db:migrate
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Then scope your models to an account:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
class Project < ApplicationRecord
|
|
55
|
+
scoped_to_account
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## What gets generated
|
|
60
|
+
|
|
61
|
+
| File | What it does |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| `app/models/account.rb` | The tenant. `#slug` returns its URL prefix, `/123` |
|
|
64
|
+
| `app/models/current.rb` | Adds `attribute :account, :all_accounts` (created if it doesn't exist) |
|
|
65
|
+
| `app/models/concerns/account_scoping.rb` | `scoped_to_account`, included in `ApplicationRecord` |
|
|
66
|
+
| `app/controllers/concerns/tenanting.rb` | Sets `Current.account` for each request, included in `ApplicationController` |
|
|
67
|
+
| `config/initializers/tenanting.rb` | URL prefix middleware, plus the account for jobs, broadcasts, and the console |
|
|
68
|
+
| `db/migrate/*_create_accounts.rb` | The `accounts` table |
|
|
69
|
+
| `test/test_helpers/account_test_helper.rb` | `switch_to_account` for tests |
|
|
70
|
+
| `test/fixtures/accounts.yml` | Two accounts, `one` and `two` |
|
|
71
|
+
|
|
72
|
+
When the authentication generator has been run, you also get:
|
|
73
|
+
|
|
74
|
+
| File | What it does |
|
|
75
|
+
| --- | --- |
|
|
76
|
+
| `app/models/membership.rb` | Joins users to accounts. `User has_many :accounts, through: :memberships` |
|
|
77
|
+
| `app/controllers/accounts_controller.rb` | An account picker, at `/accounts` |
|
|
78
|
+
| `db/migrate/*_create_memberships.rb` | The `memberships` table, unique on user and account |
|
|
79
|
+
| `test/fixtures/memberships.yml` | Users `one` and `two` in accounts `one` and `two` |
|
|
80
|
+
|
|
81
|
+
The generator also includes `AccountScoping` in `ApplicationRecord`, adds `allow_accountless_access`
|
|
82
|
+
to the sessions and passwords controllers, and prefixes URLs in `ApplicationMailer` with the account.
|
|
83
|
+
|
|
84
|
+
## Scoping models
|
|
85
|
+
|
|
86
|
+
Call `scoped_to_account` in any model that belongs to an account. There are three ways to
|
|
87
|
+
connect a model to its account.
|
|
88
|
+
|
|
89
|
+
### Models with an `account_id` column
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
bin/rails generate model Project name:string account:references
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
class Project < ApplicationRecord
|
|
97
|
+
scoped_to_account
|
|
98
|
+
|
|
99
|
+
has_many :tasks, dependent: :destroy
|
|
100
|
+
|
|
101
|
+
validates :name, uniqueness: { scope: :account_id }
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
This adds `belongs_to :account` and a default scope on `Current.account`:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
Current.account = basecamp
|
|
109
|
+
|
|
110
|
+
Project.all # SELECT * FROM projects WHERE account_id = 1
|
|
111
|
+
Project.find(other_id) # Raises ActiveRecord::RecordNotFound for another account's project
|
|
112
|
+
Project.create!(name: "Launch").account # => basecamp
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Models that belong to a scoped model: `through:`
|
|
116
|
+
|
|
117
|
+
Tables like `tasks` don't need their own `account_id` when their parent already has one. Scope
|
|
118
|
+
them through the parent's `belongs_to` association instead:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
class Task < ApplicationRecord
|
|
122
|
+
belongs_to :project
|
|
123
|
+
scoped_to_account through: :project
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
class Comment < ApplicationRecord
|
|
127
|
+
belongs_to :task
|
|
128
|
+
scoped_to_account through: :task
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Queries filter on the parent's own scope, so chains of any depth work:
|
|
133
|
+
|
|
134
|
+
```sql
|
|
135
|
+
-- Task.all
|
|
136
|
+
SELECT * FROM tasks WHERE project_id IN (SELECT id FROM projects WHERE account_id = 1)
|
|
137
|
+
|
|
138
|
+
-- Comment.all
|
|
139
|
+
SELECT * FROM comments WHERE task_id IN (
|
|
140
|
+
SELECT id FROM tasks WHERE project_id IN (SELECT id FROM projects WHERE account_id = 1))
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Through models get `account` and `account_id` from their parent, and can't be created under,
|
|
144
|
+
or moved to, another account's parent. They can move to another parent in the same account.
|
|
145
|
+
|
|
146
|
+
An `account_id` column is still worth adding to large, frequently queried tables, because it
|
|
147
|
+
makes the scope a single indexed comparison instead of a subquery.
|
|
148
|
+
|
|
149
|
+
### Records that can exist outside of an account: `optional:`
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
class Tag < ApplicationRecord
|
|
153
|
+
scoped_to_account optional: true
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The `account_id` column can be `NULL`, for records you create outside of any account, such as in
|
|
158
|
+
seeds or an admin area:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
AccountScoping.across_accounts { Tag.create!(name: "Urgent") } # No account
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Inside an account, records without an account are hidden, and new records always belong to the
|
|
165
|
+
current account. That keeps `Tag.delete_all` in one account from deleting records every account
|
|
166
|
+
shares. When an account should also see the shared records, ask for them explicitly:
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
AccountScoping.across_accounts { Tag.where(account: [ Current.account, nil ]) }
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`optional:` is for models with an `account_id` column. For a through model whose parent is
|
|
173
|
+
optional, make the `belongs_to` optional instead.
|
|
174
|
+
|
|
175
|
+
### Protections
|
|
176
|
+
|
|
177
|
+
The default scope uses `all_queries: true`, so it also applies to updating, deleting, and
|
|
178
|
+
reloading individual records, not just to reads. Scoped models also get three validations:
|
|
179
|
+
|
|
180
|
+
- **The account must be the current one.** Mass-assigning an `account_id`, like a scaffold's
|
|
181
|
+
`params.expect(project: [ :name, :account_id ])`, can't create a record in another account.
|
|
182
|
+
The same goes for a through model's parent, like a `project_id` from another account.
|
|
183
|
+
- **The account can't change** once a record is saved, including by moving a through model to a
|
|
184
|
+
parent in another account.
|
|
185
|
+
- **`belongs_to` records must be in the same account.** `Task.create!(tag_id: params[:tag_id])`
|
|
186
|
+
fails when the tag belongs to another account, whether it's assigned by ID or as a record.
|
|
187
|
+
|
|
188
|
+
Uniqueness validations are not scoped automatically. Add `scope: :account_id` where values only
|
|
189
|
+
need to be unique within an account.
|
|
190
|
+
|
|
191
|
+
## The current account
|
|
192
|
+
|
|
193
|
+
`Current.account` is an `ActiveSupport::CurrentAttributes` attribute, so it's isolated per
|
|
194
|
+
request, per job, and per thread, and reset automatically afterwards.
|
|
195
|
+
|
|
196
|
+
### Scoped models raise without an account
|
|
197
|
+
|
|
198
|
+
Querying a scoped model when `Current.account` isn't set raises
|
|
199
|
+
`AccountScoping::MissingAccountError`:
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
Project.count
|
|
203
|
+
# => AccountScoping::MissingAccountError: Project is scoped to an account, but Current.account
|
|
204
|
+
# isn't set. Use Current.set(account: account) { ... } or AccountScoping.across_accounts { ... }.
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
This is intentional. A job, rake task, or mailer that forgot to set an account fails loudly in
|
|
208
|
+
development instead of silently reading or writing every account's data in production. The same
|
|
209
|
+
applies to building records with `Project.new`, and to associations: `account.projects` raises
|
|
210
|
+
too, because the default scope still runs inside associations.
|
|
211
|
+
|
|
212
|
+
### Acting on behalf of an account
|
|
213
|
+
|
|
214
|
+
Set the account for a block. The previous value is restored afterwards:
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
Current.set(account: account) do
|
|
218
|
+
Project.create!(name: "Launch")
|
|
219
|
+
end
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Querying across accounts
|
|
223
|
+
|
|
224
|
+
When you mean to work with every account, say so:
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
AccountScoping.across_accounts do
|
|
228
|
+
Project.where(archived: true).delete_all
|
|
229
|
+
end
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`across_accounts` only turns off account scoping. Unlike `unscoped`, other default scopes on the
|
|
233
|
+
model, such as a soft-delete scope, still apply. Both are easy to search for when reviewing code
|
|
234
|
+
that crosses accounts.
|
|
235
|
+
|
|
236
|
+
## Controllers and URLs
|
|
237
|
+
|
|
238
|
+
### Account URLs
|
|
239
|
+
|
|
240
|
+
Account URLs are prefixed with the account ID:
|
|
241
|
+
|
|
242
|
+
```
|
|
243
|
+
/123/projects/1
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
The `AccountSlug` middleware moves the `/123` prefix from `PATH_INFO` to `SCRIPT_NAME`, the same
|
|
247
|
+
way Rails handles an app mounted at a sub-path. This means:
|
|
248
|
+
|
|
249
|
+
- **Routes don't change.** No `scope ":account_id"` and no `:account_id` parameter to pass around.
|
|
250
|
+
- **URL helpers keep the prefix.** `project_path(@project)` returns `/123/projects/1`, and
|
|
251
|
+
`redirect_to @project` stays inside the account.
|
|
252
|
+
- **Pages outside an account work unchanged**, like `/session/new` or `/accounts`.
|
|
253
|
+
|
|
254
|
+
To link into an account from outside it, pass its slug as the `script_name`:
|
|
255
|
+
|
|
256
|
+
```erb
|
|
257
|
+
<%= link_to account.name, root_url(script_name: account.slug) %>
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
To link out of an account, pass an empty `script_name`:
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
redirect_to accounts_url(script_name: "")
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Requiring an account
|
|
267
|
+
|
|
268
|
+
`Tenanting` adds a `require_account` before action to `ApplicationController`. It reads the
|
|
269
|
+
account ID from the URL and sets `Current.account`. It works like the `Authentication` concern:
|
|
270
|
+
|
|
271
|
+
```ruby
|
|
272
|
+
class HomeController < ApplicationController
|
|
273
|
+
allow_accountless_access only: :index
|
|
274
|
+
end
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
With authentication, the account is looked up through `Current.user.accounts`, so users can only
|
|
278
|
+
reach accounts they're members of. A URL for any other account returns 404 Not Found. A request
|
|
279
|
+
without an account prefix redirects to the account picker, which goes straight into the account
|
|
280
|
+
when the user only has one.
|
|
281
|
+
|
|
282
|
+
Without authentication, any account ID in the URL is accepted, and requests without one return
|
|
283
|
+
404. Add your own authorization in `find_account_by_slug`.
|
|
284
|
+
|
|
285
|
+
## Background jobs, mailers, and broadcasts
|
|
286
|
+
|
|
287
|
+
### Jobs
|
|
288
|
+
|
|
289
|
+
Every Active Job remembers the account it was enqueued in and runs in that account. This
|
|
290
|
+
includes mailers delivered with `deliver_later` and jobs that don't inherit from `ApplicationJob`:
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
Current.set(account: account) do
|
|
294
|
+
ExportJob.perform_later(project) # Runs with Current.account = account
|
|
295
|
+
end
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The account is serialized as a GlobalID in the job's `current_account` key. Arguments are
|
|
299
|
+
deserialized inside the account, so scoped records can be passed as arguments. A job enqueued
|
|
300
|
+
without an account raises `MissingAccountError` as soon as it queries a scoped model.
|
|
301
|
+
|
|
302
|
+
### Mailers
|
|
303
|
+
|
|
304
|
+
`ApplicationMailer#default_url_options` adds the account prefix, so links in emails point into
|
|
305
|
+
the account the email was sent from. Set `config.action_mailer.default_url_options` with your
|
|
306
|
+
host as usual.
|
|
307
|
+
|
|
308
|
+
### Turbo Stream broadcasts
|
|
309
|
+
|
|
310
|
+
If your app uses `turbo-rails`, broadcasts render with the account prefix, so links in broadcast
|
|
311
|
+
partials point into the account. Broadcasts made with `broadcast_*_later` run as jobs, so they
|
|
312
|
+
pick up the account like any other job.
|
|
313
|
+
|
|
314
|
+
## Console, seeds, and data migrations
|
|
315
|
+
|
|
316
|
+
In the console, switch into an account before working with scoped models:
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
>> switch_to_account 123
|
|
320
|
+
Switched to account 123 (Basecamp)
|
|
321
|
+
>> Project.count
|
|
322
|
+
=> 12
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
In seeds, data migrations, and rake tasks, wrap the work in `Current.set` or `across_accounts`:
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
# db/seeds.rb
|
|
329
|
+
account = Account.create!(name: "Basecamp")
|
|
330
|
+
|
|
331
|
+
Current.set(account: account) do
|
|
332
|
+
Project.create!(name: "Launch")
|
|
333
|
+
end
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
## Testing
|
|
337
|
+
|
|
338
|
+
Fixtures work as usual. Reference the account in each scoped fixture:
|
|
339
|
+
|
|
340
|
+
```yaml
|
|
341
|
+
# test/fixtures/projects.yml
|
|
342
|
+
one:
|
|
343
|
+
name: Launch
|
|
344
|
+
account: one
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Fixture accessors like `projects(:one)` load records without the default scope, so they work
|
|
348
|
+
without an account. Call `switch_to_account` before running anything else that queries scoped
|
|
349
|
+
models:
|
|
350
|
+
|
|
351
|
+
```ruby
|
|
352
|
+
class ProjectTest < ActiveSupport::TestCase
|
|
353
|
+
setup { switch_to_account accounts(:one) }
|
|
354
|
+
|
|
355
|
+
test "names are unique within an account" do
|
|
356
|
+
assert_not Project.new(name: projects(:one).name).valid?
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
In integration tests, `switch_to_account` also prefixes generated URLs with the account, so each
|
|
362
|
+
request finds its account from the URL just like in production. It restores `Current.account`
|
|
363
|
+
after each request, because Rails resets `Current` around requests:
|
|
364
|
+
|
|
365
|
+
```ruby
|
|
366
|
+
class ProjectsControllerTest < ActionDispatch::IntegrationTest
|
|
367
|
+
setup do
|
|
368
|
+
sign_in_as users(:one)
|
|
369
|
+
switch_to_account accounts(:one)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
test "create" do
|
|
373
|
+
assert_difference -> { Project.count } do
|
|
374
|
+
post projects_url, params: { project: { name: "Launch" } } # POST /123/projects
|
|
375
|
+
end
|
|
376
|
+
assert_redirected_to project_url(Project.last)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
test "another account's projects are not found" do
|
|
380
|
+
get project_url(projects(:two))
|
|
381
|
+
assert_response :not_found
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
## Customizing
|
|
387
|
+
|
|
388
|
+
The generated code is yours to change. Some common changes:
|
|
389
|
+
|
|
390
|
+
### Subdomains or custom domains instead of a path prefix
|
|
391
|
+
|
|
392
|
+
Remove the `AccountSlug` middleware from `config/initializers/tenanting.rb`, add a column to
|
|
393
|
+
accounts, and look the account up from the request in the `Tenanting` concern:
|
|
394
|
+
|
|
395
|
+
```ruby
|
|
396
|
+
def find_account_by_slug
|
|
397
|
+
Current.user&.accounts&.find_by(subdomain: request.subdomain)
|
|
398
|
+
end
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
Also change `Account#slug`, which is used as the URL prefix in mailers, broadcasts, and tests.
|
|
402
|
+
Instead, set the `host` in those places, or set `subdomain:` in your URL helpers.
|
|
403
|
+
|
|
404
|
+
### Public IDs instead of database IDs
|
|
405
|
+
|
|
406
|
+
`AccountSlug::PATTERN` matches a numeric first path segment. To keep database IDs out of URLs,
|
|
407
|
+
store a random public ID on each account, change the pattern to match it, look it up by that
|
|
408
|
+
column in `find_account_by_slug`, and return it from `Account#slug`.
|
|
409
|
+
|
|
410
|
+
Because any numeric first segment is treated as an account, avoid top-level routes whose path
|
|
411
|
+
starts with a number, or use a longer format (Fizzy pads IDs to at least 7 digits).
|
|
412
|
+
|
|
413
|
+
### Allowing unscoped queries
|
|
414
|
+
|
|
415
|
+
If you'd rather have queries run unscoped without an account, like ActsAsTenant's default,
|
|
416
|
+
replace the `raise` in `AccountScoping.scope_to_current_account` with `relation`. Consider
|
|
417
|
+
requiring the account in production anyway.
|
|
418
|
+
|
|
419
|
+
## Security model
|
|
420
|
+
|
|
421
|
+
Tenanting stops:
|
|
422
|
+
|
|
423
|
+
- Queries on scoped models from returning other accounts' records, including `update_all`,
|
|
424
|
+
`delete_all`, and updates, deletes, and reloads of single records.
|
|
425
|
+
- Queries on scoped models from running when no account has been chosen.
|
|
426
|
+
- Records from being created in, or moved to, another account through mass assignment.
|
|
427
|
+
- `belongs_to` references to another account's records, by ID or by record.
|
|
428
|
+
- Users from reaching accounts they aren't members of (with authentication).
|
|
429
|
+
- Jobs from running in the wrong account, or in no account.
|
|
430
|
+
|
|
431
|
+
It does not cover:
|
|
432
|
+
|
|
433
|
+
- **Models without `scoped_to_account`.** Tables that aren't scoped, such as a join table that is
|
|
434
|
+
only reached through a scoped model, rely on that model being scoped. Consider `through:`.
|
|
435
|
+
- **`unscoped`, raw SQL, and `across_accounts`.** These bypass scoping on purpose. Review them.
|
|
436
|
+
- **Polymorphic `belongs_to`.** These aren't checked by the same-account validation.
|
|
437
|
+
- **Cache keys.** Include the account in keys you build yourself, like
|
|
438
|
+
`Rails.cache.fetch([ Current.account, "stats" ])`. Record-based cache keys are already unique.
|
|
439
|
+
- **Action Cable connections.** Identify the account in `ApplicationCable::Connection` yourself.
|
|
440
|
+
- **Active Storage.** Attachments are served by signed URLs, which aren't scoped to an account.
|
|
441
|
+
|
|
442
|
+
## Migrating from ActsAsTenant
|
|
443
|
+
|
|
444
|
+
| ActsAsTenant | Tenanting |
|
|
445
|
+
| --- | --- |
|
|
446
|
+
| `acts_as_tenant :account` | `scoped_to_account` |
|
|
447
|
+
| `acts_as_tenant :account, optional: true` | `scoped_to_account optional: true` |
|
|
448
|
+
| `acts_as_tenant :account, through: :account_users` | A `has_many :through`, like `User#accounts`. See below |
|
|
449
|
+
| `ActsAsTenant.current_tenant` | `Current.account` |
|
|
450
|
+
| `ActsAsTenant.current_tenant = account` | `Current.account = account` |
|
|
451
|
+
| `ActsAsTenant.with_tenant(account) { }` | `Current.set(account: account) { }` |
|
|
452
|
+
| `ActsAsTenant.without_tenant { }` | `AccountScoping.across_accounts { }` |
|
|
453
|
+
| `set_current_tenant_by_subdomain` | Path prefixes, or see [Subdomains](#subdomains-or-custom-domains-instead-of-a-path-prefix) |
|
|
454
|
+
| `set_current_tenant_through_filter` | Edit `find_account_by_slug` |
|
|
455
|
+
| `config.require_tenant = true` | Always on |
|
|
456
|
+
| `validates_uniqueness_to_tenant :name` | `validates :name, uniqueness: { scope: :account_id }` |
|
|
457
|
+
| `ActsAsTenant::ActiveJobExtensions` | Built in, for every Active Job |
|
|
458
|
+
| `ActsAsTenant::TestTenantMiddleware` | `switch_to_account` |
|
|
459
|
+
|
|
460
|
+
To migrate:
|
|
461
|
+
|
|
462
|
+
1. Run `bin/rails generate tenanting`. If your tenant model already exists, delete the generated
|
|
463
|
+
`Account` model and migration and keep yours. Add `#slug` to it.
|
|
464
|
+
2. Replace `acts_as_tenant :account` with `scoped_to_account` in each model. Models that only
|
|
465
|
+
reach their account through a parent can use `scoped_to_account through: :parent`.
|
|
466
|
+
|
|
467
|
+
ActsAsTenant's `through:` is different: it scopes a model like `User` to accounts through a
|
|
468
|
+
many-to-many join table. Records like that belong to several accounts, so they aren't scoped.
|
|
469
|
+
Reach them through an association instead, like `Current.account.users`.
|
|
470
|
+
3. Replace `ActsAsTenant` calls using the table above.
|
|
471
|
+
4. Remove your `set_current_tenant_*` calls. `Tenanting` sets `Current.account` from the URL, or
|
|
472
|
+
from wherever you change `find_account_by_slug` to look.
|
|
473
|
+
5. Wrap code that ran without a tenant in `Current.set` or `across_accounts`. Your test suite will
|
|
474
|
+
point these out by raising `AccountScoping::MissingAccountError`.
|
|
475
|
+
6. Remove the `acts_as_tenant` gem.
|
|
476
|
+
|
|
477
|
+
## Development
|
|
478
|
+
|
|
479
|
+
```sh
|
|
480
|
+
bundle install
|
|
481
|
+
bundle exec rake test # Generator tests
|
|
482
|
+
bundle exec rake test:integration # Generates a Rails app and runs its tests
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
The integration task creates a new Rails app, runs the authentication and tenanting generators,
|
|
486
|
+
adds the models and tests in `test/integration/app`, and runs the app's test suite.
|
|
487
|
+
|
|
488
|
+
## License
|
|
489
|
+
|
|
490
|
+
Tenanting is released under the [MIT License](MIT-LICENSE).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Generates multitenancy for the app: an Account model, Current.account, an
|
|
3
|
+
AccountScoping model concern, a Tenanting controller concern, account URL
|
|
4
|
+
prefixes (/123/projects), and account propagation to jobs, mailers, and the
|
|
5
|
+
console.
|
|
6
|
+
|
|
7
|
+
Run the authentication generator first to also get memberships between users
|
|
8
|
+
and accounts, and an account picker.
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
bin/rails generate authentication
|
|
12
|
+
bin/rails generate tenanting
|
|
13
|
+
bin/rails db:migrate
|
|
14
|
+
|
|
15
|
+
Then scope models to an account:
|
|
16
|
+
|
|
17
|
+
class Project < ApplicationRecord
|
|
18
|
+
scoped_to_account
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Task < ApplicationRecord
|
|
22
|
+
belongs_to :project
|
|
23
|
+
scoped_to_account through: :project
|
|
24
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Tenanting
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
included do
|
|
5
|
+
before_action :require_account
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
class_methods do
|
|
9
|
+
def allow_accountless_access(**options)
|
|
10
|
+
skip_before_action :require_account, **options
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
def require_account
|
|
16
|
+
resume_account || request_account
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def resume_account
|
|
20
|
+
Current.account ||= find_account_by_slug
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def find_account_by_slug
|
|
24
|
+
if account_id = request.env["account_slug.id"]
|
|
25
|
+
<% if authentication? -%>
|
|
26
|
+
Current.user&.accounts&.find_by(id: account_id)
|
|
27
|
+
<% else -%>
|
|
28
|
+
Account.find_by(id: account_id)
|
|
29
|
+
<% end -%>
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def request_account
|
|
34
|
+
<% if authentication? -%>
|
|
35
|
+
if request.env["account_slug.id"]
|
|
36
|
+
head :not_found
|
|
37
|
+
else
|
|
38
|
+
redirect_to accounts_url(script_name: "")
|
|
39
|
+
end
|
|
40
|
+
<% else -%>
|
|
41
|
+
head :not_found
|
|
42
|
+
<% end -%>
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class Account < ApplicationRecord
|
|
2
|
+
<% if authentication? -%>
|
|
3
|
+
has_many :memberships, dependent: :destroy
|
|
4
|
+
has_many :users, through: :memberships
|
|
5
|
+
|
|
6
|
+
<% end -%>
|
|
7
|
+
validates :name, presence: true
|
|
8
|
+
|
|
9
|
+
# The path prefix for this account's URLs. Pass it as the script_name to link into an
|
|
10
|
+
# account from outside of it, e.g. root_url(script_name: account.slug).
|
|
11
|
+
def slug
|
|
12
|
+
"/#{id}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Scopes models to Current.account. It's included in ApplicationRecord, so any model can call:
|
|
2
|
+
#
|
|
3
|
+
# class Project < ApplicationRecord
|
|
4
|
+
# scoped_to_account # Has an account_id column
|
|
5
|
+
# end
|
|
6
|
+
#
|
|
7
|
+
# class Task < ApplicationRecord
|
|
8
|
+
# belongs_to :project
|
|
9
|
+
# scoped_to_account through: :project # Gets its account from a scoped parent
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# class Tag < ApplicationRecord
|
|
13
|
+
# scoped_to_account optional: true # Can also exist outside of any account
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Querying a scoped model without Current.account raises, so a missing account is caught
|
|
17
|
+
# instead of silently reading or writing across accounts. Be explicit instead:
|
|
18
|
+
#
|
|
19
|
+
# Current.set(account: account) { Project.all } # One account
|
|
20
|
+
# AccountScoping.across_accounts { Project.count } # Every account
|
|
21
|
+
module AccountScoping
|
|
22
|
+
extend ActiveSupport::Concern
|
|
23
|
+
|
|
24
|
+
class MissingAccountError < StandardError; end
|
|
25
|
+
|
|
26
|
+
def self.across_accounts(&)
|
|
27
|
+
Current.set(all_accounts: true, &)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.scope_to_current_account(relation) # :nodoc:
|
|
31
|
+
if Current.all_accounts
|
|
32
|
+
relation
|
|
33
|
+
elsif Current.account
|
|
34
|
+
if through = relation.model.account_scoping[:through]
|
|
35
|
+
# Relies on the parent's own scope, so chains of through models work at any depth.
|
|
36
|
+
parent = relation.model.reflect_on_association(through)
|
|
37
|
+
relation.where(parent.foreign_key => parent.klass.select(parent.association_primary_key))
|
|
38
|
+
else
|
|
39
|
+
relation.where(account: Current.account)
|
|
40
|
+
end
|
|
41
|
+
else
|
|
42
|
+
raise MissingAccountError, "#{relation.model.name} is scoped to an account, but Current.account isn't set. " \
|
|
43
|
+
"Use Current.set(account: account) { ... } or AccountScoping.across_accounts { ... }."
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
included do
|
|
48
|
+
class_attribute :account_scoping, instance_writer: false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class_methods do
|
|
52
|
+
# Scopes all queries to Current.account.
|
|
53
|
+
#
|
|
54
|
+
# [through] A belongs_to association to a scoped model, for tables without an account_id column.
|
|
55
|
+
# [optional] Allows records without an account. They can be created and queried outside of any
|
|
56
|
+
# account, but are hidden from, and can't be created in, an account.
|
|
57
|
+
def scoped_to_account(through: nil, optional: false)
|
|
58
|
+
if through && optional
|
|
59
|
+
raise ArgumentError, "optional: is for models with an account_id column. Make the #{through} association optional instead."
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
self.account_scoping = { through: through, optional: optional }.freeze
|
|
63
|
+
|
|
64
|
+
if through
|
|
65
|
+
delegate :account, :account_id, to: through, allow_nil: true
|
|
66
|
+
else
|
|
67
|
+
belongs_to :account, optional: optional, default: -> { Current.account }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
default_scope(all_queries: true) { AccountScoping.scope_to_current_account(self) }
|
|
71
|
+
|
|
72
|
+
validate :account_is_current, if: -> { Current.account && !Current.all_accounts }
|
|
73
|
+
validate :account_is_unchanged, on: :update
|
|
74
|
+
validate :associations_belong_to_same_account
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def scoped_to_account?
|
|
78
|
+
account_scoping.present?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
def account_scoping_association
|
|
84
|
+
self.class.reflect_on_association(account_scoping[:through] || :account)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def changing_account?
|
|
88
|
+
will_save_change_to_attribute?(account_scoping_association.foreign_key)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Guards against mass-assigning another account or parent, e.g. params.expect(project: [ :name, :account_id ])
|
|
92
|
+
def account_is_current
|
|
93
|
+
unless account_id == Current.account.id
|
|
94
|
+
errors.add(account_scoping_association.name, account_scoping[:through] ? "must belong to the current account" : "must be the current account")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def account_is_unchanged
|
|
99
|
+
if changing_account? && account_id != account_id_in_database_for_scoping
|
|
100
|
+
errors.add(account_scoping_association.name, account_scoping[:through] ? "must belong to the same account" : "can't be changed")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Prevents pointing at another account's records by ID, e.g. Comment.create!(task_id: params[:task_id])
|
|
105
|
+
def associations_belong_to_same_account
|
|
106
|
+
self.class.reflect_on_all_associations(:belongs_to).each do |association|
|
|
107
|
+
next if association.polymorphic? || association.name == account_scoping_association.name
|
|
108
|
+
next unless association.klass.try(:scoped_to_account?)
|
|
109
|
+
next unless (id = self[association.foreign_key]) && (will_save_change_to_attribute?(association.foreign_key) || changing_account?)
|
|
110
|
+
|
|
111
|
+
if account_id_of(association, id) != account_id
|
|
112
|
+
errors.add(association.name, "must belong to the same account")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def account_id_in_database_for_scoping
|
|
118
|
+
if account_scoping[:through]
|
|
119
|
+
account_id_of(account_scoping_association, attribute_in_database(account_scoping_association.foreign_key))
|
|
120
|
+
else
|
|
121
|
+
attribute_in_database(:account_id)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The account of the associated record with this ID, whichever account it's in.
|
|
126
|
+
def account_id_of(association, id)
|
|
127
|
+
AccountScoping.across_accounts { association.klass.find_by(association.association_primary_key => id)&.account_id } if id
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Account URLs are prefixed with the account ID: /123/projects/1
|
|
2
|
+
#
|
|
3
|
+
# The middleware moves that prefix from PATH_INFO into SCRIPT_NAME, the same way a mounted
|
|
4
|
+
# app is handled. Routes don't need an :account_id segment, and URL helpers generate
|
|
5
|
+
# account-prefixed URLs for the current request automatically.
|
|
6
|
+
module AccountSlug
|
|
7
|
+
PATTERN = %r{\A/(\d+)(?=/|\z)}
|
|
8
|
+
|
|
9
|
+
class Extractor
|
|
10
|
+
def initialize(app)
|
|
11
|
+
@app = app
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(env)
|
|
15
|
+
request = ActionDispatch::Request.new(env)
|
|
16
|
+
|
|
17
|
+
if match = PATTERN.match(request.path_info)
|
|
18
|
+
request.env["account_slug.id"] = match[1].to_i
|
|
19
|
+
request.script_name = request.script_name.to_s + match[0]
|
|
20
|
+
request.path_info = match.post_match.presence || "/"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
@app.call(env)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Rails.application.config.middleware.use AccountSlug::Extractor
|
|
29
|
+
|
|
30
|
+
# Jobs run in the account they were enqueued from. This covers every Active Job,
|
|
31
|
+
# including deliver_later, not just jobs that inherit from ApplicationJob.
|
|
32
|
+
module AccountScopedJob
|
|
33
|
+
attr_reader :current_account
|
|
34
|
+
|
|
35
|
+
def initialize(...)
|
|
36
|
+
super
|
|
37
|
+
@current_account = Current.account
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def serialize
|
|
41
|
+
super.merge("current_account" => current_account&.to_global_id&.to_s)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def deserialize(job_data)
|
|
45
|
+
super
|
|
46
|
+
@current_account = GlobalID::Locator.locate(job_data["current_account"]) if job_data["current_account"]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def perform_now
|
|
50
|
+
if current_account
|
|
51
|
+
Current.set(account: current_account) { super }
|
|
52
|
+
else
|
|
53
|
+
super
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
ActiveSupport.on_load(:active_job) { prepend AccountScopedJob }
|
|
59
|
+
|
|
60
|
+
# Adds switch_to_account(account_or_id) to the console, since scoped models raise until
|
|
61
|
+
# an account is set.
|
|
62
|
+
Rails.application.console do
|
|
63
|
+
require "irb/helper_method"
|
|
64
|
+
|
|
65
|
+
IRB::HelperMethod.register :switch_to_account, Class.new(IRB::HelperMethod::Base) {
|
|
66
|
+
description "Sets Current.account for the console session."
|
|
67
|
+
|
|
68
|
+
def execute(account)
|
|
69
|
+
Current.account = account.is_a?(Account) ? account : Account.find(account)
|
|
70
|
+
puts "Switched to account #{Current.account.id} (#{Current.account.name})"
|
|
71
|
+
Current.account
|
|
72
|
+
end
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
<% if turbo? -%>
|
|
76
|
+
|
|
77
|
+
# Turbo Stream broadcasts render outside of a request, so render them with the account
|
|
78
|
+
# prefix to keep links in broadcast partials pointing into the account.
|
|
79
|
+
module AccountScopedTurboStreams
|
|
80
|
+
private
|
|
81
|
+
def render_format(format, **rendering)
|
|
82
|
+
if Current.account
|
|
83
|
+
ApplicationController.renderer.new(script_name: Current.account.slug).render(formats: [ format ], **rendering)
|
|
84
|
+
else
|
|
85
|
+
super
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
Rails.application.config.after_initialize do
|
|
91
|
+
Turbo::StreamsChannel.singleton_class.prepend AccountScopedTurboStreams
|
|
92
|
+
end
|
|
93
|
+
<% end -%>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
class CreateMemberships < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :memberships do |t|
|
|
4
|
+
t.references :user, null: false, foreign_key: true, index: false
|
|
5
|
+
t.references :account, null: false, foreign_key: true
|
|
6
|
+
|
|
7
|
+
t.timestamps
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
add_index :memberships, %i[ user_id account_id ], unique: true
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module AccountTestHelper
|
|
2
|
+
def switch_to_account(account)
|
|
3
|
+
Current.account = account
|
|
4
|
+
end
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
module AccountIntegrationTestHelper
|
|
8
|
+
# Also prefixes generated URLs with the account, so requests resolve it from the path.
|
|
9
|
+
def switch_to_account(account)
|
|
10
|
+
super
|
|
11
|
+
@switched_account = account
|
|
12
|
+
self.default_url_options = default_url_options.merge(script_name: account&.slug)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Rails resets Current around each request, so restore the account for assertions afterwards.
|
|
16
|
+
%i[ get post patch put head delete follow_redirect! ].each do |method|
|
|
17
|
+
define_method(method) do |*args, **options|
|
|
18
|
+
super(*args, **options).tap { Current.account = @switched_account }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
ActiveSupport.on_load(:active_support_test_case) do
|
|
24
|
+
include AccountTestHelper
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
ActiveSupport.on_load(:action_dispatch_integration_test) do
|
|
28
|
+
include AccountIntegrationTestHelper
|
|
29
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
# Generates multitenancy into an app the same way `bin/rails generate authentication`
|
|
7
|
+
# generates authentication: plain application code built on Current attributes,
|
|
8
|
+
# concerns, and a middleware, which the app owns and can edit.
|
|
9
|
+
class TenantingGenerator < Rails::Generators::Base
|
|
10
|
+
include ActiveRecord::Generators::Migration
|
|
11
|
+
|
|
12
|
+
source_root File.expand_path("templates", __dir__)
|
|
13
|
+
|
|
14
|
+
def create_tenanting_files
|
|
15
|
+
template "app/models/account.rb"
|
|
16
|
+
template "app/models/concerns/account_scoping.rb"
|
|
17
|
+
template "app/controllers/concerns/tenanting.rb"
|
|
18
|
+
template "config/initializers/tenanting.rb"
|
|
19
|
+
|
|
20
|
+
if authentication?
|
|
21
|
+
template "app/models/membership.rb"
|
|
22
|
+
template "app/controllers/accounts_controller.rb"
|
|
23
|
+
template "app/views/accounts/index.html.erb"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def configure_current
|
|
28
|
+
if exist?("app/models/current.rb")
|
|
29
|
+
inject_into_class "app/models/current.rb", "Current", " attribute :account, :all_accounts\n"
|
|
30
|
+
else
|
|
31
|
+
template "app/models/current.rb"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def configure_application_record
|
|
36
|
+
if read("app/models/application_record.rb").include?(" primary_abstract_class\n")
|
|
37
|
+
inject_into_file "app/models/application_record.rb", "\n include AccountScoping\n", after: " primary_abstract_class\n"
|
|
38
|
+
else
|
|
39
|
+
inject_into_class "app/models/application_record.rb", "ApplicationRecord", " include AccountScoping\n"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def configure_user
|
|
44
|
+
if authentication?
|
|
45
|
+
inject_into_file "app/models/user.rb", <<~RUBY.indent(2), after: "has_many :sessions, dependent: :destroy\n"
|
|
46
|
+
has_many :memberships, dependent: :destroy
|
|
47
|
+
has_many :accounts, through: :memberships
|
|
48
|
+
RUBY
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def configure_controllers
|
|
53
|
+
if authentication?
|
|
54
|
+
# Tenanting has to run after Authentication so it can check the user's memberships.
|
|
55
|
+
inject_into_file "app/controllers/application_controller.rb", " include Tenanting\n", after: " include Authentication\n"
|
|
56
|
+
|
|
57
|
+
%w[ sessions passwords ].each do |name|
|
|
58
|
+
path = "app/controllers/#{name}_controller.rb"
|
|
59
|
+
inject_into_class path, "#{name.camelize}Controller", " allow_accountless_access\n" if exist?(path)
|
|
60
|
+
end
|
|
61
|
+
else
|
|
62
|
+
inject_into_class "app/controllers/application_controller.rb", "ApplicationController", " include Tenanting\n"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def configure_mailers
|
|
67
|
+
if exist?("app/mailers/application_mailer.rb")
|
|
68
|
+
inject_into_class "app/mailers/application_mailer.rb", "ApplicationMailer", <<~RUBY.indent(2)
|
|
69
|
+
# Links in emails point into the account they were sent from, including with deliver_later.
|
|
70
|
+
def default_url_options
|
|
71
|
+
super.merge(script_name: Current.account&.slug)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
RUBY
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def configure_routes
|
|
79
|
+
route "resources :accounts, only: :index" if authentication?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def add_migrations
|
|
83
|
+
migration_template "db/migrate/create_accounts.rb", File.join(db_migrate_path, "create_accounts.rb")
|
|
84
|
+
migration_template "db/migrate/create_memberships.rb", File.join(db_migrate_path, "create_memberships.rb") if authentication?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def create_test_files
|
|
88
|
+
return unless exist?("test/test_helper.rb")
|
|
89
|
+
|
|
90
|
+
template "test/fixtures/accounts.yml"
|
|
91
|
+
template "test/fixtures/memberships.yml" if authentication?
|
|
92
|
+
template "test/test_helpers/account_test_helper.rb"
|
|
93
|
+
inject_into_file "test/test_helper.rb", "require_relative \"test_helpers/account_test_helper\"\n", after: "require \"rails/test_help\"\n"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
def authentication?
|
|
98
|
+
exist?("app/controllers/concerns/authentication.rb") && exist?("app/models/user.rb")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def turbo?
|
|
102
|
+
exist?("Gemfile") && read("Gemfile").match?(/^\s*gem ["']turbo-rails["']/)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def migration_version
|
|
106
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def exist?(path)
|
|
110
|
+
File.exist?(File.join(destination_root, path))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def read(path)
|
|
114
|
+
File.read(File.join(destination_root, path))
|
|
115
|
+
end
|
|
116
|
+
end
|
data/lib/tenanting.rb
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Tenanting is a generator: `bin/rails generate tenanting` copies the multitenancy code into
|
|
4
|
+
# your app, where you own it. This file intentionally defines no constants, so the gem can't
|
|
5
|
+
# shadow the Tenanting controller concern it generates.
|
metadata
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: tenanting
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Chris Oliver
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: railties
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '8.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: activerecord
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '8.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '8.0'
|
|
40
|
+
description: 'Generates account-based multitenancy into your Rails app: Current.account,
|
|
41
|
+
scoped models that fail closed, account URL prefixes, and account-aware jobs, mailers,
|
|
42
|
+
and tests.'
|
|
43
|
+
email:
|
|
44
|
+
- excid3@gmail.com
|
|
45
|
+
executables: []
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- CHANGELOG.md
|
|
50
|
+
- MIT-LICENSE
|
|
51
|
+
- README.md
|
|
52
|
+
- lib/generators/tenanting/USAGE
|
|
53
|
+
- lib/generators/tenanting/templates/app/controllers/accounts_controller.rb.tt
|
|
54
|
+
- lib/generators/tenanting/templates/app/controllers/concerns/tenanting.rb.tt
|
|
55
|
+
- lib/generators/tenanting/templates/app/models/account.rb.tt
|
|
56
|
+
- lib/generators/tenanting/templates/app/models/concerns/account_scoping.rb.tt
|
|
57
|
+
- lib/generators/tenanting/templates/app/models/current.rb.tt
|
|
58
|
+
- lib/generators/tenanting/templates/app/models/membership.rb.tt
|
|
59
|
+
- lib/generators/tenanting/templates/app/views/accounts/index.html.erb.tt
|
|
60
|
+
- lib/generators/tenanting/templates/config/initializers/tenanting.rb.tt
|
|
61
|
+
- lib/generators/tenanting/templates/db/migrate/create_accounts.rb.tt
|
|
62
|
+
- lib/generators/tenanting/templates/db/migrate/create_memberships.rb.tt
|
|
63
|
+
- lib/generators/tenanting/templates/test/fixtures/accounts.yml.tt
|
|
64
|
+
- lib/generators/tenanting/templates/test/fixtures/memberships.yml.tt
|
|
65
|
+
- lib/generators/tenanting/templates/test/test_helpers/account_test_helper.rb.tt
|
|
66
|
+
- lib/generators/tenanting/tenanting_generator.rb
|
|
67
|
+
- lib/tenanting.rb
|
|
68
|
+
homepage: https://github.com/excid3/tenanting
|
|
69
|
+
licenses:
|
|
70
|
+
- MIT
|
|
71
|
+
metadata:
|
|
72
|
+
source_code_uri: https://github.com/excid3/tenanting
|
|
73
|
+
changelog_uri: https://github.com/excid3/tenanting/blob/main/CHANGELOG.md
|
|
74
|
+
rubygems_mfa_required: 'true'
|
|
75
|
+
rdoc_options: []
|
|
76
|
+
require_paths:
|
|
77
|
+
- lib
|
|
78
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '3.2'
|
|
83
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '0'
|
|
88
|
+
requirements: []
|
|
89
|
+
rubygems_version: 4.0.21
|
|
90
|
+
specification_version: 4
|
|
91
|
+
summary: A multitenancy generator for Rails, in the style of the authentication generator
|
|
92
|
+
test_files: []
|