reeve 0.0.1 → 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 +4 -4
- data/CHANGELOG.md +112 -0
- data/README.md +191 -17
- data/lib/generators/reeve/install/install_generator.rb +46 -0
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +69 -0
- data/lib/generators/reeve/install/templates/initializer.rb.tt +65 -0
- data/lib/reeve/audit/entry.rb +48 -0
- data/lib/reeve/audit/query.rb +84 -0
- data/lib/reeve/audit/recorder.rb +163 -0
- data/lib/reeve/audit/redactor.rb +67 -0
- data/lib/reeve/audit.rb +88 -0
- data/lib/reeve/authorization/adapter.rb +71 -0
- data/lib/reeve/authorization/adapters/plain.rb +65 -0
- data/lib/reeve/authorization/adapters/pundit.rb +84 -0
- data/lib/reeve/authorization/authorizer.rb +23 -0
- data/lib/reeve/authorization/current.rb +66 -0
- data/lib/reeve/authorization/declaration.rb +73 -0
- data/lib/reeve/authorization/guard.rb +97 -0
- data/lib/reeve/authorization/registry.rb +118 -0
- data/lib/reeve/authorization/scoper.rb +399 -0
- data/lib/reeve/authorization.rb +76 -0
- data/lib/reeve/configuration.rb +158 -0
- data/lib/reeve/context.rb +104 -0
- data/lib/reeve/decision.rb +111 -0
- data/lib/reeve/errors.rb +89 -0
- data/lib/reeve/fast_mcp.rb +24 -0
- data/lib/reeve/integrations/fast_mcp/context_builder.rb +48 -0
- data/lib/reeve/integrations/fast_mcp/tool_extension.rb +55 -0
- data/lib/reeve/invocation.rb +260 -0
- data/lib/reeve/minitest.rb +19 -0
- data/lib/reeve/rspec.rb +18 -0
- data/lib/reeve/scope_result.rb +104 -0
- data/lib/reeve/testing/assertions.rb +76 -0
- data/lib/reeve/testing/checks/audit_coverage.rb +46 -0
- data/lib/reeve/testing/checks/base.rb +155 -0
- data/lib/reeve/testing/checks/contract_version.rb +73 -0
- data/lib/reeve/testing/checks/cross_principal_leak.rb +132 -0
- data/lib/reeve/testing/checks/guard_declared.rb +33 -0
- data/lib/reeve/testing/checks/principal_required.rb +60 -0
- data/lib/reeve/testing/checks/redaction_holds.rb +142 -0
- data/lib/reeve/testing/checks/rule_present.rb +55 -0
- data/lib/reeve/testing/checks.rb +95 -0
- data/lib/reeve/testing/compliance_assertions.rb +34 -0
- data/lib/reeve/testing/compliance_suite.rb +23 -0
- data/lib/reeve/testing/ledger.rb +82 -0
- data/lib/reeve/testing/matchers/audit_every_call.rb +39 -0
- data/lib/reeve/testing/matchers/base.rb +84 -0
- data/lib/reeve/testing/matchers/deny_access_for.rb +38 -0
- data/lib/reeve/testing/matchers/pass_reeve_check.rb +32 -0
- data/lib/reeve/testing/matchers.rb +33 -0
- data/lib/reeve/testing/report.rb +58 -0
- data/lib/reeve/testing/result.rb +49 -0
- data/lib/reeve/testing.rb +60 -0
- data/lib/reeve/version.rb +1 -1
- data/lib/reeve.rb +9 -3
- metadata +52 -1
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
# The single funnel every guarded tool call passes through:
|
|
5
|
+
#
|
|
6
|
+
# resolve principal → look up guard → authorize → execute → scope → record → return
|
|
7
|
+
#
|
|
8
|
+
# One envelope rather than middleware or a patched dispatcher, so there is exactly one
|
|
9
|
+
# place where "did this get authorized and recorded?" can be answered — and exactly one
|
|
10
|
+
# place to audit when the answer must be yes.
|
|
11
|
+
#
|
|
12
|
+
# Collaborators are injected and default to null objects that fail closed: a kernel with
|
|
13
|
+
# no authorization module wired in denies everything, and one with no ledger refuses the
|
|
14
|
+
# call rather than performing it silently.
|
|
15
|
+
class Invocation
|
|
16
|
+
# A tool with no guard: there is nothing to consult, so there is nothing to allow.
|
|
17
|
+
class NullRegistry
|
|
18
|
+
def guard_for(_tool_name)
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# No policy adapter wired in: nothing can be evaluated, so nothing is permitted.
|
|
24
|
+
class NullAuthorizer
|
|
25
|
+
def authorize(context:, guard:)
|
|
26
|
+
Decision.deny(rule: Decision::POLICY_ERROR, detail: "no policy adapter is configured")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# No scoper wired in: a result that cannot be narrowed cannot be returned.
|
|
31
|
+
class NullScoper
|
|
32
|
+
def scope(context:, guard:, result:)
|
|
33
|
+
ScopeResult.deny(
|
|
34
|
+
rule: Decision::UNSCOPED_DERIVED_RESULT,
|
|
35
|
+
detail: "no scoper is configured, so the result could not be narrowed"
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Constitution II: a failure to record is a failure of the call. With no ledger
|
|
41
|
+
# configured there is nothing to fail into, so this refuses rather than no-ops.
|
|
42
|
+
class NullRecorder
|
|
43
|
+
def record(_attributes)
|
|
44
|
+
raise Error, "no audit recorder is configured (run `rails g reeve:install`)"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.call(context, **collaborators, &tool)
|
|
49
|
+
new(context, **collaborators).call(&tool)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def initialize(context, registry: nil, authorizer: nil, scoper: nil, recorder: nil,
|
|
53
|
+
config: nil)
|
|
54
|
+
@context = context
|
|
55
|
+
@config = config || Reeve.config
|
|
56
|
+
@registry = registry || NullRegistry.new
|
|
57
|
+
@authorizer = authorizer || NullAuthorizer.new
|
|
58
|
+
@scoper = scoper || NullScoper.new
|
|
59
|
+
@recorder = recorder || @config.audit_recorder || NullRecorder.new
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def call(&tool)
|
|
63
|
+
started = monotonic_now
|
|
64
|
+
@guard_label = "policy"
|
|
65
|
+
@scope_result = nil
|
|
66
|
+
|
|
67
|
+
@decision = authorize_and_run(&tool)
|
|
68
|
+
raise denial_error(@decision) if @decision.denied?
|
|
69
|
+
|
|
70
|
+
@scope_result.records
|
|
71
|
+
ensure
|
|
72
|
+
# Whatever is already on its way out of this method — a tool error or a denial.
|
|
73
|
+
@in_flight_error = $ERROR_INFO
|
|
74
|
+
@duration_ms = ((monotonic_now - started) * 1000).round
|
|
75
|
+
write_entry
|
|
76
|
+
context.clear_principal!
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
attr_reader :context, :config, :registry, :authorizer, :scoper, :recorder
|
|
82
|
+
|
|
83
|
+
def authorize_and_run(&tool)
|
|
84
|
+
principal = resolve_principal
|
|
85
|
+
if principal.nil?
|
|
86
|
+
return Decision.deny(rule: Decision::NO_PRINCIPAL,
|
|
87
|
+
detail: principal_denial_detail)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
context.principal = principal
|
|
91
|
+
|
|
92
|
+
guard = look_up_guard
|
|
93
|
+
return guard if guard.is_a?(Decision) # registry blew up
|
|
94
|
+
|
|
95
|
+
if guard.nil?
|
|
96
|
+
if deny_unguarded?
|
|
97
|
+
return Decision.deny(rule: Decision::NO_GUARD_DECLARED,
|
|
98
|
+
detail: unguarded_detail)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
return run_unguarded(&tool)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
decision = authorize(guard)
|
|
105
|
+
return decision if decision.denied?
|
|
106
|
+
|
|
107
|
+
run_guarded(guard, decision, &tool)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def resolve_principal
|
|
111
|
+
resolver = config.principal_resolver
|
|
112
|
+
return nil if resolver.nil?
|
|
113
|
+
|
|
114
|
+
resolver.call(context)
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
# A resolver that raises is a resolver that did not identify anyone (FR-001).
|
|
117
|
+
@principal_error = e
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def principal_denial_detail
|
|
122
|
+
return "principal_resolver raised #{@principal_error.class}" if @principal_error
|
|
123
|
+
return "no principal_resolver is configured" if config.principal_resolver.nil?
|
|
124
|
+
|
|
125
|
+
"principal_resolver returned nil"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def look_up_guard
|
|
129
|
+
registry.guard_for(context.tool_name)
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
Decision.deny(rule: Decision::POLICY_ERROR,
|
|
132
|
+
detail: "guard lookup raised #{e.class}: #{e.message}")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def deny_unguarded?
|
|
136
|
+
config.unguarded_tools != :allow_with_warning
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def unguarded_detail
|
|
140
|
+
"#{context.tool_name} has no guard_with declaration"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def authorize(guard)
|
|
144
|
+
decision = authorizer.authorize(context: context, guard: guard)
|
|
145
|
+
return decision if decision.is_a?(Decision)
|
|
146
|
+
|
|
147
|
+
Decision.deny(
|
|
148
|
+
rule: Decision::POLICY_ERROR,
|
|
149
|
+
detail: "authorizer returned #{decision.class}, expected a Reeve::Decision"
|
|
150
|
+
)
|
|
151
|
+
rescue StandardError => e
|
|
152
|
+
Decision.deny(rule: Decision::POLICY_ERROR, detail: "policy raised #{e.class}: #{e.message}")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def run_guarded(guard, decision, &tool)
|
|
156
|
+
result = execute(&tool)
|
|
157
|
+
|
|
158
|
+
@scope_result = narrow(guard, result)
|
|
159
|
+
@scope_result.denied? ? @scope_result.decision : decision
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The degraded mode a host opts into while retrofitting guards onto existing tools
|
|
163
|
+
# (FR-023). The call is unscoped — that is the whole point of the warning.
|
|
164
|
+
def run_unguarded(&tool)
|
|
165
|
+
warn_about_unguarded_tool
|
|
166
|
+
@guard_label = "none"
|
|
167
|
+
result = execute(&tool)
|
|
168
|
+
@scope_result = ScopeResult.unscoped(records: result, rule: Decision::UNGUARDED_TOOL)
|
|
169
|
+
Decision.allow(rule: Decision::UNGUARDED_TOOL, detail: unguarded_detail)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def execute(&tool)
|
|
173
|
+
tool.call
|
|
174
|
+
rescue StandardError => e
|
|
175
|
+
# The tool's own failure propagates untouched, but not before the ensure block
|
|
176
|
+
# records it: the invocations most worth having a trace of are the ones that broke.
|
|
177
|
+
@decision = Decision.deny(rule: Decision::TOOL_ERROR, detail: "#{e.class}: #{e.message}")
|
|
178
|
+
@scope_result = ScopeResult.deny(rule: Decision::TOOL_ERROR)
|
|
179
|
+
raise
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def narrow(guard, result)
|
|
183
|
+
scoped = scoper.scope(context: context, guard: guard, result: result)
|
|
184
|
+
return scoped if scoped.is_a?(ScopeResult)
|
|
185
|
+
|
|
186
|
+
ScopeResult.deny(
|
|
187
|
+
rule: Decision::POLICY_ERROR,
|
|
188
|
+
detail: "scoper returned #{scoped.class}, expected a Reeve::ScopeResult"
|
|
189
|
+
)
|
|
190
|
+
rescue StandardError => e
|
|
191
|
+
ScopeResult.deny(rule: Decision::POLICY_ERROR,
|
|
192
|
+
detail: "scoping raised #{e.class}: #{e.message}")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def denial_error(decision)
|
|
196
|
+
if decision.rule == Decision::OUT_OF_SCOPE_RECORD
|
|
197
|
+
DeniedError.out_of_scope(tool_name: context.tool_name, principal_id: context.principal_id)
|
|
198
|
+
else
|
|
199
|
+
DeniedError.from(decision, tool_name: context.tool_name, principal_id: context.principal_id)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def write_entry
|
|
204
|
+
return if @entry_written
|
|
205
|
+
|
|
206
|
+
@entry_written = true
|
|
207
|
+
recorder.record(entry_attributes)
|
|
208
|
+
rescue StandardError => e
|
|
209
|
+
handle_write_failure(e)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def entry_attributes
|
|
213
|
+
decision = @decision || Decision.deny(rule: Decision::POLICY_ERROR,
|
|
214
|
+
detail: "envelope aborted")
|
|
215
|
+
scope = @scope_result || ScopeResult.deny(rule: decision.rule, detail: decision.detail)
|
|
216
|
+
|
|
217
|
+
context.to_h.merge(scope.to_h).merge(
|
|
218
|
+
outcome: decision.outcome.to_s,
|
|
219
|
+
rule: decision.rule,
|
|
220
|
+
detail: decision.detail,
|
|
221
|
+
guard: @guard_label,
|
|
222
|
+
duration_ms: @duration_ms
|
|
223
|
+
)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Constitution II: a call that cannot be recorded is a failed call, and that holds
|
|
227
|
+
# whether or not the call was failing already.
|
|
228
|
+
#
|
|
229
|
+
# This used to return quietly whenever another exception was in flight, on the
|
|
230
|
+
# reasoning that the developer needs the original error more than the audit error.
|
|
231
|
+
# The reasoning was wrong: denials and tool errors are *most* of what a ledger is
|
|
232
|
+
# consulted about, so the effect was that a broken ledger stayed invisible for exactly
|
|
233
|
+
# the invocations a compliance review looks for, in the default mode, with no
|
|
234
|
+
# exception raised. The in-flight error is now carried on the audit error instead of
|
|
235
|
+
# being traded against it.
|
|
236
|
+
def handle_write_failure(error)
|
|
237
|
+
if config.audit_failure_mode == :warn
|
|
238
|
+
log("reeve could not record invocation #{context.invocation_id}: #{error.message}")
|
|
239
|
+
return
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
raise AuditWriteError.new(
|
|
243
|
+
invocation_id: context.invocation_id, cause: error, during: @in_flight_error
|
|
244
|
+
)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def warn_about_unguarded_tool
|
|
248
|
+
log("reeve: #{context.tool_name} has no guard_with declaration and ran unguarded " \
|
|
249
|
+
"(unguarded_tools is :allow_with_warning)")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def log(message)
|
|
253
|
+
config.logger&.warn(message)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def monotonic_now
|
|
257
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "testing"
|
|
4
|
+
require_relative "testing/assertions"
|
|
5
|
+
require_relative "testing/compliance_assertions"
|
|
6
|
+
|
|
7
|
+
# The Minitest front-end's entry point.
|
|
8
|
+
#
|
|
9
|
+
# # test/test_helper.rb
|
|
10
|
+
# require "reeve/minitest"
|
|
11
|
+
# Reeve::Testing.compliance_principals = -> { [users(:alice), users(:bob)] }
|
|
12
|
+
#
|
|
13
|
+
# class ComplianceTest < ActiveSupport::TestCase
|
|
14
|
+
# include Reeve::Testing::ComplianceAssertions
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# Nothing here requires Minitest. The assertions call the `assert` their including test
|
|
18
|
+
# case already provides, so a stock `rails new` application proves every guarantee without
|
|
19
|
+
# adding a test framework — and Minitest stays a development-only dependency of this gem.
|
data/lib/reeve/rspec.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "testing"
|
|
4
|
+
require_relative "testing/matchers"
|
|
5
|
+
require_relative "testing/compliance_suite"
|
|
6
|
+
|
|
7
|
+
# The RSpec front-end's entry point.
|
|
8
|
+
#
|
|
9
|
+
# # spec/spec_helper.rb
|
|
10
|
+
# require "reeve/rspec"
|
|
11
|
+
# RSpec.configure { |config| config.include Reeve::Testing::Matchers }
|
|
12
|
+
#
|
|
13
|
+
# RSpec is a development dependency of this gem and never a runtime one: requiring *this*
|
|
14
|
+
# file is what pulls it in, and nothing under lib/reeve/testing/checks does.
|
|
15
|
+
#
|
|
16
|
+
# The include is left to the host rather than done here. A gem that silently mixes methods
|
|
17
|
+
# into every example group in an application is a gem you cannot reason about, and the one
|
|
18
|
+
# line above is not a burden worth that.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
# What the scoper hands back to the envelope: the decision, the value the caller may
|
|
5
|
+
# actually receive, and the identifiers the ledger records.
|
|
6
|
+
#
|
|
7
|
+
# The envelope never reads records out of the tool's own return value — only out of
|
|
8
|
+
# this object — which is how invariant 3 ("no path returns records without scoping")
|
|
9
|
+
# holds by construction rather than by review.
|
|
10
|
+
class ScopeResult
|
|
11
|
+
SCOPED = "scoped"
|
|
12
|
+
|
|
13
|
+
attr_reader :decision, :records, :record_type, :record_ids, :record_count
|
|
14
|
+
|
|
15
|
+
def self.allow(records:, record_type: nil, record_ids: nil, record_count: nil,
|
|
16
|
+
truncated: false, derived: false, rule: SCOPED)
|
|
17
|
+
new(
|
|
18
|
+
decision: Decision.allow(rule: rule),
|
|
19
|
+
records: records,
|
|
20
|
+
record_type: record_type,
|
|
21
|
+
record_ids: record_ids,
|
|
22
|
+
record_count: record_count,
|
|
23
|
+
truncated: truncated,
|
|
24
|
+
derived: derived
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.deny(rule:, detail: nil)
|
|
29
|
+
new(decision: Decision.deny(rule: rule, detail: detail), records: nil)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# For a result nothing narrowed — the `:allow_with_warning` path. It still has to say
|
|
33
|
+
# what came back: an entry reading `record_count: 0` for a call that returned the whole
|
|
34
|
+
# table is not merely incomplete but false, and this is the one path on which
|
|
35
|
+
# everything came back.
|
|
36
|
+
def self.unscoped(records:, rule:)
|
|
37
|
+
identifiers = identifiers_in(records)
|
|
38
|
+
limit = Reeve.config.max_recorded_ids
|
|
39
|
+
|
|
40
|
+
new(
|
|
41
|
+
decision: Decision.allow(rule: rule),
|
|
42
|
+
records: records,
|
|
43
|
+
record_type: type_of(records),
|
|
44
|
+
record_ids: identifiers.first(limit),
|
|
45
|
+
record_count: identifiers.size,
|
|
46
|
+
truncated: identifiers.size > limit
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.identifiers_in(records)
|
|
51
|
+
Array(records).filter_map { |record| record.id.to_s if record.respond_to?(:id) }
|
|
52
|
+
rescue StandardError
|
|
53
|
+
[]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.type_of(records)
|
|
57
|
+
first = Array(records).first
|
|
58
|
+
first.respond_to?(:id) ? first.class.name : nil
|
|
59
|
+
rescue StandardError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def initialize(decision:, records: nil, record_type: nil, record_ids: nil,
|
|
64
|
+
record_count: nil, truncated: false, derived: false)
|
|
65
|
+
@decision = decision
|
|
66
|
+
@derived = derived ? true : false
|
|
67
|
+
@records = records
|
|
68
|
+
@record_type = @derived ? nil : record_type
|
|
69
|
+
@record_ids = (@derived ? [] : Array(record_ids)).map(&:to_s).freeze
|
|
70
|
+
@record_count = record_count || @record_ids.size
|
|
71
|
+
@truncated = truncated ? true : false
|
|
72
|
+
freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def allowed?
|
|
76
|
+
decision.allowed?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def denied?
|
|
80
|
+
decision.denied?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def derived?
|
|
84
|
+
@derived
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def truncated?
|
|
88
|
+
@truncated
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The ledger-facing half of the entry; Context#to_h supplies the other half.
|
|
92
|
+
def to_h
|
|
93
|
+
{
|
|
94
|
+
outcome: decision.outcome.to_s,
|
|
95
|
+
rule: decision.rule,
|
|
96
|
+
record_type: record_type,
|
|
97
|
+
record_ids: record_ids,
|
|
98
|
+
record_count: record_count,
|
|
99
|
+
truncated: truncated?,
|
|
100
|
+
derived: derived?
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Testing
|
|
5
|
+
# The Minitest front-end.
|
|
6
|
+
#
|
|
7
|
+
# require "reeve/minitest"
|
|
8
|
+
#
|
|
9
|
+
# class InvoiceSearchToolTest < ActiveSupport::TestCase
|
|
10
|
+
# include Reeve::Testing::Assertions
|
|
11
|
+
#
|
|
12
|
+
# test "does not leak across principals" do
|
|
13
|
+
# assert_denies_access_for InvoiceSearchTool, stranger, query: "AC"
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# test "is audited" do
|
|
17
|
+
# assert_audits_every_call InvoiceSearchTool
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# Note what this module does *not* reference: Minitest. It calls the +assert+ its
|
|
22
|
+
# including class already provides, which is why requiring it costs a stock
|
|
23
|
+
# `rails new` application nothing and adds no dependency to this gem (FR-026).
|
|
24
|
+
#
|
|
25
|
+
# Every message passed to +assert+ is the check's own. Nothing here writes a sentence.
|
|
26
|
+
module Assertions
|
|
27
|
+
# FR-016.
|
|
28
|
+
def assert_denies_access_for(tool, principal, invoke: nil, ledger: nil, **arguments)
|
|
29
|
+
assert_reeve_check(
|
|
30
|
+
Checks::CrossPrincipalLeak.new(
|
|
31
|
+
tool: tool, principals: [principal], expect: :nothing,
|
|
32
|
+
arguments: arguments, invoke: invoke, ledger: ledger
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# FR-016, the compliance form: two principals with disjoint records.
|
|
38
|
+
def assert_no_cross_principal_leak(tool, principals: nil, invoke: nil, ledger: nil,
|
|
39
|
+
**arguments)
|
|
40
|
+
assert_reeve_check(
|
|
41
|
+
Checks::CrossPrincipalLeak.new(
|
|
42
|
+
tool: tool, principals: principals || Testing.compliance_principals,
|
|
43
|
+
arguments: arguments, invoke: invoke, ledger: ledger
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# FR-017. Pass `invoke:` to point the assertion at the host's own call site, which
|
|
49
|
+
# is where an audit bypass actually lives.
|
|
50
|
+
def assert_audits_every_call(tool, principal: nil, invoke: nil, ledger: nil, **arguments)
|
|
51
|
+
assert_reeve_check(
|
|
52
|
+
Checks::AuditCoverage.new(
|
|
53
|
+
tool: tool, principal: principal || Testing.compliance_principals.first,
|
|
54
|
+
arguments: arguments, invoke: invoke, ledger: ledger
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# SC-009's escape hatch: any of the seven, straight from Minitest.
|
|
60
|
+
def assert_reeve_check(check)
|
|
61
|
+
result = check.call
|
|
62
|
+
assert result.passed?, result.message
|
|
63
|
+
result
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A whole Report at once, for a host that would rather have one test than seven.
|
|
67
|
+
def assert_reeve_compliance(principals: nil, **options)
|
|
68
|
+
report = Checks.run_all(
|
|
69
|
+
principals: principals || Testing.compliance_principals, **options
|
|
70
|
+
)
|
|
71
|
+
assert report.passed?, report.to_s
|
|
72
|
+
report
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Testing
|
|
5
|
+
module Checks
|
|
6
|
+
# FR-017, FR-008: exactly one ledger entry per invocation.
|
|
7
|
+
#
|
|
8
|
+
# The failure this is built to catch is not a broken tool but a caller that reaches
|
|
9
|
+
# past the envelope — `MyTool.new.call(...)` somewhere in a controller. So the check
|
|
10
|
+
# takes the invoker as a collaborator, defaulting to +Reeve.invoke+, and counts the
|
|
11
|
+
# rows that appeared around it. A tool invoked outside the envelope produces none.
|
|
12
|
+
#
|
|
13
|
+
# Reeve::Checks::AuditCoverage.new(tool: InvoiceExportTool, principal: alice).call
|
|
14
|
+
class AuditCoverage < Base
|
|
15
|
+
def initialize(tool:, principal:, arguments: {}, invoke: nil, ledger: nil)
|
|
16
|
+
super(tool: tool, arguments: arguments, invoke: invoke, ledger: ledger)
|
|
17
|
+
@principal = principal
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
return ledger_unavailable("audit coverage") unless ledger.available?
|
|
22
|
+
|
|
23
|
+
made = attempt(principal: @principal)
|
|
24
|
+
count = made.rows.size
|
|
25
|
+
return held(count) if count == 1
|
|
26
|
+
|
|
27
|
+
failed(message_for(count), entries: count, error: made.error&.class&.name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def held(count)
|
|
33
|
+
passed("#{tool_label} produced exactly one audit entry for 1 invocation",
|
|
34
|
+
entries: count)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def message_for(count)
|
|
38
|
+
entries = "#{count} audit #{count == 1 ? 'entry' : 'entries'}"
|
|
39
|
+
base = "expected every call to be audited, but #{tool_label} produced " \
|
|
40
|
+
"#{entries} for 1 invocation"
|
|
41
|
+
count.zero? ? "#{base} — it is invoked outside Reeve.invoke" : base
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Testing
|
|
5
|
+
module Checks
|
|
6
|
+
# What every check shares: a tool, a way to invoke it, a window onto the ledger, and
|
|
7
|
+
# the vocabulary its failure messages are written in.
|
|
8
|
+
#
|
|
9
|
+
# Plain Ruby, deliberately. Nothing under checks/ may reference RSpec or Minitest,
|
|
10
|
+
# because the whole point of this layer is that a rake task, a deploy gate or a
|
|
11
|
+
# boot-time assertion can run it with neither framework installed (FR-026).
|
|
12
|
+
class Base
|
|
13
|
+
# One attempt to call the tool: what came back, or what stopped it, plus the
|
|
14
|
+
# ledger rows the attempt produced.
|
|
15
|
+
Attempt = Struct.new(:principal, :records, :denial, :error, :rows,
|
|
16
|
+
keyword_init: true) do
|
|
17
|
+
def denied?
|
|
18
|
+
!denial.nil?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def failed?
|
|
22
|
+
!error.nil?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def entry
|
|
26
|
+
rows.last
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.check_name
|
|
31
|
+
name.to_s.split("::").last
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(tool: nil, arguments: {}, invoke: nil, ledger: nil)
|
|
35
|
+
@tool = tool
|
|
36
|
+
@arguments = arguments
|
|
37
|
+
@invoke = invoke || Reeve.method(:invoke)
|
|
38
|
+
@ledger = ledger || Ledger.default
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call
|
|
42
|
+
raise NotImplementedError, "#{self.class} must implement #call"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def check_name
|
|
46
|
+
self.class.check_name
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
attr_reader :tool, :arguments, :invoke, :ledger
|
|
52
|
+
|
|
53
|
+
# Runs the tool once and reports rather than raises: a check never blows up on the
|
|
54
|
+
# violation it exists to find.
|
|
55
|
+
def attempt(principal:, arguments: self.arguments)
|
|
56
|
+
marker = ledger.marker
|
|
57
|
+
records = invoke.call(tool: tool, arguments: arguments, principal: principal)
|
|
58
|
+
build_attempt(principal, marker, records: records)
|
|
59
|
+
rescue Reeve::DeniedError => e
|
|
60
|
+
build_attempt(principal, marker, denial: e)
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
build_attempt(principal, marker, error: e)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_attempt(principal, marker, records: nil, denial: nil, error: nil)
|
|
66
|
+
Attempt.new(
|
|
67
|
+
principal: principal, records: records, denial: denial, error: error,
|
|
68
|
+
rows: ledger.entries_after(marker)
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def passed(message, details = {})
|
|
73
|
+
Result.passed(check: check_name, message: message, details: base_details.merge(details))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def failed(message, details = {})
|
|
77
|
+
Result.failed(check: check_name, message: message, details: base_details.merge(details))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def base_details
|
|
81
|
+
{ tool: tool_label }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def ledger_unavailable(what)
|
|
85
|
+
failed("#{what} cannot be verified for #{tool_label}: #{ledger.unavailable_reason}",
|
|
86
|
+
ledger: :unavailable)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The name a developer will recognise in a failure: the constant, not the
|
|
90
|
+
# snake_cased protocol name the ledger stores.
|
|
91
|
+
def tool_label
|
|
92
|
+
return "the ledger" if tool.nil?
|
|
93
|
+
|
|
94
|
+
tool.respond_to?(:name) && tool.name ? tool.name : tool.to_s
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def declaration
|
|
98
|
+
return nil if tool.nil?
|
|
99
|
+
|
|
100
|
+
Reeve.registry.for_class(tool)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def guard_label
|
|
104
|
+
guard = declaration
|
|
105
|
+
guard ? guard.policy_name : "none"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def principal_label(principal)
|
|
109
|
+
return "no principal" if principal.nil?
|
|
110
|
+
|
|
111
|
+
identifier = principal.respond_to?(:id) ? principal.id : principal
|
|
112
|
+
"#{principal.class.name}##{identifier}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# [["Invoice", "7"], ...] — the type and identity of everything the tool returned
|
|
116
|
+
# that has an identity at all. A derived value (a count, a summary) has none, and
|
|
117
|
+
# is reported as such rather than silently compared.
|
|
118
|
+
def identifiers(records)
|
|
119
|
+
collection(records).filter_map do |record|
|
|
120
|
+
next unless identity?(record)
|
|
121
|
+
|
|
122
|
+
[record.class.name, record.id.to_s]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def collection(records)
|
|
127
|
+
return [] if records.nil?
|
|
128
|
+
return records.to_a if records.respond_to?(:to_a) && !records.is_a?(Hash)
|
|
129
|
+
|
|
130
|
+
[records]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def identity?(record)
|
|
134
|
+
record.respond_to?(:id) && record.class.respond_to?(:name) && !record.class.name.nil?
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def format_identifiers(pairs)
|
|
138
|
+
pairs.map { |type, id| "#{type}##{id}" }.join(", ")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# "(guard: InvoicePolicy, decision: allow via InvoicePolicy#index)" — the two facts
|
|
142
|
+
# a developer needs next after learning that a tool leaked.
|
|
143
|
+
def provenance(entry)
|
|
144
|
+
parts = ["guard: #{guard_label}"]
|
|
145
|
+
parts << "decision: #{entry.outcome} via #{entry.rule}" if entry
|
|
146
|
+
"(#{parts.join(', ')})"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def pluralize(count, noun)
|
|
150
|
+
count == 1 ? "1 #{noun}" : "#{count} #{noun}s"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|