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,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
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Testing
5
+ module Checks
6
+ # FR-015: the ledger the host actually migrated implements the entry shape this
7
+ # version of the gem writes.
8
+ #
9
+ # The audit-entry contract is versioned in prose and in +Audit::CONTRACT_VERSION+;
10
+ # what this check adds is the third party to that agreement — the table. A host that
11
+ # upgraded the gem and skipped the migration has a ledger one shape behind, and every
12
+ # other check in this kit would go on passing while columns quietly went unwritten.
13
+ #
14
+ # What each half of this check is worth, stated plainly because it is easy to read
15
+ # more into the version number than it carries:
16
+ #
17
+ # * The **column list** is the real test. It is compared against the columns the
18
+ # host's table actually has, and it is written out by hand rather than read back
19
+ # off the model — a check that derives its expectation from the thing it is
20
+ # checking checks nothing.
21
+ # * The **version number** is compared against what the ledger model reports, which
22
+ # is this gem's own +Audit::CONTRACT_VERSION+ — so by default it compares the gem
23
+ # to itself and can only pass. It earns its keep when a host passes `expected:` to
24
+ # pin the version it built its exports against.
25
+ #
26
+ # Those two are not as separate as they look, and that is by design. A contract bump
27
+ # is defined as a change to the shape or to what a value means; every such bump also
28
+ # moves the `contract_version` column, which means the column list catches a stale
29
+ # table even when the bump was purely semantic. Contract 2 is the worked example: the
30
+ # change was `metadata`'s meaning, no existing column moved, and a host still on the
31
+ # contract 1 table fails here on a missing `contract_version` column rather than
32
+ # passing while writing rows nothing can interpret.
33
+ #
34
+ # The constant below is written out rather than read from +Audit+ for the same
35
+ # reason the column list is, and one more: the testing kit loads with no ledger and
36
+ # no ActiveRecord at all (spec/reeve/testing/isolation_spec.rb), so it cannot
37
+ # reference the audit module. Bumping the contract means editing both by hand, and
38
+ # spec/reeve/audit/contract_version_spec.rb fails if they drift.
39
+ #
40
+ # Reeve::Checks::ContractVersion.new.call
41
+ # Reeve::Checks::ContractVersion.new(expected: 2).call # pinned by the host
42
+ class ContractVersion < Base
43
+ TABLE = "reeve_audit_entries"
44
+
45
+ COLUMNS = %w[
46
+ invocation_id occurred_at agent_id agent_name principal_type principal_id
47
+ tool_name arguments outcome rule detail record_type record_ids record_count
48
+ truncated derived guard duration_ms metadata contract_version
49
+ ].freeze
50
+
51
+ EXPECTED_VERSION = 2
52
+
53
+ def initialize(tool: nil, expected: EXPECTED_VERSION, ledger: nil)
54
+ super(tool: tool, ledger: ledger)
55
+ @expected = expected
56
+ end
57
+
58
+ def call
59
+ return ledger_unavailable("the audit-entry contract version") unless ledger.available?
60
+
61
+ recorded = ledger.contract_version
62
+ return version_mismatch(recorded) unless recorded == @expected
63
+
64
+ missing = COLUMNS - ledger.columns
65
+ return missing_columns(missing) unless missing.empty?
66
+
67
+ passed("the ledger implements audit-entry contract version #{@expected}",
68
+ version: @expected)
69
+ end
70
+
71
+ private
72
+
73
+ def version_mismatch(recorded)
74
+ failed(
75
+ "expected the ledger to implement audit-entry contract version #{@expected}, " \
76
+ "but it reports version #{recorded.inspect} — this reeve version cannot read " \
77
+ "that shape",
78
+ version: recorded
79
+ )
80
+ end
81
+
82
+ def missing_columns(missing)
83
+ failed(
84
+ "expected the ledger to implement audit-entry contract version #{@expected}, " \
85
+ "but #{TABLE} is missing: #{missing.join(', ')} — run `rails g reeve:install` " \
86
+ "and migrate",
87
+ version: @expected, missing: missing
88
+ )
89
+ end
90
+
91
+ def base_details
92
+ { tool: nil }
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Testing
5
+ module Checks
6
+ # FR-016, FR-003. The check the whole kit exists for: does this tool hand one
7
+ # principal another principal's records?
8
+ #
9
+ # It answers by construction rather than by introspection. The contract's host-setup
10
+ # rule is two fixture principals with *disjoint* records, so invoking the tool once
11
+ # per principal and intersecting the identifiers that come back is decisive: a
12
+ # non-empty intersection is a leak, and no policy needs to be read to know it.
13
+ # A denial contributes an empty set — nothing returned is nothing leaked.
14
+ #
15
+ # Reeve::Checks::CrossPrincipalLeak.new(
16
+ # tool: InvoiceSearchTool, principals: [alice, bob], arguments: { query: "AC" }
17
+ # ).call
18
+ #
19
+ # Two expectations, because two questions are worth asking:
20
+ #
21
+ # :disjoint (default) — no identifier reaches two principals. The compliance
22
+ # question, and the one FR-016 is written about.
23
+ # :nothing — none of these principals may receive any record at all.
24
+ # What `deny_access_for(stranger)` asserts.
25
+ class CrossPrincipalLeak < Base
26
+ EXPECTATIONS = %i[disjoint nothing].freeze
27
+
28
+ def initialize(tool:, principals:, arguments: {}, expect: :disjoint, invoke: nil,
29
+ ledger: nil)
30
+ unless EXPECTATIONS.include?(expect)
31
+ raise ArgumentError,
32
+ "expect must be one of #{EXPECTATIONS.map(&:inspect).join(', ')}, " \
33
+ "got #{expect.inspect}"
34
+ end
35
+
36
+ super(tool: tool, arguments: arguments, invoke: invoke, ledger: ledger)
37
+ @principals = Array(principals)
38
+ @expect = expect
39
+ end
40
+
41
+ def call
42
+ return no_principals if principals.empty?
43
+
44
+ attempts = principals.map { |principal| attempt(principal: principal) }
45
+ @expect == :nothing ? verify_nothing(attempts) : verify_disjoint(attempts)
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :principals
51
+
52
+ def no_principals
53
+ failed("#{tool_label} cannot be checked for cross-principal leaks: no principals " \
54
+ "were supplied (the compliance suite needs two, with disjoint records)",
55
+ leaked: [])
56
+ end
57
+
58
+ # ---- :nothing -------------------------------------------------------------
59
+
60
+ def verify_nothing(attempts)
61
+ offenders = attempts.reject { |attempt| identifiers(attempt.records).empty? }
62
+ return nothing_held(attempts) if offenders.empty?
63
+
64
+ failed(nothing_message(offenders), leaked_details(offenders))
65
+ end
66
+
67
+ def nothing_held(attempts)
68
+ passed(
69
+ "#{tool_label} returned no records to " \
70
+ "#{attempts.map { |a| principal_label(a.principal) }.join(', ')}",
71
+ leaked: [], denials: attempts.filter_map { |a| a.denial&.rule }
72
+ )
73
+ end
74
+
75
+ def nothing_message(offenders)
76
+ offenders.map do |attempt|
77
+ leaked = identifiers(attempt.records)
78
+ "expected #{tool_label} to deny access for #{principal_label(attempt.principal)}, " \
79
+ "but it returned #{pluralize(leaked.size, 'record')} that principal may not " \
80
+ "see: #{format_identifiers(leaked)} #{provenance(attempt.entry)}"
81
+ end.join("; ")
82
+ end
83
+
84
+ # ---- :disjoint ------------------------------------------------------------
85
+
86
+ def verify_disjoint(attempts)
87
+ leak = first_overlap(attempts)
88
+ return disjoint_held(attempts) if leak.nil?
89
+
90
+ owner, other, shared = leak
91
+ failed(disjoint_message(owner, other, shared),
92
+ leaked: shared, rule: owner.entry&.rule,
93
+ principals: [principal_label(owner.principal),
94
+ principal_label(other.principal)])
95
+ end
96
+
97
+ def disjoint_message(owner, other, shared)
98
+ "expected #{tool_label} to return no records belonging to another principal, " \
99
+ "but it returned #{pluralize(shared.size, 'record')} to " \
100
+ "#{principal_label(owner.principal)} that also belong to " \
101
+ "#{principal_label(other.principal)}: #{format_identifiers(shared)} " \
102
+ "#{provenance(owner.entry)}"
103
+ end
104
+
105
+ def first_overlap(attempts)
106
+ attempts.combination(2).each do |owner, other|
107
+ shared = identifiers(owner.records) & identifiers(other.records)
108
+ return [owner, other, shared] unless shared.empty?
109
+ end
110
+ nil
111
+ end
112
+
113
+ def disjoint_held(attempts)
114
+ total = attempts.sum { |attempt| identifiers(attempt.records).size }
115
+ passed(
116
+ "#{tool_label} returned no records belonging to another principal " \
117
+ "(#{pluralize(attempts.size, 'principal')}, #{pluralize(total, 'record')})",
118
+ leaked: [], denials: attempts.filter_map { |a| a.denial&.rule }
119
+ )
120
+ end
121
+
122
+ def leaked_details(offenders)
123
+ {
124
+ leaked: offenders.flat_map { |attempt| identifiers(attempt.records) },
125
+ rule: offenders.first.entry&.rule,
126
+ principals: offenders.map { |attempt| principal_label(attempt.principal) }
127
+ }
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Testing
5
+ module Checks
6
+ # FR-002, FR-004: a tool with no `guard_with` is not neutral, it is denied. This is
7
+ # the check that tells you *before* production which of your tools that is.
8
+ #
9
+ # Reeve::Checks::GuardDeclared.new(tool: InvoiceSearchTool).call
10
+ class GuardDeclared < Base
11
+ def call
12
+ guard = declaration
13
+ return failure if guard.nil?
14
+
15
+ passed(
16
+ "#{tool_label} is guarded by #{guard.policy_name} (action: #{guard.action})",
17
+ policy: guard.policy_name, action: guard.action
18
+ )
19
+ end
20
+
21
+ private
22
+
23
+ def failure
24
+ failed(
25
+ "expected #{tool_label} to declare a guard, but it has no guard_with " \
26
+ "declaration — reeve denies every call to an unguarded tool",
27
+ policy: nil
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end