mecha_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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c200b3d9ad34b3b2d2f25317ee9f3ff50561fee162efae95b407968545f98620
4
+ data.tar.gz: 66d7ee8fa49c8ab9892e4e6b60a69c53e795373f97e8e4218b8bed559e59367e
5
+ SHA512:
6
+ metadata.gz: 3ca9ce6bd065fde61d3e6da6a778110c05a406f90ee34051e8e5c8c319b8548f6ae82cf081b7a7e684fdcff1b07eba71053ef2c22f162b069d1ad1ca6113071d
7
+ data.tar.gz: c580b7ae8951ed814a13cfb2b8e9fd04e9819b65b759ff2816293971d2cbfd18eee458a64b6ec503697702702ef0554a895de9252f892429584a41e28f137cae
data/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright 2026 Jeff Lunt
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the "Software"), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
7
+ of the Software, and to permit persons to whom the Software is furnished to do
8
+ so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # MechaTenant
2
+
3
+ `mecha_tenant` enforces closed-by-default multi-tenant isolation in Rails applications. It combines a YAML model registry, explicit `tenant_scope` query helpers, static Prism AST controller analysis, and behavioral IDOR route probing.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's `Gemfile`:
8
+
9
+ ```ruby
10
+ gem "mecha_tenant"
11
+ ```
12
+
13
+ Or if developing locally:
14
+
15
+ ```ruby
16
+ gem "mecha_tenant", path: "~/code/mecha_tenant"
17
+ ```
18
+
19
+ And then execute:
20
+
21
+ ```bash
22
+ bundle install
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ### 1. Define `config/mecha_tenant.yml`
28
+
29
+ Classify every `ApplicationRecord` model as either `tenant` (with an explicit `ownership` description) or `global`:
30
+
31
+ ```yaml
32
+ tenant_root: "User"
33
+
34
+ debug_colorization:
35
+ tenant: :light_purple
36
+ global: :light_cyan
37
+ skipped: :yellow
38
+ enforced: :green
39
+ violation: :light_red
40
+ unmapped: :dark_grey
41
+
42
+ models:
43
+ - model: "User"
44
+ classification: "tenant"
45
+ ownership: "self (id == current_user.id)"
46
+
47
+ - model: "EmailMessage"
48
+ classification: "tenant"
49
+ ownership: "email_account.user_id"
50
+
51
+ - model: "SvcOption"
52
+ classification: "global"
53
+ notes: "Global service configuration options"
54
+ ```
55
+
56
+ ### 2. Configure the Initializer
57
+
58
+ Create `config/initializers/mecha_tenant.rb` to define tenant scopes for each tenant-classified model and include the controller concern:
59
+
60
+ ```ruby
61
+ # config/initializers/mecha_tenant.rb
62
+ require "mecha_tenant"
63
+
64
+ MechaTenant.configure do |config|
65
+ config.config_path = Rails.root.join("config/mecha_tenant.yml")
66
+ config.tenant_method = :current_user
67
+ config.base_model_class = "ApplicationRecord"
68
+ config.controller_paths = [Rails.root.join("app/controllers")]
69
+
70
+ config.define_scope "User" do |user|
71
+ User.where(id: user.id)
72
+ end
73
+
74
+ config.define_scope "EmailMessage" do |user|
75
+ EmailMessage.where(email_account_id: user.email_accounts.select(:id))
76
+ end
77
+ end
78
+
79
+ ActiveSupport.on_load(:action_controller_base) do
80
+ include MechaTenant::Controller
81
+ end
82
+ ```
83
+
84
+ ### 3. Scoping Queries in Controllers
85
+
86
+ In controllers, query tenant models via `tenant_scope(Model)` or through associations on `current_user`. Direct naked model references (e.g. `EmailMessage.find(params[:id])`) are flagged by the static AST auditor unless explicitly exempted:
87
+
88
+ ```ruby
89
+ class EmailsController < ApplicationController
90
+ def show
91
+ @message = tenant_scope(EmailMessage).find(params[:id])
92
+ end
93
+ end
94
+ ```
95
+
96
+ For admin or public endpoints that intentionally operate without tenant scoping, declare `skip_tenant_enforcement!` with a mandatory `reason:`:
97
+
98
+ ```ruby
99
+ class Admin::DashboardsController < ApplicationController
100
+ skip_tenant_enforcement! only: [:show], reason: "Admin global dashboard metrics across all tenants"
101
+
102
+ def show
103
+ @total_users = User.count
104
+ end
105
+ end
106
+ ```
107
+
108
+ ### 4. Rake Tasks & Auditing
109
+
110
+ `mecha_tenant` automatically registers Rake tasks via its Railtie:
111
+
112
+ ```bash
113
+ # Print all models and controller actions with their tenancy status
114
+ bin/rails mecha_tenant
115
+
116
+ # Statically verify model registry parity and controller AST scoping
117
+ bin/rails mecha_tenant:verify:static
118
+
119
+ # Run behavioral cross-tenant scope and IDOR route matrix tests
120
+ bin/rails mecha_tenant:verify:behavioral
121
+
122
+ # Run both static and behavioral verification
123
+ bin/rails mecha_tenant:verify
124
+ ```
125
+
126
+ ### 5. Dynamic Parallel Matrix Testing in Consuming Apps
127
+
128
+ `mecha_tenant` ships with [`MechaTenant::MatrixTest`](lib/mecha_tenant/matrix_test.rb), which dynamically generates an individual Minitest test method for every registered tenant model (scope isolation) and every enforced parameterized `:id` route (cross-tenant IDOR protection) so they run in parallel across all workers.
129
+
130
+ In your consuming Rails application's `test/test_helper.rb`:
131
+
132
+ ```ruby
133
+ MechaTenant.configuration.configure_test_matrix(
134
+ setup: -> {
135
+ # Create @victim, @attacker, and @victim_records hash mapping model names to victim instances
136
+ },
137
+ context: -> {
138
+ {
139
+ victim: @victim,
140
+ attacker: @attacker,
141
+ victim_records: @victim_records,
142
+ ui_auth: ->(session) { login_as(session, @attacker); {} },
143
+ api_auth: ->(_session) { { "Authorization" => "Bearer #{@attacker_jwt}" } }
144
+ }
145
+ }
146
+ )
147
+
148
+ require "mecha_tenant/matrix_test" unless ENV["MECHA_MATRIX_STANDALONE"] == "1"
149
+ ```
150
+
151
+ ## Running Tests
152
+
153
+ To run the gem's standalone test suite:
154
+
155
+ ```bash
156
+ bundle exec rake test
157
+ ```
158
+
159
+ ## License
160
+
161
+ Copyright 2026 Jeff Lunt. Released under the [MIT License](LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << "test"
7
+ t.libs << "lib"
8
+ t.test_files = FileList["test/**/*_test.rb"]
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MechaTenant
4
+ module Auditor
5
+ class Prober
6
+ ScopeFailure = Struct.new(:model, :message, keyword_init: true)
7
+ RouteFailure = Struct.new(:route, :target_model, :expected, :actual, :message, keyword_init: true)
8
+
9
+ Result = Struct.new(:scoped_models_count, :probed_routes_count, :scope_failures, :route_failures, keyword_init: true) do
10
+ def success?
11
+ scope_failures.empty? && route_failures.empty?
12
+ end
13
+
14
+ def failures
15
+ scope_failures + route_failures
16
+ end
17
+ end
18
+
19
+ def initialize(integration_session = nil, registry: MechaTenant.registry, configuration: MechaTenant.configuration)
20
+ @session = integration_session
21
+ @registry = registry
22
+ @configuration = configuration
23
+ end
24
+
25
+ # Verify that every registered tenant model's scope isolates victim records from attacker
26
+ def probe_model_scopes(victim:, attacker:, victim_records:)
27
+ scope_failures = []
28
+ scoped_count = 0
29
+ normalized_records = normalize_records_map(victim_records)
30
+
31
+ @registry.tenant_models.each_key do |model_name|
32
+ res = probe_single_model_scope(
33
+ model_name,
34
+ victim: victim,
35
+ attacker: attacker,
36
+ victim_records: normalized_records
37
+ )
38
+ scoped_count += res.scoped_models_count
39
+ scope_failures.concat(res.scope_failures)
40
+ end
41
+
42
+ Result.new(
43
+ scoped_models_count: scoped_count,
44
+ probed_routes_count: 0,
45
+ scope_failures: scope_failures,
46
+ route_failures: []
47
+ )
48
+ end
49
+
50
+ def probe_single_model_scope(model_name, victim:, attacker:, victim_records:)
51
+ scope_failures = []
52
+ normalized_records = normalize_records_map(victim_records)
53
+ record = normalized_records[model_name.to_s]
54
+
55
+ if record.nil?
56
+ scope_failures << ScopeFailure.new(
57
+ model: model_name.to_s,
58
+ message: "Missing victim test record for registered tenant model '#{model_name}'"
59
+ )
60
+ else
61
+ begin
62
+ victim_scope = @registry.scope_for(model_name.to_s, victim)
63
+ attacker_scope = @registry.scope_for(model_name.to_s, attacker)
64
+
65
+ unless victim_scope.where(id: record.id).exists?
66
+ scope_failures << ScopeFailure.new(
67
+ model: model_name.to_s,
68
+ message: "Victim scope for #{model_name} did not include victim's own record (id=#{record.id})"
69
+ )
70
+ end
71
+
72
+ if attacker_scope.where(id: record.id).exists?
73
+ scope_failures << ScopeFailure.new(
74
+ model: model_name.to_s,
75
+ message: "CROSS-TENANT LEAK: Attacker scope for #{model_name} included victim's record (id=#{record.id})"
76
+ )
77
+ end
78
+ rescue => e
79
+ scope_failures << ScopeFailure.new(
80
+ model: model_name.to_s,
81
+ message: "Exception evaluating scope for #{model_name}: #{e.class}: #{e.message}"
82
+ )
83
+ end
84
+ end
85
+
86
+ Result.new(
87
+ scoped_models_count: 1,
88
+ probed_routes_count: 0,
89
+ scope_failures: scope_failures,
90
+ route_failures: []
91
+ )
92
+ end
93
+
94
+ # Dynamically probe all enforced parameterized routes as attacker attempting to access victim's records
95
+ def probe_idor_routes(victim_records:, ui_auth:, api_auth: nil)
96
+ raise ArgumentError, "Integration session is required for route probing" if @session.nil?
97
+
98
+ route_failures = []
99
+ probed_count = 0
100
+ normalized_records = normalize_records_map(victim_records)
101
+
102
+ enforced_id_audits = self.class.collect_enforced_id_audits(@registry, @configuration)
103
+
104
+ enforced_id_audits.each do |audit|
105
+ res = probe_single_idor_route(
106
+ audit,
107
+ victim_records: normalized_records,
108
+ ui_auth: ui_auth,
109
+ api_auth: api_auth
110
+ )
111
+ probed_count += res.probed_routes_count
112
+ route_failures.concat(res.route_failures)
113
+ end
114
+
115
+ Result.new(
116
+ scoped_models_count: 0,
117
+ probed_routes_count: probed_count,
118
+ scope_failures: [],
119
+ route_failures: route_failures
120
+ )
121
+ end
122
+
123
+ def probe_single_idor_route(audit, victim_records:, ui_auth:, api_auth: nil)
124
+ raise ArgumentError, "Integration session is required for route probing" if @session.nil?
125
+
126
+ route_failures = []
127
+ normalized_records = normalize_records_map(victim_records)
128
+
129
+ target_model_name = infer_target_model(audit, normalized_records)
130
+ victim_record = target_model_name ? normalized_records[target_model_name] : nil
131
+
132
+ target_id = victim_record ? victim_record.id.to_s : "00000000-0000-0000-0000-000000000000"
133
+ snapshot_before = victim_record ? snapshot_record(victim_record) : nil
134
+
135
+ path = audit.path.gsub(":id", target_id).gsub(/:[a-zA-Z_]+/, "1")
136
+ is_api = path.start_with?("/api/")
137
+
138
+ headers = if is_api && api_auth
139
+ api_auth.call(@session) || {}
140
+ else
141
+ ui_auth.call(@session) || {}
142
+ end
143
+
144
+ status = dispatch_request(audit.verb, path, headers)
145
+
146
+ allowed_statuses = if audit.scopes_used.empty?
147
+ [302, 303, 400, 404, 422]
148
+ else
149
+ [404]
150
+ end
151
+
152
+ unless allowed_statuses.include?(status)
153
+ route_failures << RouteFailure.new(
154
+ route: "#{audit.verb} #{audit.path} (#{audit.controller}##{audit.action})",
155
+ target_model: target_model_name || "Unknown",
156
+ expected: allowed_statuses,
157
+ actual: status,
158
+ message: "Expected HTTP #{allowed_statuses.join('/')} when attacker requests victim #{target_model_name} (id=#{target_id}), got HTTP #{status}"
159
+ )
160
+ end
161
+
162
+ if victim_record
163
+ reloaded = victim_record.class.find_by(id: victim_record.id)
164
+ if reloaded.nil?
165
+ route_failures << RouteFailure.new(
166
+ route: "#{audit.verb} #{audit.path} (#{audit.controller}##{audit.action})",
167
+ target_model: target_model_name,
168
+ expected: "record intact",
169
+ actual: "record deleted",
170
+ message: "CROSS-TENANT DESTRUCTION: Victim #{target_model_name} (id=#{target_id}) was deleted by attacker request!"
171
+ )
172
+ elsif snapshot_record(reloaded) != snapshot_before
173
+ route_failures << RouteFailure.new(
174
+ route: "#{audit.verb} #{audit.path} (#{audit.controller}##{audit.action})",
175
+ target_model: target_model_name,
176
+ expected: "record unchanged",
177
+ actual: "record mutated",
178
+ message: "CROSS-TENANT MUTATION: Victim #{target_model_name} (id=#{target_id}) was modified by attacker request!"
179
+ )
180
+ end
181
+ end
182
+
183
+ Result.new(
184
+ scoped_models_count: 0,
185
+ probed_routes_count: 1,
186
+ scope_failures: [],
187
+ route_failures: route_failures
188
+ )
189
+ end
190
+
191
+ def probe_all(victim:, attacker:, victim_records:, ui_auth:, api_auth: nil)
192
+ scope_res = probe_model_scopes(victim: victim, attacker: attacker, victim_records: victim_records)
193
+ route_res = probe_idor_routes(victim_records: victim_records, ui_auth: ui_auth, api_auth: api_auth)
194
+
195
+ Result.new(
196
+ scoped_models_count: scope_res.scoped_models_count,
197
+ probed_routes_count: route_res.probed_routes_count,
198
+ scope_failures: scope_res.scope_failures,
199
+ route_failures: route_res.route_failures
200
+ )
201
+ end
202
+
203
+ def self.collect_enforced_id_audits(registry = MechaTenant.registry, configuration = MechaTenant.configuration)
204
+ static_result = Static.new(registry, configuration).audit
205
+ static_result.action_audits.select do |audit|
206
+ audit.status == :enforced && audit.path.include?(":id")
207
+ end
208
+ end
209
+
210
+ private
211
+
212
+ def normalize_records_map(records_hash)
213
+ records_hash.each_with_object({}) do |(k, v), h|
214
+ key = k.is_a?(Class) ? k.name.to_s : k.to_s
215
+ h[key] = v
216
+ end
217
+ end
218
+
219
+ def infer_target_model(audit, normalized_records)
220
+ # 1. Check explicit tenant_scope(Model) used by the action
221
+ audit.scopes_used.each do |scope_str|
222
+ if scope_str =~ /\Atenant_scope\((.+)\)\z/
223
+ model_name = Regexp.last_match(1)
224
+ return model_name if normalized_records.key?(model_name)
225
+ end
226
+ end
227
+
228
+ # 2. Check current_user.<association> used by the action
229
+ tenant_root_class = @registry.tenant_root.safe_constantize
230
+ if tenant_root_class && tenant_root_class.respond_to?(:reflect_on_association)
231
+ audit.scopes_used.each do |scope_str|
232
+ if scope_str =~ /\A#{Regexp.escape(@configuration.tenant_method.to_s)}\.([a-zA-Z0-9_]+)\z/
233
+ assoc_name = Regexp.last_match(1).to_sym
234
+ refl = tenant_root_class.reflect_on_association(assoc_name)
235
+ if refl
236
+ klass_name = refl.class_name.to_s
237
+ return klass_name if normalized_records.key?(klass_name)
238
+ end
239
+ end
240
+ end
241
+ end
242
+
243
+ # 3. Fallback to controller name singularized (e.g. Api::V1::EmailsController -> EmailMessage / EmailHandler)
244
+ demodulized = audit.controller.sub(/Controller\z/, "").split("::").last.singularize
245
+ return demodulized if normalized_records.key?(demodulized)
246
+
247
+ nil
248
+ end
249
+
250
+ def snapshot_record(record)
251
+ record.attributes.except("updated_at")
252
+ end
253
+
254
+ def dispatch_request(verb, path, headers = {})
255
+ case verb.to_s.upcase
256
+ when "GET", "*"
257
+ @session.get path, headers: headers
258
+ when "POST"
259
+ @session.post path, headers: headers
260
+ when "PATCH"
261
+ @session.patch path, headers: headers
262
+ when "PUT"
263
+ @session.put path, headers: headers
264
+ when "DELETE"
265
+ @session.delete path, headers: headers
266
+ when "HEAD"
267
+ @session.head path, headers: headers
268
+ else
269
+ @session.get path, headers: headers
270
+ end
271
+
272
+ @session.response.status
273
+ rescue ActionController::RoutingError, ActiveRecord::RecordNotFound
274
+ 404
275
+ end
276
+ end
277
+ end
278
+ end