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.
@@ -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