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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +112 -0
  3. data/README.md +191 -17
  4. data/lib/generators/reeve/install/install_generator.rb +46 -0
  5. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +69 -0
  6. data/lib/generators/reeve/install/templates/initializer.rb.tt +65 -0
  7. data/lib/reeve/audit/entry.rb +48 -0
  8. data/lib/reeve/audit/query.rb +84 -0
  9. data/lib/reeve/audit/recorder.rb +163 -0
  10. data/lib/reeve/audit/redactor.rb +67 -0
  11. data/lib/reeve/audit.rb +88 -0
  12. data/lib/reeve/authorization/adapter.rb +71 -0
  13. data/lib/reeve/authorization/adapters/plain.rb +65 -0
  14. data/lib/reeve/authorization/adapters/pundit.rb +84 -0
  15. data/lib/reeve/authorization/authorizer.rb +23 -0
  16. data/lib/reeve/authorization/current.rb +66 -0
  17. data/lib/reeve/authorization/declaration.rb +73 -0
  18. data/lib/reeve/authorization/guard.rb +97 -0
  19. data/lib/reeve/authorization/registry.rb +118 -0
  20. data/lib/reeve/authorization/scoper.rb +399 -0
  21. data/lib/reeve/authorization.rb +76 -0
  22. data/lib/reeve/configuration.rb +158 -0
  23. data/lib/reeve/context.rb +104 -0
  24. data/lib/reeve/decision.rb +111 -0
  25. data/lib/reeve/errors.rb +89 -0
  26. data/lib/reeve/fast_mcp.rb +24 -0
  27. data/lib/reeve/integrations/fast_mcp/context_builder.rb +48 -0
  28. data/lib/reeve/integrations/fast_mcp/tool_extension.rb +55 -0
  29. data/lib/reeve/invocation.rb +260 -0
  30. data/lib/reeve/minitest.rb +19 -0
  31. data/lib/reeve/rspec.rb +18 -0
  32. data/lib/reeve/scope_result.rb +104 -0
  33. data/lib/reeve/testing/assertions.rb +76 -0
  34. data/lib/reeve/testing/checks/audit_coverage.rb +46 -0
  35. data/lib/reeve/testing/checks/base.rb +155 -0
  36. data/lib/reeve/testing/checks/contract_version.rb +73 -0
  37. data/lib/reeve/testing/checks/cross_principal_leak.rb +132 -0
  38. data/lib/reeve/testing/checks/guard_declared.rb +33 -0
  39. data/lib/reeve/testing/checks/principal_required.rb +60 -0
  40. data/lib/reeve/testing/checks/redaction_holds.rb +142 -0
  41. data/lib/reeve/testing/checks/rule_present.rb +55 -0
  42. data/lib/reeve/testing/checks.rb +95 -0
  43. data/lib/reeve/testing/compliance_assertions.rb +34 -0
  44. data/lib/reeve/testing/compliance_suite.rb +23 -0
  45. data/lib/reeve/testing/ledger.rb +82 -0
  46. data/lib/reeve/testing/matchers/audit_every_call.rb +39 -0
  47. data/lib/reeve/testing/matchers/base.rb +84 -0
  48. data/lib/reeve/testing/matchers/deny_access_for.rb +38 -0
  49. data/lib/reeve/testing/matchers/pass_reeve_check.rb +32 -0
  50. data/lib/reeve/testing/matchers.rb +33 -0
  51. data/lib/reeve/testing/report.rb +58 -0
  52. data/lib/reeve/testing/result.rb +49 -0
  53. data/lib/reeve/testing.rb +60 -0
  54. data/lib/reeve/version.rb +1 -1
  55. data/lib/reeve.rb +9 -3
  56. 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.
@@ -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