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 +7 -0
- data/LICENSE +19 -0
- data/README.md +162 -0
- data/Rakefile +11 -0
- data/lib/mecha_authorize/auditor/prober.rb +183 -0
- data/lib/mecha_authorize/auditor/static.rb +152 -0
- data/lib/mecha_authorize/configuration.rb +50 -0
- data/lib/mecha_authorize/controller.rb +169 -0
- data/lib/mecha_authorize/matcher.rb +233 -0
- data/lib/mecha_authorize/matrix_test.rb +72 -0
- data/lib/mecha_authorize/railtie.rb +11 -0
- data/lib/mecha_authorize/version.rb +6 -0
- data/lib/mecha_authorize.rb +35 -0
- data/lib/tasks/mecha_authorize.rake +160 -0
- metadata +83 -0
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,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,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: []
|