rls_multi_tenant 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/.rubocop.yml +46 -0
- data/README.md +141 -0
- data/lib/rls_multi_tenant/concerns/multi_tenant.rb +41 -0
- data/lib/rls_multi_tenant/concerns/tenant_context.rb +74 -0
- data/lib/rls_multi_tenant/generators/install/install_generator.rb +91 -0
- data/lib/rls_multi_tenant/generators/install/templates/create_app_user.rb +33 -0
- data/lib/rls_multi_tenant/generators/install/templates/create_tenant.rb +20 -0
- data/lib/rls_multi_tenant/generators/install/templates/db_admin.rake +27 -0
- data/lib/rls_multi_tenant/generators/install/templates/enable_rls.rb +27 -0
- data/lib/rls_multi_tenant/generators/install/templates/enable_uuid_extension.rb +5 -0
- data/lib/rls_multi_tenant/generators/install/templates/rls_multi_tenant.rb +15 -0
- data/lib/rls_multi_tenant/generators/install/templates/tenant_model.rb +7 -0
- data/lib/rls_multi_tenant/generators/migration/migration_generator.rb +57 -0
- data/lib/rls_multi_tenant/generators/migration/templates/create_app_user.rb +33 -0
- data/lib/rls_multi_tenant/generators/migration/templates/create_tenant.rb +20 -0
- data/lib/rls_multi_tenant/generators/migration/templates/enable_rls.rb +27 -0
- data/lib/rls_multi_tenant/generators/migration/templates/enable_uuid_extension.rb +5 -0
- data/lib/rls_multi_tenant/generators/model/model_generator.rb +81 -0
- data/lib/rls_multi_tenant/generators/model/templates/migration.rb +35 -0
- data/lib/rls_multi_tenant/generators/model/templates/model.rb +5 -0
- data/lib/rls_multi_tenant/generators/task/task_generator.rb +31 -0
- data/lib/rls_multi_tenant/generators/task/templates/db_admin.rake +24 -0
- data/lib/rls_multi_tenant/railtie.rb +37 -0
- data/lib/rls_multi_tenant/rls_helper.rb +66 -0
- data/lib/rls_multi_tenant/rls_multi_tenant.rb +45 -0
- data/lib/rls_multi_tenant/security_validator.rb +61 -0
- data/lib/rls_multi_tenant/version.rb +5 -0
- data/lib/rls_multi_tenant.rb +48 -0
- data/rls_multi_tenant.gemspec +44 -0
- metadata +203 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e90e7f280c785aa09f9f643da2420a38423f888592a750d4587dcc961ede1de1
|
|
4
|
+
data.tar.gz: cb1ec28221f70d26bcf6444be404feccf6098417325921692f8c95ba4d945a3c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e28c40e201630a87955c890b4f417d43c0e7418674a6bf776d44cd4a39b28cb6c49b4873c6807e0e21247b8e120682a986e1bccf95cdc47d1be29f2b63c213c6
|
|
7
|
+
data.tar.gz: 986900e03f1234f1f19813f1680af7675861c35a437b64582512854bdc9f7d05828a78a6bf6755f656a2b05fa7e47447312c83d53796bea4929962f1ed18b70c
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
require:
|
|
2
|
+
- rubocop-rails
|
|
3
|
+
- rubocop-rspec
|
|
4
|
+
|
|
5
|
+
AllCops:
|
|
6
|
+
TargetRubyVersion: 3.0
|
|
7
|
+
NewCops: enable
|
|
8
|
+
Exclude:
|
|
9
|
+
- 'vendor/**/*'
|
|
10
|
+
- 'spec/tmp/**/*'
|
|
11
|
+
- 'tmp/**/*'
|
|
12
|
+
|
|
13
|
+
Style/Documentation:
|
|
14
|
+
Enabled: false
|
|
15
|
+
|
|
16
|
+
Style/StringLiterals:
|
|
17
|
+
EnforcedStyle: single_quotes
|
|
18
|
+
|
|
19
|
+
Style/FrozenStringLiteralComment:
|
|
20
|
+
Enabled: true
|
|
21
|
+
|
|
22
|
+
Style/ClassAndModuleChildren:
|
|
23
|
+
Enabled: false
|
|
24
|
+
|
|
25
|
+
Metrics/LineLength:
|
|
26
|
+
Max: 120
|
|
27
|
+
|
|
28
|
+
Metrics/BlockLength:
|
|
29
|
+
Exclude:
|
|
30
|
+
- 'spec/**/*'
|
|
31
|
+
- 'lib/rls_multi_tenant/generators/**/*'
|
|
32
|
+
|
|
33
|
+
RSpec/ExampleLength:
|
|
34
|
+
Max: 20
|
|
35
|
+
|
|
36
|
+
RSpec/MultipleExpectations:
|
|
37
|
+
Max: 5
|
|
38
|
+
|
|
39
|
+
RSpec/NestedGroups:
|
|
40
|
+
Max: 4
|
|
41
|
+
|
|
42
|
+
RSpec/DescribeClass:
|
|
43
|
+
Enabled: false
|
|
44
|
+
|
|
45
|
+
RSpec/DescribeMethod:
|
|
46
|
+
Enabled: false
|
data/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# RLS Multi-Tenant
|
|
2
|
+
|
|
3
|
+
A Rails gem that provides PostgreSQL Row Level Security (RLS) based multi-tenancy for Rails applications.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 🔒 **Row Level Security**: Automatic tenant isolation using PostgreSQL RLS
|
|
8
|
+
- 🛡️ **Security Validation**: Prevents running with privileged database users
|
|
9
|
+
- 🔄 **Context Switching**: Easy tenant context management
|
|
10
|
+
- 📦 **Auto-inclusion**: Automatic model configuration
|
|
11
|
+
- 🚀 **Generators**: Rails generators for quick setup
|
|
12
|
+
- ⚙️ **Configurable**: Flexible configuration options
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
Add this line to your application's Gemfile:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem 'rls_multi_tenant', path: 'gems/rls_multi_tenant'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
And then execute:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bundle install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
1. **Install the gem configuration:**
|
|
31
|
+
```bash
|
|
32
|
+
rails generate rls_multi_tenant:install
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
2. **Configure environment variables:**
|
|
36
|
+
```bash
|
|
37
|
+
POSTGRES_USER=your_admin_user # This is the user that will run the migrations
|
|
38
|
+
POSTGRES_PASSWORD=your_admin_user_password
|
|
39
|
+
POSTGRES_APP_USER=your_app_user # This is the user that will run the app
|
|
40
|
+
POSTGRES_APP_PASSWORD=your_password
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
3. **Run migrations:**
|
|
44
|
+
```bash
|
|
45
|
+
rails db_as:admin[migrate] # Custom rake task to run migrations with admin privileges
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
### Basic Multi-Tenant Models
|
|
51
|
+
|
|
52
|
+
Create a new model:
|
|
53
|
+
```bash
|
|
54
|
+
rails generate rls_multi_tenant:model User name email
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Your models automatically include the `MultiTenant` concern:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
class User < ApplicationRecord
|
|
61
|
+
# Automatically includes MultiTenant concern
|
|
62
|
+
# include RlsMultiTenant::Concerns::MultiTenant
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Tenant Context Switching
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# Create a new tenant
|
|
70
|
+
tenant = Tenant.create!(name: "Tenant 1")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
# Switch tenant context for a block
|
|
75
|
+
Tenant.switch(tenant) do
|
|
76
|
+
User.create!(name: "User from Tenant 1", email: "user@example.com") # Automatically assigned to current tenant
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Switch tenant context permanently
|
|
80
|
+
Tenant.switch!(tenant)
|
|
81
|
+
User.create!(name: "User from Tenant 1", email: "user@example.com")
|
|
82
|
+
Tenant.reset! # Reset context
|
|
83
|
+
|
|
84
|
+
# Get current tenant
|
|
85
|
+
current_tenant = Tenant.current
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Configuration
|
|
89
|
+
|
|
90
|
+
Configure the gem in `config/initializers/rls_multi_tenant.rb`:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
RlsMultiTenant.configure do |config|
|
|
94
|
+
config.tenant_class_name = "Tenant" # Your tenant model class
|
|
95
|
+
config.tenant_id_column = :tenant_id # Tenant ID column name
|
|
96
|
+
config.app_user_env_var = "POSTGRES_APP_USER" # Environment variable for app user
|
|
97
|
+
config.enable_security_validation = true # Enable security checks. This will check if the app user is set without superuser privileges.
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Database Admin Task
|
|
102
|
+
```bash
|
|
103
|
+
# Run migrations with admin privileges (required because app user can't run migrations)
|
|
104
|
+
rails db_as:admin[migrate]
|
|
105
|
+
|
|
106
|
+
# Run seeds with admin privileges
|
|
107
|
+
rails db_as:admin[seed]
|
|
108
|
+
|
|
109
|
+
# Create database with admin privileges
|
|
110
|
+
rails db_as:admin[create]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Security Features
|
|
114
|
+
|
|
115
|
+
The gem includes multiple security layers:
|
|
116
|
+
|
|
117
|
+
1. **Environment Validation**: Ensures `POSTGRES_APP_USER` is set and not privileged
|
|
118
|
+
2. **Database User Validation**: Checks that the database user doesn't have SUPERUSER privileges
|
|
119
|
+
3. **RLS Policies**: Automatic tenant isolation at the database level
|
|
120
|
+
|
|
121
|
+
## Requirements
|
|
122
|
+
|
|
123
|
+
- Rails 7.0+
|
|
124
|
+
- PostgreSQL 12+ (with UUID extension support)
|
|
125
|
+
- Ruby 3.0+
|
|
126
|
+
|
|
127
|
+
## UUID Support
|
|
128
|
+
|
|
129
|
+
This gem uses UUIDs for the tenant model by default to ensure proper multi-tenant isolation. The `enable_uuid` migration must be run before creating tenant tables.
|
|
130
|
+
|
|
131
|
+
## Contributing
|
|
132
|
+
|
|
133
|
+
1. Fork the repository
|
|
134
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
135
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
136
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
|
137
|
+
5. Create a Pull Request
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RlsMultiTenant
|
|
4
|
+
module Concerns
|
|
5
|
+
module MultiTenant
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
belongs_to :tenant, class_name: RlsMultiTenant.tenant_class_name, foreign_key: RlsMultiTenant.tenant_id_column
|
|
10
|
+
|
|
11
|
+
validates RlsMultiTenant.tenant_id_column, presence: true
|
|
12
|
+
|
|
13
|
+
before_validation :set_tenant_id
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def set_tenant_id
|
|
18
|
+
current_tenant = RlsMultiTenant.tenant_class.current
|
|
19
|
+
if current_tenant && self.send(RlsMultiTenant.tenant_id_column).blank?
|
|
20
|
+
self.send("#{RlsMultiTenant.tenant_id_column}=", current_tenant.id)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class_methods do
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def extract_tenant_id(tenant_or_id)
|
|
29
|
+
case tenant_or_id
|
|
30
|
+
when RlsMultiTenant.tenant_class
|
|
31
|
+
tenant_or_id.id
|
|
32
|
+
when String, Integer
|
|
33
|
+
tenant_or_id
|
|
34
|
+
else
|
|
35
|
+
raise ArgumentError, "Expected #{RlsMultiTenant.tenant_class_name} object or tenant_id, got #{tenant_or_id.class}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RlsMultiTenant
|
|
4
|
+
module Concerns
|
|
5
|
+
module TenantContext
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
SET_TENANT_ID_SQL = 'SET rls.tenant_id = %s'.freeze
|
|
9
|
+
RESET_TENANT_ID_SQL = 'RESET rls.tenant_id'.freeze
|
|
10
|
+
|
|
11
|
+
class_methods do
|
|
12
|
+
# Switch tenant context for a block
|
|
13
|
+
def switch(tenant_or_id)
|
|
14
|
+
tenant_id = extract_tenant_id(tenant_or_id)
|
|
15
|
+
connection.execute format(SET_TENANT_ID_SQL, connection.quote(tenant_id))
|
|
16
|
+
yield
|
|
17
|
+
ensure
|
|
18
|
+
reset!
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Switch tenant context permanently (until reset)
|
|
22
|
+
def switch!(tenant_or_id)
|
|
23
|
+
tenant_id = extract_tenant_id(tenant_or_id)
|
|
24
|
+
connection.execute format(SET_TENANT_ID_SQL, connection.quote(tenant_id))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Reset tenant context
|
|
28
|
+
def reset!
|
|
29
|
+
connection.execute RESET_TENANT_ID_SQL
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Get current tenant from context
|
|
33
|
+
def current
|
|
34
|
+
return nil unless connection.active?
|
|
35
|
+
|
|
36
|
+
result = connection.execute("SHOW rls.tenant_id")
|
|
37
|
+
tenant_id = result.first&.dig('rls.tenant_id')
|
|
38
|
+
|
|
39
|
+
return nil if tenant_id.blank?
|
|
40
|
+
|
|
41
|
+
RlsMultiTenant.tenant_class.find_by(id: tenant_id)
|
|
42
|
+
rescue ActiveRecord::StatementInvalid, PG::Error
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def extract_tenant_id(tenant_or_id)
|
|
49
|
+
case tenant_or_id
|
|
50
|
+
when RlsMultiTenant.tenant_class
|
|
51
|
+
tenant_or_id.id
|
|
52
|
+
when String, Integer
|
|
53
|
+
tenant_or_id
|
|
54
|
+
else
|
|
55
|
+
raise ArgumentError, "Expected #{RlsMultiTenant.tenant_class_name} object or tenant_id, got #{tenant_or_id.class}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Instance methods
|
|
61
|
+
def switch(tenant_or_id)
|
|
62
|
+
self.class.switch(tenant_or_id) { yield }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def switch!(tenant_or_id)
|
|
66
|
+
self.class.switch!(tenant_or_id)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def reset!
|
|
70
|
+
self.class.reset!
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
|
|
5
|
+
module RlsMultiTenant
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
desc "Install RLS Multi-tenant gem configuration and initial setup"
|
|
11
|
+
|
|
12
|
+
def create_initializer
|
|
13
|
+
template "rls_multi_tenant.rb", "config/initializers/rls_multi_tenant.rb"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create_tenant_model
|
|
17
|
+
unless File.exist?(File.join(destination_root, "app/models/tenant.rb"))
|
|
18
|
+
template "tenant_model.rb", "app/models/tenant.rb"
|
|
19
|
+
else
|
|
20
|
+
say "Tenant model already exists, skipping creation", :yellow
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def create_db_admin_task
|
|
25
|
+
unless File.exist?(File.join(destination_root, "lib/tasks/db_admin.rake"))
|
|
26
|
+
template "db_admin.rake", "lib/tasks/db_admin.rake"
|
|
27
|
+
else
|
|
28
|
+
say "Database admin task already exists, skipping creation", :yellow
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def create_uuid_migration
|
|
33
|
+
unless Dir.glob(File.join(destination_root, "db/migrate/*_enable_uuid_extension.rb")).any?
|
|
34
|
+
create_migration_with_timestamp("enable_uuid", 1)
|
|
35
|
+
else
|
|
36
|
+
say "UUID extension migration already exists, skipping creation", :yellow
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def create_app_user_migration
|
|
41
|
+
unless Dir.glob(File.join(destination_root, "db/migrate/*_create_app_user.rb")).any?
|
|
42
|
+
create_migration_with_timestamp("create_app_user", 2)
|
|
43
|
+
else
|
|
44
|
+
say "App user migration already exists, skipping creation", :yellow
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def create_tenant_migration
|
|
49
|
+
unless Dir.glob(File.join(destination_root, "db/migrate/*_create_tenants.rb")).any?
|
|
50
|
+
create_migration_with_timestamp("create_tenant", 3)
|
|
51
|
+
else
|
|
52
|
+
say "Tenant migration already exists, skipping creation", :yellow
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def show_instructions
|
|
57
|
+
say "\n" + "="*60, :green
|
|
58
|
+
say "RLS Multi-tenant gem installed successfully!", :green
|
|
59
|
+
say "="*60, :green
|
|
60
|
+
say "\nNext steps:", :yellow
|
|
61
|
+
say "1. Configure your environment variables:\n", :yellow
|
|
62
|
+
say " POSTGRES_USER=your_admin_user # This is the user that will run the migrations", :yellow
|
|
63
|
+
say " POSTGRES_PASSWORD=your_admin_user_password", :yellow
|
|
64
|
+
say " POSTGRES_APP_USER=your_app_user # This is the user that will run the app", :yellow
|
|
65
|
+
say " POSTGRES_APP_PASSWORD=your_app_user_password", :yellow
|
|
66
|
+
say "\n2. Make sure to use the POSTGRES_APP_USER in your database.yml.", :yellow
|
|
67
|
+
say "\n3. Run the migrations with admin privileges:\n", :yellow
|
|
68
|
+
say " rails db_as:admin[migrate]", :yellow
|
|
69
|
+
say " Note: We must use the admin user because the app user doesn't have migration privileges", :yellow
|
|
70
|
+
say "\n4. Use 'rails generate rls_multi_tenant:model' for new multi-tenant models", :yellow
|
|
71
|
+
say "="*60, :green
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def create_migration_with_timestamp(migration_type, order)
|
|
77
|
+
base_timestamp = Time.current.strftime("%Y%m%d%H%M")
|
|
78
|
+
timestamp = "#{base_timestamp}#{sprintf('%02d', order)}"
|
|
79
|
+
|
|
80
|
+
case migration_type
|
|
81
|
+
when "enable_uuid"
|
|
82
|
+
template "enable_uuid_extension.rb", "db/migrate/#{timestamp}_enable_uuid_extension.rb"
|
|
83
|
+
when "create_app_user"
|
|
84
|
+
template "create_app_user.rb", "db/migrate/#{timestamp}_create_app_user.rb"
|
|
85
|
+
when "create_tenant"
|
|
86
|
+
template "create_tenant.rb", "db/migrate/#{timestamp}_create_tenants.rb"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
class CreateAppUser < ActiveRecord::Migration[<%= Rails.version.to_f %>]
|
|
2
|
+
def up
|
|
3
|
+
app_user = ENV['<%= RlsMultiTenant.app_user_env_var %>']
|
|
4
|
+
app_password = ENV['POSTGRES_APP_PASSWORD']
|
|
5
|
+
|
|
6
|
+
# Create user with RLS privileges in PostgreSQL
|
|
7
|
+
execute <<-SQL
|
|
8
|
+
DO $$
|
|
9
|
+
BEGIN
|
|
10
|
+
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '#{app_user}') THEN
|
|
11
|
+
CREATE ROLE #{app_user} WITH LOGIN PASSWORD '#{app_password}';
|
|
12
|
+
END IF;
|
|
13
|
+
END
|
|
14
|
+
$$;
|
|
15
|
+
SQL
|
|
16
|
+
|
|
17
|
+
# Grant basic permissions to the user
|
|
18
|
+
execute "GRANT CONNECT ON DATABASE #{ActiveRecord::Base.connection.current_database} TO #{app_user};"
|
|
19
|
+
execute "GRANT USAGE ON SCHEMA public TO #{app_user};"
|
|
20
|
+
execute "GRANT CREATE ON SCHEMA public TO #{app_user};"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def down
|
|
24
|
+
app_user = ENV['<%= RlsMultiTenant.app_user_env_var %>']
|
|
25
|
+
|
|
26
|
+
# Revoke permissions
|
|
27
|
+
execute "REVOKE ALL ON SCHEMA public FROM #{app_user};"
|
|
28
|
+
execute "REVOKE CONNECT ON DATABASE #{ActiveRecord::Base.connection.current_database} FROM #{app_user};"
|
|
29
|
+
|
|
30
|
+
# Drop user
|
|
31
|
+
execute "DROP ROLE IF EXISTS #{app_user};"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
class CreateTenants < ActiveRecord::Migration[<%= Rails.version.to_f %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :tenants, id: :uuid do |t|
|
|
4
|
+
t.string :name, null: false
|
|
5
|
+
|
|
6
|
+
t.timestamps
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
add_index :tenants, :name, unique: true
|
|
10
|
+
|
|
11
|
+
reversible do |dir|
|
|
12
|
+
dir.up do
|
|
13
|
+
execute "GRANT SELECT, INSERT, UPDATE, DELETE ON tenants TO #{ENV['<%= RlsMultiTenant.app_user_env_var %>']}"
|
|
14
|
+
end
|
|
15
|
+
dir.down do
|
|
16
|
+
execute "REVOKE SELECT, INSERT, UPDATE, DELETE ON tenants FROM #{ENV['<%= RlsMultiTenant.app_user_env_var %>']}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
namespace :db_as do
|
|
2
|
+
desc "Run a db: task with admin privileges. Usage: `rails db_as:admin[migrate]`"
|
|
3
|
+
task :admin, [:sub_task] do |t, args|
|
|
4
|
+
sub_task = args[:sub_task]
|
|
5
|
+
|
|
6
|
+
# Check if a sub-task was provided
|
|
7
|
+
if sub_task.nil? || !Rake::Task.task_defined?("db:#{sub_task}")
|
|
8
|
+
puts "Usage: rails db_as:admin[sub_task]"
|
|
9
|
+
puts "Valid sub-tasks: #{Rake.application.tasks.map(&:name).select { |name| name.start_with?("db:") }.map { |name| name.split(':', 2).last }.uniq.sort.join(', ')}"
|
|
10
|
+
exit
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
database_url = "postgresql://#{ENV['POSTGRES_USER']}:#{ENV['POSTGRES_PASSWORD']}@#{ENV['POSTGRES_HOST']}:5432/#{ENV['POSTGRES_DB']}"
|
|
14
|
+
|
|
15
|
+
# Set the DATABASE_URL with admin user details.
|
|
16
|
+
ENV['DATABASE_URL'] = database_url
|
|
17
|
+
ENV['CACHE_DATABASE_URL'] = "#{database_url}_cache"
|
|
18
|
+
ENV['QUEUE_DATABASE_URL'] = "#{database_url}_queue"
|
|
19
|
+
ENV['CABLE_DATABASE_URL'] = "#{database_url}_cable"
|
|
20
|
+
|
|
21
|
+
# Set a flag to indicate we're running with admin privileges
|
|
22
|
+
ENV['RLS_MULTI_TENANT_ADMIN_MODE'] = 'true'
|
|
23
|
+
|
|
24
|
+
puts "Executing db:#{sub_task} with admin privileges..."
|
|
25
|
+
Rake::Task["db:#{sub_task}"].invoke
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
class EnableRlsFor<%= table_name.camelize %> < ActiveRecord::Migration[<%= Rails.version.to_f %>]
|
|
2
|
+
def change
|
|
3
|
+
reversible do |dir|
|
|
4
|
+
dir.up do
|
|
5
|
+
# Enable Row Level Security
|
|
6
|
+
execute 'ALTER TABLE <%= table_name %> ENABLE ROW LEVEL SECURITY'
|
|
7
|
+
|
|
8
|
+
# Create RLS policy
|
|
9
|
+
execute "CREATE POLICY <%= table_name %>_app_user ON <%= table_name %> TO #{ENV['<%= RlsMultiTenant.app_user_env_var %>']} USING (<%= RlsMultiTenant.tenant_id_column %> = NULLIF(current_setting('rls.tenant_id', TRUE), '')::uuid)"
|
|
10
|
+
|
|
11
|
+
# Grant permissions
|
|
12
|
+
execute "GRANT SELECT, INSERT, UPDATE, DELETE ON <%= table_name %> TO #{ENV['<%= RlsMultiTenant.app_user_env_var %>']}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
dir.down do
|
|
16
|
+
# Revoke permissions
|
|
17
|
+
execute "REVOKE SELECT, INSERT, UPDATE, DELETE ON <%= table_name %> FROM #{ENV['<%= RlsMultiTenant.app_user_env_var %>']}"
|
|
18
|
+
|
|
19
|
+
# Drop policy
|
|
20
|
+
execute "DROP POLICY <%= table_name %>_app_user ON <%= table_name %>"
|
|
21
|
+
|
|
22
|
+
# Disable RLS
|
|
23
|
+
execute "ALTER TABLE <%= table_name %> DISABLE ROW LEVEL SECURITY"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RlsMultiTenant.configure do |config|
|
|
4
|
+
# Configure the tenant model class name
|
|
5
|
+
config.tenant_class_name = "Tenant"
|
|
6
|
+
|
|
7
|
+
# Configure the tenant ID column name
|
|
8
|
+
config.tenant_id_column = :tenant_id
|
|
9
|
+
|
|
10
|
+
# Configure the environment variable for the app user
|
|
11
|
+
config.app_user_env_var = "POSTGRES_APP_USER"
|
|
12
|
+
|
|
13
|
+
# Enable/disable security validation
|
|
14
|
+
config.enable_security_validation = true
|
|
15
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
|
|
5
|
+
module RlsMultiTenant
|
|
6
|
+
module Generators
|
|
7
|
+
class MigrationGenerator < Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
argument :migration_type, type: :string, desc: "Type of migration to generate"
|
|
11
|
+
|
|
12
|
+
desc "Generate RLS Multi-tenant migrations"
|
|
13
|
+
|
|
14
|
+
def create_migration
|
|
15
|
+
case migration_type
|
|
16
|
+
when "create_tenant"
|
|
17
|
+
create_tenant_migration
|
|
18
|
+
when "create_app_user"
|
|
19
|
+
create_app_user_migration
|
|
20
|
+
when "enable_rls"
|
|
21
|
+
create_enable_rls_migration
|
|
22
|
+
when "enable_uuid"
|
|
23
|
+
create_enable_uuid_migration
|
|
24
|
+
else
|
|
25
|
+
say "Unknown migration type: #{migration_type}", :red
|
|
26
|
+
say "Available types: create_tenant, create_app_user, enable_rls, enable_uuid", :yellow
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def create_app_user_migration
|
|
33
|
+
timestamp = Time.current.strftime("%Y%m%d%H%M%S")
|
|
34
|
+
template "create_app_user.rb", "db/migrate/#{timestamp}_create_app_user.rb"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def create_enable_rls_migration
|
|
38
|
+
timestamp = Time.current.strftime("%Y%m%d%H%M%S")
|
|
39
|
+
template "enable_rls.rb", "db/migrate/#{timestamp}_enable_rls_for_#{table_name}.rb"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def create_tenant_migration
|
|
43
|
+
timestamp = Time.current.strftime("%Y%m%d%H%M%S")
|
|
44
|
+
template "create_tenant.rb", "db/migrate/#{timestamp}_create_tenant.rb"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def create_enable_uuid_migration
|
|
48
|
+
timestamp = Time.current.strftime("%Y%m%d%H%M%S")
|
|
49
|
+
template "enable_uuid_extension.rb", "db/migrate/#{timestamp}_enable_uuid_extension.rb"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def table_name
|
|
53
|
+
@table_name ||= ask("Enter table name for RLS:")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
class CreateAppUser < ActiveRecord::Migration[<%= Rails.version.to_f %>]
|
|
2
|
+
def up
|
|
3
|
+
app_user = ENV['<%= RlsMultiTenant.app_user_env_var %>']
|
|
4
|
+
app_password = ENV['POSTGRES_APP_PASSWORD']
|
|
5
|
+
|
|
6
|
+
# Create user with RLS privileges in PostgreSQL
|
|
7
|
+
execute <<-SQL
|
|
8
|
+
DO $$
|
|
9
|
+
BEGIN
|
|
10
|
+
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '#{app_user}') THEN
|
|
11
|
+
CREATE ROLE #{app_user} WITH LOGIN PASSWORD '#{app_password}';
|
|
12
|
+
END IF;
|
|
13
|
+
END
|
|
14
|
+
$$;
|
|
15
|
+
SQL
|
|
16
|
+
|
|
17
|
+
# Grant basic permissions to the user
|
|
18
|
+
execute "GRANT CONNECT ON DATABASE #{ActiveRecord::Base.connection.current_database} TO #{app_user};"
|
|
19
|
+
execute "GRANT USAGE ON SCHEMA public TO #{app_user};"
|
|
20
|
+
execute "GRANT CREATE ON SCHEMA public TO #{app_user};"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def down
|
|
24
|
+
app_user = ENV['<%= RlsMultiTenant.app_user_env_var %>']
|
|
25
|
+
|
|
26
|
+
# Revoke permissions
|
|
27
|
+
execute "REVOKE ALL ON SCHEMA public FROM #{app_user};"
|
|
28
|
+
execute "REVOKE CONNECT ON DATABASE #{ActiveRecord::Base.connection.current_database} FROM #{app_user};"
|
|
29
|
+
|
|
30
|
+
# Drop user
|
|
31
|
+
execute "DROP ROLE IF EXISTS #{app_user};"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
class CreateTenants < ActiveRecord::Migration[<%= Rails.version.to_f %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :tenants, id: :uuid do |t|
|
|
4
|
+
t.string :name, null: false
|
|
5
|
+
|
|
6
|
+
t.timestamps
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
add_index :tenants, :name, unique: true
|
|
10
|
+
|
|
11
|
+
reversible do |dir|
|
|
12
|
+
dir.up do
|
|
13
|
+
execute "GRANT SELECT, INSERT, UPDATE, DELETE ON tenants TO #{ENV['<%= RlsMultiTenant.app_user_env_var %>']}"
|
|
14
|
+
end
|
|
15
|
+
dir.down do
|
|
16
|
+
execute "REVOKE SELECT, INSERT, UPDATE, DELETE ON tenants FROM #{ENV['<%= RlsMultiTenant.app_user_env_var %>']}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|