postgres_multitenant 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: adf23754bd33057352e117a40249d291bea349d0a6b92df9690ac6c09e3eba4a
4
+ data.tar.gz: 70fee68ab239014dd1e336faf79982322c8c6e7ed0089861142bbee558a0de7d
5
+ SHA512:
6
+ metadata.gz: b81bfceab7f0aee9f2b59daa16079791692aeb0c82f060dbdf47b1424c03eb54f191c3cc35bc7636192f10b9a053a7c2da649d5480e6e593bb8ff5d6c79f3032
7
+ data.tar.gz: e96fb48ee50239aa068464dd9a097cdb7f6adb112f307e5445e7eda9bca6f1f3d7aa836ff20d3474a770657de657eed6ac54212c91f0291eebb2d66695c532cf
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - Unreleased
4
+
5
+ - Initial public release.
6
+ - Adds PostgreSQL schema switching middleware for Rails applications.
7
+ - Adds schema lifecycle helpers and tenant rake tasks.
8
+ - Adds `rails generate postgres_multitenant:install`.
9
+ - Supports Rails 6.1 through Rails 8.1.x.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joshua Needham - DAD Studios
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # PostgresMultitenant
2
+
3
+ Schema-based multi-tenancy for Rails applications backed by PostgreSQL schemas.
4
+
5
+ PostgresMultitenant provides:
6
+
7
+ - request middleware that switches PostgreSQL `search_path` by tenant subdomain
8
+ - schema lifecycle helpers for creating, dropping, switching, and listing tenants
9
+ - tenant schema sync from `db/tenant_schema.rb`, with `db/schema.rb` fallback
10
+ - Rails rake tasks for tenant lifecycle operations
11
+ - a Rails install generator
12
+
13
+ ## Requirements
14
+
15
+ - Ruby 3.1+
16
+ - Rails 6.1+
17
+ - PostgreSQL with `schema_search_path` support
18
+
19
+ The gem is compatible with Rails 8.1.x, including Rails 8.1.3. Rails 8.1 itself requires Ruby 3.2+, so applications on Ruby 3.1 should stay on an older compatible Rails release.
20
+
21
+ ## Installation
22
+
23
+ Add the gem to your Rails app:
24
+
25
+ ```ruby
26
+ gem "postgres_multitenant"
27
+ ```
28
+
29
+ Then run:
30
+
31
+ ```bash
32
+ bundle install
33
+ bin/rails generate postgres_multitenant:install
34
+ ```
35
+
36
+ The generator creates:
37
+
38
+ - `config/initializers/postgres_multitenant.rb`
39
+ - `db/tenant_schema.rb`
40
+
41
+ Generator options:
42
+
43
+ ```bash
44
+ bin/rails generate postgres_multitenant:install --tenant-class-name=Account --tenant-foreign-key=account_id
45
+ ```
46
+
47
+ ## Configuration
48
+
49
+ Edit `config/initializers/postgres_multitenant.rb` after running the generator:
50
+
51
+ ```ruby
52
+ PostgresMultitenant.configure do |config|
53
+ # Public-schema model that identifies tenants by subdomain.
54
+ config.tenant_class_name = "Organization"
55
+
56
+ # Required: return tenant-scoped table names without schema prefixes.
57
+ config.tenant_table_resolver = -> { %w[clients projects bookings] }
58
+
59
+ # Optional: scope tenant records considered for routing and tenant migration.
60
+ config.tenant_scope_proc = ->(klass) { klass.where(active: true) }
61
+
62
+ # Optional: custom subdomain lookup.
63
+ config.tenant_finder = ->(subdomain) { Organization.find_by(subdomain: subdomain) }
64
+
65
+ # Optional: custom schema naming.
66
+ config.tenant_schema_name_proc = ->(tenant) { tenant.subdomain.tr("-", "_") }
67
+
68
+ # Tenant foreign key expected on tenant-scoped tables during development checks.
69
+ config.tenant_foreign_key = "organization_id"
70
+
71
+ # Optional: override request-to-subdomain extraction.
72
+ # config.subdomain_extractor = ->(request) { request.subdomain.presence }
73
+
74
+ # Strategy when tenant is not found:
75
+ # :raise -> raise PostgresMultitenant::TenantNotFoundError
76
+ # :public -> continue in config.default_schema
77
+ # :custom -> call config.tenant_not_found_handler
78
+ config.tenant_not_found_strategy = :raise
79
+
80
+ # Required when tenant_not_found_strategy is :custom.
81
+ # config.tenant_not_found_handler = ->(_request, _subdomain) {
82
+ # [404, {"Content-Type" => "text/plain"}, ["Tenant not found"]]
83
+ # }
84
+ end
85
+ ```
86
+
87
+ ## Data Model Contract
88
+
89
+ Your tenant class, for example `Organization`, should:
90
+
91
+ - live in the public schema
92
+ - expose a `subdomain` attribute, unless `tenant_finder` is configured
93
+ - expose a schema name through `schema_name`, or use `tenant_schema_name_proc`
94
+
95
+ Tenant-scoped tables should include the configured tenant foreign key, such as `organization_id`, unless the table is listed in `config.system_tables`.
96
+
97
+ ## Tenant Schema
98
+
99
+ Prefer defining tenant-only tables in `db/tenant_schema.rb`:
100
+
101
+ ```ruby
102
+ ActiveRecord::Schema.define do
103
+ create_table "projects", force: :cascade do |t|
104
+ t.bigint "organization_id", null: false
105
+ t.string "name", null: false
106
+ t.timestamps
107
+ end
108
+ end
109
+ ```
110
+
111
+ If `db/tenant_schema.rb` is absent, PostgresMultitenant loads `db/schema.rb` and prunes tables that are not returned by:
112
+
113
+ - `config.tenant_table_resolver.call`
114
+ - `config.system_tables`
115
+
116
+ ## Usage
117
+
118
+ ```ruby
119
+ PostgresMultitenant.switch("acme") { Project.count }
120
+ PostgresMultitenant.create("acme")
121
+ PostgresMultitenant.drop("acme")
122
+ PostgresMultitenant.current #=> "public" or tenant schema
123
+ PostgresMultitenant.tenants #=> ["acme", "beacon"]
124
+ PostgresMultitenant.reset!
125
+ ```
126
+
127
+ Schema names must be lowercase PostgreSQL-safe identifiers matching:
128
+
129
+ ```text
130
+ \A[a-z][a-z0-9_]*\z
131
+ ```
132
+
133
+ Reserved schemas such as `public`, `information_schema`, and `pg_*` cannot be used as tenant schemas.
134
+
135
+ ## Rake Tasks
136
+
137
+ ```bash
138
+ bin/rails tenants:create[acme]
139
+ bin/rails tenants:drop[acme]
140
+ bin/rails tenants:list
141
+ bin/rails tenants:migrate
142
+ ```
143
+
144
+ `tenants:migrate` keeps tenant schemas in sync by loading `db/tenant_schema.rb` or `db/schema.rb`; it does not run Rails migrations inside tenant schemas.
145
+
146
+ ## Development
147
+
148
+ Run the test suite:
149
+
150
+ ```bash
151
+ rake test
152
+ ```
153
+
154
+ Build the gem:
155
+
156
+ ```bash
157
+ gem build postgres_multitenant.gemspec
158
+ ```
159
+
160
+ ## Security Notes
161
+
162
+ - Keep `tenant_not_found_strategy = :raise` unless public-schema fallback is intentional.
163
+ - Keep public tables and tenant tables separated by design.
164
+ - Do not include tenant-owned data in public-schema models.
165
+ - Review `config.tenant_table_resolver` whenever adding tenant-scoped tables.
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module PostgresMultitenant
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path('templates', __dir__)
9
+
10
+ desc 'Creates a PostgresMultitenant initializer and tenant schema file.'
11
+
12
+ class_option :tenant_class_name,
13
+ type: :string,
14
+ default: 'Organization',
15
+ desc: 'Public-schema model used to find tenants.'
16
+
17
+ class_option :tenant_foreign_key,
18
+ type: :string,
19
+ default: 'organization_id',
20
+ desc: 'Foreign key column expected on tenant-scoped tables.'
21
+
22
+ def copy_initializer
23
+ template 'postgres_multitenant.rb.tt', 'config/initializers/postgres_multitenant.rb'
24
+ end
25
+
26
+ def copy_tenant_schema
27
+ copy_file 'tenant_schema.rb', 'db/tenant_schema.rb'
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ PostgresMultitenant.configure do |config|
4
+ # Public-schema model that identifies tenants by subdomain.
5
+ config.tenant_class_name = '<%= options[:tenant_class_name] %>'
6
+
7
+ # Required: return tenant-scoped table names without schema prefixes.
8
+ # Replace this with the table list or app-specific registry for your tenant data.
9
+ config.tenant_table_resolver = -> { [] }
10
+
11
+ # Optional: scope tenant records considered for routing and tenant migration.
12
+ # config.tenant_scope_proc = ->(klass) { klass.where(active: true) }
13
+
14
+ # Optional: custom subdomain lookup.
15
+ # config.tenant_finder = ->(subdomain) { <%= options[:tenant_class_name] %>.find_by(subdomain: subdomain) }
16
+
17
+ # Optional: custom schema naming.
18
+ # config.tenant_schema_name_proc = ->(tenant) { tenant.subdomain.tr('-', '_') }
19
+
20
+ # Tenant foreign key expected on tenant-scoped tables during development checks.
21
+ config.tenant_foreign_key = '<%= options[:tenant_foreign_key] %>'
22
+
23
+ # Optional: override request-to-subdomain extraction.
24
+ # config.subdomain_extractor = ->(request) { request.subdomain.presence }
25
+
26
+ # Default behavior is to raise when a tenant subdomain cannot be resolved.
27
+ config.tenant_not_found_strategy = :raise
28
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Define only tenant-scoped tables in this file. If this file is absent,
4
+ # PostgresMultitenant falls back to db/schema.rb and prunes non-tenant tables after load.
5
+ ActiveRecord::Schema.define do
6
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/string/inflections'
4
+
5
+ module PostgresMultitenant
6
+ class Configuration
7
+ attr_accessor :tenant_class_name,
8
+ :tenant_finder,
9
+ :tenant_scope_proc,
10
+ :tenant_schema_name_proc,
11
+ :tenant_foreign_key,
12
+ :tenant_table_resolver,
13
+ :system_tables,
14
+ :default_schema,
15
+ :excluded_subdomains,
16
+ :subdomain_extractor,
17
+ :tenant_not_found_strategy,
18
+ :tenant_not_found_handler,
19
+ :terminate_connections_on_drop
20
+
21
+ def initialize
22
+ @tenant_class_name = 'Organization'
23
+ @tenant_finder = nil
24
+ @tenant_scope_proc = nil
25
+ @tenant_schema_name_proc = nil
26
+ @tenant_foreign_key = 'organization_id'
27
+ @tenant_table_resolver = nil
28
+ @default_schema = 'public'
29
+ @system_tables = %w[
30
+ action_text_rich_texts
31
+ active_storage_attachments
32
+ active_storage_blobs
33
+ active_storage_variant_records
34
+ ]
35
+ @excluded_subdomains = %w[www api admin mail ftp]
36
+ @subdomain_extractor = nil
37
+ @tenant_not_found_strategy = :raise
38
+ @tenant_not_found_handler = nil
39
+ @terminate_connections_on_drop = false
40
+ end
41
+
42
+ def tenant_class
43
+ return nil if tenant_class_name.to_s.empty?
44
+
45
+ tenant_class_name.constantize
46
+ rescue NameError
47
+ nil
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostgresMultitenant
4
+ module ConnectionPoolExtensions
5
+ def checkin(conn)
6
+ reset_schema_search_path(conn)
7
+ super
8
+ end
9
+
10
+ private
11
+
12
+ def reset_schema_search_path(conn)
13
+ return unless conn.respond_to?(:schema_search_path=)
14
+
15
+ conn.schema_search_path = PostgresMultitenant.configuration.default_schema
16
+ rescue StandardError
17
+ # no-op; never break checkin flow
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/object/blank'
4
+ require 'active_support/core_ext/object/inclusion'
5
+
6
+ module PostgresMultitenant
7
+ class Middleware
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ request = ActionDispatch::Request.new(env)
14
+ subdomain = extract_subdomain(request)
15
+ return run_in_schema(PostgresMultitenant.configuration.default_schema, env) if subdomain.blank?
16
+
17
+ tenant = PostgresMultitenant.find_tenant_by_subdomain(subdomain) if subdomain.present?
18
+
19
+ if tenant
20
+ schema_name = PostgresMultitenant.schema_name_for(tenant)
21
+ return run_in_schema(schema_name.presence || PostgresMultitenant.configuration.default_schema, env)
22
+ end
23
+
24
+ handle_tenant_not_found(env, request, subdomain)
25
+ end
26
+
27
+ private
28
+
29
+ def extract_subdomain(request)
30
+ extractor = PostgresMultitenant.configuration.subdomain_extractor
31
+ return extractor.call(request) if extractor
32
+
33
+ host = request.host.to_s.downcase
34
+
35
+ # Skip for localhost, IP addresses, and single-part domains
36
+ return nil if host == 'localhost'
37
+ return nil if host =~ /\A\d+\.\d+\.\d+\.\d+\z/
38
+
39
+ parts = host.split('.')
40
+
41
+ # Need at least 3 parts for a subdomain (subdomain.domain.tld)
42
+ return nil if parts.length < 3
43
+
44
+ # Return first part as subdomain
45
+ subdomain = parts.first
46
+
47
+ # Skip common non-tenant subdomains
48
+ return nil if subdomain.in?(PostgresMultitenant.configuration.excluded_subdomains)
49
+
50
+ subdomain
51
+ end
52
+
53
+ def run_in_schema(schema_name, env)
54
+ SchemaManager.with_schema(schema_name) { @app.call(env) }
55
+ end
56
+
57
+ def handle_tenant_not_found(env, request, subdomain)
58
+ case PostgresMultitenant.configuration.tenant_not_found_strategy
59
+ when :raise
60
+ raise PostgresMultitenant::TenantNotFoundError, "No tenant found for subdomain '#{subdomain}'"
61
+ when :custom
62
+ handler = PostgresMultitenant.configuration.tenant_not_found_handler
63
+ raise ArgumentError, 'tenant_not_found_handler must be configured for :custom strategy' unless handler
64
+
65
+ handler.call(request, subdomain)
66
+ else
67
+ run_in_schema(PostgresMultitenant.configuration.default_schema, env)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostgresMultitenant
4
+ class Railtie < Rails::Railtie
5
+ initializer 'postgres_multitenant.configure_middleware' do |app|
6
+ app.config.middleware.use PostgresMultitenant::Middleware
7
+ end
8
+
9
+ initializer 'postgres_multitenant.patch_connection_pool' do
10
+ ActiveSupport.on_load(:active_record) do
11
+ pool = ActiveRecord::ConnectionAdapters::ConnectionPool
12
+ pool.prepend(PostgresMultitenant::ConnectionPoolExtensions) unless pool < PostgresMultitenant::ConnectionPoolExtensions
13
+ end
14
+ end
15
+
16
+ # Load rake tasks
17
+ rake_tasks do
18
+ load File.expand_path('../tasks/postgres_multitenant.rake', __dir__)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/object/blank'
4
+
5
+ module PostgresMultitenant
6
+ class UnsupportedAdapterError < StandardError; end
7
+
8
+ # Exception raised when schema drop fails after max retries
9
+ class SchemaDropError < StandardError; end
10
+
11
+ class SchemaManager
12
+ RESERVED_SCHEMAS = %w[public information_schema].freeze
13
+ SYSTEM_SCHEMA_PREFIX = 'pg_'
14
+
15
+ # SECURITY: Only tenant-specific tables should be created in tenant schemas.
16
+ def self.tenant_tables
17
+ return @tenant_tables_cache if defined?(@tenant_tables_cache) && @tenant_tables_cache
18
+
19
+ resolver = PostgresMultitenant.configuration.tenant_table_resolver
20
+ raise StandardError, 'Configure PostgresMultitenant.tenant_table_resolver.' unless resolver
21
+
22
+ tenant_scoped_tables = resolver.call
23
+
24
+ system_tables = PostgresMultitenant.configuration.system_tables
25
+
26
+ # Combine and deduplicate
27
+ @tenant_tables_cache = (Array(tenant_scoped_tables) + Array(system_tables)).uniq.sort
28
+ end
29
+
30
+ # Validate that tenant table configuration is correct
31
+ # Ensures models are loaded and schema matches expectations
32
+ def self.validate_tenant_table_configuration!
33
+ detected = tenant_tables
34
+
35
+ # Ensure we found some tables
36
+ if detected.empty?
37
+ raise StandardError, 'No tenant tables detected. Configure PostgresMultitenant.tenant_table_resolver.'
38
+ end
39
+
40
+ # In test/development, validate that schema has tenant foreign key columns
41
+ if Rails.env.test? || Rails.env.development?
42
+ system_tables = PostgresMultitenant.configuration.system_tables
43
+ tenant_foreign_key = PostgresMultitenant.configuration.tenant_foreign_key
44
+ missing_org_id = detected.reject { |table| system_tables.include?(table) }
45
+ .select { |table| !column_exists_in_schema?(table, tenant_foreign_key) }
46
+
47
+ if missing_org_id.any?
48
+ Rails.logger.warn(
49
+ "WARNING: These tenant tables are missing #{tenant_foreign_key} column: #{missing_org_id.join(', ')}"
50
+ )
51
+ end
52
+ end
53
+
54
+ detected
55
+ end
56
+
57
+ # Helper method to check if a column exists in a table
58
+ def self.column_exists_in_schema?(table_name, column_name)
59
+ connection.column_exists?(table_name, column_name)
60
+ rescue ActiveRecord::StatementInvalid
61
+ false
62
+ end
63
+
64
+ def self.reset_tenant_table_cache!
65
+ @tenant_tables_cache = nil
66
+ end
67
+
68
+ class << self
69
+ # Create a new tenant schema and run migrations
70
+ def create_schema(name)
71
+ validate_schema_name!(name)
72
+
73
+ connection.execute("CREATE SCHEMA IF NOT EXISTS #{connection.quote_table_name(name)}")
74
+
75
+ # Load schema.rb into the new tenant schema
76
+ with_schema(name) do
77
+ load_schema_file
78
+ end
79
+ end
80
+
81
+ # Drop a tenant schema
82
+ def drop_schema(name)
83
+ validate_schema_name!(name)
84
+ connection.execute("DROP SCHEMA IF EXISTS #{connection.quote_table_name(name)} CASCADE")
85
+ end
86
+
87
+ # Switch to a tenant schema for a block
88
+ def with_schema(name, &block)
89
+ raise ArgumentError, 'Block required' unless block
90
+
91
+ name = normalize_schema_name(name)
92
+ validate_schema_name!(name) unless name == 'public'
93
+
94
+ original = current_schema
95
+ switched = false
96
+ switch_to(name)
97
+ switched = true
98
+ yield
99
+ ensure
100
+ switch_to(original) if switched && original
101
+ end
102
+
103
+ # Get current schema from search_path
104
+ def current_schema
105
+ conn = connection
106
+ ensure_schema_search_path_supported!(conn)
107
+
108
+ schema = conn.schema_search_path.to_s.split(',').first.to_s.strip.delete('"')
109
+ schema.presence || 'public'
110
+ end
111
+
112
+ # List all tenant schemas (excluding system schemas)
113
+ def tenant_schemas
114
+ connection.select_values(
115
+ ActiveRecord::Base.sanitize_sql_array([
116
+ 'SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT IN (?) AND schema_name NOT LIKE ?',
117
+ RESERVED_SCHEMAS,
118
+ "#{SYSTEM_SCHEMA_PREFIX}%"
119
+ ])
120
+ )
121
+ end
122
+
123
+ # Check if a schema exists
124
+ def schema_exists?(name)
125
+ result = connection.select_value(
126
+ ActiveRecord::Base.sanitize_sql_array([
127
+ 'SELECT 1 FROM information_schema.schemata WHERE schema_name = ? LIMIT 1',
128
+ name
129
+ ])
130
+ )
131
+ result.present?
132
+ end
133
+
134
+ # Reset connection to public schema
135
+ def reset!
136
+ switch_to('public')
137
+ end
138
+
139
+ # Drop a schema with retry logic and connection termination
140
+ #
141
+ # @param name [String] The schema name to drop
142
+ # @param max_attempts [Integer] Maximum number of retry attempts (default: 5)
143
+ # @param raise_on_failure [Boolean] Whether to raise exception on failure (default: true)
144
+ # @return [Boolean] true if successful, false if failed and raise_on_failure is false
145
+ # @raise [SchemaDropError] if max attempts exceeded and raise_on_failure is true
146
+ def drop_schema_with_retry(name, max_attempts: 5, raise_on_failure: true)
147
+ validate_schema_name!(name)
148
+
149
+ # If schema doesn't exist, return true (idempotent)
150
+ unless schema_exists?(name)
151
+ Rails.logger.info("Schema #{name} does not exist, skipping drop")
152
+ return true
153
+ end
154
+
155
+ attempts = 0
156
+ last_error = nil
157
+
158
+ while attempts < max_attempts
159
+ attempts += 1
160
+
161
+ begin
162
+ # Terminate active connections only when explicitly configured.
163
+ terminate_schema_connections(name) if PostgresMultitenant.configuration.terminate_connections_on_drop
164
+
165
+ Rails.logger.info("Dropping schema: #{name} (attempt #{attempts}/#{max_attempts})")
166
+ drop_schema(name)
167
+ Rails.logger.info("Successfully dropped schema: #{name}")
168
+ return true
169
+ rescue ActiveRecord::StatementInvalid => e
170
+ last_error = e
171
+
172
+ # Check if error is retryable (locked schema, active connections, etc.)
173
+ if e.message.include?('PG::ObjectInUse') ||
174
+ e.message.include?('being accessed by other users') ||
175
+ e.message.include?('deadlock') ||
176
+ e.message.include?('lock timeout')
177
+
178
+ if attempts < max_attempts
179
+ wait_time = 2**(attempts - 1) # Exponential backoff: 1, 2, 4, 8, 16
180
+ Rails.logger.warn(
181
+ "Failed to drop schema #{name} (attempt #{attempts}/#{max_attempts}): " \
182
+ "#{e.message}. Retrying in #{wait_time}s..."
183
+ )
184
+ sleep(wait_time)
185
+ else
186
+ # Max attempts reached
187
+ Rails.logger.error("Failed to drop schema #{name} after #{max_attempts} attempts: #{e.message}")
188
+ if raise_on_failure
189
+ raise SchemaDropError,
190
+ "Failed to drop schema '#{name}' after #{max_attempts} attempts: #{e.message}"
191
+ else
192
+ return false
193
+ end
194
+ end
195
+ else
196
+ # Non-retryable error
197
+ Rails.logger.error("Non-retryable error dropping schema #{name}: #{e.message}")
198
+ if raise_on_failure
199
+ raise SchemaDropError, "Failed to drop schema '#{name}': #{e.message}"
200
+ else
201
+ return false
202
+ end
203
+ end
204
+ end
205
+ end
206
+
207
+ # Should not reach here, but handle it
208
+ if raise_on_failure
209
+ raise SchemaDropError, "Failed to drop schema '#{name}' after #{max_attempts} attempts: #{last_error&.message}"
210
+ else
211
+ false
212
+ end
213
+ end
214
+
215
+ # Terminate all active connections to a specific schema
216
+ #
217
+ # @param name [String] The schema name
218
+ def terminate_schema_connections(name)
219
+ Rails.logger.info("Terminating known schema-scoped idle connections for schema: #{name}")
220
+
221
+ sql = <<~SQL.squish
222
+ SELECT pg_terminate_backend(pg_stat_activity.pid)
223
+ FROM pg_stat_activity
224
+ WHERE pg_stat_activity.datname = current_database()
225
+ AND pid <> pg_backend_pid()
226
+ AND state = 'idle'
227
+ AND query ILIKE 'SET search_path TO "#{connection.quote_string(name)}"%'
228
+ SQL
229
+
230
+ begin
231
+ connection.execute(sql)
232
+ rescue StandardError => e
233
+ Rails.logger.warn("Error terminating connections for schema #{name}: #{e.message}")
234
+ # Don't raise - this is a best-effort operation
235
+ end
236
+ end
237
+
238
+ # Find schemas that don't have a corresponding organization
239
+ #
240
+ # @return [Array<String>] List of orphaned schema names
241
+ def find_orphaned_schemas
242
+ all_tenant_schemas = tenant_schemas
243
+ tenant_class = PostgresMultitenant.tenant_class
244
+ return [] unless tenant_class
245
+
246
+ tenant_schema_names = []
247
+ if tenant_class.respond_to?(:find_each)
248
+ tenant_class.find_each do |tenant|
249
+ schema_name = PostgresMultitenant.schema_name_for(tenant)
250
+ tenant_schema_names << schema_name if schema_name.present?
251
+ end
252
+ else
253
+ tenant_class.all.each do |tenant|
254
+ schema_name = PostgresMultitenant.schema_name_for(tenant)
255
+ tenant_schema_names << schema_name if schema_name.present?
256
+ end
257
+ end
258
+
259
+ orphaned = all_tenant_schemas - tenant_schema_names
260
+ orphaned
261
+ end
262
+
263
+ # Clean up all orphaned schemas (schemas without corresponding organizations)
264
+ #
265
+ # @param dry_run [Boolean] If true, only report what would be cleaned (default: false)
266
+ # @return [Hash] Cleanup report with :cleaned, :failed, or :would_clean keys
267
+ def cleanup_orphaned_schemas(dry_run: false)
268
+ orphaned = find_orphaned_schemas
269
+
270
+ if orphaned.empty?
271
+ Rails.logger.info('No orphaned schemas found')
272
+ return { cleaned: [], failed: [], would_clean: [] }
273
+ end
274
+
275
+ Rails.logger.info("Found #{orphaned.size} orphaned schema#{'s' if orphaned.size != 1}: #{orphaned.join(', ')}")
276
+
277
+ if dry_run
278
+ Rails.logger.info("DRY RUN: Would clean up #{orphaned.size} schema#{'s' if orphaned.size != 1}")
279
+ return { would_clean: orphaned }
280
+ end
281
+
282
+ cleaned = []
283
+ failed = []
284
+
285
+ orphaned.each do |schema_name|
286
+ begin
287
+ drop_schema_with_retry(schema_name, raise_on_failure: true)
288
+ Rails.logger.info("Successfully cleaned up orphaned schema: #{schema_name}")
289
+ cleaned << schema_name
290
+ rescue StandardError => e
291
+ Rails.logger.error("Failed to clean up orphaned schema #{schema_name}: #{e.message}")
292
+ failed << schema_name
293
+ end
294
+ end
295
+
296
+ Rails.logger.info("Cleanup complete. Cleaned: #{cleaned.size}, Failed: #{failed.size}")
297
+ { cleaned: cleaned, failed: failed }
298
+ end
299
+
300
+ private
301
+
302
+ def switch_to(name)
303
+ name = normalize_schema_name(name)
304
+ conn = connection
305
+ ensure_schema_search_path_supported!(conn)
306
+
307
+ quoted = conn.quote_table_name(name)
308
+ conn.schema_search_path =
309
+ if name == 'public'
310
+ quoted
311
+ else
312
+ "#{quoted},public"
313
+ end
314
+ end
315
+
316
+ def validate_schema_name!(name)
317
+ name = normalize_schema_name(name)
318
+
319
+ raise ArgumentError, 'Schema name cannot be blank' if name.blank?
320
+ raise ArgumentError, 'Schema name contains invalid characters' unless name =~ /\A[a-z][a-z0-9_]*\z/
321
+ raise ArgumentError, 'Schema name is reserved' if RESERVED_SCHEMAS.include?(name)
322
+ raise ArgumentError, 'Schema name cannot start with pg_' if name.start_with?(SYSTEM_SCHEMA_PREFIX)
323
+ end
324
+
325
+ def normalize_schema_name(name)
326
+ name.to_s
327
+ end
328
+
329
+ def ensure_schema_search_path_supported!(conn)
330
+ return if conn.respond_to?(:schema_search_path) && conn.respond_to?(:schema_search_path=)
331
+
332
+ raise UnsupportedAdapterError,
333
+ 'PostgresMultitenant requires an ActiveRecord adapter with schema_search_path support, such as PostgreSQL.'
334
+ end
335
+
336
+ def load_schema_file
337
+ # SECURITY: Load only tenant-specific tables to prevent cross-schema leaks.
338
+ require_relative 'tenant_schema_loader'
339
+ TenantSchemaLoader.load_into_current_schema
340
+ end
341
+
342
+ def connection
343
+ ActiveRecord::Base.connection
344
+ end
345
+ end
346
+ end
347
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostgresMultitenant
4
+ # Loads tenant schemas without using eval on regex-extracted fragments.
5
+ class TenantSchemaLoader
6
+ class << self
7
+ def load_into_current_schema
8
+ schema_file = tenant_schema_file
9
+ return unless schema_file
10
+
11
+ load_full_schema_file(schema_file)
12
+ prune_non_tenant_tables! unless schema_file.basename.to_s == 'tenant_schema.rb'
13
+
14
+ create_rails_metadata_tables
15
+ end
16
+
17
+ private
18
+
19
+ def tenant_schema_file
20
+ tenant_schema = Rails.root.join('db', 'tenant_schema.rb')
21
+ return tenant_schema if File.exist?(tenant_schema)
22
+
23
+ schema = Rails.root.join('db', 'schema.rb')
24
+ return schema if File.exist?(schema)
25
+
26
+ nil
27
+ end
28
+
29
+ def load_full_schema_file(schema_file)
30
+ # schema.rb/tenant_schema.rb is trusted application code generated by Rails.
31
+ # Loading the full file avoids fragile regex extraction and eval calls.
32
+ Kernel.load(schema_file.to_s)
33
+ rescue StandardError => e
34
+ raise StandardError, "Failed to load tenant schema from #{schema_file}: #{e.message}"
35
+ end
36
+
37
+ def prune_non_tenant_tables!
38
+ connection = ActiveRecord::Base.connection
39
+ keep_tables = SchemaManager.tenant_tables + %w[ar_internal_metadata schema_migrations]
40
+ drop_tables = connection.tables - keep_tables
41
+
42
+ drop_tables.each do |table|
43
+ connection.execute("DROP TABLE IF EXISTS #{connection.quote_table_name(table)} CASCADE")
44
+ end
45
+ end
46
+
47
+ def create_rails_metadata_tables
48
+ connection = ActiveRecord::Base.connection
49
+
50
+ connection.transaction(requires_new: true) do
51
+ create_ar_internal_metadata(connection)
52
+ create_schema_migrations(connection)
53
+ end
54
+ end
55
+
56
+ def create_ar_internal_metadata(connection)
57
+ connection.execute(<<-SQL)
58
+ CREATE TABLE IF NOT EXISTS ar_internal_metadata (
59
+ key character varying NOT NULL,
60
+ value character varying,
61
+ created_at timestamp(6) without time zone NOT NULL,
62
+ updated_at timestamp(6) without time zone NOT NULL,
63
+ PRIMARY KEY (key)
64
+ );
65
+ SQL
66
+ end
67
+
68
+ def create_schema_migrations(connection)
69
+ connection.execute(<<-SQL)
70
+ CREATE TABLE IF NOT EXISTS schema_migrations (
71
+ version character varying NOT NULL,
72
+ PRIMARY KEY (version)
73
+ );
74
+ SQL
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostgresMultitenant
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'postgres_multitenant/version'
4
+ require_relative 'postgres_multitenant/configuration'
5
+ require_relative 'postgres_multitenant/schema_manager'
6
+ require_relative 'postgres_multitenant/tenant_schema_loader'
7
+ require_relative 'postgres_multitenant/middleware'
8
+ require_relative 'postgres_multitenant/connection_pool_extensions'
9
+ require_relative 'postgres_multitenant/railtie' if defined?(Rails)
10
+
11
+ # PostgresMultitenant module provides multi-tenancy support for Rails applications
12
+ # using PostgreSQL schemas.
13
+ module PostgresMultitenant
14
+ class TenantNotFoundError < StandardError; end
15
+
16
+ class << self
17
+ def configuration
18
+ @configuration ||= Configuration.new
19
+ end
20
+
21
+ def configure
22
+ yield(configuration)
23
+ end
24
+
25
+ def tenant_class
26
+ configuration.tenant_class
27
+ end
28
+
29
+ def find_tenant_by_subdomain(subdomain)
30
+ SchemaManager.with_schema('public') do
31
+ if configuration.tenant_finder
32
+ configuration.tenant_finder&.call(subdomain)
33
+ else
34
+ find_tenant_using_class(subdomain)
35
+ end
36
+ end
37
+ end
38
+
39
+ def schema_name_for(tenant)
40
+ return configuration.tenant_schema_name_proc.call(tenant) if configuration.tenant_schema_name_proc
41
+
42
+ return tenant.schema_name if tenant.respond_to?(:schema_name)
43
+ return tenant.subdomain.tr('-', '_') if tenant.respond_to?(:subdomain)
44
+
45
+ nil
46
+ end
47
+
48
+ # Convenience method to switch schemas
49
+ def switch(tenant_name, &block)
50
+ SchemaManager.with_schema(tenant_name, &block)
51
+ end
52
+
53
+ # Convenience method to create tenant
54
+ def create(tenant_name)
55
+ SchemaManager.create_schema(tenant_name)
56
+ end
57
+
58
+ # Convenience method to drop tenant
59
+ def drop(tenant_name)
60
+ SchemaManager.drop_schema(tenant_name)
61
+ end
62
+
63
+ # Get current tenant
64
+ def current
65
+ SchemaManager.current_schema
66
+ end
67
+
68
+ # List all tenants
69
+ def tenants
70
+ SchemaManager.tenant_schemas
71
+ end
72
+
73
+ # Reset to public schema
74
+ def reset!
75
+ SchemaManager.reset!
76
+ end
77
+
78
+ private
79
+
80
+ def find_tenant_using_class(subdomain)
81
+ klass = tenant_class
82
+ return unless klass
83
+
84
+ relation = if configuration.tenant_scope_proc
85
+ configuration.tenant_scope_proc&.call(klass)
86
+ else
87
+ klass.all
88
+ end
89
+ relation.find_by(subdomain: subdomain)
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :tenants do
4
+ desc 'Create a new tenant schema'
5
+ task :create, [:name] => :environment do |_t, args|
6
+ name = args[:name]
7
+ abort 'Usage: rake tenants:create[tenant_name]' if name.blank?
8
+
9
+ puts "Creating tenant schema: #{name}"
10
+ PostgresMultitenant::SchemaManager.create_schema(name)
11
+ puts "Successfully created tenant: #{name}"
12
+ end
13
+
14
+ desc 'Drop a tenant schema'
15
+ task :drop, [:name] => :environment do |_t, args|
16
+ name = args[:name]
17
+ abort 'Usage: rake tenants:drop[tenant_name]' if name.blank?
18
+
19
+ print "Are you sure you want to drop tenant '#{name}'? This cannot be undone. [y/N] "
20
+ confirm = $stdin.gets.chomp.downcase
21
+ abort 'Aborted.' unless confirm == 'y'
22
+
23
+ puts "Dropping tenant schema: #{name}"
24
+ PostgresMultitenant::SchemaManager.drop_schema(name)
25
+ puts "Successfully dropped tenant: #{name}"
26
+ end
27
+
28
+ desc 'List all tenant schemas'
29
+ task list: :environment do
30
+ tenants = PostgresMultitenant::SchemaManager.tenant_schemas
31
+
32
+ if tenants.empty?
33
+ puts 'No tenant schemas found.'
34
+ else
35
+ puts "Tenant schemas (#{tenants.count}):"
36
+ tenants.each { |t| puts " - #{t}" }
37
+ end
38
+ end
39
+
40
+ desc 'Migrate all tenant schemas'
41
+ task migrate: :environment do
42
+ tenant_class = PostgresMultitenant.tenant_class
43
+ if tenant_class.nil?
44
+ puts 'No tenant class configured for PostgresMultitenant; skipping tenant migration.'
45
+ next
46
+ end
47
+
48
+ tenants = if PostgresMultitenant.configuration.tenant_scope_proc
49
+ PostgresMultitenant.configuration.tenant_scope_proc.call(tenant_class)
50
+ else
51
+ tenant_class.all
52
+ end
53
+
54
+ if tenants.empty?
55
+ puts 'No active tenants to migrate.'
56
+ next
57
+ end
58
+
59
+ puts "Migrating #{tenants.count} tenant(s)..."
60
+
61
+ tenants.each do |tenant|
62
+ schema_name = PostgresMultitenant.schema_name_for(tenant)
63
+ if schema_name.blank?
64
+ puts " Skipping tenant without schema name: #{tenant.inspect}"
65
+ next
66
+ end
67
+ puts " Migrating: #{schema_name}"
68
+ unless PostgresMultitenant::SchemaManager.schema_exists?(schema_name)
69
+ # If a tenant exists in the public DB but its schema is missing (common in tests/dev resets),
70
+ # create it first (which also loads `db/schema.rb` into that schema).
71
+ PostgresMultitenant::SchemaManager.create_schema(schema_name)
72
+ next
73
+ end
74
+
75
+ PostgresMultitenant::SchemaManager.with_schema(schema_name) do
76
+ # Tenants are not migrated via ActiveRecord migrations (public tables don't exist in tenant schemas).
77
+ # Instead, tenant schemas are kept in sync by re-loading tenant table definitions from `db/schema.rb`.
78
+ PostgresMultitenant::TenantSchemaLoader.load_into_current_schema
79
+ end
80
+ end
81
+
82
+ puts 'All tenants migrated successfully.'
83
+ end
84
+
85
+ desc 'Rollback all tenant schemas'
86
+ task :rollback, [:step] => :environment do |_t, args|
87
+ step = (args[:step] || 1).to_i
88
+ abort(
89
+ 'Tenants do not use ActiveRecord migrations (they are synced from db/schema.rb). ' \
90
+ "Rollback is not supported (requested step=#{step}). " \
91
+ 'To change tenant schema, update db/schema.rb (or migrations) and run `rake tenants:migrate`.'
92
+ )
93
+ end
94
+
95
+ desc 'Show migration status for all tenants'
96
+ task status: :environment do
97
+ abort(
98
+ 'Tenants do not use ActiveRecord migrations (they are synced from db/schema.rb). ' \
99
+ 'Migration status is not applicable. Use `rake tenants:list` and keep tenant schemas in sync via `rake tenants:migrate`.'
100
+ )
101
+ end
102
+ end
103
+
104
+ # Enhance db:migrate to also migrate tenants
105
+ namespace :db do
106
+ task migrate: :environment do
107
+ # After public schema migration, sync tenant schemas to match `db/schema.rb`.
108
+ tenant_class = PostgresMultitenant.tenant_class
109
+ Rake::Task['tenants:migrate'].invoke if tenant_class&.table_exists?
110
+ rescue StandardError => e
111
+ puts "ERROR: Tenant migration failed: #{e.message}"
112
+ raise
113
+ end
114
+ end
metadata ADDED
@@ -0,0 +1,167 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: postgres_multitenant
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Joshua Needham - DAD Studios
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: actionpack
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '6.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: activerecord
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '6.1'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '9.0'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '6.1'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '9.0'
52
+ - !ruby/object:Gem::Dependency
53
+ name: activesupport
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '6.1'
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '9.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '6.1'
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '9.0'
72
+ - !ruby/object:Gem::Dependency
73
+ name: railties
74
+ requirement: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '6.1'
79
+ - - "<"
80
+ - !ruby/object:Gem::Version
81
+ version: '9.0'
82
+ type: :runtime
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '6.1'
89
+ - - "<"
90
+ - !ruby/object:Gem::Version
91
+ version: '9.0'
92
+ - !ruby/object:Gem::Dependency
93
+ name: minitest
94
+ requirement: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - "~>"
97
+ - !ruby/object:Gem::Version
98
+ version: '5.0'
99
+ type: :development
100
+ prerelease: false
101
+ version_requirements: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - "~>"
104
+ - !ruby/object:Gem::Version
105
+ version: '5.0'
106
+ - !ruby/object:Gem::Dependency
107
+ name: rake
108
+ requirement: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - "~>"
111
+ - !ruby/object:Gem::Version
112
+ version: '13.0'
113
+ type: :development
114
+ prerelease: false
115
+ version_requirements: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - "~>"
118
+ - !ruby/object:Gem::Version
119
+ version: '13.0'
120
+ description: Schema-based multi-tenant isolation for Rails apps backed by PostgreSQL
121
+ schemas.
122
+ email:
123
+ - oss@postgresmultitenant.dev
124
+ executables: []
125
+ extensions: []
126
+ extra_rdoc_files: []
127
+ files:
128
+ - CHANGELOG.md
129
+ - LICENSE.txt
130
+ - README.md
131
+ - lib/generators/postgres_multitenant/install/install_generator.rb
132
+ - lib/generators/postgres_multitenant/install/templates/postgres_multitenant.rb.tt
133
+ - lib/generators/postgres_multitenant/install/templates/tenant_schema.rb
134
+ - lib/postgres_multitenant.rb
135
+ - lib/postgres_multitenant/configuration.rb
136
+ - lib/postgres_multitenant/connection_pool_extensions.rb
137
+ - lib/postgres_multitenant/middleware.rb
138
+ - lib/postgres_multitenant/railtie.rb
139
+ - lib/postgres_multitenant/schema_manager.rb
140
+ - lib/postgres_multitenant/tenant_schema_loader.rb
141
+ - lib/postgres_multitenant/version.rb
142
+ - lib/tasks/postgres_multitenant.rake
143
+ homepage: https://github.com/DAD-Studios/postgres_multitenant
144
+ licenses:
145
+ - MIT
146
+ metadata:
147
+ source_code_uri: https://github.com/DAD-Studios/postgres_multitenant
148
+ changelog_uri: https://github.com/DAD-Studios/postgres_multitenant/blob/main/CHANGELOG.md
149
+ rubygems_mfa_required: 'true'
150
+ rdoc_options: []
151
+ require_paths:
152
+ - lib
153
+ required_ruby_version: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - ">="
156
+ - !ruby/object:Gem::Version
157
+ version: '3.1'
158
+ required_rubygems_version: !ruby/object:Gem::Requirement
159
+ requirements:
160
+ - - ">="
161
+ - !ruby/object:Gem::Version
162
+ version: '0'
163
+ requirements: []
164
+ rubygems_version: 3.7.2
165
+ specification_version: 4
166
+ summary: Schema-based multi-tenant isolation for Rails apps.
167
+ test_files: []