mecha_authorize 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8f08a1ab8bc1c24142b6a7938ae981b7df4ac925d324c5b709e01032ac2afb03
4
+ data.tar.gz: 5ecf4101189a14053e7692ba44c3fee6c3951d53b246dc95976d237291efdc82
5
+ SHA512:
6
+ metadata.gz: e5ee312dbd97f3511a143aa241e187b7897de4dce760ba17a1a7f0a3ed63de1b7912bcd75829fd2fa02aff0a338b932a0deb5d3416dc5d51127dbe767ed20720
7
+ data.tar.gz: dda55c536ca1c54706e6a052c62520b97328125b73f174917ca2afd9dd22c379f787750900ee0825cd9940c2dfe0f98687fd7e2c4c444c1c3745622b35fcef22
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,162 @@
1
+ # MechaAuthorize
2
+
3
+ `mecha_authorize` provides a YAML-driven, closed-by-default route authorization system for Rails applications. Every incoming request is evaluated against an explicit route registry before controller actions run, with built-in static route parity auditing and behavioral persona matrix probing.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's `Gemfile`:
8
+
9
+ ```ruby
10
+ gem "mecha_authorize"
11
+ ```
12
+
13
+ Or if developing locally:
14
+
15
+ ```ruby
16
+ gem "mecha_authorize", path: "~/code/mecha_authorize"
17
+ ```
18
+
19
+ And then execute:
20
+
21
+ ```bash
22
+ bundle install
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ### 1. Define `config/mecha_authorize.yml`
28
+
29
+ Declare your application routes and role permissions in `config/mecha_authorize.yml`. Any route not matching an entry in this file is denied by default (404).
30
+
31
+ ```yaml
32
+ debug_colorization:
33
+ public: :green
34
+ admin: :light_red
35
+ user_ui: :light_purple
36
+ user_api: :light_cyan
37
+ none: :dark_grey
38
+
39
+ routes:
40
+ - route_pattern: "/"
41
+ verb: "GET"
42
+ public: true
43
+ admin: false
44
+ user_ui: false
45
+ user_api: false
46
+
47
+ - route_pattern: "/home"
48
+ verb: "GET"
49
+ public: false
50
+ admin: true
51
+ user_ui: true
52
+ user_api: false
53
+
54
+ - route_pattern: "/admin/*"
55
+ verb: "*"
56
+ public: false
57
+ admin: true
58
+ user_ui: false
59
+ user_api: false
60
+
61
+ - route_pattern: "/api/v1/items/:id"
62
+ verbs: ["GET", "PATCH", "DELETE"]
63
+ public: false
64
+ admin: false
65
+ user_ui: false
66
+ user_api: true
67
+ ```
68
+
69
+ ### 2. Configure the Initializer
70
+
71
+ Create `config/initializers/mecha_authorize.rb` to define role resolvers and include the controller concern:
72
+
73
+ ```ruby
74
+ # config/initializers/mecha_authorize.rb
75
+ require "mecha_authorize"
76
+
77
+ MechaAuthorize.configure do |config|
78
+ config.config_path = Rails.root.join("config/mecha_authorize.yml")
79
+
80
+ config.define_role :public do |_user, _request|
81
+ true
82
+ end
83
+
84
+ config.define_role :admin do |user, _request|
85
+ user&.admin? || false
86
+ end
87
+
88
+ config.define_role :user_ui do |user, _request|
89
+ user&.user_permission&.user_ui? || false
90
+ end
91
+
92
+ config.define_role :user_api do |user, _request|
93
+ user&.user_permission&.user_api? || false
94
+ end
95
+
96
+ config.on_unauthorized = ->(controller) {
97
+ controller.head :not_found
98
+ }
99
+ end
100
+
101
+ ActiveSupport.on_load(:action_controller_base) do
102
+ include MechaAuthorize::Controller
103
+ end
104
+ ```
105
+
106
+ ### 3. Rake Tasks & Auditing
107
+
108
+ `mecha_authorize` automatically registers Rake tasks via its Railtie:
109
+
110
+ ```bash
111
+ # Print all routes and their colorized authorization matrix
112
+ bin/rails mecha_authorize
113
+
114
+ # Statically audit routes for dead rules and mounted engine bypasses
115
+ bin/rails mecha_authorize:verify:static
116
+
117
+ # Run behavioral persona matrix tests
118
+ bin/rails mecha_authorize:verify:behavioral
119
+
120
+ # Run both static and behavioral verification
121
+ bin/rails mecha_authorize:verify
122
+ ```
123
+
124
+ ### 4. Dynamic Parallel Matrix Testing in Consuming Apps
125
+
126
+ `mecha_authorize` ships with [`MechaAuthorize::MatrixTest`](lib/mecha_authorize/matrix_test.rb), which dynamically generates an individual Minitest test method per route so that Minitest's parallel worker pool executes route probes across all CPU cores.
127
+
128
+ In your consuming Rails application's `test/test_helper.rb`, configure the persona callbacks and require the matrix test:
129
+
130
+ ```ruby
131
+ MechaAuthorize.configuration.configure_test_matrix(
132
+ setup: -> {
133
+ # Set up test users / tokens for each role
134
+ },
135
+ teardown: -> {
136
+ # Optional teardown logic
137
+ },
138
+ personas: -> {
139
+ {
140
+ anonymous: ->(session) { session.reset!; {} },
141
+ admin: ->(session) { login_as(session, @admin_user); {} },
142
+ user_ui: ->(session) { login_as(session, @ui_user); {} },
143
+ user_api: ->(_session) { { "Authorization" => "Bearer #{@api_jwt}" } },
144
+ restricted: ->(session) { login_as(session, @restricted_user); {} }
145
+ }
146
+ }
147
+ )
148
+
149
+ require "mecha_authorize/matrix_test" unless ENV["MECHA_MATRIX_STANDALONE"] == "1"
150
+ ```
151
+
152
+ ## Running Tests
153
+
154
+ To run the gem's standalone test suite:
155
+
156
+ ```bash
157
+ bundle exec rake test
158
+ ```
159
+
160
+ ## License
161
+
162
+ Copyright 2026 Jeff Lunt. Released under the [MIT License](LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << "test"
7
+ t.libs << "lib"
8
+ t.test_files = FileList["test/**/*_test.rb"]
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MechaAuthorize
4
+ module Auditor
5
+ class Prober
6
+ ProbeFailure = Struct.new(:route, :persona, :expected_status, :actual_status, :message, keyword_init: true)
7
+
8
+ Result = Struct.new(:probed_count, :failures, keyword_init: true) do
9
+ def success?
10
+ failures.empty?
11
+ end
12
+ end
13
+
14
+ DEFAULT_PARAM_SUBSTITUTIONS = {
15
+ ":id" => "1",
16
+ ":card_id" => "card-1",
17
+ ":table_id" => "table-1",
18
+ ":provider" => "google_oauth2",
19
+ ":application_id" => "app-1",
20
+ ":queue_id" => "queue-1",
21
+ ":filename" => "test.png",
22
+ ":path" => "test"
23
+ }.freeze
24
+
25
+ def initialize(integration_session = nil, matcher = MechaAuthorize.matcher)
26
+ @session = integration_session
27
+ @matcher = matcher
28
+ end
29
+
30
+ def probe_all_routes(persona_generator)
31
+ failures = []
32
+ probed_count = 0
33
+
34
+ routes = collect_prober_routes
35
+
36
+ routes.each do |route_info|
37
+ route_res = probe_route(route_info, persona_generator)
38
+ probed_count += route_res.probed_count
39
+ failures.concat(route_res.failures)
40
+ end
41
+
42
+ Result.new(probed_count: probed_count, failures: failures)
43
+ end
44
+
45
+ def probe_route(route_info, persona_generator)
46
+ failures = []
47
+ probed_count = 0
48
+
49
+ verb = route_info[:verb]
50
+ raw_path = route_info[:path]
51
+ path = substitute_params(raw_path)
52
+ rule = @matcher.match(verb, path, raw_path)
53
+
54
+ personas = persona_generator.respond_to?(:call) ? persona_generator.call : persona_generator
55
+
56
+ personas.each do |persona_name, user_setup|
57
+ probed_count += 1
58
+ expected_status = calculate_expected_status(raw_path, rule, persona_name)
59
+
60
+ extra_headers = user_setup.call(@session) || {}
61
+
62
+ actual_status = dispatch_probe_request(verb, path, extra_headers)
63
+
64
+ unless status_matches?(actual_status, expected_status)
65
+ failures << ProbeFailure.new(
66
+ route: "#{verb} #{raw_path} (#{path})",
67
+ persona: persona_name,
68
+ expected_status: expected_status,
69
+ actual_status: actual_status,
70
+ message: "Expected HTTP #{expected_status} but received HTTP #{actual_status}"
71
+ )
72
+ end
73
+ end
74
+
75
+ Result.new(probed_count: probed_count, failures: failures)
76
+ end
77
+
78
+ def substitute_params(path)
79
+ result = path.dup
80
+ DEFAULT_PARAM_SUBSTITUTIONS.each do |param, val|
81
+ result.gsub!(param, val)
82
+ end
83
+ # Generic fallback for any remaining :param
84
+ result.gsub!(/:[a-zA-Z_]+/, "1")
85
+ result
86
+ end
87
+
88
+ def collect_prober_routes
89
+ self.class.collect_prober_routes
90
+ end
91
+
92
+ def self.collect_prober_routes
93
+ return [] unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
94
+
95
+ routes = Rails.application.routes.routes.map do |r|
96
+ ActionDispatch::Routing::RouteWrapper.new(r)
97
+ end.reject(&:internal?)
98
+
99
+ all = []
100
+
101
+ routes.each do |r|
102
+ if r.engine?
103
+ engine_routes = r.rack_app.routes
104
+ engine_routes = engine_routes.routes if engine_routes.is_a?(ActionDispatch::Routing::RouteSet)
105
+ clean_mount = r.path.sub("(.:format)", "").sub("/*path", "").sub("/*filename", "").chomp("/")
106
+
107
+ engine_routes.map { |er| ActionDispatch::Routing::RouteWrapper.new(er) }.reject(&:internal?).each do |er|
108
+ verb = er.verb.to_s.empty? ? "*" : er.verb.to_s.upcase
109
+ full_path = "#{clean_mount}#{er.path}".sub("(.:format)", "")
110
+ all << { verb: verb, path: full_path }
111
+ end
112
+ else
113
+ verb = r.verb.to_s.empty? ? "*" : r.verb.to_s.upcase
114
+ all << { verb: verb, path: r.path.sub("(.:format)", "") }
115
+ end
116
+ end
117
+
118
+ all.uniq { |r| [r[:verb], r[:path]] }
119
+ end
120
+
121
+ private
122
+
123
+ def calculate_expected_status(raw_path, rule, persona_name)
124
+ sentinel = MechaAuthorize.configuration.probe_status
125
+ is_api = raw_path.start_with?("/api/")
126
+
127
+ return 404 if rule.nil?
128
+
129
+ if rule[:public]
130
+ return sentinel
131
+ end
132
+
133
+ case persona_name
134
+ when :anonymous
135
+ is_api ? [400, 401, 404] : 404
136
+ when :admin
137
+ rule[:admin] ? sentinel : (is_api ? [400, 401, 403, 404] : 404)
138
+ when :user_ui
139
+ (rule[:user_ui] && !is_api) ? sentinel : (is_api ? [400, 401, 403, 404] : 404)
140
+ when :user_api
141
+ rule[:user_api] ? sentinel : (is_api ? [400, 401, 403, 404] : 404)
142
+ when :restricted
143
+ is_api ? [400, 401, 403, 404] : 404
144
+ else
145
+ 404
146
+ end
147
+ end
148
+
149
+ def status_matches?(actual_status, expected_status)
150
+ if expected_status.is_a?(Array)
151
+ expected_status.include?(actual_status)
152
+ else
153
+ actual_status == expected_status
154
+ end
155
+ end
156
+
157
+ def dispatch_probe_request(verb, path, extra_headers = {})
158
+ headers = { MechaAuthorize.configuration.probe_header => "1" }.merge(extra_headers)
159
+
160
+ case verb.to_s.upcase
161
+ when "GET", "*"
162
+ @session.get path, headers: headers
163
+ when "POST"
164
+ @session.post path, headers: headers
165
+ when "PATCH"
166
+ @session.patch path, headers: headers
167
+ when "PUT"
168
+ @session.put path, headers: headers
169
+ when "DELETE"
170
+ @session.delete path, headers: headers
171
+ when "HEAD"
172
+ @session.head path, headers: headers
173
+ else
174
+ @session.get path, headers: headers
175
+ end
176
+
177
+ @session.response.status
178
+ rescue ActionController::RoutingError
179
+ 404
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MechaAuthorize
4
+ module Auditor
5
+ class Static
6
+ Result = Struct.new(
7
+ :mapped_routes,
8
+ :unmapped_routes,
9
+ :dead_rules,
10
+ :engine_warnings,
11
+ keyword_init: true
12
+ ) do
13
+ def success?
14
+ dead_rules.empty? && engine_warnings.empty?
15
+ end
16
+ end
17
+
18
+ def initialize(matcher = MechaAuthorize.matcher)
19
+ @matcher = matcher
20
+ end
21
+
22
+ def audit
23
+ rails_routes = collect_all_routes
24
+ mapped = []
25
+ unmapped = []
26
+ matched_rules = Set.new
27
+
28
+ rails_routes.each do |r|
29
+ rule = @matcher.match_route_spec(r[:verb], r[:path])
30
+
31
+ if rule
32
+ mapped << r.merge(rule: rule)
33
+ matched_rules << rule
34
+ else
35
+ unmapped << r
36
+ end
37
+ end
38
+
39
+ # Check for dead/stale rules in mecha_authorize.yml
40
+ # A rule is dead if it does not match any actual Rails route and is not a wildcard
41
+ dead_rules = []
42
+ @matcher.all_rules.each do |(verb, pattern), rule|
43
+ next if pattern.include?("*") # Wildcards cover dynamic/future paths
44
+
45
+ # Check if any route matched this specific pattern and verb
46
+ matched = rails_routes.any? do |r|
47
+ (verb == "*" || r[:verb] == "*" || r[:verb] == verb) &&
48
+ route_matches_pattern?(r[:path], pattern)
49
+ end
50
+
51
+ dead_rules << { verb: verb, pattern: pattern, rule: rule } unless matched
52
+ end
53
+
54
+ # Check mounted engines for authorization gate coverage
55
+ engine_warnings = audit_mounted_engines
56
+
57
+ Result.new(
58
+ mapped_routes: mapped,
59
+ unmapped_routes: unmapped,
60
+ dead_rules: dead_rules,
61
+ engine_warnings: engine_warnings
62
+ )
63
+ end
64
+
65
+ private
66
+
67
+ def route_matches_pattern?(route_path, config_pattern)
68
+ clean_route = route_path.sub(/\(\.:format\)$/, "")
69
+ clean_config = config_pattern.sub(/\(\.:format\)$/, "")
70
+
71
+ return true if clean_route == clean_config
72
+
73
+ # Check parameterized segment matching
74
+ route_regex = pattern_to_regex(clean_route)
75
+ config_regex = pattern_to_regex(clean_config)
76
+
77
+ route_regex.match?(clean_config) || config_regex.match?(clean_route)
78
+ end
79
+
80
+ def pattern_to_regex(pattern)
81
+ segments = pattern.split("/", -1)
82
+ regex_segments = segments.map do |seg|
83
+ if seg.start_with?(":")
84
+ "[^/]+"
85
+ elsif seg.start_with?("*")
86
+ ".*"
87
+ else
88
+ Regexp.escape(seg)
89
+ end
90
+ end
91
+ Regexp.new("\\A#{regex_segments.join('/')}\\z")
92
+ end
93
+
94
+ def collect_all_routes
95
+ routes = Rails.application.routes.routes.map do |r|
96
+ ActionDispatch::Routing::RouteWrapper.new(r)
97
+ end.reject(&:internal?)
98
+
99
+ all = []
100
+
101
+ routes.each do |r|
102
+ if r.engine?
103
+ engine_routes = r.rack_app.routes
104
+ engine_routes = engine_routes.routes if engine_routes.is_a?(ActionDispatch::Routing::RouteSet)
105
+ clean_mount = r.path.sub("(.:format)", "").sub("/*path", "").sub("/*filename", "").chomp("/")
106
+
107
+ engine_routes.map { |er| ActionDispatch::Routing::RouteWrapper.new(er) }.reject(&:internal?).each do |er|
108
+ verb = extract_verb(er)
109
+ full_path = "#{clean_mount}#{er.path}".sub("(.:format)", "")
110
+ all << { verb: verb, path: full_path, engine: r.rack_app.name }
111
+ end
112
+ else
113
+ verb = extract_verb(r)
114
+ all << { verb: verb, path: r.path.sub("(.:format)", ""), engine: nil }
115
+ end
116
+ end
117
+
118
+ all
119
+ end
120
+
121
+ def extract_verb(route_wrapper)
122
+ raw_verb = route_wrapper.verb.to_s
123
+ raw_verb.empty? ? "*" : raw_verb.upcase
124
+ end
125
+
126
+ def audit_mounted_engines
127
+ warnings = []
128
+ routes = Rails.application.routes.routes.select { |r| r.app.engine? if r.app.respond_to?(:engine?) }
129
+
130
+ routes.each do |route|
131
+ rack_app = route.app.rack_app
132
+ engine_name = rack_app.name || rack_app.to_s
133
+
134
+ # Check if engine's base controller inherits ApplicationController or includes MechaAuthorize
135
+ base_controller_name = "#{engine_name}::ApplicationController"
136
+ base_controller = base_controller_name.safe_constantize
137
+
138
+ if base_controller
139
+ inherits_app_ctrl = base_controller < ::ApplicationController
140
+ includes_mecha_auth = base_controller.ancestors.include?(MechaAuthorize::Controller)
141
+
142
+ unless inherits_app_ctrl || includes_mecha_auth
143
+ warnings << "Mounted engine #{engine_name} at #{route.path.spec} base controller #{base_controller_name} does not inherit ApplicationController or include MechaAuthorize::Controller."
144
+ end
145
+ end
146
+ end
147
+
148
+ warnings
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MechaAuthorize
4
+ class Configuration
5
+ attr_accessor :config_path,
6
+ :probe_header,
7
+ :probe_status,
8
+ :debug_colorization,
9
+ :on_unauthorized,
10
+ :test_personas,
11
+ :test_setup,
12
+ :test_teardown
13
+
14
+ def initialize
15
+ @config_path = defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? Rails.root.join("config/mecha_authorize.yml") : "config/mecha_authorize.yml"
16
+ @probe_header = "X-Mecha-Authorize-Probe"
17
+ @probe_status = 251 # Special sentinel HTTP status (251 Verified)
18
+ @roles = {}
19
+ @debug_colorization = {
20
+ public: :green,
21
+ admin: :light_red,
22
+ user_ui: :light_purple,
23
+ user_api: :light_cyan,
24
+ none: :dark_grey
25
+ }
26
+ @on_unauthorized = ->(controller) { controller.send(:render_404) }
27
+ @test_personas = nil
28
+ @test_setup = nil
29
+ @test_teardown = nil
30
+ end
31
+
32
+ def define_role(name, &block)
33
+ @roles[name.to_sym] = block
34
+ end
35
+
36
+ def roles
37
+ @roles
38
+ end
39
+
40
+ def role_names
41
+ @roles.keys
42
+ end
43
+
44
+ def configure_test_matrix(personas: nil, setup: nil, teardown: nil)
45
+ @test_personas = personas if personas
46
+ @test_setup = setup if setup
47
+ @test_teardown = teardown if teardown
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module MechaAuthorize
6
+ module Controller
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ before_action :authorize_route!
11
+ end
12
+
13
+ class_methods do
14
+ def authorization_routes
15
+ if defined?(@authorization_routes) && !@authorization_routes.nil?
16
+ @authorization_routes
17
+ elsif superclass.respond_to?(:authorization_routes) && superclass != ActionController::Base
18
+ superclass.authorization_routes
19
+ elsif defined?(AUTHORIZATION_ROUTES)
20
+ AUTHORIZATION_ROUTES
21
+ else
22
+ MechaAuthorize.matcher&.all_rules || {}
23
+ end
24
+ end
25
+
26
+ def authorization_routes=(routes)
27
+ @authorization_routes = routes
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def authorize_route!
34
+ verb = extract_request_verb
35
+ path = request.path
36
+ route_uri_pattern = request.respond_to?(:route_uri_pattern) ? request.route_uri_pattern : nil
37
+
38
+ # If custom authorization_routes are set on the controller class (e.g. In isolated unit tests),
39
+ # evaluate against that custom dictionary; otherwise use the global MechaAuthorize.matcher.
40
+ custom_routes = self.class.authorization_routes
41
+ rule = if custom_routes && custom_routes != AUTHORIZATION_ROUTES && custom_routes != MechaAuthorize.matcher&.all_rules
42
+ match_custom_routes(verb, path, route_uri_pattern, custom_routes)
43
+ else
44
+ MechaAuthorize.matcher.match(verb, path, route_uri_pattern)
45
+ end
46
+
47
+ # 1. Closed-by-default: If no rule matches, deny access
48
+ if rule.nil?
49
+ render_unauthorized
50
+ return
51
+ end
52
+
53
+ # 2. Public bypass
54
+ if rule[:public]
55
+ if probe_request?
56
+ head MechaAuthorize.configuration.probe_status
57
+ return
58
+ end
59
+ return
60
+ end
61
+
62
+ # 3. Authentication requirement for non-public routes
63
+ unless logged_in?
64
+ render_unauthorized
65
+ return
66
+ end
67
+
68
+ # 4. Check permissions
69
+ has_access = check_role_access(rule)
70
+
71
+ unless has_access
72
+ render_unauthorized
73
+ return
74
+ end
75
+
76
+ # 5. Early return on probe requests with sentinel status
77
+ if probe_request?
78
+ head MechaAuthorize.configuration.probe_status and return
79
+ end
80
+ end
81
+
82
+ def extract_request_verb
83
+ if request.respond_to?(:request_method) && request.request_method.present?
84
+ request.request_method.to_s.upcase
85
+ elsif request.respond_to?(:raw_request_method) && request.raw_request_method.present?
86
+ request.raw_request_method.to_s.upcase
87
+ else
88
+ begin
89
+ v = request.method
90
+ v.is_a?(String) ? v.upcase : "GET"
91
+ rescue ArgumentError
92
+ "GET"
93
+ end
94
+ end
95
+ end
96
+
97
+ def probe_request?
98
+ header_name = MechaAuthorize.configuration.probe_header
99
+ val = nil
100
+ if request.respond_to?(:headers) && request.headers
101
+ val = request.headers[header_name]
102
+ end
103
+ if val.nil? && request.respond_to?(:get_header)
104
+ val = request.get_header("HTTP_#{header_name.upcase.tr('-', '_')}")
105
+ end
106
+ val == "1" || val == "true"
107
+ end
108
+
109
+ def check_role_access(rule)
110
+ user = current_user
111
+ roles = MechaAuthorize.configuration.roles
112
+
113
+ roles.any? do |role_name, resolver|
114
+ next false if role_name == :public
115
+ rule[role_name] && resolver.call(user, request)
116
+ end
117
+ end
118
+
119
+ def render_unauthorized
120
+ MechaAuthorize.configuration.on_unauthorized.call(self)
121
+ end
122
+
123
+ def current_user
124
+ nil
125
+ end
126
+
127
+ def logged_in?
128
+ respond_to?(:current_user, true) && !current_user.nil?
129
+ end
130
+
131
+ def match_custom_routes(verb, path, route_uri_pattern, routes_hash)
132
+ # Check compound key [verb, path]
133
+ key = [verb.to_s.upcase, path]
134
+ return routes_hash[key] if routes_hash.key?(key)
135
+
136
+ # Check wildcard verb ["*", path]
137
+ wildcard_verb_key = ["*", path]
138
+ return routes_hash[wildcard_verb_key] if routes_hash.key?(wildcard_verb_key)
139
+
140
+ # Check plain path key (legacy backward-compatibility for mocks)
141
+ return routes_hash[path] if routes_hash.key?(path)
142
+
143
+ # Check wildcards
144
+ routes_hash.each do |k, v|
145
+ pattern = k.is_a?(Array) ? k[1] : k
146
+ rule_verb = k.is_a?(Array) ? k[0] : "*"
147
+
148
+ next unless rule_verb == "*" || rule_verb == verb.to_s.upcase
149
+
150
+ if pattern.include?("*") && (
151
+ File.fnmatch(pattern, path) || (pattern.end_with?("/*") && path == pattern.delete_suffix("/*"))
152
+ )
153
+ return v
154
+ end
155
+ end
156
+
157
+ # Dynamic pattern fallback
158
+ if route_uri_pattern
159
+ clean_pat = route_uri_pattern.sub(/\(\.:format\)$/, "")
160
+ return routes_hash[[verb.to_s.upcase, clean_pat]] if routes_hash.key?([verb.to_s.upcase, clean_pat])
161
+ return routes_hash[["*", clean_pat]] if routes_hash.key?([clean_pat])
162
+ return routes_hash[clean_pat] if routes_hash.key?(clean_pat)
163
+ return routes_hash[route_uri_pattern] if routes_hash.key?(route_uri_pattern)
164
+ end
165
+
166
+ nil
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module MechaAuthorize
6
+ class Matcher
7
+ VALID_VERBS = %w[GET POST PUT PATCH DELETE HEAD OPTIONS *].freeze
8
+
9
+ attr_reader :routes, :indexed_routes, :wildcard_routes, :colorization
10
+
11
+ def initialize(config_path_or_hash = nil, role_names = %i[public admin user_ui user_api])
12
+ @role_names = role_names.map(&:to_sym)
13
+ @indexed_routes = {}
14
+ @wildcard_routes = []
15
+ @raw_routes = []
16
+ @colorization = {}
17
+
18
+ if config_path_or_hash.is_a?(Hash)
19
+ load_from_hash(config_path_or_hash)
20
+ elsif config_path_or_hash
21
+ load_from_file(config_path_or_hash)
22
+ end
23
+ end
24
+
25
+ def load_from_file(path)
26
+ raise "Authorization configuration file missing: #{path}" unless File.exist?(path)
27
+
28
+ data = begin
29
+ YAML.load_file(path)
30
+ rescue => e
31
+ raise "Invalid YAML syntax in #{path}: #{e.message}"
32
+ end
33
+
34
+ load_from_hash(data)
35
+ end
36
+
37
+ def load_from_hash(data)
38
+ unless data.is_a?(Hash) && data.key?("routes") && data["routes"].is_a?(Array)
39
+ raise "Authorization config must have a top-level 'routes' list"
40
+ end
41
+
42
+ @raw_routes = data["routes"]
43
+ @indexed_routes = {}
44
+ @wildcard_routes = []
45
+
46
+ @raw_routes.each_with_index do |route, idx|
47
+ validate_route_entry!(route, idx)
48
+ register_route_entry(route)
49
+ end
50
+
51
+ if data["debug_colorization"].is_a?(Hash)
52
+ @colorization = data["debug_colorization"].transform_keys(&:to_sym).transform_values { |v| v.to_s.sub(/^:/, "").to_sym }
53
+ end
54
+
55
+ @indexed_routes.freeze
56
+ @wildcard_routes.freeze
57
+ self
58
+ end
59
+
60
+ # Match incoming request by HTTP verb and path
61
+ def match(verb, path, route_uri_pattern = nil)
62
+ v = (verb || "GET").to_s.upcase
63
+ clean_path = path.to_s.sub(/\(\.:format\)$/, "")
64
+
65
+ # 1. Exact match [verb, path]
66
+ rule = @indexed_routes[[v, clean_path]]
67
+ return rule if rule
68
+
69
+ # Fallback for HEAD -> GET
70
+ if v == "HEAD" && (head_rule = @indexed_routes[["GET", clean_path]])
71
+ return head_rule
72
+ end
73
+
74
+ # 2. Match [wildcard_verb "*", path]
75
+ wildcard_verb_rule = @indexed_routes[["*", clean_path]]
76
+ return wildcard_verb_rule if wildcard_verb_rule
77
+
78
+ # 3. Path wildcards (e.g. /admin/*)
79
+ @wildcard_routes.each do |w|
80
+ next unless w[:verb] == "*" || w[:verb] == v || (v == "HEAD" && w[:verb] == "GET")
81
+
82
+ pattern = w[:pattern]
83
+ if File.fnmatch(pattern, clean_path) || (pattern.end_with?("/*") && clean_path == pattern.delete_suffix("/*"))
84
+ return w[:rule]
85
+ end
86
+ end
87
+
88
+ # 4. Rails dynamic route URI pattern fallback (e.g. /emails/:id/thread)
89
+ if route_uri_pattern
90
+ clean_pattern = route_uri_pattern.to_s.sub(/\(\.:format\)$/, "")
91
+ rule = @indexed_routes[[v, clean_pattern]] || (v == "HEAD" && @indexed_routes[["GET", clean_pattern]]) || @indexed_routes[["*", clean_pattern]]
92
+ return rule if rule
93
+ end
94
+
95
+ # 5. Regex parameterized fallback (matches paths like /auth/google_oauth2/callback to /auth/:provider/callback)
96
+ regex_rule = match_by_pattern_regex(v, clean_path)
97
+ return regex_rule if regex_rule
98
+
99
+ nil
100
+ end
101
+
102
+ # Helper for static matching and route inspection: finds if a route specification matches
103
+ def match_route_spec(verb, pattern)
104
+ v = (verb || "GET").to_s.upcase
105
+ clean_pattern = pattern.to_s.sub(/\(\.:format\)$/, "")
106
+
107
+ # Exact match
108
+ rule = @indexed_routes[[v, clean_pattern]] || @indexed_routes[["*", clean_pattern]]
109
+ return rule if rule
110
+
111
+ # Wildcard path check
112
+ @wildcard_routes.each do |w|
113
+ next unless w[:verb] == "*" || w[:verb] == v
114
+
115
+ if File.fnmatch(w[:pattern], clean_pattern) || (w[:pattern].end_with?("/*") && clean_pattern.start_with?(w[:pattern].delete_suffix("/*")))
116
+ return w[:rule]
117
+ end
118
+ end
119
+
120
+ # Reverse regex matching: check if clean_pattern matches any indexed route pattern
121
+ @indexed_routes.each do |(rule_verb, rule_pattern), r|
122
+ next unless rule_verb == "*" || rule_verb == v
123
+ next unless rule_pattern.include?(":")
124
+
125
+ regex = pattern_to_regex(rule_pattern)
126
+ return r if regex.match?(clean_pattern)
127
+ end
128
+
129
+ # Forward regex matching: check if any indexed pattern matches clean_pattern (e.g., /auth/:provider/callback vs /auth/google_oauth2)
130
+ if clean_pattern.include?(":")
131
+ spec_regex = pattern_to_regex(clean_pattern)
132
+ @indexed_routes.each do |(rule_verb, rule_pattern), r|
133
+ next unless rule_verb == "*" || rule_verb == v
134
+ return r if spec_regex.match?(rule_pattern)
135
+ end
136
+ end
137
+
138
+ nil
139
+ end
140
+
141
+ def all_rules
142
+ @indexed_routes
143
+ end
144
+
145
+ private
146
+
147
+ def validate_route_entry!(route, idx)
148
+ unless route.is_a?(Hash)
149
+ raise "Authorization route at index #{idx} is not a hash"
150
+ end
151
+
152
+ pattern = route["route_pattern"]
153
+ unless pattern.is_a?(String) && !pattern.strip.empty? && pattern.start_with?("/")
154
+ raise "Authorization route at index #{idx} must have a non-empty string starting with '/' for 'route_pattern'"
155
+ end
156
+
157
+ verbs = extract_verbs(route)
158
+ if verbs.empty?
159
+ raise "Authorization route '#{pattern}' at index #{idx} must explicitly define 'verb' or 'verbs'"
160
+ end
161
+
162
+ verbs.each do |verb|
163
+ unless VALID_VERBS.include?(verb.upcase)
164
+ raise "Authorization route '#{pattern}' key 'verb' has invalid HTTP verb '#{verb}'. Allowed verbs: #{VALID_VERBS.join(', ')}"
165
+ end
166
+ end
167
+
168
+ @role_names.each do |role|
169
+ val = route[role.to_s]
170
+ unless val == true || val == false
171
+ raise "Authorization route '#{pattern}' key '#{role}' must be a boolean (true or false), got #{val.inspect}"
172
+ end
173
+ end
174
+ end
175
+
176
+ def extract_verbs(route)
177
+ if route.key?("verbs") && route["verbs"].is_a?(Array)
178
+ route["verbs"].map { |v| v.to_s.strip.upcase }
179
+ elsif route.key?("verb") && route["verb"].is_a?(String)
180
+ [route["verb"].to_s.strip.upcase]
181
+ else
182
+ []
183
+ end
184
+ end
185
+
186
+ def register_route_entry(route)
187
+ pattern = route["route_pattern"]
188
+ verbs = extract_verbs(route)
189
+
190
+ rule = @role_names.each_with_object({}) do |role, h|
191
+ h[role] = route[role.to_s]
192
+ end.freeze
193
+
194
+ verbs.each do |verb|
195
+ key = [verb, pattern]
196
+ @indexed_routes[key] = rule
197
+
198
+ if pattern.include?("*")
199
+ @wildcard_routes << {
200
+ verb: verb,
201
+ pattern: pattern,
202
+ rule: rule
203
+ }
204
+ end
205
+ end
206
+ end
207
+
208
+ def pattern_to_regex(pattern)
209
+ segments = pattern.split("/", -1)
210
+ regex_segments = segments.map do |seg|
211
+ if seg.start_with?(":")
212
+ "[^/]+"
213
+ elsif seg.start_with?("*")
214
+ ".*"
215
+ else
216
+ Regexp.escape(seg)
217
+ end
218
+ end
219
+ Regexp.new("\\A#{regex_segments.join('/')}\\z")
220
+ end
221
+
222
+ def match_by_pattern_regex(verb, path)
223
+ @indexed_routes.each do |(rule_verb, rule_pattern), rule|
224
+ next unless rule_verb == "*" || rule_verb == verb || (verb == "HEAD" && rule_verb == "GET")
225
+ next unless rule_pattern.include?(":")
226
+
227
+ regex = pattern_to_regex(rule_pattern)
228
+ return rule if regex.match?(path)
229
+ end
230
+ nil
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,72 @@
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_authorize"
8
+
9
+ module MechaAuthorize
10
+ class MatrixTest < ActionDispatch::IntegrationTest
11
+ setup do
12
+ if MechaAuthorize.configuration.test_setup
13
+ instance_exec(&MechaAuthorize.configuration.test_setup)
14
+ end
15
+ end
16
+
17
+ teardown do
18
+ if MechaAuthorize.configuration.test_teardown
19
+ instance_exec(&MechaAuthorize.configuration.test_teardown)
20
+ end
21
+ end
22
+
23
+ test "static route authorization audit passes with zero dead rules and zero engine bypasses" do
24
+ result = MechaAuthorize::Auditor::Static.new.audit
25
+
26
+ assert_empty result.dead_rules, "Found dead/stale rules in mecha_authorize.yml: #{result.dead_rules.inspect}"
27
+ assert_empty result.engine_warnings, "Found engine bypass warnings: #{result.engine_warnings.inspect}"
28
+ assert result.mapped_routes.any?, "Expected routes to be mapped to authorization rules"
29
+ end
30
+
31
+ test "unmapped routes strictly return 404 for all personas" do
32
+ get "/unmapped_nonexistent_test_route", headers: { MechaAuthorize.configuration.probe_header => "1" }
33
+ assert_response :not_found
34
+
35
+ get "/unmapped_nonexistent_test_route"
36
+ assert_response :not_found
37
+ end
38
+
39
+ test "wrong HTTP verb strictly returns 404 closed-by-default" do
40
+ delete "/login", headers: { MechaAuthorize.configuration.probe_header => "1" }
41
+ assert_response :not_found
42
+ end
43
+
44
+ def self.define_route_matrix_tests!
45
+ return if @route_matrix_tests_defined
46
+
47
+ routes = MechaAuthorize::Auditor::Prober.collect_prober_routes
48
+ return if routes.empty?
49
+
50
+ @route_matrix_tests_defined = true
51
+
52
+ routes.each do |route_info|
53
+ verb = route_info[:verb]
54
+ raw_path = route_info[:path]
55
+
56
+ test "behavioral persona matrix probe for #{verb} #{raw_path}" do
57
+ personas_proc = MechaAuthorize.configuration.test_personas
58
+ assert_not_nil personas_proc, "MechaAuthorize.configuration.test_personas must be configured for behavioral matrix testing"
59
+
60
+ prober = MechaAuthorize::Auditor::Prober.new(self)
61
+ personas = instance_exec(&personas_proc)
62
+ result = prober.probe_route(route_info, personas)
63
+
64
+ assert result.success?, "Prober failed for #{verb} #{raw_path}:\n" +
65
+ result.failures.map { |f| " - #{f.route} [#{f.persona}]: #{f.message}" }.join("\n")
66
+ end
67
+ end
68
+ end
69
+
70
+ define_route_matrix_tests!
71
+ end
72
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module MechaAuthorize
6
+ class Railtie < Rails::Railtie
7
+ rake_tasks do
8
+ load File.expand_path("../tasks/mecha_authorize.rake", __dir__)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MechaAuthorize
4
+ VERSION = "0.1.0"
5
+ Version = VERSION
6
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mecha_authorize/version"
4
+ require_relative "mecha_authorize/configuration"
5
+ require_relative "mecha_authorize/matcher"
6
+ require_relative "mecha_authorize/controller"
7
+ require_relative "mecha_authorize/auditor/static"
8
+ require_relative "mecha_authorize/auditor/prober"
9
+ require_relative "mecha_authorize/railtie" if defined?(Rails::Railtie)
10
+
11
+ module MechaAuthorize
12
+ class << self
13
+ def configuration
14
+ @configuration ||= Configuration.new
15
+ end
16
+
17
+ def configure
18
+ yield(configuration) if block_given?
19
+ reload!
20
+ end
21
+
22
+ def reset_configuration!
23
+ @configuration = Configuration.new
24
+ @matcher = nil
25
+ end
26
+
27
+ def matcher
28
+ @matcher ||= Matcher.new(configuration.config_path, configuration.role_names)
29
+ end
30
+
31
+ def reload!
32
+ @matcher = Matcher.new(configuration.config_path, configuration.role_names)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tiny_color"
4
+ require_relative "../mecha_authorize"
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 routes and their MechaAuthorize authorization controls"
17
+ task mecha_authorize: :environment do
18
+ routes = Rails.application.routes.routes.map { |r| ActionDispatch::Routing::RouteWrapper.new(r) }.reject(&:internal?)
19
+
20
+ all_mapped_routes = []
21
+
22
+ routes.each do |r|
23
+ if r.engine?
24
+ engine_routes = r.rack_app.routes
25
+ engine_routes = engine_routes.routes if engine_routes.is_a?(ActionDispatch::Routing::RouteSet)
26
+
27
+ clean_mount = r.path.sub("(.:format)", "").sub("/*path", "").sub("/*filename", "").chomp("/")
28
+
29
+ engine_routes.map { |er| ActionDispatch::Routing::RouteWrapper.new(er) }.reject(&:internal?).each do |er|
30
+ full_path = "#{clean_mount}#{er.path}".sub("(.:format)", "")
31
+ verb = er.verb.to_s.empty? ? "*" : er.verb.to_s.upcase
32
+ all_mapped_routes << { verb: verb, path: full_path }
33
+ end
34
+ else
35
+ verb = r.verb.to_s.empty? ? "*" : r.verb.to_s.upcase
36
+ all_mapped_routes << { verb: verb, path: r.path.sub("(.:format)", "") }
37
+ end
38
+ end
39
+
40
+ matcher = MechaAuthorize.matcher
41
+ colors = MechaAuthorize.configuration.debug_colorization
42
+
43
+ colorize_char = ->(char, perm) {
44
+ return char if char == "." && perm != :none
45
+ color = colors[perm]
46
+ if color && char.respond_to?(color)
47
+ char.send(color)
48
+ else
49
+ char
50
+ end
51
+ }
52
+
53
+ max_verb_len = all_mapped_routes.map { |r| r[:verb].to_s.length }.max || 4
54
+ max_verb_len = [max_verb_len, 4].max
55
+
56
+ max_path_len = all_mapped_routes.map { |r| r[:path].to_s.length }.max || 11
57
+ max_path_len = [max_path_len, 11].max
58
+
59
+ puts sprintf("%-#{max_verb_len}s %-#{max_path_len}s %s", "Verb", "URI Pattern", "Access")
60
+
61
+ all_mapped_routes.each do |r|
62
+ rule = matcher.match_route_spec(r[:verb], r[:path])
63
+
64
+ has_access = rule && (rule[:public] || rule[:admin] || rule[:user_ui] || rule[:user_api])
65
+
66
+ if has_access
67
+ public_char = rule[:public] ? "P" : "."
68
+ admin_char = rule[:admin] ? "A" : "."
69
+ user_ui_char = rule[:user_ui] ? "U" : "."
70
+ user_api_char = rule[:user_api] ? "X" : "."
71
+
72
+ color_to_apply = if rule[:public]
73
+ colors[:public]
74
+ elsif rule[:admin]
75
+ colors[:admin]
76
+ elsif rule[:user_ui]
77
+ colors[:user_ui]
78
+ elsif rule[:user_api]
79
+ colors[:user_api]
80
+ end
81
+
82
+ public_colorized = colorize_char.call(public_char, :public)
83
+ admin_colorized = colorize_char.call(admin_char, :admin)
84
+ user_ui_colorized = colorize_char.call(user_ui_char, :user_ui)
85
+ user_api_colorized = colorize_char.call(user_api_char, :user_api)
86
+ else
87
+ public_char = "."
88
+ admin_char = "."
89
+ user_ui_char = "."
90
+ user_api_char = "."
91
+
92
+ color_to_apply = colors[:none]
93
+
94
+ public_colorized = colorize_char.call(public_char, :none)
95
+ admin_colorized = colorize_char.call(admin_char, :none)
96
+ user_ui_colorized = colorize_char.call(user_ui_char, :none)
97
+ user_api_colorized = colorize_char.call(user_api_char, :none)
98
+ end
99
+
100
+ padded_path = sprintf("%-#{max_path_len}s", r[:path])
101
+ path_colorized = if color_to_apply && padded_path.respond_to?(color_to_apply)
102
+ padded_path.send(color_to_apply)
103
+ else
104
+ padded_path
105
+ end
106
+
107
+ access_string = "#{public_colorized}#{admin_colorized}#{user_ui_colorized}#{user_api_colorized}"
108
+ puts sprintf("%-#{max_verb_len}s %s %s", r[:verb], path_colorized, access_string)
109
+ end
110
+ end
111
+
112
+ namespace :mecha_authorize do
113
+ namespace :verify do
114
+ desc "Statically verify route authorization parity, dead rules, and engine protection"
115
+ task static: :environment do
116
+ puts "Running MechaAuthorize static route audit..."
117
+ auditor = MechaAuthorize::Auditor::Static.new
118
+ result = auditor.audit
119
+
120
+ puts " -> #{result.mapped_routes.size} routes mapped to explicit authorization rules"
121
+
122
+ if result.unmapped_routes.any?
123
+ puts " -> #{result.unmapped_routes.size} unmapped routes (defaulting to closed-by-default 404):"
124
+ result.unmapped_routes.each do |r|
125
+ puts " [404] #{r[:verb]} #{r[:path]}"
126
+ end
127
+ end
128
+
129
+ if result.dead_rules.any?
130
+ puts "\nERROR: Found #{result.dead_rules.size} dead/stale rule(s) in mecha_authorize.yml:".light_red
131
+ result.dead_rules.each do |dr|
132
+ puts " - #{dr[:verb]} #{dr[:pattern]}"
133
+ end
134
+ end
135
+
136
+ if result.engine_warnings.any?
137
+ puts "\nWARNING: Mounted engine bypass warning(s):".light_red
138
+ result.engine_warnings.each do |w|
139
+ puts " - #{w}"
140
+ end
141
+ end
142
+
143
+ if result.success?
144
+ puts "\n✓ Static route authorization audit passed: 0 dead rules, 0 engine bypasses.".green
145
+ else
146
+ abort("\n✗ Static route authorization audit failed. Please prune dead rules or resolve engine bypasses.")
147
+ end
148
+ end
149
+
150
+ desc "Run behavioral persona matrix test suite"
151
+ task behavioral: :environment do
152
+ puts "Running MechaAuthorize behavioral persona matrix test suite..."
153
+ matrix_test_path = File.expand_path("../mecha_authorize/matrix_test.rb", __dir__)
154
+ system({ "MECHA_MATRIX_STANDALONE" => "1" }, "bin/rails", "test", matrix_test_path) || abort("Behavioral verification failed.")
155
+ end
156
+ end
157
+
158
+ desc "Run both static parity and behavioral persona verification"
159
+ task verify: %w[verify:static verify:behavioral]
160
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mecha_authorize
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: tiny_color
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
+ description: MechaAuthorize provides a YAML-driven, closed-by-default route authorization
41
+ system for Rails applications, complete with static route parity auditing and behavioral
42
+ persona matrix probing.
43
+ email:
44
+ - jefflunt@gmail.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - LICENSE
50
+ - README.md
51
+ - Rakefile
52
+ - lib/mecha_authorize.rb
53
+ - lib/mecha_authorize/auditor/prober.rb
54
+ - lib/mecha_authorize/auditor/static.rb
55
+ - lib/mecha_authorize/configuration.rb
56
+ - lib/mecha_authorize/controller.rb
57
+ - lib/mecha_authorize/matcher.rb
58
+ - lib/mecha_authorize/matrix_test.rb
59
+ - lib/mecha_authorize/railtie.rb
60
+ - lib/mecha_authorize/version.rb
61
+ - lib/tasks/mecha_authorize.rake
62
+ homepage: https://github.com/jefflunt/mecha_authorize
63
+ licenses:
64
+ - MIT
65
+ metadata: {}
66
+ rdoc_options: []
67
+ require_paths:
68
+ - lib
69
+ required_ruby_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: 3.2.0
74
+ required_rubygems_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '0'
79
+ requirements: []
80
+ rubygems_version: 4.0.16
81
+ specification_version: 4
82
+ summary: Closed-by-default route authorization and auditing for Rails
83
+ test_files: []