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
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module MechaTenant
|
|
7
|
+
module Auditor
|
|
8
|
+
class Static
|
|
9
|
+
Violation = Struct.new(
|
|
10
|
+
:file,
|
|
11
|
+
:line,
|
|
12
|
+
:controller,
|
|
13
|
+
:method_name,
|
|
14
|
+
:model,
|
|
15
|
+
:kind,
|
|
16
|
+
:message,
|
|
17
|
+
:snippet,
|
|
18
|
+
keyword_init: true
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
ActionAudit = Struct.new(
|
|
22
|
+
:verb,
|
|
23
|
+
:path,
|
|
24
|
+
:controller,
|
|
25
|
+
:action,
|
|
26
|
+
:status,
|
|
27
|
+
:scopes_used,
|
|
28
|
+
:skip_reason,
|
|
29
|
+
:route_roles,
|
|
30
|
+
keyword_init: true
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
Result = Struct.new(
|
|
34
|
+
:mapped_models,
|
|
35
|
+
:unmapped_models,
|
|
36
|
+
:dead_models,
|
|
37
|
+
:missing_scopes,
|
|
38
|
+
:dead_skip_rules,
|
|
39
|
+
:violations,
|
|
40
|
+
:action_audits,
|
|
41
|
+
keyword_init: true
|
|
42
|
+
) do
|
|
43
|
+
def success?
|
|
44
|
+
unmapped_models.empty? &&
|
|
45
|
+
dead_models.empty? &&
|
|
46
|
+
missing_scopes.empty? &&
|
|
47
|
+
dead_skip_rules.empty? &&
|
|
48
|
+
violations.empty?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def initialize(registry = MechaTenant.registry, configuration = MechaTenant.configuration)
|
|
53
|
+
@registry = registry
|
|
54
|
+
@configuration = configuration
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def audit
|
|
58
|
+
ensure_models_loaded!
|
|
59
|
+
|
|
60
|
+
actual_models = discover_application_models
|
|
61
|
+
registered_models = @registry.models.keys
|
|
62
|
+
|
|
63
|
+
mapped_models = []
|
|
64
|
+
unmapped_models = []
|
|
65
|
+
|
|
66
|
+
actual_models.sort.each do |model_name|
|
|
67
|
+
if @registry.registered?(model_name)
|
|
68
|
+
mapped_models << @registry.model_entry(model_name)
|
|
69
|
+
else
|
|
70
|
+
unmapped_models << model_name
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
dead_models = (registered_models - actual_models).sort
|
|
75
|
+
|
|
76
|
+
missing_scopes = check_scope_parity
|
|
77
|
+
dead_skip_rules = []
|
|
78
|
+
violations = []
|
|
79
|
+
method_metadata_by_controller = {}
|
|
80
|
+
|
|
81
|
+
controller_files.each do |file_path|
|
|
82
|
+
file_result = analyze_controller_file(file_path)
|
|
83
|
+
dead_skip_rules.concat(file_result[:dead_skip_rules])
|
|
84
|
+
violations.concat(file_result[:violations])
|
|
85
|
+
file_result[:controllers].each do |ctrl_name, meta|
|
|
86
|
+
method_metadata_by_controller[ctrl_name] = meta
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
action_audits = build_route_action_audits(method_metadata_by_controller, violations)
|
|
91
|
+
|
|
92
|
+
Result.new(
|
|
93
|
+
mapped_models: mapped_models,
|
|
94
|
+
unmapped_models: unmapped_models,
|
|
95
|
+
dead_models: dead_models,
|
|
96
|
+
missing_scopes: missing_scopes,
|
|
97
|
+
dead_skip_rules: dead_skip_rules,
|
|
98
|
+
violations: violations,
|
|
99
|
+
action_audits: action_audits
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Analyze a single Ruby source string for a given controller class (useful for unit testing)
|
|
104
|
+
def audit_source(source, controller_class:, file_path: "(inline)")
|
|
105
|
+
parsed = Prism.parse(source)
|
|
106
|
+
lines = source.lines
|
|
107
|
+
known_models = Set.new(@registry.models.keys)
|
|
108
|
+
known_models << @configuration.base_model_class.to_s if @configuration.base_model_class
|
|
109
|
+
|
|
110
|
+
analyze_controller_ast(
|
|
111
|
+
parsed.value,
|
|
112
|
+
lines: lines,
|
|
113
|
+
file_path: file_path,
|
|
114
|
+
known_models: known_models,
|
|
115
|
+
explicit_controller_class: controller_class
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def ensure_models_loaded!
|
|
122
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
123
|
+
Rails.application.eager_load!
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def discover_application_models
|
|
128
|
+
base_class_name = @configuration.base_model_class.to_s
|
|
129
|
+
base_class = base_class_name.safe_constantize
|
|
130
|
+
return [] unless base_class
|
|
131
|
+
|
|
132
|
+
base_class.descendants
|
|
133
|
+
.reject(&:abstract_class?)
|
|
134
|
+
.select { |klass| klass.name.present? && !klass.name.include?("#<") }
|
|
135
|
+
.select { |klass| app_model_class?(klass) }
|
|
136
|
+
.map(&:name)
|
|
137
|
+
.uniq
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def app_model_class?(klass)
|
|
141
|
+
return true unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
142
|
+
|
|
143
|
+
const_loc = Object.const_source_location(klass.name)
|
|
144
|
+
return true if const_loc.nil?
|
|
145
|
+
|
|
146
|
+
file_path = const_loc.first.to_s
|
|
147
|
+
file_path.start_with?(Rails.root.join("app/models").to_s)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def check_scope_parity
|
|
151
|
+
errors = []
|
|
152
|
+
@registry.models.each do |model_name, entry|
|
|
153
|
+
if entry[:classification] == :tenant && !@registry.scope_defined?(model_name)
|
|
154
|
+
errors << "Tenant model '#{model_name}' is missing a `define_scope` declaration in MechaTenant configuration"
|
|
155
|
+
elsif entry[:classification] == :global && @registry.scope_defined?(model_name)
|
|
156
|
+
errors << "Global model '#{model_name}' must not have a `define_scope` declaration in MechaTenant configuration"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
errors
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def controller_files
|
|
163
|
+
paths = Array(@configuration.controller_paths)
|
|
164
|
+
paths.flat_map do |dir|
|
|
165
|
+
Dir.glob(File.join(dir.to_s, "**", "*_controller.rb")).sort
|
|
166
|
+
end.uniq
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def analyze_controller_file(file_path)
|
|
170
|
+
source = File.read(file_path)
|
|
171
|
+
lines = source.lines
|
|
172
|
+
parsed = Prism.parse(source)
|
|
173
|
+
known_models = Set.new(@registry.models.keys)
|
|
174
|
+
known_models << @configuration.base_model_class.to_s if @configuration.base_model_class
|
|
175
|
+
|
|
176
|
+
analyze_controller_ast(
|
|
177
|
+
parsed.value,
|
|
178
|
+
lines: lines,
|
|
179
|
+
file_path: relative_path(file_path),
|
|
180
|
+
known_models: known_models
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def analyze_controller_ast(root_node, lines:, file_path:, known_models:, explicit_controller_class: nil)
|
|
185
|
+
violations = []
|
|
186
|
+
dead_skip_rules = []
|
|
187
|
+
controllers_meta = {}
|
|
188
|
+
|
|
189
|
+
class_entries = extract_controller_classes(root_node)
|
|
190
|
+
|
|
191
|
+
class_entries.each do |entry|
|
|
192
|
+
ctrl_name = explicit_controller_class ? explicit_controller_class.name.to_s : entry[:name]
|
|
193
|
+
ctrl_klass = explicit_controller_class || ctrl_name.safe_constantize
|
|
194
|
+
class_node = entry[:node]
|
|
195
|
+
|
|
196
|
+
methods = extract_instance_methods(class_node)
|
|
197
|
+
method_names = methods.keys.map(&:to_s)
|
|
198
|
+
|
|
199
|
+
if ctrl_klass && ctrl_klass.respond_to?(:own_tenant_enforcement_rules)
|
|
200
|
+
dead_skip_rules.concat(check_dead_skip_rules(ctrl_klass, ctrl_name, method_names, file_path))
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Determine which methods are enforced vs skipped
|
|
204
|
+
enforced_methods, skipped_methods = partition_methods(ctrl_klass, methods)
|
|
205
|
+
|
|
206
|
+
scopes_by_method = {}
|
|
207
|
+
calls_by_method = {}
|
|
208
|
+
|
|
209
|
+
methods.each do |m_name, m_node|
|
|
210
|
+
scan = scan_method_node(
|
|
211
|
+
m_node,
|
|
212
|
+
lines: lines,
|
|
213
|
+
file_path: file_path,
|
|
214
|
+
ctrl_name: ctrl_name,
|
|
215
|
+
method_name: m_name,
|
|
216
|
+
known_models: known_models
|
|
217
|
+
)
|
|
218
|
+
scopes_by_method[m_name] = scan[:scopes_used]
|
|
219
|
+
calls_by_method[m_name] = scan[:local_calls]
|
|
220
|
+
|
|
221
|
+
if enforced_methods.include?(m_name)
|
|
222
|
+
violations.concat(scan[:violations])
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
controllers_meta[ctrl_name] = {
|
|
227
|
+
klass: ctrl_klass,
|
|
228
|
+
methods: methods.keys,
|
|
229
|
+
enforced_methods: enforced_methods,
|
|
230
|
+
skipped_methods: skipped_methods,
|
|
231
|
+
scopes_by_method: scopes_by_method,
|
|
232
|
+
calls_by_method: calls_by_method
|
|
233
|
+
}
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
{
|
|
237
|
+
violations: violations,
|
|
238
|
+
dead_skip_rules: dead_skip_rules,
|
|
239
|
+
controllers: controllers_meta
|
|
240
|
+
}
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def check_dead_skip_rules(ctrl_klass, ctrl_name, defined_methods, file_path)
|
|
244
|
+
dead = []
|
|
245
|
+
action_methods = Set.new(ctrl_klass.action_methods.map(&:to_s)) + Set.new(defined_methods)
|
|
246
|
+
|
|
247
|
+
ctrl_klass.own_tenant_enforcement_rules.each do |rule|
|
|
248
|
+
Array(rule[:only]).each do |act|
|
|
249
|
+
unless action_methods.include?(act.to_s)
|
|
250
|
+
dead << {
|
|
251
|
+
controller: ctrl_name,
|
|
252
|
+
action: act.to_s,
|
|
253
|
+
file: file_path,
|
|
254
|
+
message: "#{ctrl_name} skips non-existent action ':#{act}'"
|
|
255
|
+
}
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
Array(rule[:except]).each do |act|
|
|
259
|
+
unless action_methods.include?(act.to_s)
|
|
260
|
+
dead << {
|
|
261
|
+
controller: ctrl_name,
|
|
262
|
+
action: act.to_s,
|
|
263
|
+
file: file_path,
|
|
264
|
+
message: "#{ctrl_name} excepts non-existent action ':#{act}'"
|
|
265
|
+
}
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
dead
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def partition_methods(ctrl_klass, methods_hash)
|
|
274
|
+
tenant_method_name = @configuration.tenant_method.to_s
|
|
275
|
+
enforced = Set.new
|
|
276
|
+
skipped = Set.new
|
|
277
|
+
|
|
278
|
+
public_actions = ctrl_klass ? Set.new(ctrl_klass.action_methods.map(&:to_s)) : Set.new(methods_hash.keys)
|
|
279
|
+
|
|
280
|
+
methods_hash.each_key do |m_name|
|
|
281
|
+
if m_name == tenant_method_name
|
|
282
|
+
skipped << m_name
|
|
283
|
+
elsif ctrl_klass && ctrl_klass.respond_to?(:tenant_enforcement_skipped_for?)
|
|
284
|
+
if public_actions.include?(m_name)
|
|
285
|
+
if ctrl_klass.tenant_enforcement_skipped_for?(m_name)
|
|
286
|
+
skipped << m_name
|
|
287
|
+
else
|
|
288
|
+
enforced << m_name
|
|
289
|
+
end
|
|
290
|
+
else
|
|
291
|
+
# Private/helper method: enforced if any public action on this controller is enforced,
|
|
292
|
+
# or if the controller itself does not have a blanket skip rule.
|
|
293
|
+
blanket_skip = ctrl_klass.tenant_enforcement_rules.any? { |r| r[:only].nil? && r[:except].nil? }
|
|
294
|
+
if blanket_skip
|
|
295
|
+
skipped << m_name
|
|
296
|
+
else
|
|
297
|
+
enforced << m_name
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
else
|
|
301
|
+
enforced << m_name
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
[enforced, skipped]
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def scan_method_node(method_node, lines:, file_path:, ctrl_name:, method_name:, known_models:)
|
|
309
|
+
violations = []
|
|
310
|
+
scopes_used = Set.new
|
|
311
|
+
local_calls = Set.new
|
|
312
|
+
|
|
313
|
+
tenant_method_sym = @configuration.tenant_method.to_sym
|
|
314
|
+
|
|
315
|
+
walk_ast(method_node, nil) do |node, parent|
|
|
316
|
+
case node
|
|
317
|
+
when Prism::CallNode
|
|
318
|
+
# Track calls to tenant_scope(Model)
|
|
319
|
+
if node.receiver.nil? && node.name == :tenant_scope
|
|
320
|
+
arg = node.arguments&.arguments&.first
|
|
321
|
+
model_name = extract_constant_name(arg)
|
|
322
|
+
if model_name
|
|
323
|
+
if @registry.global_model?(model_name)
|
|
324
|
+
line_no = node.location.start_line
|
|
325
|
+
violations << Violation.new(
|
|
326
|
+
file: file_path,
|
|
327
|
+
line: line_no,
|
|
328
|
+
controller: ctrl_name,
|
|
329
|
+
method_name: method_name,
|
|
330
|
+
model: model_name,
|
|
331
|
+
kind: :global_model_in_tenant_scope,
|
|
332
|
+
message: "Global model '#{model_name}' cannot be passed to tenant_scope(#{model_name})",
|
|
333
|
+
snippet: lines[line_no - 1].to_s.strip
|
|
334
|
+
)
|
|
335
|
+
elsif !@registry.registered?(model_name)
|
|
336
|
+
line_no = node.location.start_line
|
|
337
|
+
violations << Violation.new(
|
|
338
|
+
file: file_path,
|
|
339
|
+
line: line_no,
|
|
340
|
+
controller: ctrl_name,
|
|
341
|
+
method_name: method_name,
|
|
342
|
+
model: model_name,
|
|
343
|
+
kind: :unregistered_model_in_tenant_scope,
|
|
344
|
+
message: "Unregistered model '#{model_name}' passed to tenant_scope(#{model_name})",
|
|
345
|
+
snippet: lines[line_no - 1].to_s.strip
|
|
346
|
+
)
|
|
347
|
+
else
|
|
348
|
+
scopes_used << "tenant_scope(#{model_name})"
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
elsif call_on_tenant_root?(node, tenant_method_sym)
|
|
352
|
+
scopes_used << "#{tenant_method_sym}.#{node.name}"
|
|
353
|
+
elsif node.receiver.nil?
|
|
354
|
+
local_calls << node.name.to_s
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
358
|
+
# Skip if this node is the child of an outer ConstantPathNode (we evaluate the outermost path or check parent)
|
|
359
|
+
if parent.is_a?(Prism::ConstantPathNode)
|
|
360
|
+
# Wait: if parent is EmailMessage::CATEGORIES, then `node` is `EmailMessage` and `parent` is `EmailMessage::CATEGORIES`.
|
|
361
|
+
# Neither `EmailMessage::CATEGORIES` (not a model) nor `EmailMessage` (namespace of a constant) is a model query!
|
|
362
|
+
next
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
const_name = extract_constant_name(node)
|
|
366
|
+
next unless const_name && known_models.include?(const_name)
|
|
367
|
+
|
|
368
|
+
# Allowed if directly inside tenant_scope(Model) arguments
|
|
369
|
+
if inside_tenant_scope_arg?(node, parent)
|
|
370
|
+
next
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
line_no = node.location.start_line
|
|
374
|
+
violations << Violation.new(
|
|
375
|
+
file: file_path,
|
|
376
|
+
line: line_no,
|
|
377
|
+
controller: ctrl_name,
|
|
378
|
+
method_name: method_name,
|
|
379
|
+
model: const_name,
|
|
380
|
+
kind: :naked_model_reference,
|
|
381
|
+
message: "Direct reference to model '#{const_name}' in #{ctrl_name}##{method_name}; use `tenant_scope(#{const_name})` or `#{tenant_method_sym}` association, or declare `skip_tenant_enforcement!`",
|
|
382
|
+
snippet: lines[line_no - 1].to_s.strip
|
|
383
|
+
)
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
{
|
|
388
|
+
violations: violations,
|
|
389
|
+
scopes_used: scopes_used.to_a,
|
|
390
|
+
local_calls: local_calls.to_a
|
|
391
|
+
}
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def inside_tenant_scope_arg?(node, parent)
|
|
395
|
+
return false unless parent.is_a?(Prism::ArgumentsNode)
|
|
396
|
+
|
|
397
|
+
# We check in walk_ast by passing the enclosing CallNode when parent is ArgumentsNode
|
|
398
|
+
false
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def call_on_tenant_root?(call_node, tenant_method_sym)
|
|
402
|
+
recv = call_node.receiver
|
|
403
|
+
return false unless recv.is_a?(Prism::CallNode)
|
|
404
|
+
return false unless recv.receiver.nil? && recv.name == tenant_method_sym
|
|
405
|
+
|
|
406
|
+
tenant_klass = @registry.tenant_root.to_s.safe_constantize
|
|
407
|
+
if tenant_klass && tenant_klass.respond_to?(:reflect_on_association)
|
|
408
|
+
!tenant_klass.reflect_on_association(call_node.name).nil?
|
|
409
|
+
else
|
|
410
|
+
true
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def walk_ast(node, parent, enclosing_call = nil, &block)
|
|
415
|
+
return if node.nil?
|
|
416
|
+
|
|
417
|
+
if node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
|
|
418
|
+
# Check if directly passed as the argument to `tenant_scope(...)`
|
|
419
|
+
if parent.is_a?(Prism::ArgumentsNode) &&
|
|
420
|
+
enclosing_call.is_a?(Prism::CallNode) &&
|
|
421
|
+
enclosing_call.receiver.nil? &&
|
|
422
|
+
enclosing_call.name == :tenant_scope
|
|
423
|
+
return
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
yield(node, parent)
|
|
428
|
+
|
|
429
|
+
next_enclosing_call = node.is_a?(Prism::CallNode) ? node : enclosing_call
|
|
430
|
+
|
|
431
|
+
node.compact_child_nodes.each do |child|
|
|
432
|
+
walk_ast(child, node, next_enclosing_call, &block)
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def extract_constant_name(node)
|
|
437
|
+
case node
|
|
438
|
+
when Prism::ConstantReadNode
|
|
439
|
+
node.name.to_s
|
|
440
|
+
when Prism::ConstantPathNode
|
|
441
|
+
node.slice.sub(/\A::/, "")
|
|
442
|
+
else
|
|
443
|
+
nil
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def extract_controller_classes(root_node, namespace_prefix = [])
|
|
448
|
+
results = []
|
|
449
|
+
return results if root_node.nil?
|
|
450
|
+
|
|
451
|
+
root_node.compact_child_nodes.each do |child|
|
|
452
|
+
case child
|
|
453
|
+
when Prism::ModuleNode
|
|
454
|
+
mod_name = extract_constant_name(child.constant_path)
|
|
455
|
+
results.concat(extract_controller_classes(child.body, namespace_prefix + [mod_name]))
|
|
456
|
+
when Prism::ClassNode
|
|
457
|
+
cls_name = extract_constant_name(child.constant_path)
|
|
458
|
+
full_name = (namespace_prefix + [cls_name]).compact.join("::")
|
|
459
|
+
# Check if nested classes exist inside (exclude test double classes if any, or include controller classes)
|
|
460
|
+
if full_name.end_with?("Controller")
|
|
461
|
+
results << { name: full_name, node: child }
|
|
462
|
+
end
|
|
463
|
+
results.concat(extract_controller_classes(child.body, namespace_prefix + [cls_name]))
|
|
464
|
+
else
|
|
465
|
+
results.concat(extract_controller_classes(child, namespace_prefix))
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
results
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def extract_instance_methods(class_node)
|
|
473
|
+
methods = {}
|
|
474
|
+
return methods unless class_node.body
|
|
475
|
+
|
|
476
|
+
class_node.body.compact_child_nodes.each do |node|
|
|
477
|
+
if node.is_a?(Prism::DefNode) && node.receiver.nil?
|
|
478
|
+
methods[node.name.to_s] = node
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
methods
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def build_route_action_audits(method_metadata_by_controller, violations)
|
|
486
|
+
return [] unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
487
|
+
|
|
488
|
+
routes = Rails.application.routes.routes.map do |r|
|
|
489
|
+
ActionDispatch::Routing::RouteWrapper.new(r)
|
|
490
|
+
end.reject(&:internal?)
|
|
491
|
+
|
|
492
|
+
audits = []
|
|
493
|
+
seen = Set.new
|
|
494
|
+
|
|
495
|
+
routes.each do |r|
|
|
496
|
+
next if r.engine?
|
|
497
|
+
|
|
498
|
+
ctrl_path = r.controller.presence
|
|
499
|
+
action_name = r.action.presence
|
|
500
|
+
next unless ctrl_path && action_name
|
|
501
|
+
next if ctrl_path.start_with?("rails/", "active_storage/", "action_mailbox/", "turbo/")
|
|
502
|
+
|
|
503
|
+
verb = r.verb.to_s.empty? ? "*" : r.verb.to_s.upcase
|
|
504
|
+
path = r.path.sub("(.:format)", "")
|
|
505
|
+
dedup_key = [verb, path, ctrl_path, action_name]
|
|
506
|
+
next if seen.include?(dedup_key)
|
|
507
|
+
seen << dedup_key
|
|
508
|
+
|
|
509
|
+
ctrl_class_name = "#{ctrl_path.camelize}Controller"
|
|
510
|
+
ctrl_klass = ctrl_class_name.safe_constantize
|
|
511
|
+
meta = method_metadata_by_controller[ctrl_class_name]
|
|
512
|
+
|
|
513
|
+
skip_reason = if ctrl_klass && ctrl_klass.respond_to?(:tenant_enforcement_reason_for)
|
|
514
|
+
ctrl_klass.tenant_enforcement_reason_for(action_name)
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
action_violations = violations.select do |v|
|
|
518
|
+
v.controller == ctrl_class_name && v.method_name == action_name
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
scopes = resolve_action_scopes(meta, action_name)
|
|
522
|
+
|
|
523
|
+
status = if action_violations.any?
|
|
524
|
+
:violation
|
|
525
|
+
elsif skip_reason
|
|
526
|
+
:skipped
|
|
527
|
+
else
|
|
528
|
+
:enforced
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
route_roles = if @configuration.route_classifier
|
|
532
|
+
@configuration.route_classifier.call(verb, path)
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
audits << ActionAudit.new(
|
|
536
|
+
verb: verb,
|
|
537
|
+
path: path,
|
|
538
|
+
controller: ctrl_class_name,
|
|
539
|
+
action: action_name,
|
|
540
|
+
status: status,
|
|
541
|
+
scopes_used: scopes,
|
|
542
|
+
skip_reason: skip_reason,
|
|
543
|
+
route_roles: route_roles
|
|
544
|
+
)
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
audits
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def resolve_action_scopes(meta, action_name)
|
|
551
|
+
return [] unless meta
|
|
552
|
+
|
|
553
|
+
scopes = Set.new(meta[:scopes_by_method][action_name] || [])
|
|
554
|
+
|
|
555
|
+
# Include scopes from before_actions or private helper methods called by this action
|
|
556
|
+
called_helpers = meta[:calls_by_method][action_name] || []
|
|
557
|
+
called_helpers.each do |helper_name|
|
|
558
|
+
(meta[:scopes_by_method][helper_name] || []).each { |s| scopes << s }
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# Also check controller before_action filters if available
|
|
562
|
+
klass = meta[:klass]
|
|
563
|
+
if klass && klass.respond_to?(:_process_action_callbacks)
|
|
564
|
+
klass._process_action_callbacks.each do |cb|
|
|
565
|
+
next unless cb.kind == :before && cb.filter.is_a?(Symbol)
|
|
566
|
+
filter_name = cb.filter.to_s
|
|
567
|
+
(meta[:scopes_by_method][filter_name] || []).each { |s| scopes << s }
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
scopes.to_a.sort
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def relative_path(path)
|
|
575
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
576
|
+
path.to_s.sub(/\A#{Regexp.escape(Rails.root.to_s)}\/?/, "")
|
|
577
|
+
else
|
|
578
|
+
path.to_s
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MechaTenant
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :config_path,
|
|
6
|
+
:tenant_method,
|
|
7
|
+
:base_model_class,
|
|
8
|
+
:controller_paths,
|
|
9
|
+
:route_classifier,
|
|
10
|
+
:enforce_runtime,
|
|
11
|
+
:debug_colorization,
|
|
12
|
+
:test_setup,
|
|
13
|
+
:test_teardown,
|
|
14
|
+
:test_matrix_context
|
|
15
|
+
|
|
16
|
+
attr_reader :scopes
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@config_path = if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
20
|
+
Rails.root.join("config/mecha_tenant.yml")
|
|
21
|
+
else
|
|
22
|
+
"config/mecha_tenant.yml"
|
|
23
|
+
end
|
|
24
|
+
@tenant_method = :current_user
|
|
25
|
+
@base_model_class = "ApplicationRecord"
|
|
26
|
+
@controller_paths = if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
27
|
+
[Rails.root.join("app/controllers")]
|
|
28
|
+
else
|
|
29
|
+
["app/controllers"]
|
|
30
|
+
end
|
|
31
|
+
@route_classifier = nil
|
|
32
|
+
@enforce_runtime = true
|
|
33
|
+
@scopes = {}
|
|
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
|
+
@test_setup = nil
|
|
43
|
+
@test_teardown = nil
|
|
44
|
+
@test_matrix_context = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def define_scope(model_name, callable = nil, &block)
|
|
48
|
+
resolver = callable || block
|
|
49
|
+
raise ConfigurationError, "Scope for #{model_name} must be a callable Proc or block" unless resolver.respond_to?(:call)
|
|
50
|
+
|
|
51
|
+
@scopes[model_name.to_s] = resolver
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def configure_test_matrix(context: nil, setup: nil, teardown: nil)
|
|
55
|
+
@test_matrix_context = context if context
|
|
56
|
+
@test_setup = setup if setup
|
|
57
|
+
@test_teardown = teardown if teardown
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|