pg_multitenant_schemas 0.1.3 → 0.2.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 +4 -4
- data/.ruby-version +1 -0
- data/CHANGELOG.md +46 -0
- data/README.md +269 -16
- data/docs/README.md +77 -0
- data/docs/configuration.md +340 -0
- data/docs/context.md +292 -0
- data/docs/errors.md +498 -0
- data/docs/integration_testing.md +454 -0
- data/docs/migrator.md +291 -0
- data/docs/rails_integration.md +468 -0
- data/docs/schema_switcher.md +182 -0
- data/docs/tenant_resolver.md +394 -0
- data/docs/testing.md +358 -0
- data/examples/context_management.rb +198 -0
- data/examples/migration_workflow.rb +50 -0
- data/examples/rails_integration/controller_examples.rb +368 -0
- data/examples/schema_operations.rb +124 -0
- data/lib/pg_multitenant_schemas/configuration.rb +4 -4
- data/lib/pg_multitenant_schemas/migration_display_reporter.rb +30 -0
- data/lib/pg_multitenant_schemas/migration_executor.rb +81 -0
- data/lib/pg_multitenant_schemas/migration_schema_operations.rb +54 -0
- data/lib/pg_multitenant_schemas/migration_status_reporter.rb +65 -0
- data/lib/pg_multitenant_schemas/migrator.rb +89 -0
- data/lib/pg_multitenant_schemas/schema_switcher.rb +40 -66
- data/lib/pg_multitenant_schemas/tasks/advanced_tasks.rake +21 -0
- data/lib/pg_multitenant_schemas/tasks/basic_tasks.rake +20 -0
- data/lib/pg_multitenant_schemas/tasks/pg_multitenant_schemas.rake +53 -143
- data/lib/pg_multitenant_schemas/tasks/tenant_tasks.rake +65 -0
- data/lib/pg_multitenant_schemas/tenant_task_helpers.rb +102 -0
- data/lib/pg_multitenant_schemas/version.rb +1 -1
- data/lib/pg_multitenant_schemas.rb +10 -5
- data/pg_multitenant_schemas.gemspec +10 -9
- data/rails_integration/app/controllers/application_controller.rb +6 -0
- data/rails_integration/app/models/tenant.rb +6 -0
- metadata +39 -17
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# Tenant Resolver - Tenant Identification and Resolution
|
|
2
|
+
|
|
3
|
+
**File**: `lib/pg_multitenant_schemas/tenant_resolver.rb`
|
|
4
|
+
|
|
5
|
+
## 📋 Overview
|
|
6
|
+
|
|
7
|
+
The `TenantResolver` is responsible for identifying and resolving tenant information from various sources like HTTP requests, subdomains, headers, or custom logic. It provides flexible tenant identification strategies for different application architectures.
|
|
8
|
+
|
|
9
|
+
## 🎯 Purpose
|
|
10
|
+
|
|
11
|
+
- **Request Analysis**: Extract tenant information from HTTP requests
|
|
12
|
+
- **Flexible Resolution**: Support multiple tenant identification strategies
|
|
13
|
+
- **Caching**: Efficient tenant lookup with caching mechanisms
|
|
14
|
+
- **Error Handling**: Graceful handling of tenant resolution failures
|
|
15
|
+
|
|
16
|
+
## 🔧 Key Methods
|
|
17
|
+
|
|
18
|
+
### Core Resolution
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# Resolve tenant from request
|
|
22
|
+
TenantResolver.resolve_from_request(request)
|
|
23
|
+
|
|
24
|
+
# Resolve tenant by subdomain
|
|
25
|
+
TenantResolver.resolve_by_subdomain('acme')
|
|
26
|
+
|
|
27
|
+
# Resolve tenant by custom header
|
|
28
|
+
TenantResolver.resolve_by_header(request, 'X-Tenant-ID')
|
|
29
|
+
|
|
30
|
+
# Resolve tenant by domain
|
|
31
|
+
TenantResolver.resolve_by_domain('acme.myapp.com')
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Configuration and Caching
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
# Configure resolution strategy
|
|
38
|
+
TenantResolver.configure do |config|
|
|
39
|
+
config.strategy = :subdomain
|
|
40
|
+
config.cache_enabled = true
|
|
41
|
+
config.cache_ttl = 5.minutes
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Clear tenant cache
|
|
45
|
+
TenantResolver.clear_cache!
|
|
46
|
+
|
|
47
|
+
# Get cached tenant
|
|
48
|
+
TenantResolver.cached_tenant(identifier)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 🏗️ Implementation Details
|
|
52
|
+
|
|
53
|
+
### Resolution Strategies
|
|
54
|
+
|
|
55
|
+
#### Subdomain Strategy
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
def resolve_by_subdomain(subdomain)
|
|
59
|
+
return nil if subdomain.blank?
|
|
60
|
+
|
|
61
|
+
tenant_model.find_by(subdomain: subdomain)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Usage in controller
|
|
65
|
+
def resolve_tenant_from_request
|
|
66
|
+
subdomain = request.subdomain
|
|
67
|
+
TenantResolver.resolve_by_subdomain(subdomain)
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
#### Domain Strategy
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
def resolve_by_domain(domain)
|
|
75
|
+
return nil if domain.blank?
|
|
76
|
+
|
|
77
|
+
# Extract domain from full host
|
|
78
|
+
domain = extract_domain(domain) if domain.include?('.')
|
|
79
|
+
|
|
80
|
+
tenant_model.find_by(domain: domain)
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
#### Header Strategy
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
def resolve_by_header(request, header_name = 'X-Tenant-ID')
|
|
88
|
+
tenant_id = request.headers[header_name]
|
|
89
|
+
return nil if tenant_id.blank?
|
|
90
|
+
|
|
91
|
+
tenant_model.find(tenant_id)
|
|
92
|
+
rescue ActiveRecord::RecordNotFound
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
#### Custom Strategy
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
def resolve_by_custom_logic(request)
|
|
101
|
+
# Implement custom tenant resolution logic
|
|
102
|
+
# Examples:
|
|
103
|
+
# - Path-based: /tenants/acme/dashboard
|
|
104
|
+
# - JWT token: Extract tenant from JWT
|
|
105
|
+
# - Database lookup: Complex tenant hierarchies
|
|
106
|
+
|
|
107
|
+
case request.path
|
|
108
|
+
when /^\/tenants\/(\w+)/
|
|
109
|
+
subdomain = $1
|
|
110
|
+
resolve_by_subdomain(subdomain)
|
|
111
|
+
else
|
|
112
|
+
resolve_by_subdomain(request.subdomain)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Caching Implementation
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
class TenantResolver
|
|
121
|
+
class << self
|
|
122
|
+
def cached_tenant(identifier)
|
|
123
|
+
return resolve_tenant(identifier) unless cache_enabled?
|
|
124
|
+
|
|
125
|
+
Rails.cache.fetch(cache_key(identifier), expires_in: cache_ttl) do
|
|
126
|
+
resolve_tenant(identifier)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def cache_key(identifier)
|
|
133
|
+
"pg_multitenant:tenant:#{identifier}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def cache_enabled?
|
|
137
|
+
configuration.cache_enabled
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def cache_ttl
|
|
141
|
+
configuration.cache_ttl || 5.minutes
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## 🔄 Usage Patterns
|
|
148
|
+
|
|
149
|
+
### Rails Controller Integration
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
class ApplicationController < ActionController::Base
|
|
153
|
+
before_action :resolve_and_set_tenant
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def resolve_and_set_tenant
|
|
158
|
+
@current_tenant = TenantResolver.resolve_from_request(request)
|
|
159
|
+
|
|
160
|
+
if @current_tenant
|
|
161
|
+
PgMultitenantSchemas::Context.switch_to_tenant(@current_tenant)
|
|
162
|
+
else
|
|
163
|
+
handle_tenant_not_found
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def handle_tenant_not_found
|
|
168
|
+
# Handle tenant resolution failure
|
|
169
|
+
case request.format
|
|
170
|
+
when :json
|
|
171
|
+
render json: { error: 'Tenant not found' }, status: :not_found
|
|
172
|
+
else
|
|
173
|
+
redirect_to tenant_selection_path
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### API Applications
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
class ApiController < ActionController::API
|
|
183
|
+
before_action :authenticate_and_resolve_tenant
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def authenticate_and_resolve_tenant
|
|
188
|
+
# Extract tenant from JWT token
|
|
189
|
+
token = request.headers['Authorization']&.split(' ')&.last
|
|
190
|
+
payload = JWT.decode(token, Rails.application.secret_key_base).first
|
|
191
|
+
|
|
192
|
+
tenant_id = payload['tenant_id']
|
|
193
|
+
@current_tenant = TenantResolver.resolve_by_id(tenant_id)
|
|
194
|
+
|
|
195
|
+
if @current_tenant
|
|
196
|
+
PgMultitenantSchemas::Context.switch_to_tenant(@current_tenant)
|
|
197
|
+
else
|
|
198
|
+
render json: { error: 'Invalid tenant' }, status: :unauthorized
|
|
199
|
+
end
|
|
200
|
+
rescue JWT::DecodeError
|
|
201
|
+
render json: { error: 'Invalid token' }, status: :unauthorized
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Background Jobs
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
class TenantJob < ApplicationJob
|
|
210
|
+
def perform(tenant_identifier, *args)
|
|
211
|
+
tenant = TenantResolver.resolve_by_subdomain(tenant_identifier)
|
|
212
|
+
|
|
213
|
+
if tenant
|
|
214
|
+
PgMultitenantSchemas::Context.with_tenant(tenant) do
|
|
215
|
+
process_tenant_specific_task(*args)
|
|
216
|
+
end
|
|
217
|
+
else
|
|
218
|
+
Rails.logger.error "Tenant not found: #{tenant_identifier}"
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Multi-Strategy Resolution
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
class FlexibleTenantResolver < TenantResolver
|
|
228
|
+
def self.resolve_from_request(request)
|
|
229
|
+
# Try multiple strategies in order
|
|
230
|
+
strategies = [:header, :subdomain, :path, :session]
|
|
231
|
+
|
|
232
|
+
strategies.each do |strategy|
|
|
233
|
+
tenant = try_strategy(strategy, request)
|
|
234
|
+
return tenant if tenant
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
def self.try_strategy(strategy, request)
|
|
243
|
+
case strategy
|
|
244
|
+
when :header
|
|
245
|
+
resolve_by_header(request, 'X-Tenant-ID')
|
|
246
|
+
when :subdomain
|
|
247
|
+
resolve_by_subdomain(request.subdomain)
|
|
248
|
+
when :path
|
|
249
|
+
resolve_by_path(request.path)
|
|
250
|
+
when :session
|
|
251
|
+
resolve_by_session(request.session)
|
|
252
|
+
end
|
|
253
|
+
rescue StandardError => e
|
|
254
|
+
Rails.logger.warn "Tenant resolution strategy #{strategy} failed: #{e.message}"
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## 🎛️ Configuration Options
|
|
261
|
+
|
|
262
|
+
### Basic Configuration
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
TenantResolver.configure do |config|
|
|
266
|
+
config.strategy = :subdomain # Default resolution strategy
|
|
267
|
+
config.cache_enabled = true # Enable tenant caching
|
|
268
|
+
config.cache_ttl = 5.minutes # Cache time-to-live
|
|
269
|
+
config.fallback_strategy = :session # Fallback if primary fails
|
|
270
|
+
end
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Advanced Configuration
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
TenantResolver.configure do |config|
|
|
277
|
+
# Resolution strategies
|
|
278
|
+
config.primary_strategy = :subdomain
|
|
279
|
+
config.fallback_strategies = [:header, :session]
|
|
280
|
+
|
|
281
|
+
# Caching
|
|
282
|
+
config.cache_enabled = Rails.env.production?
|
|
283
|
+
config.cache_ttl = 10.minutes
|
|
284
|
+
config.cache_namespace = 'tenant_resolver'
|
|
285
|
+
|
|
286
|
+
# Error handling
|
|
287
|
+
config.raise_on_not_found = false
|
|
288
|
+
config.log_resolution_failures = true
|
|
289
|
+
|
|
290
|
+
# Custom resolvers
|
|
291
|
+
config.custom_resolvers = {
|
|
292
|
+
api: ApiTenantResolver,
|
|
293
|
+
admin: AdminTenantResolver
|
|
294
|
+
}
|
|
295
|
+
end
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
## 🚨 Important Considerations
|
|
299
|
+
|
|
300
|
+
### Performance
|
|
301
|
+
|
|
302
|
+
- **Caching**: Enable caching for production environments
|
|
303
|
+
- **Database Queries**: Minimize tenant lookup queries
|
|
304
|
+
- **Index Optimization**: Ensure tenant lookup fields are indexed
|
|
305
|
+
|
|
306
|
+
### Security
|
|
307
|
+
|
|
308
|
+
- **Input Validation**: Validate tenant identifiers to prevent injection
|
|
309
|
+
- **Access Control**: Verify user has access to resolved tenant
|
|
310
|
+
- **Audit Logging**: Log tenant resolution for security auditing
|
|
311
|
+
|
|
312
|
+
### Error Handling
|
|
313
|
+
|
|
314
|
+
- **Graceful Degradation**: Handle tenant resolution failures gracefully
|
|
315
|
+
- **Fallback Strategies**: Implement fallback resolution methods
|
|
316
|
+
- **User Experience**: Provide clear error messages for tenant issues
|
|
317
|
+
|
|
318
|
+
## 🔍 Debugging and Monitoring
|
|
319
|
+
|
|
320
|
+
### Debug Tenant Resolution
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
# Add logging to tenant resolution
|
|
324
|
+
module TenantResolutionLogger
|
|
325
|
+
def resolve_from_request(request)
|
|
326
|
+
Rails.logger.debug "Resolving tenant from: #{request.host}"
|
|
327
|
+
|
|
328
|
+
tenant = super(request)
|
|
329
|
+
|
|
330
|
+
if tenant
|
|
331
|
+
Rails.logger.debug "Resolved tenant: #{tenant.subdomain}"
|
|
332
|
+
else
|
|
333
|
+
Rails.logger.warn "Failed to resolve tenant from: #{request.host}"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
tenant
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
TenantResolver.prepend(TenantResolutionLogger)
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### Monitor Resolution Performance
|
|
344
|
+
|
|
345
|
+
```ruby
|
|
346
|
+
# Track resolution time
|
|
347
|
+
def resolve_with_timing(identifier)
|
|
348
|
+
start_time = Time.current
|
|
349
|
+
|
|
350
|
+
tenant = resolve_without_timing(identifier)
|
|
351
|
+
|
|
352
|
+
duration = Time.current - start_time
|
|
353
|
+
Rails.logger.info "Tenant resolution took #{duration * 1000}ms"
|
|
354
|
+
|
|
355
|
+
tenant
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
alias_method :resolve_without_timing, :resolve_tenant
|
|
359
|
+
alias_method :resolve_tenant, :resolve_with_timing
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### Cache Statistics
|
|
363
|
+
|
|
364
|
+
```ruby
|
|
365
|
+
# Monitor cache hit rates
|
|
366
|
+
class TenantCacheMonitor
|
|
367
|
+
def self.cache_stats
|
|
368
|
+
{
|
|
369
|
+
hits: Rails.cache.fetch('tenant_cache_hits', raw: true) || 0,
|
|
370
|
+
misses: Rails.cache.fetch('tenant_cache_misses', raw: true) || 0,
|
|
371
|
+
hit_rate: calculate_hit_rate
|
|
372
|
+
}
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def self.increment_hits
|
|
376
|
+
Rails.cache.increment('tenant_cache_hits', 1, raw: true, expires_in: 1.day)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def self.increment_misses
|
|
380
|
+
Rails.cache.increment('tenant_cache_misses', 1, raw: true, expires_in: 1.day)
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
## 🔗 Related Components
|
|
386
|
+
|
|
387
|
+
- **[Context](context.md)**: Uses resolved tenant for context switching
|
|
388
|
+
- **[Configuration](configuration.md)**: Tenant model and resolution configuration
|
|
389
|
+
- **[Rails Integration](rails_integration.md)**: Controller and middleware integration
|
|
390
|
+
- **[Errors](errors.md)**: Tenant resolution error handling
|
|
391
|
+
|
|
392
|
+
## 📝 Examples
|
|
393
|
+
|
|
394
|
+
See [examples/tenant_resolution.rb](../examples/) for complete tenant resolution patterns and [Rails integration examples](../examples/rails_integration/) for framework-specific implementations.
|