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 +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +165 -0
- data/lib/generators/postgres_multitenant/install/install_generator.rb +31 -0
- data/lib/generators/postgres_multitenant/install/templates/postgres_multitenant.rb.tt +28 -0
- data/lib/generators/postgres_multitenant/install/templates/tenant_schema.rb +6 -0
- data/lib/postgres_multitenant/configuration.rb +50 -0
- data/lib/postgres_multitenant/connection_pool_extensions.rb +20 -0
- data/lib/postgres_multitenant/middleware.rb +71 -0
- data/lib/postgres_multitenant/railtie.rb +21 -0
- data/lib/postgres_multitenant/schema_manager.rb +347 -0
- data/lib/postgres_multitenant/tenant_schema_loader.rb +78 -0
- data/lib/postgres_multitenant/version.rb +5 -0
- data/lib/postgres_multitenant.rb +92 -0
- data/lib/tasks/postgres_multitenant.rake +114 -0
- metadata +167 -0
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,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,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: []
|