reeve 0.0.1 → 0.2.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 +191 -0
  3. data/README.md +280 -17
  4. data/lib/generators/reeve/install/install_generator.rb +46 -0
  5. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +77 -0
  6. data/lib/generators/reeve/install/templates/initializer.rb.tt +65 -0
  7. data/lib/reeve/audit/entry.rb +54 -0
  8. data/lib/reeve/audit/query.rb +84 -0
  9. data/lib/reeve/audit/recorder.rb +181 -0
  10. data/lib/reeve/audit/redactor.rb +67 -0
  11. data/lib/reeve/audit.rb +97 -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 +97 -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 +111 -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 +97 -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 +10 -3
  56. metadata +53 -2
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Testing
5
+ module Checks
6
+ # FR-001: with no resolvable principal, the call is denied before any policy is
7
+ # consulted.
8
+ #
9
+ # The evidence is the rule. `no_principal` is only reachable on the path that denies
10
+ # *ahead* of authorization; a denial carrying any other rule means the policy was
11
+ # asked a question about nobody, and got an answer.
12
+ #
13
+ # Reeve::Checks::PrincipalRequired.new(tool: InvoiceSearchTool).call
14
+ class PrincipalRequired < Base
15
+ def call
16
+ made = attempt(principal: nil)
17
+ return held(made) if made.denial&.rule == Decision::NO_PRINCIPAL
18
+ return wrong_rule(made) if made.denied?
19
+ return raised(made) if made.failed?
20
+
21
+ allowed(made)
22
+ end
23
+
24
+ private
25
+
26
+ def held(made)
27
+ passed("#{tool_label} denies with no_principal when no principal resolves",
28
+ rule: made.denial.rule)
29
+ end
30
+
31
+ def wrong_rule(made)
32
+ failed(
33
+ "expected #{tool_label} to deny with no_principal when no principal resolves, " \
34
+ "but it denied with #{made.denial.rule} — the policy was consulted before the " \
35
+ "principal was established",
36
+ rule: made.denial.rule
37
+ )
38
+ end
39
+
40
+ def raised(made)
41
+ failed(
42
+ "expected #{tool_label} to deny with no_principal when no principal resolves, " \
43
+ "but it raised #{made.error.class}: #{made.error.message}",
44
+ rule: nil, error: made.error.class.name
45
+ )
46
+ end
47
+
48
+ def allowed(made)
49
+ returned = identifiers(made.records)
50
+ failed(
51
+ "expected #{tool_label} to deny when no principal resolves, but it allowed the " \
52
+ "call and returned #{pluralize(returned.size, 'record')}: " \
53
+ "#{format_identifiers(returned)}",
54
+ rule: nil, returned: returned
55
+ )
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Reeve
6
+ module Testing
7
+ module Checks
8
+ # FR-011: a value declared sensitive appears in no ledger entry.
9
+ #
10
+ # Proved rather than inspected. The check calls the tool with a unique sentinel in
11
+ # each declared-sensitive argument and then looks for that sentinel in every column
12
+ # of the row that was written — including nested structures, since the redactor
13
+ # recurses and so must the check.
14
+ #
15
+ # It also catches the quieter bug: `redact :ssn` on a tool whose argument is
16
+ # actually named `customer_ssn`. That declaration compiles, registers, and redacts
17
+ # nothing at all, and no amount of looking at written rows would reveal it.
18
+ #
19
+ # Reeve::Checks::RedactionHolds.new(tool: InvoiceSearchTool, principal: alice).call
20
+ class RedactionHolds < Base
21
+ FILLER = "reeve-check"
22
+
23
+ def initialize(tool:, principal:, arguments: {}, invoke: nil, ledger: nil)
24
+ super(tool: tool, arguments: arguments, invoke: invoke, ledger: ledger)
25
+ @principal = principal
26
+ end
27
+
28
+ def call
29
+ return ledger_unavailable("redaction") unless ledger.available?
30
+
31
+ inert = declared_names - accepted_names
32
+ return inert_declaration(inert.first) unless inert.empty? || accepts_anything?
33
+
34
+ names = names_to_probe
35
+ return nothing_declared if names.empty?
36
+
37
+ probe(names)
38
+ end
39
+
40
+ private
41
+
42
+ def probe(names)
43
+ sentinels = names.to_h { |name| [name, "reeve-sentinel-#{SecureRandom.hex(8)}"] }
44
+ made = attempt(principal: @principal, arguments: probe_arguments(sentinels))
45
+ return no_entry(names) if made.rows.empty?
46
+
47
+ leak = first_leak(made.rows, sentinels)
48
+ return held(names) if leak.nil?
49
+
50
+ name, entry, column = leak
51
+ failed(
52
+ "expected #{tool_label} to redact #{name}, but the value passed for it appears " \
53
+ "in audit entry #{entry.id}, in column #{column}",
54
+ redacted: names, leaked: name, entry: entry.id, column: column
55
+ )
56
+ end
57
+
58
+ def first_leak(entries, sentinels)
59
+ entries.each do |entry|
60
+ sentinels.each do |name, sentinel|
61
+ column = column_containing(entry, sentinel)
62
+ return [name, entry, column] if column
63
+ end
64
+ end
65
+ nil
66
+ end
67
+
68
+ def column_containing(entry, sentinel)
69
+ entry.attributes.each do |column, value|
70
+ return column if value.to_s.include?(sentinel)
71
+ end
72
+ nil
73
+ end
74
+
75
+ def held(names)
76
+ passed("#{tool_label} keeps #{names.join(', ')} out of the ledger entirely",
77
+ redacted: names)
78
+ end
79
+
80
+ def nothing_declared
81
+ passed("#{tool_label} declares no redacted argument that it also accepts, so " \
82
+ "there is nothing for the ledger to expose",
83
+ redacted: [])
84
+ end
85
+
86
+ def no_entry(names)
87
+ failed("expected #{tool_label} to redact #{names.join(', ')}, but the invocation " \
88
+ "wrote no audit entry to look in",
89
+ redacted: names)
90
+ end
91
+
92
+ def inert_declaration(name)
93
+ failed(
94
+ "expected #{tool_label} to redact #{name}, but #call accepts no such argument " \
95
+ "(it accepts #{accepted_names.join(', ')}), so the declaration redacts nothing",
96
+ redacted: declared_names, inert: name
97
+ )
98
+ end
99
+
100
+ # Sentinels for the sensitive names, filler for anything else #call insists on.
101
+ def probe_arguments(sentinels)
102
+ required = call_parameters.filter_map { |type, name| name if type == :keyreq }
103
+ filler = required.to_h { |name| [name, FILLER] }
104
+ filler.merge(arguments).merge(sentinels)
105
+ end
106
+
107
+ def names_to_probe
108
+ candidates = (declared_names + global_names).uniq
109
+ accepts_anything? ? candidates : candidates & accepted_names
110
+ end
111
+
112
+ # Only the tool's own declaration is treated as a promise about *this* tool. The
113
+ # process-wide list is a safety net, and is probed only where the tool happens to
114
+ # take an argument by that name.
115
+ def declared_names
116
+ guard = declaration
117
+ guard ? guard.redacted_arguments.dup : []
118
+ end
119
+
120
+ def global_names
121
+ Array(Reeve.config.redact_arguments)
122
+ end
123
+
124
+ def accepted_names
125
+ call_parameters.filter_map { |type, name| name if %i[key keyreq].include?(type) }
126
+ end
127
+
128
+ def accepts_anything?
129
+ call_parameters.any? { |type, _| type == :keyrest }
130
+ end
131
+
132
+ def call_parameters
133
+ return [] unless tool.respond_to?(:instance_method)
134
+
135
+ tool.instance_method(:call).parameters
136
+ rescue NameError
137
+ []
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Testing
5
+ module Checks
6
+ # FR-009: every entry explains itself.
7
+ #
8
+ # A ledger row that records *that* a call was denied but not *what* denied it is the
9
+ # row you are holding during an incident, and it answers nothing. The gem's own
10
+ # recorder cannot write one — Entry validates the column — but a host-supplied
11
+ # recorder can, which is exactly the case this check covers.
12
+ #
13
+ # Reeve::Checks::RulePresent.new(tool: InvoiceSearchTool, principal: alice).call
14
+ class RulePresent < 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("the deciding rule") unless ledger.available?
22
+
23
+ entries = attempt(principal: @principal).rows
24
+ return no_entry if entries.empty?
25
+
26
+ missing = entries.reject { |entry| present?(entry.rule) }
27
+ return held(entries) if missing.empty?
28
+
29
+ failed(
30
+ "expected every audit entry for #{tool_label} to name the rule that decided, " \
31
+ "but entry #{missing.map(&:id).join(', ')} has no rule",
32
+ entries: entries.size, without_rule: missing.map(&:id)
33
+ )
34
+ end
35
+
36
+ private
37
+
38
+ def held(entries)
39
+ passed("every audit entry #{tool_label} produced names the rule that decided",
40
+ rules: entries.map(&:rule))
41
+ end
42
+
43
+ def no_entry
44
+ failed("expected #{tool_label} to write an audit entry naming the rule that " \
45
+ "decided, but it produced no entry at all",
46
+ entries: 0)
47
+ end
48
+
49
+ def present?(value)
50
+ !value.nil? && !value.to_s.strip.empty?
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "checks/base"
4
+ require_relative "checks/guard_declared"
5
+ require_relative "checks/cross_principal_leak"
6
+ require_relative "checks/audit_coverage"
7
+ require_relative "checks/rule_present"
8
+ require_relative "checks/redaction_holds"
9
+ require_relative "checks/principal_required"
10
+ require_relative "checks/contract_version"
11
+
12
+ module Reeve
13
+ module Testing
14
+ # The seven guarantees, as objects.
15
+ #
16
+ # This is the whole of the testing kit's logic. The RSpec matchers and the Minitest
17
+ # assertions are adapters over it and contain no assertions of their own, which is why
18
+ # the same violation reads identically from either — and from neither:
19
+ #
20
+ # report = Reeve::Checks.run_all(principals: [alice, bob])
21
+ # abort report.to_s unless report.passed?
22
+ #
23
+ # Nothing here loads a test framework (FR-026).
24
+ module Checks
25
+ ALL = [
26
+ GuardDeclared,
27
+ CrossPrincipalLeak,
28
+ AuditCoverage,
29
+ RulePresent,
30
+ RedactionHolds,
31
+ PrincipalRequired,
32
+ ContractVersion
33
+ ].freeze
34
+
35
+ # The checks that are asked once about the ledger rather than once per tool.
36
+ GLOBAL = [ContractVersion].freeze
37
+
38
+ # The compliance suite's engine (FR-018): every check, against every registered
39
+ # guarded tool, in one Report.
40
+ #
41
+ # +principals+ must be two fixture principals with disjoint records — that
42
+ # disjointness is what makes a shared identifier proof of a leak.
43
+ def self.run_all(principals:, tools: nil, arguments: {}, invoke: nil, ledger: nil)
44
+ reports = ALL.map do |check|
45
+ run(check, principals: principals, tools: tools, arguments: arguments,
46
+ invoke: invoke, ledger: ledger)
47
+ end
48
+
49
+ Report.new(reports.flat_map(&:results))
50
+ end
51
+
52
+ # One check, across every tool it applies to. This is what both front-ends build a
53
+ # test method out of, so that a failing suite names the guarantee that broke rather
54
+ # than reporting "compliance" as one undifferentiated red.
55
+ def self.run(check, principals:, tools: nil, arguments: {}, invoke: nil, ledger: nil)
56
+ return Report.new([check.new(ledger: ledger).call]) if GLOBAL.include?(check)
57
+
58
+ subjects = tools || Reeve.registry.map(&:tool_class)
59
+ Report.new(
60
+ subjects.map do |tool|
61
+ build(check, tool: tool, principals: principals, arguments: arguments,
62
+ invoke: invoke, ledger: ledger).call
63
+ end
64
+ )
65
+ end
66
+
67
+ # Every per-tool check for one tool, instantiated but not run.
68
+ def self.for_tool(tool, principals:, arguments: {}, invoke: nil, ledger: nil)
69
+ (ALL - GLOBAL).map do |check|
70
+ build(check, tool: tool, principals: principals, arguments: arguments,
71
+ invoke: invoke, ledger: ledger)
72
+ end
73
+ end
74
+
75
+ # The one place that knows what each check's constructor wants. Front-ends and hosts
76
+ # ask for a check by class and get a configured one back.
77
+ def self.build(check, tool:, principals:, arguments: {}, invoke: nil, ledger: nil)
78
+ common = { tool: tool, arguments: arguments, invoke: invoke, ledger: ledger }
79
+
80
+ case check.check_name
81
+ when "GuardDeclared" then GuardDeclared.new(tool: tool, ledger: ledger)
82
+ when "ContractVersion" then ContractVersion.new(ledger: ledger)
83
+ when "PrincipalRequired" then PrincipalRequired.new(**common)
84
+ when "CrossPrincipalLeak" then CrossPrincipalLeak.new(principals: Array(principals),
85
+ **common)
86
+ else check.new(principal: Array(principals).first, **common)
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ # The name the contract and every host will type. `Reeve::Checks::CrossPrincipalLeak`
93
+ # is the public spelling; the file layout under testing/ is an implementation detail.
94
+ Checks = Testing::Checks
95
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Testing
5
+ # The shared compliance suite (FR-018), for Minitest. Including it *is* the suite:
6
+ # Minitest runs every `test_`-prefixed method it finds.
7
+ #
8
+ # class ComplianceTest < ActiveSupport::TestCase
9
+ # include Reeve::Testing::ComplianceAssertions
10
+ # end
11
+ #
12
+ # Host setup is two fixture principals with disjoint records and nothing else:
13
+ #
14
+ # Reeve::Testing.compliance_principals = -> { [users(:alice), users(:bob)] }
15
+ #
16
+ # One method per check, matching the RSpec shared example group one for one, so a red
17
+ # build names the guarantee that broke rather than reporting "compliance" as one
18
+ # undifferentiated failure.
19
+ module ComplianceAssertions
20
+ include Assertions
21
+
22
+ Checks::ALL.each do |check|
23
+ # CrossPrincipalLeak -> test_reeve_cross_principal_leak
24
+ method_name = "test_reeve_#{check.check_name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase}"
25
+
26
+ define_method(method_name) do
27
+ report = Checks.run(check, principals: Testing.compliance_principals)
28
+ assert report.passed?, report.to_s
29
+ report
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The shared compliance suite (FR-018), for RSpec.
4
+ #
5
+ # RSpec.describe "reeve compliance" do
6
+ # it_behaves_like "a reeve-compliant server"
7
+ # end
8
+ #
9
+ # Host setup is two fixture principals with disjoint records and nothing else:
10
+ #
11
+ # Reeve::Testing.compliance_principals = -> { [users(:alice), users(:bob)] }
12
+ #
13
+ # One example per check rather than one for the lot, so a red build names the guarantee
14
+ # that broke — "CrossPrincipalLeak" is an incident, "compliance" is a shrug.
15
+ RSpec.shared_examples "a reeve-compliant server" do
16
+ Reeve::Checks::ALL.each do |check|
17
+ it "satisfies #{check.check_name} for every registered guarded tool" do
18
+ report = Reeve::Checks.run(check, principals: Reeve::Testing.compliance_principals)
19
+
20
+ expect(report).to be_passed, report.to_s
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Testing
5
+ # The checks' read-only window onto the audit ledger.
6
+ #
7
+ # It exists so that `require "reeve/testing"` stays loadable with no ActiveRecord in
8
+ # the process (FR-020, SC-008): nothing here mentions +Reeve::Audit::Entry+ until a
9
+ # check actually asks a question, and every question has an answer for the case where
10
+ # the ledger is absent.
11
+ #
12
+ # A host with its own non-ActiveRecord recorder can pass any object with these six
13
+ # methods as `ledger:` to any ledger-reading check.
14
+ class Ledger
15
+ def self.default
16
+ new
17
+ end
18
+
19
+ def available?
20
+ return false if entry_class.nil?
21
+
22
+ entry_class.table_exists?
23
+ rescue StandardError
24
+ false
25
+ end
26
+
27
+ # Why not, in the words a developer can act on.
28
+ def unavailable_reason
29
+ return nil if available?
30
+ if entry_class.nil?
31
+ return "the audit ledger is not loaded — add `require \"reeve/audit\"` to your " \
32
+ "test helper, or pass a `ledger:` of your own"
33
+ end
34
+
35
+ "the #{entry_class.table_name} table does not exist — run `rails g reeve:install` " \
36
+ "and migrate"
37
+ end
38
+
39
+ # A position in the ledger, taken before an invocation so the entries that
40
+ # invocation wrote can be told apart from everything already there.
41
+ def marker
42
+ return nil unless available?
43
+
44
+ entry_class.maximum(:id)
45
+ end
46
+
47
+ def entries_after(marker)
48
+ return [] unless available?
49
+
50
+ scope = entry_class.order(:id)
51
+ marker.nil? ? scope.to_a : scope.where("id > ?", marker).to_a
52
+ end
53
+
54
+ def count
55
+ available? ? entry_class.count : 0
56
+ end
57
+
58
+ def contract_version
59
+ return nil unless entry_class.respond_to?(:contract_version)
60
+
61
+ entry_class.contract_version
62
+ end
63
+
64
+ def columns
65
+ return [] unless available?
66
+
67
+ entry_class.column_names.map(&:to_s)
68
+ end
69
+
70
+ private
71
+
72
+ def entry_class
73
+ return @entry_class if defined?(@entry_class)
74
+
75
+ @entry_class =
76
+ if Reeve.const_defined?(:Audit, false) && Reeve::Audit.const_defined?(:Entry, false)
77
+ Reeve::Audit::Entry
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Testing
5
+ module Matchers
6
+ # FR-017, from RSpec:
7
+ #
8
+ # it { is_expected.to audit_every_call }
9
+ # it { is_expected.to audit_every_call.for_principal(alice).with(query: "AC") }
10
+ #
11
+ # Point it at the host's own call site to prove that *that* goes through the
12
+ # envelope, which is where an audit bypass actually lives:
13
+ #
14
+ # it { is_expected.to audit_every_call.invoked_by(MyServer.method(:dispatch)) }
15
+ class AuditEveryCall < Base
16
+ def self.check
17
+ Checks::AuditCoverage
18
+ end
19
+
20
+ def for_principal(principal)
21
+ @principal = principal
22
+ self
23
+ end
24
+
25
+ def description
26
+ "audit every call"
27
+ end
28
+
29
+ private
30
+
31
+ def check_for(tool)
32
+ Checks::AuditCoverage.new(
33
+ tool: tool, principal: @principal || default_principal, **options
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Testing
5
+ module Matchers
6
+ # What every reeve matcher is: a check, and nothing else.
7
+ #
8
+ # +failure_message+ returns the check's own +message+ verbatim. No matcher composes
9
+ # a sentence, reformats one, or adds context (FR-019) — that is the whole reason the
10
+ # same violation reads identically here, in Minitest, and in a rake task.
11
+ #
12
+ # Written against RSpec's matcher protocol by duck-typing rather than by including
13
+ # +RSpec::Matchers::DSL+, so nothing under lib/reeve/testing needs RSpec loaded in
14
+ # order to be *parsed*; only a suite that uses these needs it present.
15
+ class Base
16
+ attr_reader :result
17
+
18
+ def initialize
19
+ @arguments = {}
20
+ @invoke = nil
21
+ @ledger = nil
22
+ end
23
+
24
+ # The arguments the tool is called with.
25
+ def with(**arguments)
26
+ @arguments = arguments
27
+ self
28
+ end
29
+
30
+ # How the tool is called. Point this at a host's own invoker to prove that *it*
31
+ # goes through the envelope.
32
+ def invoked_by(callable)
33
+ @invoke = callable
34
+ self
35
+ end
36
+
37
+ def reading(ledger)
38
+ @ledger = ledger
39
+ self
40
+ end
41
+
42
+ def matches?(subject)
43
+ @result = check_for(subject).call
44
+ @result.passed?
45
+ end
46
+
47
+ # No `does_not_match?`: RSpec negates `matches?` for us, and a check that had a
48
+ # separate negative path could drift from its positive one.
49
+ def failure_message
50
+ result.message
51
+ end
52
+
53
+ def failure_message_when_negated
54
+ "expected #{result.check} to fail, but it passed: #{result.message}"
55
+ end
56
+
57
+ def description
58
+ self.class.check.check_name
59
+ end
60
+
61
+ def supports_block_expectations?
62
+ false
63
+ end
64
+
65
+ private
66
+
67
+ attr_reader :arguments, :invoke, :ledger
68
+
69
+ def check_for(_subject)
70
+ raise NotImplementedError, "#{self.class} must build its check"
71
+ end
72
+
73
+ def options
74
+ { arguments: arguments, invoke: invoke, ledger: ledger }
75
+ end
76
+
77
+ # The principal a matcher falls back to when the example did not name one.
78
+ def default_principal
79
+ Testing.compliance_principals.first
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Testing
5
+ module Matchers
6
+ # FR-016, from RSpec:
7
+ #
8
+ # it { is_expected.to deny_access_for(stranger).with(query: "AC") }
9
+ #
10
+ # Backed by CrossPrincipalLeak in its :nothing expectation — this principal may
11
+ # receive no record at all. A denial satisfies it; so does an allowed call that
12
+ # returns an empty scope, because from the agent's side those are the same thing,
13
+ # and FR-006 requires that they stay the same thing.
14
+ class DenyAccessFor < Base
15
+ def self.check
16
+ Checks::CrossPrincipalLeak
17
+ end
18
+
19
+ def initialize(principal)
20
+ super()
21
+ @principal = principal
22
+ end
23
+
24
+ def description
25
+ "deny access for #{@principal.inspect}"
26
+ end
27
+
28
+ private
29
+
30
+ def check_for(tool)
31
+ Checks::CrossPrincipalLeak.new(
32
+ tool: tool, principals: [@principal], expect: :nothing, **options
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Testing
5
+ module Matchers
6
+ # The escape hatch that keeps SC-009 honest: every one of the seven checks is
7
+ # assertable from RSpec, not only the two with names of their own.
8
+ #
9
+ # expect(Reeve::Checks::RedactionHolds.new(tool: T, principal: alice))
10
+ # .to pass_reeve_check
11
+ class PassReeveCheck < Base
12
+ def self.check
13
+ Checks::Base
14
+ end
15
+
16
+ def description
17
+ "pass its reeve check"
18
+ end
19
+
20
+ def failure_message_when_negated
21
+ "expected #{result.check} to fail, but it passed: #{result.message}"
22
+ end
23
+
24
+ private
25
+
26
+ def check_for(check)
27
+ check
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end