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,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module MechaTenant
|
|
6
|
+
module Controller
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
if respond_to?(:helper_method)
|
|
11
|
+
helper_method :tenant_scope
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class_methods do
|
|
16
|
+
def skip_tenant_enforcement!(only: nil, except: nil, reason:)
|
|
17
|
+
if reason.nil? || !reason.is_a?(String) || reason.strip.empty?
|
|
18
|
+
raise ArgumentError, "skip_tenant_enforcement! requires a non-empty 'reason:' string"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
@mecha_tenant_skip_rules ||= []
|
|
22
|
+
rule = {
|
|
23
|
+
only: only ? Array(only).map(&:to_s) : nil,
|
|
24
|
+
except: except ? Array(except).map(&:to_s) : nil,
|
|
25
|
+
reason: reason.strip
|
|
26
|
+
}.freeze
|
|
27
|
+
@mecha_tenant_skip_rules << rule
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def own_tenant_enforcement_rules
|
|
31
|
+
@mecha_tenant_skip_rules || []
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def tenant_enforcement_rules
|
|
35
|
+
inherited = if superclass.respond_to?(:tenant_enforcement_rules)
|
|
36
|
+
superclass.tenant_enforcement_rules
|
|
37
|
+
else
|
|
38
|
+
[]
|
|
39
|
+
end
|
|
40
|
+
inherited + own_tenant_enforcement_rules
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def reset_tenant_enforcement_rules!
|
|
44
|
+
@mecha_tenant_skip_rules = []
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def tenant_enforcement_skipped_for?(action_name)
|
|
48
|
+
!tenant_enforcement_reason_for(action_name).nil?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def tenant_enforcement_reason_for(action_name)
|
|
52
|
+
act = action_name.to_s
|
|
53
|
+
tenant_enforcement_rules.reverse_each do |rule|
|
|
54
|
+
if rule[:only]
|
|
55
|
+
return rule[:reason] if rule[:only].include?(act)
|
|
56
|
+
elsif rule[:except]
|
|
57
|
+
return rule[:reason] unless rule[:except].include?(act)
|
|
58
|
+
else
|
|
59
|
+
return rule[:reason]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def tenant_scope(model_class)
|
|
67
|
+
tenant_method = MechaTenant.configuration.tenant_method
|
|
68
|
+
tenant = respond_to?(tenant_method, true) ? send(tenant_method) : nil
|
|
69
|
+
scope = MechaTenant.registry.scope_for(model_class, tenant)
|
|
70
|
+
@_mecha_tenant_scoped_models ||= []
|
|
71
|
+
@_mecha_tenant_scoped_models << model_class.to_s
|
|
72
|
+
scope
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def mecha_tenant_scoped_models
|
|
76
|
+
@_mecha_tenant_scoped_models || []
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MechaTenant
|
|
4
|
+
module Errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
class ConfigurationError < Error; end
|
|
7
|
+
class UnregisteredModelError < Error; end
|
|
8
|
+
class GlobalModelScopeError < Error; end
|
|
9
|
+
class TenantMissingError < Error; end
|
|
10
|
+
class UnscopedQueryError < Error; end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
Error = Errors::Error
|
|
14
|
+
ConfigurationError = Errors::ConfigurationError
|
|
15
|
+
UnregisteredModelError = Errors::UnregisteredModelError
|
|
16
|
+
GlobalModelScopeError = Errors::GlobalModelScopeError
|
|
17
|
+
TenantMissingError = Errors::TenantMissingError
|
|
18
|
+
UnscopedQueryError = Errors::UnscopedQueryError
|
|
19
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
unless defined?(ActionDispatch::IntegrationTest)
|
|
4
|
+
app_test_helper = File.join(Dir.pwd, "test/test_helper.rb")
|
|
5
|
+
require app_test_helper if File.exist?(app_test_helper)
|
|
6
|
+
end
|
|
7
|
+
require "mecha_tenant"
|
|
8
|
+
|
|
9
|
+
module MechaTenant
|
|
10
|
+
class MatrixTest < ActionDispatch::IntegrationTest
|
|
11
|
+
setup do
|
|
12
|
+
if MechaTenant.configuration.test_setup
|
|
13
|
+
instance_exec(&MechaTenant.configuration.test_setup)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
teardown do
|
|
18
|
+
if MechaTenant.configuration.test_teardown
|
|
19
|
+
instance_exec(&MechaTenant.configuration.test_teardown)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
test "static model registry and controller AST audit passes with zero violations" do
|
|
24
|
+
result = MechaTenant::Auditor::Static.new.audit
|
|
25
|
+
|
|
26
|
+
assert_empty result.unmapped_models, "Found unmapped models missing from config/mecha_tenant.yml: #{result.unmapped_models.inspect}"
|
|
27
|
+
assert_empty result.dead_models, "Found dead models in config/mecha_tenant.yml: #{result.dead_models.inspect}"
|
|
28
|
+
assert_empty result.missing_scopes, "Found missing/invalid scopes: #{result.missing_scopes.inspect}"
|
|
29
|
+
assert_empty result.dead_skip_rules, "Found dead skip_tenant_enforcement! rules: #{result.dead_skip_rules.inspect}"
|
|
30
|
+
assert_empty result.violations, "Found controller AST tenant violations:\n" +
|
|
31
|
+
result.violations.map { |v| " - #{v.file}:#{v.line} [#{v.controller}##{v.method_name}] #{v.message}" }.join("\n")
|
|
32
|
+
assert result.success?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
test "static AST auditor deterministically catches naked model queries and invalid tenant_scope calls" do
|
|
36
|
+
auditor = MechaTenant::Auditor::Static.new
|
|
37
|
+
tenant_model = MechaTenant.registry.tenant_models.keys.first
|
|
38
|
+
global_model = MechaTenant.registry.global_models.keys.first
|
|
39
|
+
return unless tenant_model && global_model
|
|
40
|
+
|
|
41
|
+
bad_source = <<~RUBY
|
|
42
|
+
class VulnerableController < ApplicationController
|
|
43
|
+
def show
|
|
44
|
+
@msg = #{tenant_model}.find(params[:id])
|
|
45
|
+
@opt = tenant_scope(#{global_model}).first
|
|
46
|
+
@ok_const = #{tenant_model}::SOME_CONSTANT
|
|
47
|
+
@ok_scoped = tenant_scope(#{tenant_model}).find(params[:id])
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
RUBY
|
|
51
|
+
|
|
52
|
+
dummy_class = Class.new(ApplicationController) do
|
|
53
|
+
def self.name
|
|
54
|
+
"VulnerableController"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def show; end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
res = auditor.audit_source(bad_source, controller_class: dummy_class, file_path: "app/controllers/vulnerable_controller.rb")
|
|
61
|
+
violations = res[:violations]
|
|
62
|
+
|
|
63
|
+
assert_equal 2, violations.size
|
|
64
|
+
assert_equal :naked_model_reference, violations[0].kind
|
|
65
|
+
assert_equal tenant_model, violations[0].model
|
|
66
|
+
assert_equal 3, violations[0].line
|
|
67
|
+
|
|
68
|
+
assert_equal :global_model_in_tenant_scope, violations[1].kind
|
|
69
|
+
assert_equal global_model, violations[1].model
|
|
70
|
+
assert_equal 4, violations[1].line
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.define_tenant_matrix_tests!
|
|
74
|
+
return if @tenant_matrix_tests_defined
|
|
75
|
+
@tenant_matrix_tests_defined = true
|
|
76
|
+
|
|
77
|
+
# 1. One test per registered tenant model for scope isolation
|
|
78
|
+
MechaTenant.registry.tenant_models.each_key do |model_name|
|
|
79
|
+
test "behavioral tenant scope isolation for model #{model_name}" do
|
|
80
|
+
ctx_proc = MechaTenant.configuration.test_matrix_context
|
|
81
|
+
assert_not_nil ctx_proc, "MechaTenant.configuration.test_matrix_context must be configured for behavioral matrix testing"
|
|
82
|
+
|
|
83
|
+
ctx = instance_exec(&ctx_proc)
|
|
84
|
+
prober = MechaTenant::Auditor::Prober.new(self)
|
|
85
|
+
result = prober.probe_single_model_scope(
|
|
86
|
+
model_name,
|
|
87
|
+
victim: ctx[:victim],
|
|
88
|
+
attacker: ctx[:attacker],
|
|
89
|
+
victim_records: ctx[:victim_records]
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
assert result.success?, "Model scope isolation failed for #{model_name}:\n" +
|
|
93
|
+
result.scope_failures.map { |f| " - [#{f.model}]: #{f.message}" }.join("\n")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# 2. One test per enforced parameterized (:id) route for cross-tenant IDOR protection
|
|
98
|
+
enforced_id_audits = MechaTenant::Auditor::Prober.collect_enforced_id_audits
|
|
99
|
+
enforced_id_audits.each do |audit|
|
|
100
|
+
test "behavioral IDOR route isolation for #{audit.verb} #{audit.path} (#{audit.controller}##{audit.action})" do
|
|
101
|
+
ctx_proc = MechaTenant.configuration.test_matrix_context
|
|
102
|
+
assert_not_nil ctx_proc, "MechaTenant.configuration.test_matrix_context must be configured for behavioral matrix testing"
|
|
103
|
+
|
|
104
|
+
ctx = instance_exec(&ctx_proc)
|
|
105
|
+
prober = MechaTenant::Auditor::Prober.new(self)
|
|
106
|
+
result = prober.probe_single_idor_route(
|
|
107
|
+
audit,
|
|
108
|
+
victim_records: ctx[:victim_records],
|
|
109
|
+
ui_auth: ctx[:ui_auth],
|
|
110
|
+
api_auth: ctx[:api_auth]
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
assert result.success?, "Cross-tenant IDOR route prober failed for #{audit.verb} #{audit.path}:\n" +
|
|
114
|
+
result.route_failures.map { |f| " - #{f.route} [#{f.target_model}]: #{f.message}" }.join("\n")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
define_tenant_matrix_tests!
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module MechaTenant
|
|
6
|
+
class Registry
|
|
7
|
+
VALID_CLASSIFICATIONS = %w[tenant global].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :models, :tenant_root, :colorization
|
|
10
|
+
|
|
11
|
+
def initialize(config_path_or_hash = nil, scopes = {})
|
|
12
|
+
@scopes = scopes.transform_keys(&:to_s)
|
|
13
|
+
@models = {}
|
|
14
|
+
@tenant_root = "User"
|
|
15
|
+
@colorization = {}
|
|
16
|
+
|
|
17
|
+
if config_path_or_hash.is_a?(Hash)
|
|
18
|
+
load_from_hash(config_path_or_hash)
|
|
19
|
+
elsif config_path_or_hash
|
|
20
|
+
load_from_file(config_path_or_hash)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def load_from_file(path)
|
|
25
|
+
raise ConfigurationError, "Tenancy configuration file missing: #{path}" unless File.exist?(path)
|
|
26
|
+
|
|
27
|
+
data = begin
|
|
28
|
+
YAML.load_file(path)
|
|
29
|
+
rescue => e
|
|
30
|
+
raise ConfigurationError, "Invalid YAML syntax in #{path}: #{e.message}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
load_from_hash(data)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def load_from_hash(data)
|
|
37
|
+
unless data.is_a?(Hash) && data.key?("models") && data["models"].is_a?(Array)
|
|
38
|
+
raise ConfigurationError, "Tenancy config must have a top-level 'models' list"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@tenant_root = data["tenant_root"].to_s.strip if data["tenant_root"].present?
|
|
42
|
+
@models = {}
|
|
43
|
+
|
|
44
|
+
data["models"].each_with_index do |entry, idx|
|
|
45
|
+
validate_model_entry!(entry, idx)
|
|
46
|
+
model_name = entry["model"].strip
|
|
47
|
+
if @models.key?(model_name)
|
|
48
|
+
raise ConfigurationError, "Duplicate model entry '#{model_name}' in tenancy configuration"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@models[model_name] = {
|
|
52
|
+
model: model_name,
|
|
53
|
+
classification: entry["classification"].to_sym,
|
|
54
|
+
ownership: entry["ownership"]&.to_s&.strip,
|
|
55
|
+
notes: entry["notes"]&.to_s&.strip
|
|
56
|
+
}.freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if data["debug_colorization"].is_a?(Hash)
|
|
60
|
+
@colorization = data["debug_colorization"].transform_keys(&:to_sym).transform_values do |v|
|
|
61
|
+
v.to_s.sub(/^:/, "").to_sym
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@models.freeze
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def registered?(model_or_name)
|
|
70
|
+
@models.key?(normalize_model_name(model_or_name))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def model_entry(model_or_name)
|
|
74
|
+
@models[normalize_model_name(model_or_name)]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def tenant_model?(model_or_name)
|
|
78
|
+
entry = model_entry(model_or_name)
|
|
79
|
+
entry ? entry[:classification] == :tenant : false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def global_model?(model_or_name)
|
|
83
|
+
entry = model_entry(model_or_name)
|
|
84
|
+
entry ? entry[:classification] == :global : false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def tenant_models
|
|
88
|
+
@models.select { |_k, v| v[:classification] == :tenant }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def global_models
|
|
92
|
+
@models.select { |_k, v| v[:classification] == :global }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def scope_defined?(model_or_name)
|
|
96
|
+
@scopes.key?(normalize_model_name(model_or_name))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def scope_for(model_or_name, tenant)
|
|
100
|
+
name = normalize_model_name(model_or_name)
|
|
101
|
+
entry = @models[name]
|
|
102
|
+
|
|
103
|
+
if entry.nil?
|
|
104
|
+
raise UnregisteredModelError, "Model '#{name}' is not registered in MechaTenant configuration"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
if entry[:classification] == :global
|
|
108
|
+
raise GlobalModelScopeError, "Model '#{name}' is classified as :global and cannot be queried via tenant_scope"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
raise TenantMissingError, "Cannot resolve tenant_scope(#{name}) when current tenant is nil" if tenant.nil?
|
|
112
|
+
|
|
113
|
+
resolver = @scopes[name]
|
|
114
|
+
if resolver.nil?
|
|
115
|
+
raise ConfigurationError, "No scope resolver defined for tenant model '#{name}'"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
resolver.call(tenant)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def normalize_model_name(model_or_name)
|
|
124
|
+
case model_or_name
|
|
125
|
+
when Class, Module
|
|
126
|
+
model_or_name.name.to_s
|
|
127
|
+
else
|
|
128
|
+
model_or_name.to_s
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def validate_model_entry!(entry, idx)
|
|
133
|
+
unless entry.is_a?(Hash)
|
|
134
|
+
raise ConfigurationError, "Tenancy model entry at index #{idx} must be a Hash"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
model_name = entry["model"]
|
|
138
|
+
unless model_name.is_a?(String) && !model_name.strip.empty?
|
|
139
|
+
raise ConfigurationError, "Tenancy model entry at index #{idx} must have a non-empty string for 'model'"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
classification = entry["classification"]
|
|
143
|
+
unless classification.is_a?(String) && VALID_CLASSIFICATIONS.include?(classification)
|
|
144
|
+
raise ConfigurationError,
|
|
145
|
+
"Tenancy model '#{model_name}' at index #{idx} must have classification in #{VALID_CLASSIFICATIONS.inspect}, got #{classification.inspect}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
if classification == "tenant"
|
|
149
|
+
ownership = entry["ownership"]
|
|
150
|
+
unless ownership.is_a?(String) && !ownership.strip.empty?
|
|
151
|
+
raise ConfigurationError,
|
|
152
|
+
"Tenancy model '#{model_name}' (classification: tenant) must explicitly declare 'ownership'"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
data/lib/mecha_tenant.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "mecha_tenant/version"
|
|
4
|
+
require_relative "mecha_tenant/errors"
|
|
5
|
+
require_relative "mecha_tenant/configuration"
|
|
6
|
+
require_relative "mecha_tenant/registry"
|
|
7
|
+
require_relative "mecha_tenant/controller"
|
|
8
|
+
require_relative "mecha_tenant/auditor/static"
|
|
9
|
+
require_relative "mecha_tenant/auditor/prober"
|
|
10
|
+
require_relative "mecha_tenant/railtie" if defined?(Rails::Railtie)
|
|
11
|
+
|
|
12
|
+
module MechaTenant
|
|
13
|
+
class << self
|
|
14
|
+
def configuration
|
|
15
|
+
@configuration ||= Configuration.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def configure
|
|
19
|
+
yield(configuration) if block_given?
|
|
20
|
+
reload!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reset_configuration!
|
|
24
|
+
@configuration = Configuration.new
|
|
25
|
+
@registry = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def registry
|
|
29
|
+
@registry ||= Registry.new(configuration.config_path, configuration.scopes)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def reload!
|
|
33
|
+
@registry = Registry.new(configuration.config_path, configuration.scopes)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tiny_color"
|
|
4
|
+
require_relative "../mecha_tenant"
|
|
5
|
+
|
|
6
|
+
class String
|
|
7
|
+
def dark_grey
|
|
8
|
+
respond_to?(:grey) ? grey : self
|
|
9
|
+
end unless method_defined?(:dark_grey)
|
|
10
|
+
|
|
11
|
+
def dark_gray
|
|
12
|
+
respond_to?(:gray) ? gray : self
|
|
13
|
+
end unless method_defined?(:dark_gray)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
desc "Print all models and controller actions with their MechaTenant isolation controls"
|
|
17
|
+
task mecha_tenant: :environment do
|
|
18
|
+
auditor = MechaTenant::Auditor::Static.new
|
|
19
|
+
result = auditor.audit
|
|
20
|
+
colors = MechaTenant.configuration.debug_colorization
|
|
21
|
+
|
|
22
|
+
colorize = ->(text, key) {
|
|
23
|
+
color = colors[key]
|
|
24
|
+
if color && text.respond_to?(color)
|
|
25
|
+
text.send(color)
|
|
26
|
+
else
|
|
27
|
+
text
|
|
28
|
+
end
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
puts "=== Model Tenancy Registry ==="
|
|
32
|
+
all_model_names = result.mapped_models.map { |m| m[:model] } + result.unmapped_models
|
|
33
|
+
max_model_len = [all_model_names.map(&:length).max || 5, 5].max
|
|
34
|
+
max_class_len = 14
|
|
35
|
+
|
|
36
|
+
puts sprintf("%-#{max_model_len}s %-#{max_class_len}s %s", "Model", "Classification", "Ownership")
|
|
37
|
+
|
|
38
|
+
result.mapped_models.each do |entry|
|
|
39
|
+
cls_sym = entry[:classification]
|
|
40
|
+
padded_model = sprintf("%-#{max_model_len}s", entry[:model])
|
|
41
|
+
padded_class = sprintf("%-#{max_class_len}s", cls_sym.to_s.upcase)
|
|
42
|
+
ownership = entry[:ownership] || (cls_sym == :global ? "(global/system)" : "-")
|
|
43
|
+
|
|
44
|
+
puts sprintf(
|
|
45
|
+
"%s %s %s",
|
|
46
|
+
colorize.call(padded_model, cls_sym),
|
|
47
|
+
colorize.call(padded_class, cls_sym),
|
|
48
|
+
ownership
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
result.unmapped_models.each do |model_name|
|
|
53
|
+
padded_model = sprintf("%-#{max_model_len}s", model_name)
|
|
54
|
+
padded_class = sprintf("%-#{max_class_len}s", "UNMAPPED")
|
|
55
|
+
puts sprintf(
|
|
56
|
+
"%s %s %s",
|
|
57
|
+
colorize.call(padded_model, :unmapped),
|
|
58
|
+
colorize.call(padded_class, :unmapped),
|
|
59
|
+
"MISSING FROM config/mecha_tenant.yml"
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
puts "\n=== Controller Action Tenancy Enforcement ==="
|
|
64
|
+
audits = result.action_audits
|
|
65
|
+
max_verb_len = [audits.map { |a| a.verb.to_s.length }.max || 4, 4].max
|
|
66
|
+
max_path_len = [audits.map { |a| a.path.to_s.length }.max || 11, 11].max
|
|
67
|
+
max_target_len = [audits.map { |a| "#{a.controller}##{a.action}".length }.max || 17, 17].max
|
|
68
|
+
max_status_len = 9
|
|
69
|
+
|
|
70
|
+
puts sprintf(
|
|
71
|
+
"%-#{max_verb_len}s %-#{max_path_len}s %-#{max_target_len}s %-#{max_status_len}s %s",
|
|
72
|
+
"Verb",
|
|
73
|
+
"URI Pattern",
|
|
74
|
+
"Controller#Action",
|
|
75
|
+
"Status",
|
|
76
|
+
"Scoping / Reason"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
audits.each do |a|
|
|
80
|
+
padded_verb = sprintf("%-#{max_verb_len}s", a.verb)
|
|
81
|
+
padded_path = sprintf("%-#{max_path_len}s", a.path)
|
|
82
|
+
padded_target = sprintf("%-#{max_target_len}s", "#{a.controller}##{a.action}")
|
|
83
|
+
padded_status = sprintf("%-#{max_status_len}s", a.status.to_s.upcase)
|
|
84
|
+
|
|
85
|
+
detail = case a.status
|
|
86
|
+
when :skipped
|
|
87
|
+
"SKIP: #{a.skip_reason}"
|
|
88
|
+
when :violation
|
|
89
|
+
"VIOLATION"
|
|
90
|
+
else
|
|
91
|
+
a.scopes_used.any? ? a.scopes_used.join(", ") : "(no model queries)"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
puts sprintf(
|
|
95
|
+
"%s %s %s %s %s",
|
|
96
|
+
padded_verb,
|
|
97
|
+
colorize.call(padded_path, a.status),
|
|
98
|
+
padded_target,
|
|
99
|
+
colorize.call(padded_status, a.status),
|
|
100
|
+
detail
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
namespace :mecha_tenant do
|
|
106
|
+
namespace :verify do
|
|
107
|
+
desc "Statically verify model tenancy registry parity and strict controller lookup scoping"
|
|
108
|
+
task static: :environment do
|
|
109
|
+
puts "Running MechaTenant static model & controller AST audit..."
|
|
110
|
+
auditor = MechaTenant::Auditor::Static.new
|
|
111
|
+
result = auditor.audit
|
|
112
|
+
|
|
113
|
+
tenant_count = result.mapped_models.count { |m| m[:classification] == :tenant }
|
|
114
|
+
global_count = result.mapped_models.count { |m| m[:classification] == :global }
|
|
115
|
+
enforced_count = result.action_audits.count { |a| a.status == :enforced }
|
|
116
|
+
skipped_count = result.action_audits.count { |a| a.status == :skipped }
|
|
117
|
+
|
|
118
|
+
puts " -> #{result.mapped_models.size} models registered (#{tenant_count} tenant, #{global_count} global)"
|
|
119
|
+
puts " -> #{result.action_audits.size} controller routes audited (#{enforced_count} enforced, #{skipped_count} explicitly skipped)"
|
|
120
|
+
|
|
121
|
+
if result.unmapped_models.any?
|
|
122
|
+
puts "\nERROR: Found #{result.unmapped_models.size} unmapped model(s) missing from config/mecha_tenant.yml:".light_red
|
|
123
|
+
result.unmapped_models.each { |m| puts " - #{m}" }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if result.dead_models.any?
|
|
127
|
+
puts "\nERROR: Found #{result.dead_models.size} dead/stale model(s) in config/mecha_tenant.yml:".light_red
|
|
128
|
+
result.dead_models.each { |m| puts " - #{m}" }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
if result.missing_scopes.any?
|
|
132
|
+
puts "\nERROR: Found #{result.missing_scopes.size} model scope configuration error(s):".light_red
|
|
133
|
+
result.missing_scopes.each { |err| puts " - #{err}" }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
if result.dead_skip_rules.any?
|
|
137
|
+
puts "\nERROR: Found #{result.dead_skip_rules.size} dead skip_tenant_enforcement! rule(s):".light_red
|
|
138
|
+
result.dead_skip_rules.each { |dr| puts " - #{dr[:file]}: #{dr[:message]}" }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if result.violations.any?
|
|
142
|
+
puts "\nERROR: Found #{result.violations.size} unscoped model reference violation(s) in controllers:".light_red
|
|
143
|
+
result.violations.each do |v|
|
|
144
|
+
puts " - #{v.file}:#{v.line} [#{v.controller}##{v.method_name}] #{v.message}"
|
|
145
|
+
puts " #{v.snippet}"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
if result.success?
|
|
150
|
+
puts "\n✓ Static tenant isolation audit passed: 0 unmapped models, 0 dead rules, 0 controller violations.".green
|
|
151
|
+
else
|
|
152
|
+
abort("\n✗ Static tenant isolation audit failed. Please resolve the errors above.")
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
desc "Run behavioral cross-tenant scope and IDOR route matrix test suite"
|
|
157
|
+
task behavioral: :environment do
|
|
158
|
+
puts "Running MechaTenant behavioral cross-tenant isolation matrix test suite..."
|
|
159
|
+
matrix_test_path = File.expand_path("../mecha_tenant/matrix_test.rb", __dir__)
|
|
160
|
+
system({ "MECHA_MATRIX_STANDALONE" => "1" }, "bin/rails", "test", matrix_test_path) || abort("Behavioral tenant verification failed.")
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
desc "Run both static AST/registry verification and behavioral cross-tenant IDOR verification"
|
|
165
|
+
task verify: %w[verify:static verify:behavioral]
|
|
166
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mecha_tenant
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Jeff Lunt
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activesupport
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: prism
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: tiny_color
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
description: MechaTenant enforces strict tenant isolation in Rails applications via
|
|
55
|
+
a YAML model classification registry, explicit tenant_scope helpers, Prism AST static
|
|
56
|
+
analysis of controllers, and behavioral IDOR route probing.
|
|
57
|
+
email:
|
|
58
|
+
- jefflunt@gmail.com
|
|
59
|
+
executables: []
|
|
60
|
+
extensions: []
|
|
61
|
+
extra_rdoc_files: []
|
|
62
|
+
files:
|
|
63
|
+
- LICENSE
|
|
64
|
+
- README.md
|
|
65
|
+
- Rakefile
|
|
66
|
+
- lib/mecha_tenant.rb
|
|
67
|
+
- lib/mecha_tenant/auditor/prober.rb
|
|
68
|
+
- lib/mecha_tenant/auditor/static.rb
|
|
69
|
+
- lib/mecha_tenant/configuration.rb
|
|
70
|
+
- lib/mecha_tenant/controller.rb
|
|
71
|
+
- lib/mecha_tenant/errors.rb
|
|
72
|
+
- lib/mecha_tenant/matrix_test.rb
|
|
73
|
+
- lib/mecha_tenant/railtie.rb
|
|
74
|
+
- lib/mecha_tenant/registry.rb
|
|
75
|
+
- lib/mecha_tenant/version.rb
|
|
76
|
+
- lib/tasks/mecha_tenant.rake
|
|
77
|
+
homepage: https://github.com/jefflunt/mecha_tenant
|
|
78
|
+
licenses:
|
|
79
|
+
- MIT
|
|
80
|
+
metadata: {}
|
|
81
|
+
rdoc_options: []
|
|
82
|
+
require_paths:
|
|
83
|
+
- lib
|
|
84
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: 3.2.0
|
|
89
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - ">="
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: '0'
|
|
94
|
+
requirements: []
|
|
95
|
+
rubygems_version: 4.0.16
|
|
96
|
+
specification_version: 4
|
|
97
|
+
summary: Closed-by-default multi-tenant model registry, AST controller auditing, and
|
|
98
|
+
IDOR probing for Rails
|
|
99
|
+
test_files: []
|