├── .circleci └── config.yml ├── .github └── CODEOWNERS ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-version ├── Appraisals ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── gemfiles ├── rails_7.0.gemfile ├── rails_7.1.gemfile ├── rails_7.2.gemfile └── rails_8.0.gemfile ├── lib ├── rails_multitenant.rb └── rails_multitenant │ ├── global_context_registry.rb │ ├── global_context_registry │ ├── current.rb │ ├── current_instance.rb │ └── registry_dependent_on.rb │ ├── middleware │ ├── extensions.rb │ ├── isolated_context_registry.rb │ └── railtie.rb │ ├── multitenant_model.rb │ ├── rspec.rb │ └── version.rb ├── rails_multitenant.gemspec └── spec ├── be_multitenant_on_matcher_spec.rb ├── db ├── database.yml └── schema.rb ├── external_item_spec.rb ├── external_item_with_optional_org_spec.rb ├── item_spec.rb ├── item_subtype_spec.rb ├── item_with_optional_org_spec.rb ├── rails_multitenant ├── global_context_registry │ └── current_spec.rb ├── global_context_registry_spec.rb └── middleware │ └── isolated_context_registry_spec.rb ├── rails_multitenant_spec.rb └── spec_helper.rb /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | lint: 4 | docker: 5 | - image: cimg/ruby:3.2.5 6 | working_directory: ~/rails-multitenant 7 | steps: 8 | - checkout 9 | - restore_cache: 10 | keys: 11 | - v1-gems-ruby-3.2.5-{{ checksum "rails_multitenant.gemspec" }}-{{ checksum "Gemfile" }} 12 | - v1-gems-ruby-3.2.5- 13 | - run: 14 | name: Install Gems 15 | command: | 16 | if ! bundle check --path=vendor/bundle; then 17 | bundle install --path=vendor/bundle --jobs=4 --retry=3 18 | bundle clean 19 | fi 20 | - save_cache: 21 | key: v1-gems-ruby-3.2.5-{{ checksum "rails_multitenant.gemspec" }}-{{ checksum "Gemfile" }} 22 | paths: 23 | - "vendor/bundle" 24 | - "gemfiles/vendor/bundle" 25 | - run: 26 | name: Run Rubocop 27 | command: bundle exec rubocop --config .rubocop.yml 28 | test: 29 | parameters: 30 | gemfile: 31 | type: string 32 | ruby_version: 33 | type: string 34 | docker: 35 | - image: cimg/ruby:<< parameters.ruby_version >> 36 | environment: 37 | CIRCLE_TEST_REPORTS: "test-results" 38 | BUNDLE_GEMFILE: << parameters.gemfile >> 39 | working_directory: ~/rails-multitenant 40 | steps: 41 | - checkout 42 | - restore_cache: 43 | keys: 44 | - v1-gems-ruby-<< parameters.ruby_version >>-{{ checksum "rails_multitenant.gemspec" }}-{{ checksum "<< parameters.gemfile >>" }} 45 | - v1-gems-ruby-<< parameters.ruby_version >>- 46 | - run: 47 | name: Install Gems 48 | command: | 49 | if ! bundle check --path=vendor/bundle; then 50 | bundle install --path=vendor/bundle --jobs=4 --retry=3 51 | bundle clean 52 | fi 53 | - save_cache: 54 | key: v1-gems-ruby-<< parameters.ruby_version >>-{{ checksum "rails_multitenant.gemspec" }}-{{ checksum "<< parameters.gemfile >>" }} 55 | paths: 56 | - "vendor/bundle" 57 | - "gemfiles/vendor/bundle" 58 | - run: 59 | name: Run Tests 60 | command: | 61 | bundle exec rspec --format RspecJunitFormatter --out $CIRCLE_TEST_REPORTS/rspec/junit.xml --format progress spec 62 | - store_test_results: 63 | path: "test-results" 64 | workflows: 65 | build: 66 | jobs: 67 | - lint 68 | - test: 69 | matrix: 70 | parameters: 71 | gemfile: 72 | - gemfiles/rails_7.0.gemfile 73 | - gemfiles/rails_7.1.gemfile 74 | - gemfiles/rails_7.2.gemfile 75 | ruby_version: 76 | - 3.1.6 77 | - 3.2.5 78 | - 3.3.5 79 | - test: 80 | matrix: 81 | parameters: 82 | gemfile: 83 | - gemfiles/rails_8.0.gemfile 84 | ruby_version: 85 | - 3.2.5 86 | - 3.3.5 87 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @salsify/pim-core-backend 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /log/ 11 | 12 | bundle 13 | .idea 14 | *.iml 15 | /gemfiles/*.gemfile.lock 16 | config 17 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format documentation 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | salsify_rubocop: conf/rubocop.yml 3 | 4 | AllCops: 5 | TargetRubyVersion: 3.1 6 | Exclude: 7 | - 'gemfiles/**/*' 8 | - 'vendor/**/*' 9 | 10 | Metrics/LineLength: 11 | Enabled: true 12 | Max: 120 13 | IgnoreCopDirectives: true 14 | IgnoredPatterns: 15 | - ^# 16 | 17 | Style/FrozenStringLiteralComment: 18 | Enabled: true 19 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.3.0 2 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise 'rails-7.0' do 4 | gem 'activerecord', '~> 7.0.8' 5 | gem 'activesupport', '~> 7.0.8' 6 | gem 'sqlite3', '~> 1.7' 7 | end 8 | 9 | appraise 'rails-7.1' do 10 | gem 'activerecord', '~> 7.1.4' 11 | gem 'activesupport', '~> 7.1.4' 12 | end 13 | 14 | appraise 'rails-7.2' do 15 | gem 'activerecord', '~> 7.2.1' 16 | gem 'activesupport', '~> 7.2.1' 17 | end 18 | 19 | appraise 'rails-8.0' do 20 | gem 'activerecord', '~> 8.0.0' 21 | gem 'activesupport', '~> 8.0.0' 22 | end 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.21.0 4 | - Add support for Rails 8.0 5 | 6 | ## 0.20.0 7 | * Rails 7.2 support 8 | * Drop unsupported rails version 6.1 9 | * Drop unsupported ruby 3.0 10 | 11 | ## 0.19.0 12 | * Rails 7.1 support 13 | * Drop unsupported rails versions < 6.1 14 | * Drop unsupported ruby versions < 3.0 15 | 16 | ## 0.18.1 17 | * Fix issue where only direct dependencies of `Current` and `CurrentInstance` classes are cleared. 18 | 19 | ## 0.18.0 20 | * Add better support for registry dependencies in a development environment where classes may be reloaded. 21 | * Add a `global_context_mutually_dependent_on` method to support registering bidirectional references. 22 | 23 | ## 0.17.0 24 | * Add support for Ruby 3.2. 25 | * Drop support for Rails 5.2. 26 | 27 | ## 0.16.0 28 | * Add support for Rails 7.0. 29 | * Drop unsupported rails versions < 5.2 30 | * Drop unsupported ruby versions < 2.7 31 | 32 | ## 0.15.0 33 | * Add support for Rails 6.1. 34 | 35 | ## 0.14.0 36 | * Add `RailsMultitenant::GlobalContextRegistry.with_unscoped_queries` - queries executed within the block will skip scoping 37 | * Add `RailsMultitenant::GlobalContextRegistry.disable_scoped_queries` and `RailsMultitenant::GlobalContextRegistry.enable_scoped_queries` - Methods for skipping and resuming scoping when blocks are not usable 38 | 39 | ## 0.13.0 40 | * Add `RailsMultitenant::GlobalContextRegistry.merge!` and 41 | ` RailsMultitenant::GlobalContextRegistry.with_merged_registry` 42 | 43 | ## 0.12.0 44 | * Drop support for Ruby < 2.4 and Rails < 4.2. 45 | * Add support for Rails 6. 46 | 47 | ## 0.11.0 48 | * Provide shorthands to access `RailsMultitenant::GlobalContextRegistry` methods via `RailsMultitenant`. 49 | For example `RailsMultitenant[:organization_id] = 'some value'` will set that value in the registry. 50 | 51 | ## 0.10.0 52 | * Rails 5.2 support. 53 | 54 | ## 0.9.0 55 | * Modify `Current.current` to return a specified default, when not already initialized, or `nil` 56 | when no default is specified. 57 | * Add `Current.provide_default` to optionally specify a default value for `Current.current`. 58 | * Add `Current.current=` / `Current.current?` / `Current.current!` / `Current.as_current`. 59 | 60 | ## 0.8.0 61 | * Switch usage of Fixnum to Integer for Ruby > 2.4 62 | * Test with multiple Rubies 63 | 64 | ## 0.7.2 65 | * Fix bug that prevents clearing dependents of classes derived from CurrentInstance. 66 | 67 | ## 0.7.1 68 | * Added as_current multi-tenant class method. 69 | 70 | ## 0.7.0 71 | * Add Rack middleware to create a new isolated registry per request. 72 | 73 | ## 0.6.0 74 | * Rails 5.1 support. 75 | 76 | ## 0.5.2 77 | * Optimize `CurrentInstance.current` / `CurrentInstance.current_id` / `CurrentInstance.current=` 78 | / `CurrentInstance.current_id=`. 79 | 80 | ## 0.5.1 81 | * Fix incorrect Rails version dependency in rubygems.org. 82 | 83 | ## 0.5.0 84 | * Add `required` option to `multitenant_on` and `multitenant_on_model`. 85 | 86 | ## 0.4.0 87 | * Fix be_multitenant_on matcher to handle models that don't include the `RailsMultitenant::MultitenantModel` module. 88 | * Fix `context_entity_id_field` to work with inheritance. 89 | * Drop Rails 3.2 and 4.0 support since `unscope` doesn't work propertly with default scopes. 90 | 91 | ## 0.3.1 92 | * Fix strip__scope 93 | 94 | ## 0.3.0 95 | * Modify `RailsMultitenant::GlobalContextRegistry#new_registry` to accept an arg 96 | specifying the new registry to set. The previous registry is still returned. 97 | 98 | ## 0.2.0 99 | * Merged [PR 2](https://github.com/salsify/rails-multitenant/pull/2) which adds support for 100 | multi-tenancy based on a foreign key to an external model. As part of this the `multitenant_model_on` 101 | method was renamed to `multitenant_on_model`. 102 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in rails_multitenant.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Salsify, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RailsMultitenant 2 | 3 | [![Gem Version](https://badge.fury.io/rb/rails_multitenant.svg)][gem] 4 | [![Build Status](https://circleci.com/gh/salsify/rails-multitenant.svg?style=svg)][circleci] 5 | 6 | [gem]: https://rubygems.org/gems/rails_multitenant 7 | [circleci]: https://circleci.com/gh/salsify/rails-multitenant 8 | 9 | rails_multitenant is a gem for isolating ActiveRecord models from different tenants. The gem assumes tables storing 10 | multi-tenant models include an appropriate tenant id column. 11 | 12 | ## Installation 13 | 14 | Add this line to your application's Gemfile: 15 | 16 | ```ruby 17 | gem 'rails_multitenant' 18 | ``` 19 | 20 | And then execute: 21 | 22 | $ bundle 23 | 24 | Or install it yourself as: 25 | 26 | $ gem install rails_multitenant 27 | 28 | If you're using Rails, there's nothing else you need to do. 29 | 30 | Otherwise, you need to insert `RailsMultitenant::Middleware::IsolatedContextRegistry` into your middleware stack 31 | 32 | ## Usage 33 | 34 | The gem supports two multi-tenancy strategies: 35 | 36 | 1. Based on a model attribute, typically a foreign key to an entity owned by another service 37 | 2. Based on a model association 38 | 39 | The gem uses ActiveRecord default scopes to make isolating tenants fairly transparent. 40 | 41 | ### Multi-tenancy Based on Model Attributes 42 | 43 | The following model is multi-tenant based on an `organization_id` attribute: 44 | 45 | ```ruby 46 | class Product < ActiveRecord::Base 47 | include RailsMultitenant::MultitenantModel 48 | 49 | multitenant_on :organization_id 50 | end 51 | ``` 52 | 53 | The model can then be used as follows: 54 | 55 | ```ruby 56 | RailsMultitenant::GlobalContextRegistry[:organization_id] = 'my-org' 57 | 58 | # Only returns products from 'my-org' 59 | Product.all 60 | 61 | # Returns products across all orgs 62 | Product.strip_organization_scope.all 63 | 64 | # Or set the current organization in block form 65 | RailsMultitenant::GlobalContextRegistry.with_isolated_registry(organization_id: 'my-org') do 66 | # Only returns products from 'my-org' 67 | Product.all 68 | end 69 | ``` 70 | 71 | By default this adds an ActiveRecord validation to ensure the multi-tenant attribute is present but this can be disabled 72 | by passing `required: false` to `multitenant_on`. 73 | 74 | ### Multi-tenancy Based on Associated Models 75 | 76 | The following model is multi-tenant based on an `Organization` model: 77 | 78 | ```ruby 79 | class Product < ActiveRecord::Base 80 | include RailsMultitenant::MultitenantModel 81 | 82 | multitenant_on_model :organization 83 | end 84 | ``` 85 | 86 | The model can then be used as follows: 87 | 88 | ```ruby 89 | Organization.current_id = 1 90 | 91 | # Only returns products from organization 1 92 | Product.all 93 | 94 | # Use the automatically generated belongs_to association to get 95 | # a product's organization 96 | Product.first.organization 97 | 98 | # Or set the current organization in block form 99 | Organization.as_current_id(1) do 100 | # Only returns products from organization 1 101 | Product.all 102 | end 103 | ``` 104 | 105 | By default this adds an ActiveRecord validation to ensure the tenant model is present but this can be disabled 106 | by passing `required: false` to `multitenant_on_model`. 107 | 108 | ### `current` Models 109 | 110 | Classes can be enabled to have current, thread-local instances. For a standard class this is done with: 111 | 112 | ```ruby 113 | class MyClass 114 | include RailsMultitenant::GlobalContextRegistry::Current 115 | end 116 | 117 | MyClass.current = MyClass.new 118 | ``` 119 | 120 | For an `ActiveRecord` model you can use the following, which additionally allows storing the current model ID. 121 | 122 | ```ruby 123 | class MyClass 124 | include RailsMultitenant::GlobalContextRegistry::CurrentInstance 125 | end 126 | 127 | MyClass.current_id = 123 128 | MyClass.current # => # 129 | ``` 130 | 131 | #### Dependency Tracking 132 | 133 | For classes that are dependent on other `Current` classes you can register dependencies. 134 | 135 | ```ruby 136 | class DependentClass 137 | include RailsMultitenant::GlobalContextRegistry::Current 138 | provide_default :default 139 | global_context_dependent_on MyClass 140 | 141 | def self.default 142 | new(MyClass.current.dependent_id) 143 | end 144 | 145 | def initialize(id) 146 | @id = id 147 | end 148 | end 149 | ``` 150 | 151 | When doing so, clearing the `current` class on the referenced class will also clear the `current` context of the dependent class. 152 | 153 | ```ruby 154 | klass = MyClass.new 155 | MyClass.current = klass 156 | DependentClass.current # => # 157 | 158 | MyClass.current = nil 159 | DependentClass.current # => nil 160 | ``` 161 | 162 | For bi-directional dependencies you can use `#global_context_mutually_dependent_on` instead of `#global_context_dependent_on`. 163 | 164 | ### Shorthand 165 | 166 | When using `rails-multitenant` in a project, it is common to need to set values in `RailsMultitenant::GlobalContextRegistry` at the rails console. 167 | 168 | This is difficult to type. Alternatively you can shorten it to `RailsMultitenant`. For example you might type `RailsMultitenant[:organization_id] = 'some value'` and it will have the same effect as the long version. 169 | 170 | This is mainly intended as a console convenience. Using the long form in source code is fine, and more explicit. 171 | 172 | 173 | ## Development 174 | 175 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake false` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 176 | 177 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 178 | 179 | ## Contributing 180 | 181 | Bug reports and pull requests are welcome on GitHub at https://github.com/salsify/rails-multitenant. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](contributor-covenant.org) code of conduct. 182 | 183 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | 5 | require 'rspec/core/rake_task' 6 | RSpec::Core::RakeTask.new(:spec) do |task| 7 | task.verbose = false 8 | end 9 | 10 | task default: :spec 11 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'rails_multitenant' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /gemfiles/rails_7.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.0.8" 6 | gem "activesupport", "~> 7.0.8" 7 | gem "sqlite3", "~> 1.7" 8 | 9 | gemspec path: "../" 10 | -------------------------------------------------------------------------------- /gemfiles/rails_7.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.1.4" 6 | gem "activesupport", "~> 7.1.4" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_7.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.2.1" 6 | gem "activesupport", "~> 7.2.1" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_8.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 8.0.0" 6 | gem "activesupport", "~> 8.0.0" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /lib/rails_multitenant.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/all' 4 | require 'active_record' 5 | 6 | require 'rails_multitenant/global_context_registry' 7 | require 'rails_multitenant/multitenant_model' 8 | 9 | require 'rails_multitenant/middleware/extensions' 10 | 11 | module RailsMultitenant 12 | extend self 13 | 14 | delegate :get, :[], :fetch, :set, :[]=, :delete, :with_isolated_registry, :merge!, :with_merged_registry, 15 | to: :GlobalContextRegistry 16 | end 17 | 18 | # rails_multitenant/rspec has to be explicitly included by clients who want to use it 19 | -------------------------------------------------------------------------------- /lib/rails_multitenant/global_context_registry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'global_context_registry/current' 4 | require_relative 'global_context_registry/current_instance' 5 | require_relative 'global_context_registry/registry_dependent_on' 6 | 7 | # Handles storing of global state that may be swapped out or unset at 8 | # various points. We use this in dandelion to store the current user, 9 | # org, catalog, etc. 10 | # 11 | # Note: You must ensure your entries are .dup'able. 12 | # 13 | module RailsMultitenant 14 | module GlobalContextRegistry 15 | extend self 16 | 17 | EMPTY_ARRAY = [].freeze 18 | 19 | # Set this global 20 | def set(symbol, value) 21 | globals[symbol] = value 22 | end 23 | alias_method :[]=, :set 24 | 25 | # delete this global 26 | def delete(symbol) 27 | globals.delete(symbol) 28 | end 29 | 30 | # Pass with a generator block for the value 31 | def fetch(symbol) 32 | result = globals[symbol] 33 | unless result 34 | result = yield 35 | globals[symbol] = result 36 | end 37 | result 38 | end 39 | 40 | # get the global identified by the symbol 41 | def get(symbol) 42 | globals[symbol] 43 | end 44 | alias_method :[], :get 45 | 46 | # merge the given values into the registry 47 | def merge!(values) 48 | globals.merge!(values) 49 | end 50 | 51 | # Duplicate the registry 52 | def duplicate_registry 53 | globals.transform_values do |value| 54 | value.nil? || value.is_a?(Integer) ? value : value.dup 55 | end 56 | end 57 | 58 | # Run a block of code with an the given registry 59 | def with_isolated_registry(registry = {}) 60 | prior_globals = new_registry(registry) 61 | yield 62 | ensure 63 | self.globals = prior_globals 64 | end 65 | 66 | # Run a block of code with the given values merged into the current registry 67 | def with_merged_registry(values = {}) 68 | prior_globals = new_registry(globals.merge(values)) 69 | yield 70 | ensure 71 | self.globals = prior_globals 72 | end 73 | 74 | # Prefer .with_isolated_registry to the following two methods. 75 | # Note: these methods are intended for use in a manner like .with_isolated_registry, 76 | # but in contexts where around semantics are not allowed. 77 | 78 | # Set a new, by default empty registry, returning the previous one. 79 | def new_registry(registry = {}) 80 | priors = globals 81 | self.globals = registry 82 | priors 83 | end 84 | 85 | # Replace the registry with one you previously took away with .new_registry 86 | def replace_registry(registry) 87 | self.globals = registry 88 | end 89 | 90 | # Run a block of code that disregards scoping during read queries 91 | def with_unscoped_queries 92 | # disabling Style/ExplicitBlockArgument for performance reasons 93 | with_merged_registry(__use_unscoped_queries: true) do 94 | yield 95 | end 96 | end 97 | 98 | def use_unscoped_queries? 99 | self[:__use_unscoped_queries] == true 100 | end 101 | 102 | # Prefer .with_unscoped_queries to the following two methods. 103 | # Note: these methods are intended for use in a manner like .with_admin_registry, 104 | # but in contexts where around semantics are not allowed. 105 | 106 | def disable_scoped_queries 107 | self[:__use_unscoped_queries] = true 108 | end 109 | 110 | def enable_scoped_queries 111 | self[:__use_unscoped_queries] = nil 112 | end 113 | 114 | private 115 | 116 | @dependencies = {} 117 | 118 | def add_dependency(parent, dependent) 119 | parent = parent.respond_to?(:name) ? parent.name : parent 120 | dependent = dependent.respond_to?(:name) ? dependent.name : dependent 121 | 122 | raise 'dependencies cannot be registered for anonymous classes' if parent.blank? || dependent.blank? 123 | 124 | ((@dependencies[parent] ||= []) << dependent).tap(&:uniq!) 125 | end 126 | 127 | def dependencies_for(klass) 128 | @dependencies[klass.name]&.map(&:safe_constantize)&.tap(&:compact!) || EMPTY_ARRAY 129 | end 130 | 131 | def globals 132 | registry = Thread.current[:global_context_registry] 133 | unless registry 134 | registry = {} 135 | Thread.current[:global_context_registry] = registry 136 | end 137 | registry 138 | end 139 | 140 | def globals=(value) 141 | Thread.current[:global_context_registry] = value 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /lib/rails_multitenant/global_context_registry/current.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'registry_dependent_on' 4 | 5 | module RailsMultitenant 6 | module GlobalContextRegistry 7 | # This module allows your to have a current, thread-local instance 8 | # of this class. It currently assumes your class has a zero-arg 9 | # constructor. 10 | module Current 11 | extend ActiveSupport::Concern 12 | 13 | included do 14 | class_attribute :default_provider, instance_writer: false 15 | end 16 | 17 | module ClassMethods 18 | def current 19 | GlobalContextRegistry.fetch(current_registry_obj) { __current_default } 20 | end 21 | 22 | def current=(object) 23 | raise "#{object} is not a #{self}" if object.present? && !object.is_a?(self) 24 | 25 | GlobalContextRegistry.set(current_registry_obj, object) 26 | __clear_dependents! 27 | end 28 | 29 | def current? 30 | GlobalContextRegistry.get(current_registry_obj).present? 31 | end 32 | 33 | def current! 34 | current || raise("No current #{name} set") 35 | end 36 | 37 | def clear_current!(cleared = nil) 38 | GlobalContextRegistry.delete(current_registry_obj) 39 | __clear_dependents!(cleared) 40 | end 41 | 42 | def as_current(object) 43 | old_object = current if current? 44 | self.current = object 45 | yield 46 | ensure 47 | self.current = old_object 48 | end 49 | 50 | include RegistryDependentOn 51 | 52 | def provide_default(provider = nil, &block) 53 | self.default_provider = provider ? provider.to_proc : block 54 | end 55 | 56 | private 57 | 58 | def current_registry_obj 59 | return @current_registry_obj if @current_registry_obj 60 | 61 | @current_registry_obj = "#{__key_class.name.underscore}_obj".to_sym 62 | end 63 | 64 | def current_registry_default_provider 65 | "#{__key_class.name.underscore}_default_provider".to_sym 66 | end 67 | 68 | def __current_default 69 | if default_provider 70 | default = default_provider.call(self) 71 | raise "#{default} is not a #{self}" if default.present? && !default.is_a?(self) 72 | 73 | default 74 | end 75 | end 76 | 77 | def __clear_dependents!(initial_cleared = nil) 78 | GlobalContextRegistry.send(:dependencies_for, __key_class).tap do |dependencies| 79 | next unless dependencies.present? 80 | 81 | (cleared = initial_cleared || []) << self 82 | dependencies.each { |obj| obj.clear_current!(cleared) unless cleared.include?(obj) } 83 | end 84 | end 85 | 86 | def __key_class 87 | respond_to?(:base_class) ? base_class : self 88 | end 89 | end 90 | 91 | def as_current 92 | old_object = self.class.current if self.class.current? 93 | self.class.current = self 94 | yield 95 | ensure 96 | self.class.current = old_object 97 | end 98 | 99 | def current? 100 | self.class.current? && equal?(self.class.current) 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/rails_multitenant/global_context_registry/current_instance.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'registry_dependent_on' 4 | 5 | module RailsMultitenant 6 | module GlobalContextRegistry 7 | # This module allows you to have a current, thread-local instance 8 | # of a class. This module assumes that you are mixing into a Rails 9 | # model, and separately stores and id in thread local storage for 10 | # lazy loading. 11 | module CurrentInstance 12 | extend ActiveSupport::Concern 13 | 14 | module ClassMethods 15 | def current_id=(id) 16 | GlobalContextRegistry.delete(current_instance_registry_obj) 17 | GlobalContextRegistry.set(current_instance_registry_id, id) 18 | __clear_dependents! 19 | end 20 | 21 | def current=(object) 22 | raise "#{object} is not a #{self}" if object.present? && !object.is_a?(self) 23 | 24 | GlobalContextRegistry.set(current_instance_registry_obj, object) 25 | GlobalContextRegistry.set(current_instance_registry_id, object.try(:id)) 26 | __clear_dependents! 27 | end 28 | 29 | def current_id 30 | GlobalContextRegistry.get(current_instance_registry_id) 31 | end 32 | 33 | def current 34 | GlobalContextRegistry.fetch(current_instance_registry_obj) do 35 | (current_id ? find(current_id) : nil) 36 | end 37 | end 38 | 39 | def current? 40 | !!current 41 | end 42 | 43 | def current! 44 | current || raise("No current #{name} set") 45 | end 46 | 47 | def as_current_id(id) 48 | old_id = current_id 49 | self.current_id = id 50 | yield 51 | ensure 52 | self.current_id = old_id 53 | end 54 | 55 | def as_current(model) 56 | old_model = current 57 | self.current = model 58 | yield 59 | ensure 60 | self.current = old_model 61 | end 62 | 63 | def clear_current!(cleared = nil) 64 | GlobalContextRegistry.delete(current_instance_registry_obj) 65 | __clear_dependents!(cleared) 66 | end 67 | 68 | private 69 | 70 | def __clear_dependents!(initial_cleared = nil) 71 | key_class = respond_to?(:base_class) ? base_class : self 72 | 73 | GlobalContextRegistry.send(:dependencies_for, key_class).tap do |dependencies| 74 | next unless dependencies.present? 75 | 76 | (cleared = initial_cleared || []) << self 77 | dependencies.each { |obj| obj.clear_current!(cleared) unless cleared.include?(obj) } 78 | end 79 | end 80 | 81 | def current_instance_registry_id 82 | return @current_instance_registry_id if @current_instance_registry_id 83 | 84 | key_class = respond_to?(:base_class) ? base_class : self 85 | @current_instance_registry_id = "#{key_class.name.underscore}_id".to_sym 86 | end 87 | 88 | def current_instance_registry_obj 89 | return @current_instance_registry_obj if @current_instance_registry_obj 90 | 91 | key_class = respond_to?(:base_class) ? base_class : self 92 | @current_instance_registry_obj = "#{key_class.name.underscore}_obj".to_sym 93 | end 94 | include RegistryDependentOn 95 | end 96 | 97 | def as_current 98 | old_id = self.class.current_id 99 | self.class.current = self 100 | yield 101 | ensure 102 | self.class.current_id = old_id 103 | end 104 | 105 | def current? 106 | id == self.class.current_id 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/rails_multitenant/global_context_registry/registry_dependent_on.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsMultitenant 4 | module GlobalContextRegistry 5 | module RegistryDependentOn 6 | # Is this class dependent on changes in another GlobalContextRegistry- 7 | # stored object? Register that dependency here. 8 | def global_context_dependent_on(*klasses) 9 | klasses.each { |klass| GlobalContextRegistry.send(:add_dependency, klass, self) } 10 | end 11 | 12 | # Registers a bi-directional dependency on another class. 13 | def global_context_mutually_dependent_on(*klasses) 14 | global_context_dependent_on(*klasses) 15 | klasses.each { |klass| klass.global_context_dependent_on(self) } 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/rails_multitenant/middleware/extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_multitenant/middleware/isolated_context_registry' 4 | 5 | require 'rails_multitenant/middleware/railtie' if defined?(Rails) 6 | -------------------------------------------------------------------------------- /lib/rails_multitenant/middleware/isolated_context_registry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsMultitenant 4 | module Middleware 5 | class IsolatedContextRegistry 6 | def initialize(app) 7 | @app = app 8 | end 9 | 10 | def call(env) 11 | GlobalContextRegistry.with_isolated_registry do 12 | @app.call(env) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/rails_multitenant/middleware/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsMultitenant 4 | module Middleware 5 | class Railtie < ::Rails::Railtie 6 | initializer 'rails_multitenant.middleware' do |app| 7 | app.config.middleware.insert 0, ::RailsMultitenant::Middleware::IsolatedContextRegistry 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/rails_multitenant/multitenant_model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsMultitenant 4 | module MultitenantModel 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | class_attribute :context_entity_id_field 9 | end 10 | 11 | module ClassMethods 12 | 13 | def multitenant_on(context_entity_id_field, required: true) 14 | self.context_entity_id_field = context_entity_id_field 15 | validates_presence_of(context_entity_id_field) if required 16 | 17 | context_entity = context_entity_id_field.to_s.gsub(/_id$/, '') 18 | scope_sym = "from_current_#{context_entity}".to_sym 19 | 20 | scope scope_sym, -> do 21 | unless GlobalContextRegistry.use_unscoped_queries? 22 | where(context_entity_id_field => GlobalContextRegistry[context_entity_id_field]) 23 | end 24 | end 25 | 26 | default_scope { send(scope_sym) } 27 | 28 | scope "strip_#{context_entity}_scope", -> do 29 | unscope(where: context_entity_id_field) 30 | end 31 | end 32 | 33 | def multitenant_on_model(context_entity, required: true) 34 | multitenant_on("#{context_entity}_id".to_sym, required: required) 35 | 36 | # Rails 5 added required validation to belongs_to associations and 37 | # an `optional` setting to disable it. We already do validation on 38 | # the foreign key so we always disable the native Rails validation. 39 | belongs_to(context_entity, optional: true) 40 | end 41 | 42 | def validates_multitenant_uniqueness_of(*attr_names) 43 | options = attr_names.extract_options!.symbolize_keys 44 | existing_scope = Array.wrap(options.delete(:scope)) 45 | scope = existing_scope | [context_entity_id_field] 46 | validates_uniqueness_of(*attr_names, options.merge(scope: scope)) 47 | end 48 | 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/rails_multitenant/rspec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Matchers.define(:be_multitenant_on) do |expected| 4 | match do |actual| 5 | actual.respond_to?(:context_entity_id_field) && actual.context_entity_id_field == expected 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/rails_multitenant/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsMultitenant 4 | VERSION = '0.21.0' 5 | end 6 | -------------------------------------------------------------------------------- /rails_multitenant.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'rails_multitenant/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'rails_multitenant' 9 | spec.version = RailsMultitenant::VERSION 10 | spec.authors = ['Pat Breault'] 11 | spec.email = ['pbreault@salsify.com'] 12 | spec.summary = 'Automatically configures multiple tenants in a Rails environment' 13 | spec.description = 'Handles multiple tenants in a Rails environment' 14 | spec.homepage = 'https://github.com/salsify/rails-multitenant' 15 | spec.license = 'MIT' 16 | 17 | if spec.respond_to?(:metadata) 18 | spec.metadata['allowed_push_host'] = 'https://rubygems.org' 19 | spec.metadata['rubygems_mfa_required'] = 'true' 20 | else 21 | raise 'RubyGems 2.0 or newer is required to set allowed_push_host.' 22 | end 23 | 24 | spec.files = Dir['lib/**/*.rb', 'LICENSE.txt'] 25 | spec.require_paths = ['lib'] 26 | 27 | spec.required_ruby_version = '>= 3.1' 28 | 29 | spec.add_dependency 'activerecord', '>= 7.0', '< 8.1' 30 | spec.add_dependency 'activesupport', '>= 7.0', '< 8.1' 31 | 32 | spec.add_development_dependency 'appraisal' 33 | spec.add_development_dependency 'coveralls' 34 | spec.add_development_dependency 'database_cleaner', '>= 1.2' 35 | spec.add_development_dependency 'rake', '>= 12.0' 36 | spec.add_development_dependency 'rspec', '~> 3.11.0' 37 | spec.add_development_dependency 'rspec_junit_formatter' 38 | spec.add_development_dependency 'salsify_rubocop', '~> 1.27.1' 39 | spec.add_development_dependency 'simplecov', '~> 0.15.1' 40 | spec.add_development_dependency 'sqlite3', '~> 2.1' 41 | end 42 | -------------------------------------------------------------------------------- /spec/be_multitenant_on_matcher_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_multitenant/rspec' 4 | 5 | describe "be_multitenant_on matcher" do 6 | it "accepts a valid context field id" do 7 | expect(ExternalItem).to be_multitenant_on(:external_organization_id) 8 | end 9 | 10 | it "rejects an invalid context field id" do 11 | expect(ExternalItem).not_to be_multitenant_on(:other_field) 12 | end 13 | 14 | it "rejects classes that don't have a context field id" do 15 | expect(String).not_to be_multitenant_on(:other_field) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/db/database.yml: -------------------------------------------------------------------------------- 1 | sqlite3: 2 | adapter: sqlite3 3 | database: ":memory:" 4 | -------------------------------------------------------------------------------- /spec/db/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Schema.define(version: 0) do 4 | 5 | create_table(:organizations, force: true) 6 | 7 | create_table(:dependent_models, force: true) 8 | 9 | create_table(:items, force: true) do |t| 10 | t.integer :organization_id 11 | t.string :type 12 | end 13 | 14 | create_table(:item_with_optional_orgs, force: true) do |t| 15 | t.integer :organization_id 16 | t.string :type 17 | end 18 | 19 | create_table(:external_items, force: true) do |t| 20 | t.integer :external_organization_id 21 | end 22 | 23 | create_table(:external_item_with_optional_orgs, force: true) do |t| 24 | t.integer :external_organization_id 25 | end 26 | end 27 | 28 | class Organization < ActiveRecord::Base 29 | include RailsMultitenant::GlobalContextRegistry::CurrentInstance 30 | end 31 | 32 | class DependentModel < ActiveRecord::Base 33 | include RailsMultitenant::GlobalContextRegistry::CurrentInstance 34 | global_context_dependent_on Organization 35 | end 36 | 37 | class Item < ActiveRecord::Base 38 | include RailsMultitenant::MultitenantModel 39 | multitenant_on_model :organization 40 | end 41 | 42 | class ItemWithOptionalOrg < ActiveRecord::Base 43 | include RailsMultitenant::MultitenantModel 44 | multitenant_on_model :organization, required: false 45 | end 46 | 47 | class ItemSubtype < Item 48 | 49 | end 50 | 51 | class ExternalItem < ActiveRecord::Base 52 | include RailsMultitenant::MultitenantModel 53 | multitenant_on :external_organization_id 54 | end 55 | 56 | class ExternalItemWithOptionalOrg < ActiveRecord::Base 57 | include RailsMultitenant::MultitenantModel 58 | multitenant_on :external_organization_id, required: false 59 | end 60 | -------------------------------------------------------------------------------- /spec/external_item_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe ExternalItem do 4 | 5 | let!(:external_item1) { as_external_org(1) { ExternalItem.create! } } 6 | 7 | let!(:external_item2) { as_external_org(2) { ExternalItem.create! } } 8 | let!(:external_item3) { as_external_org(2) { ExternalItem.create! } } 9 | 10 | specify "org1 has the correct external items" do 11 | as_external_org(1) do 12 | expect(ExternalItem.all).to eq [external_item1] 13 | end 14 | end 15 | 16 | specify "org2 has the correct external items" do 17 | as_external_org(2) do 18 | expect(ExternalItem.all).to match_array [external_item2, external_item3] 19 | end 20 | end 21 | 22 | it "does not return external items from other orgs" do 23 | as_external_org(2) do 24 | expect(ExternalItem.where(id: external_item1.id)).to eq [] 25 | end 26 | end 27 | 28 | it "allows the organization scope to be removed" do 29 | expect(ExternalItem.strip_external_organization_scope.count).to eq 3 30 | end 31 | 32 | def as_external_org(id, &block) 33 | RailsMultitenant::GlobalContextRegistry.with_isolated_registry(external_organization_id: id, &block) 34 | end 35 | 36 | end 37 | -------------------------------------------------------------------------------- /spec/external_item_with_optional_org_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe ExternalItemWithOptionalOrg do 4 | 5 | let!(:external_item_without_org) { as_external_org(nil) { described_class.create! } } 6 | 7 | let!(:external_item_with_org) { as_external_org(1) { described_class.create! } } 8 | let!(:external_item_with_other_org) { as_external_org(2) { described_class.create! } } 9 | 10 | specify "the nil org has the correct external items" do 11 | as_external_org(nil) do 12 | expect(described_class.all).to eq([external_item_without_org]) 13 | end 14 | end 15 | 16 | specify "org1 has the correct external items" do 17 | as_external_org(1) do 18 | expect(described_class.all).to eq([external_item_with_org]) 19 | end 20 | end 21 | 22 | def as_external_org(id, &block) 23 | RailsMultitenant::GlobalContextRegistry.with_isolated_registry(external_organization_id: id, &block) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/item_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Create multiple orgs 4 | # Create an item in each 5 | # Make sure you can only see one org's item in one org 6 | 7 | describe Item do 8 | 9 | let!(:item1) { Item.create! } 10 | let(:dependent_class) do 11 | Class.new { include RailsMultitenant::GlobalContextRegistry::Current } 12 | end 13 | 14 | let!(:org2) { Organization.create! } 15 | let!(:item2) { org2.as_current { Item.create! } } 16 | let!(:item3) { org2.as_current { Item.create! } } 17 | 18 | before do 19 | stub_const('DependentClass', dependent_class) 20 | stub_const('SubOrganization', Class.new(Organization)) 21 | 22 | DependentClass.global_context_dependent_on Organization 23 | end 24 | 25 | specify "default org should have one item" do 26 | expect(Item.all).to eq [item1] 27 | end 28 | 29 | it "does not return item2" do 30 | expect(Item.where(id: item2.id)).to eq [] 31 | end 32 | 33 | it "behaves correctly when disabling scoping with a block" do 34 | RailsMultitenant::GlobalContextRegistry.with_unscoped_queries do 35 | expect(Item.where(id: item2.id)).to eq [item2] 36 | end 37 | expect(Item.where(id: item2.id)).to eq [] 38 | end 39 | 40 | it "behaves correctly when disabling and enabling scoping without a block" do 41 | RailsMultitenant::GlobalContextRegistry.disable_scoped_queries 42 | expect(Item.where(id: item2.id)).to eq [item2] 43 | RailsMultitenant::GlobalContextRegistry.enable_scoped_queries 44 | expect(Item.where(id: item2.id)).to eq [] 45 | end 46 | 47 | specify "org2 should have two items" do 48 | org2.as_current do 49 | expect(Item.all).to eq [item2, item3] 50 | end 51 | end 52 | 53 | it "does not return item1" do 54 | org2.as_current do 55 | expect(Item.where(id: item1.id)).to eq [] 56 | end 57 | end 58 | 59 | describe ".as_current" do 60 | it "returns the correct items with an org supplied" do 61 | Organization.as_current(org2) do 62 | expect(Item.all).to eq [item2, item3] 63 | end 64 | end 65 | 66 | it "allows a nil org to be supplied" do 67 | Organization.as_current(nil) do 68 | expect(Item.all).to eq [] 69 | end 70 | end 71 | 72 | it "rejects models of the wrong type" do 73 | model = Item.new 74 | expect { Organization.as_current(model) {} }.to raise_error("#{model} is not a Organization") 75 | end 76 | 77 | it "invalidates dependent models" do 78 | DependentModel.current = DependentModel.create! 79 | dependent = DependentModel.current 80 | 81 | SubOrganization.create!.as_current do 82 | expect(DependentModel.current).not_to equal(dependent) 83 | end 84 | end 85 | 86 | it "invalidates dependent objects" do 87 | dependent = DependentClass.new 88 | DependentClass.current = dependent 89 | 90 | SubOrganization.create!.as_current do 91 | expect(DependentClass.current).not_to equal(dependent) 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /spec/item_subtype_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Create multiple orgs 4 | # Create an item in each 5 | # Make sure you can only see one org's item in one org 6 | 7 | describe ItemSubtype do 8 | 9 | let!(:item1) { ItemSubtype.create! } 10 | 11 | let!(:org2) { Organization.create! } 12 | let!(:item2) { org2.as_current { ItemSubtype.create! } } 13 | let!(:item3) { org2.as_current { ItemSubtype.create! } } 14 | 15 | it "inherits the multitenant settings from its parent class" do 16 | expect(ItemSubtype).to be_multitenant_on(:organization_id) 17 | end 18 | 19 | specify "default org should have one item" do 20 | expect(ItemSubtype.all).to eq([item1]) 21 | end 22 | 23 | it "does not return item2" do 24 | expect(ItemSubtype.where(id: item2.id)).to eq([]) 25 | end 26 | 27 | specify "org2 should have two items" do 28 | org2.as_current do 29 | expect(ItemSubtype.all).to eq([item2, item3]) 30 | end 31 | end 32 | 33 | it "does not return item1" do 34 | org2.as_current do 35 | expect(ItemSubtype.where(id: item1.id)).to eq([]) 36 | end 37 | end 38 | 39 | end 40 | -------------------------------------------------------------------------------- /spec/item_with_optional_org_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe ItemWithOptionalOrg do 4 | 5 | let!(:item_without_org) { without_org { described_class.create! } } 6 | 7 | let!(:org) { Organization.create! } 8 | let!(:item_with_org) { org.as_current { described_class.create! } } 9 | 10 | let!(:other_org) { Organization.create! } 11 | let!(:item_with_other_org) { other_org.as_current { described_class.create! } } 12 | 13 | it "returns the correct items when no org is set" do 14 | without_org do 15 | expect(described_class.all).to eq([item_without_org]) 16 | end 17 | end 18 | 19 | it "returns the correct items when an org is set" do 20 | org.as_current do 21 | expect(described_class.all).to eq([item_with_org]) 22 | end 23 | end 24 | 25 | def without_org(&block) 26 | RailsMultitenant::GlobalContextRegistry.with_isolated_registry(&block) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/rails_multitenant/global_context_registry/current_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TestClass 4 | include RailsMultitenant::GlobalContextRegistry::Current 5 | provide_default :new 6 | 7 | attr_accessor :id 8 | 9 | def initialize(id: :default) 10 | @id = id 11 | end 12 | end 13 | 14 | describe RailsMultitenant::GlobalContextRegistry::Current do 15 | before do 16 | stub_const('SubClass', Class.new(TestClass)) 17 | stub_const('DependentClass', dependent_class) 18 | stub_const('CyclicallyDependentClass1', cyclically_dependent_class1) 19 | stub_const('CyclicallyDependentClass2', cyclically_dependent_class2) 20 | stub_const('BiDependentClass', bidependent_class) 21 | stub_const('NoDefaultTestClass', no_default_test_class) 22 | 23 | DependentClass.global_context_dependent_on TestClass 24 | DependentClass.global_context_dependent_on CyclicallyDependentClass2 25 | CyclicallyDependentClass1.global_context_dependent_on DependentClass 26 | CyclicallyDependentClass2.global_context_dependent_on CyclicallyDependentClass1 27 | BiDependentClass.global_context_mutually_dependent_on TestClass 28 | end 29 | 30 | let(:dependent_class) do 31 | Class.new do 32 | include RailsMultitenant::GlobalContextRegistry::Current 33 | provide_default { new } 34 | end 35 | end 36 | 37 | let(:cyclically_dependent_class1) do 38 | Class.new do 39 | include RailsMultitenant::GlobalContextRegistry::Current 40 | provide_default { new } 41 | end 42 | end 43 | 44 | let(:cyclically_dependent_class2) do 45 | Class.new do 46 | include RailsMultitenant::GlobalContextRegistry::Current 47 | provide_default { new } 48 | end 49 | end 50 | 51 | let(:bidependent_class) do 52 | Class.new do 53 | include RailsMultitenant::GlobalContextRegistry::Current 54 | provide_default { new } 55 | end 56 | end 57 | 58 | let(:no_default_test_class) do 59 | Class.new do 60 | include RailsMultitenant::GlobalContextRegistry::Current 61 | end 62 | end 63 | 64 | describe "current" do 65 | it "returns default value when supplied" do 66 | expect(TestClass.current.id).to eq(:default) 67 | expect(SubClass.current.id).to eq(:default) 68 | end 69 | 70 | it "returns nil when no default supplied" do 71 | expect(NoDefaultTestClass.current).to be_nil 72 | end 73 | end 74 | 75 | describe "current!" do 76 | it "returns current value when set" do 77 | expect(TestClass.current.id).to eq(:default) 78 | end 79 | 80 | it "raises an error when current not set" do 81 | NoDefaultTestClass.clear_current! 82 | expect { NoDefaultTestClass.current! }.to raise_error('No current NoDefaultTestClass set') 83 | end 84 | end 85 | 86 | describe "current=" do 87 | it "stores the provided object" do 88 | provided = TestClass.new(id: :provided) 89 | TestClass.current = provided 90 | expect(TestClass.current).to equal(provided) 91 | end 92 | 93 | it "clears dependencies" do 94 | dependent = DependentClass.current 95 | cyclically_dependent1 = CyclicallyDependentClass1.current 96 | cyclically_dependent2 = CyclicallyDependentClass1.current 97 | 98 | TestClass.current = TestClass.new 99 | expect(DependentClass.current).not_to equal(dependent) 100 | expect(CyclicallyDependentClass1.current).not_to equal(cyclically_dependent1) 101 | expect(CyclicallyDependentClass2.current).not_to equal(cyclically_dependent2) 102 | end 103 | 104 | it "clears bidirectional dependencies" do 105 | dependent = BiDependentClass.current 106 | TestClass.current = test_class = TestClass.new 107 | expect(BiDependentClass.current).not_to equal(dependent) 108 | 109 | BiDependentClass.current = BiDependentClass.new 110 | expect(TestClass.current).not_to equal(test_class) 111 | end 112 | end 113 | 114 | describe "current?" do 115 | it "returns false when uninitialized" do 116 | TestClass.clear_current! 117 | expect(TestClass.current?).to be false 118 | end 119 | 120 | it "returns true when initialized" do 121 | TestClass.current = TestClass.new 122 | expect(TestClass.current?).to be true 123 | end 124 | end 125 | 126 | describe "as_current" do 127 | let(:test_class1) { TestClass.new } 128 | let(:test_class2) { TestClass.new } 129 | 130 | it "sets and restores current" do 131 | TestClass.current = test_class1 132 | TestClass.as_current(test_class2) do 133 | expect(TestClass.current).to equal(test_class2) 134 | end 135 | expect(TestClass.current).to equal(test_class1) 136 | end 137 | end 138 | 139 | context "instance methods" do 140 | describe "current?" do 141 | it "returns false when not the current instance" do 142 | TestClass.clear_current! 143 | expect(TestClass.new.current?).to be false 144 | 145 | TestClass.current = TestClass.new 146 | expect(TestClass.new.current?).to be false 147 | end 148 | 149 | it "returns true when it is the current instance" do 150 | test_class = TestClass.new 151 | TestClass.current = test_class 152 | expect(test_class.current?).to be true 153 | end 154 | end 155 | 156 | describe "as_current" do 157 | let(:test_class1) { TestClass.new } 158 | let(:test_class2) { TestClass.new } 159 | 160 | it "sets and restores current" do 161 | TestClass.current = test_class1 162 | test_class2.as_current do 163 | expect(TestClass.current).to equal(test_class2) 164 | end 165 | expect(TestClass.current).to equal(test_class1) 166 | end 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /spec/rails_multitenant/global_context_registry_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe RailsMultitenant::GlobalContextRegistry do 4 | 5 | before do 6 | # The framework will setup the organization; clear that out 7 | RailsMultitenant::GlobalContextRegistry.new_registry 8 | RailsMultitenant::GlobalContextRegistry.set(:foo, 'bar') 9 | end 10 | 11 | describe ".get and .set" do 12 | specify do 13 | expect(RailsMultitenant::GlobalContextRegistry.get(:foo)).to eq 'bar' 14 | end 15 | end 16 | 17 | describe ".[] and .[]=" do 18 | before do 19 | RailsMultitenant::GlobalContextRegistry[:boo] = 'baz' 20 | end 21 | 22 | specify do 23 | expect(RailsMultitenant::GlobalContextRegistry[:boo]).to eq 'baz' 24 | end 25 | end 26 | 27 | describe ".delete" do 28 | specify do 29 | expect(RailsMultitenant::GlobalContextRegistry.delete(:foo)).to eq 'bar' 30 | expect(RailsMultitenant::GlobalContextRegistry.get(:foo)).to be_nil 31 | end 32 | end 33 | 34 | describe ".with_isolated_registry" do 35 | it "yields to the provided block" do 36 | expect { |b| RailsMultitenant::GlobalContextRegistry.with_isolated_registry(&b) }.to yield_control 37 | end 38 | 39 | it "does not inherit values from the current registry" do 40 | RailsMultitenant::GlobalContextRegistry.with_isolated_registry do 41 | expect(RailsMultitenant::GlobalContextRegistry.get(:foo)).to be_nil 42 | end 43 | end 44 | 45 | it "sets values provided as arguments" do 46 | RailsMultitenant::GlobalContextRegistry.with_isolated_registry(foo: 'updated') do 47 | expect(RailsMultitenant::GlobalContextRegistry.get(:foo)).to eq 'updated' 48 | end 49 | end 50 | 51 | it "restores the original registry after the block executes" do 52 | RailsMultitenant::GlobalContextRegistry.with_isolated_registry do 53 | # Do nothing 54 | end 55 | expect(RailsMultitenant::GlobalContextRegistry.get(:foo)).to eq('bar') 56 | end 57 | end 58 | 59 | describe ".replace_registry and .new_registry" do 60 | let!(:old_registry) { RailsMultitenant::GlobalContextRegistry.new_registry } 61 | 62 | specify do 63 | expect(old_registry).to eq(foo: 'bar') 64 | end 65 | 66 | specify do 67 | expect(RailsMultitenant::GlobalContextRegistry.get(:foo)).to be_nil 68 | end 69 | 70 | specify do 71 | RailsMultitenant::GlobalContextRegistry.replace_registry(old_registry) 72 | expect(RailsMultitenant::GlobalContextRegistry.get(:foo)).to eq 'bar' 73 | end 74 | 75 | context "when a new registry is specified" do 76 | let!(:old_registry) { RailsMultitenant::GlobalContextRegistry.new_registry(bar: 'foo') } 77 | 78 | specify do 79 | expect(old_registry).to eq(foo: 'bar') 80 | end 81 | 82 | specify do 83 | expect(RailsMultitenant::GlobalContextRegistry.get(:foo)).to be_nil 84 | end 85 | 86 | specify do 87 | expect(RailsMultitenant::GlobalContextRegistry.get(:bar)).to eq 'foo' 88 | end 89 | end 90 | end 91 | 92 | describe ".duplicate_registry" do 93 | def setup_registry; end 94 | 95 | before { setup_registry } 96 | 97 | let!(:dupe) { RailsMultitenant::GlobalContextRegistry.duplicate_registry } 98 | 99 | specify do 100 | expect(RailsMultitenant::GlobalContextRegistry.new_registry).to eq dupe 101 | end 102 | 103 | specify do 104 | expect(RailsMultitenant::GlobalContextRegistry.new_registry.object_id).not_to eq dupe.object_id 105 | end 106 | 107 | specify do 108 | expect(RailsMultitenant::GlobalContextRegistry.get(:foo).object_id).not_to eq dupe[:foo].object_id 109 | end 110 | 111 | context "with nils" do 112 | def setup_registry 113 | RailsMultitenant::GlobalContextRegistry.set(:bar, nil) 114 | end 115 | 116 | specify do 117 | expect(dupe).to eq RailsMultitenant::GlobalContextRegistry.new_registry 118 | end 119 | end 120 | 121 | context "with integers" do 122 | def setup_registry 123 | RailsMultitenant::GlobalContextRegistry.set(:bar, 5) 124 | end 125 | 126 | specify do 127 | expect(dupe).to eq RailsMultitenant::GlobalContextRegistry.new_registry 128 | end 129 | end 130 | end 131 | 132 | describe ".merge!" do 133 | it "merges values into the registry" do 134 | RailsMultitenant::GlobalContextRegistry.merge!(team: 'Eagles', color: 'Green') 135 | 136 | expect(RailsMultitenant::GlobalContextRegistry[:team]).to eq('Eagles') 137 | expect(RailsMultitenant::GlobalContextRegistry[:color]).to eq('Green') 138 | end 139 | 140 | it "overwrites existing values in the registry" do 141 | RailsMultitenant::GlobalContextRegistry[:team] = 'Patriots' 142 | 143 | RailsMultitenant::GlobalContextRegistry.merge!(team: 'Eagles', color: 'Green') 144 | 145 | expect(RailsMultitenant::GlobalContextRegistry[:team]).to eq('Eagles') 146 | end 147 | end 148 | 149 | describe ".with_merged_registry" do 150 | it "yields to the provided block" do 151 | expect { |b| RailsMultitenant::GlobalContextRegistry.with_merged_registry(foo: 'baz', &b) }.to yield_control 152 | end 153 | 154 | it "sets up a merged registry in the block" do 155 | RailsMultitenant::GlobalContextRegistry[:team] = 'Patriots' 156 | RailsMultitenant::GlobalContextRegistry.with_merged_registry(team: 'Eagles') do 157 | expect(RailsMultitenant::GlobalContextRegistry.get(:foo)).to eq('bar') 158 | expect(RailsMultitenant::GlobalContextRegistry.get(:team)).to eq('Eagles') 159 | end 160 | end 161 | 162 | it "restores the original registry after the block executes" do 163 | RailsMultitenant::GlobalContextRegistry[:team] = 'Patriots' 164 | RailsMultitenant::GlobalContextRegistry.with_merged_registry(team: 'Eagles') do 165 | # Do nothing 166 | end 167 | expect(RailsMultitenant::GlobalContextRegistry.get(:foo)).to eq('bar') 168 | expect(RailsMultitenant::GlobalContextRegistry.get(:team)).to eq('Patriots') 169 | end 170 | end 171 | 172 | describe ".with_unscoped_queries" do 173 | it "yields to the provided block" do 174 | expect { |b| RailsMultitenant::GlobalContextRegistry.with_unscoped_queries(&b) }.to yield_control 175 | end 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /spec/rails_multitenant/middleware/isolated_context_registry_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe RailsMultitenant::Middleware::IsolatedContextRegistry do 4 | let(:payload) { [200, { 'Content-Type' => 'text/plain' }, ['OK']] } 5 | let(:app) do 6 | lambda do |env| 7 | env[2] = RailsMultitenant::GlobalContextRegistry.get(:foo) 8 | env 9 | end 10 | end 11 | let(:middleware) { described_class.new(app) } 12 | 13 | describe ".call" do 14 | specify do 15 | RailsMultitenant::GlobalContextRegistry.set(:foo, 'bar') 16 | # Assert that a new global registry is created and read from, where :foo is not set 17 | expect(middleware.call(payload)).to eq [200, { 'Content-Type' => 'text/plain' }, nil] 18 | # Assert that the outer global context registry is restored 19 | expect(RailsMultitenant::GlobalContextRegistry.get(:foo)).to eq 'bar' 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/rails_multitenant_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe "delegating to GlobalContextRegistry" do 4 | it "RailsMultitenant.get returns values from the GlobalContextRegistry" do 5 | RailsMultitenant::GlobalContextRegistry.set(:organization_id, 'Salsify Housing Authority') 6 | 7 | expect(RailsMultitenant.get(:organization_id)).to eq('Salsify Housing Authority') 8 | end 9 | 10 | it "RailsMultitenant[] returns values from the GlobalContextRegistry" do 11 | RailsMultitenant::GlobalContextRegistry.set(:organization_id, 'Salsify Farmland Inc.') 12 | 13 | expect(RailsMultitenant[:organization_id]).to eq('Salsify Farmland Inc.') 14 | end 15 | 16 | it "RailsMultitenant.set assigns values in the GlobalContextRegistry" do 17 | RailsMultitenant.set(:organization_id, 'Salsify Eminient Domain') 18 | 19 | expect(RailsMultitenant::GlobalContextRegistry.get(:organization_id)).to eq('Salsify Eminient Domain') 20 | end 21 | 22 | it "RailsMultitenant[]= assigns values in the GlobalContextRegistry" do 23 | RailsMultitenant[:organization_id] = 'Salsify Co-op' 24 | 25 | expect(RailsMultitenant::GlobalContextRegistry[:organization_id]).to eq('Salsify Co-op') 26 | end 27 | 28 | it "RailsMultitenant.fetch checks and sets the GlobalContextRegistry" do 29 | RailsMultitenant::GlobalContextRegistry[:organization_id] = nil 30 | 31 | # False positive from rubocop 32 | # rubocop:disable Style/RedundantFetchBlock 33 | expect(RailsMultitenant.fetch(:organization_id) { 'Salsify Anarchists' }).to eq('Salsify Anarchists') 34 | expect(RailsMultitenant.fetch(:organization_id) { 'Salsify Crypto Anarchists' }).to eq('Salsify Anarchists') 35 | # rubocop:enable Style/RedundantFetchBlock 36 | end 37 | 38 | it "RailsMultitenant.delete removes from the GlobalContextRegistry" do 39 | RailsMultitenant::GlobalContextRegistry[:organization_id] = 'Not Salsify' 40 | 41 | RailsMultitenant.delete(:organization_id) 42 | 43 | expect(RailsMultitenant.get(:organization_id)).to be_nil 44 | end 45 | 46 | it "RailsMultitenant.with_isolated_registry leverages the GlobalContextRegistry" do 47 | RailsMultitenant::GlobalContextRegistry[:organization_id] = 'Salsify Mainland' 48 | 49 | RailsMultitenant.with_isolated_registry(organization_id: 'Salsify Private Island') do 50 | expect(RailsMultitenant::GlobalContextRegistry[:organization_id]).to eq('Salsify Private Island') 51 | end 52 | 53 | expect(RailsMultitenant::GlobalContextRegistry[:organization_id]).to eq('Salsify Mainland') 54 | end 55 | 56 | it "RailsMultitenant.merge! adds values to the GlobalContextRegistry" do 57 | RailsMultitenant::GlobalContextRegistry[:organization_id] = 'Not Salsify' 58 | 59 | RailsMultitenant[:organization_id] = 'Salsify' 60 | 61 | expect(RailsMultitenant::GlobalContextRegistry[:organization_id]).to eq('Salsify') 62 | end 63 | 64 | it "RailsMultitenant.with_merged_registry runs the block with a merged registry" do 65 | RailsMultitenant::GlobalContextRegistry[:foo] = 'bar' 66 | 67 | RailsMultitenant.with_merged_registry(organization_id: 'Salsify') do 68 | expect(RailsMultitenant::GlobalContextRegistry[:foo]).to eq('bar') 69 | expect(RailsMultitenant::GlobalContextRegistry[:organization_id]).to eq('Salsify') 70 | end 71 | end 72 | 73 | it "RailsMultitenant.with_unscoped_queries does not remove existing context" do 74 | RailsMultitenant::GlobalContextRegistry[:organization_id] = 'Salsify' 75 | 76 | RailsMultitenant::GlobalContextRegistry.with_unscoped_queries do 77 | expect(RailsMultitenant::GlobalContextRegistry.use_unscoped_queries?).to eq true 78 | expect(RailsMultitenant::GlobalContextRegistry[:organization_id]).to eq('Salsify') 79 | end 80 | 81 | expect(RailsMultitenant::GlobalContextRegistry.use_unscoped_queries?).to eq false 82 | end 83 | 84 | it "RailsMultitenant.disabled_scoped_queries and enable_scoped_queries do not impact existing context" do 85 | RailsMultitenant::GlobalContextRegistry[:organization_id] = 'Salsify' 86 | 87 | RailsMultitenant::GlobalContextRegistry.disable_scoped_queries 88 | expect(RailsMultitenant::GlobalContextRegistry.use_unscoped_queries?).to eq true 89 | expect(RailsMultitenant::GlobalContextRegistry[:organization_id]).to eq('Salsify') 90 | 91 | RailsMultitenant::GlobalContextRegistry.enable_scoped_queries 92 | expect(RailsMultitenant::GlobalContextRegistry.use_unscoped_queries?).to eq false 93 | expect(RailsMultitenant::GlobalContextRegistry[:organization_id]).to eq('Salsify') 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | require 'coveralls' 5 | 6 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ 7 | SimpleCov::Formatter::HTMLFormatter, 8 | Coveralls::SimpleCov::Formatter 9 | ]) 10 | SimpleCov.start do 11 | add_filter 'spec' 12 | end 13 | 14 | require 'active_record' 15 | 16 | require 'logger' 17 | require 'database_cleaner' 18 | require 'rails_multitenant' 19 | require 'yaml' 20 | 21 | FileUtils.makedirs('log') 22 | 23 | ActiveRecord::Base.logger = Logger.new('log/test.log') 24 | ActiveRecord::Base.logger.level = Logger::DEBUG 25 | ActiveRecord::Migration.verbose = false 26 | 27 | db_adapter = ENV.fetch('ADAPTER', 'sqlite3') 28 | db_config = YAML.safe_load(File.read('spec/db/database.yml')) 29 | ActiveRecord::Base.establish_connection(db_config[db_adapter]) 30 | require 'db/schema' 31 | 32 | RSpec.configure do |config| 33 | config.order = 'random' 34 | 35 | config.before(:suite) do 36 | DatabaseCleaner.clean_with(:truncation) 37 | end 38 | 39 | config.before do 40 | DatabaseCleaner.strategy = :transaction 41 | DatabaseCleaner.start 42 | RailsMultitenant::GlobalContextRegistry.new_registry 43 | Organization.current_id = Organization.create!.id 44 | end 45 | 46 | config.after do 47 | DatabaseCleaner.clean 48 | end 49 | end 50 | 51 | puts "Testing with Ruby #{RUBY_VERSION} and ActiveRecord #{ActiveRecord::VERSION::STRING}" 52 | --------------------------------------------------------------------------------