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 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
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-09-21)
4
+
5
+ - Initial release: `bin/rails generate tenanting`
6
+ - `scoped_to_account` in models, with `through:` for models scoped by a parent and `optional:`
7
+ for records that can exist outside of an account
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,8 @@
1
+ class AccountsController < ApplicationController
2
+ allow_accountless_access
3
+
4
+ def index
5
+ @accounts = Current.user.accounts.order(:name)
6
+ redirect_to root_url(script_name: @accounts.first.slug) if @accounts.one?
7
+ end
8
+ 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,3 @@
1
+ class Current < ActiveSupport::CurrentAttributes
2
+ attribute :account, :all_accounts
3
+ end
@@ -0,0 +1,6 @@
1
+ class Membership < ApplicationRecord
2
+ belongs_to :user
3
+ belongs_to :account
4
+
5
+ validates :user, uniqueness: { scope: :account }
6
+ end
@@ -0,0 +1,7 @@
1
+ <h1>Choose an account</h1>
2
+
3
+ <ul>
4
+ <%% @accounts.each do |account| %>
5
+ <li><%%= link_to account.name, root_url(script_name: account.slug) %></li>
6
+ <%% end %>
7
+ </ul>
@@ -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,9 @@
1
+ class CreateAccounts < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :accounts do |t|
4
+ t.string :name, null: false
5
+
6
+ t.timestamps
7
+ end
8
+ end
9
+ 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,5 @@
1
+ one:
2
+ name: Account One
3
+
4
+ two:
5
+ name: Account Two
@@ -0,0 +1,7 @@
1
+ one:
2
+ user: one
3
+ account: one
4
+
5
+ two:
6
+ user: two
7
+ account: two
@@ -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: []