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 +7 -0
- data/LICENSE +19 -0
- data/README.md +161 -0
- data/Rakefile +11 -0
- data/lib/mecha_tenant/auditor/prober.rb +278 -0
- data/lib/mecha_tenant/auditor/static.rb +583 -0
- data/lib/mecha_tenant/configuration.rb +60 -0
- data/lib/mecha_tenant/controller.rb +79 -0
- data/lib/mecha_tenant/errors.rb +19 -0
- data/lib/mecha_tenant/matrix_test.rb +121 -0
- data/lib/mecha_tenant/railtie.rb +11 -0
- data/lib/mecha_tenant/registry.rb +157 -0
- data/lib/mecha_tenant/version.rb +6 -0
- data/lib/mecha_tenant.rb +36 -0
- data/lib/tasks/mecha_tenant.rake +166 -0
- metadata +99 -0
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,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
|