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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +191 -0
- data/README.md +280 -17
- data/lib/generators/reeve/install/install_generator.rb +46 -0
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +77 -0
- data/lib/generators/reeve/install/templates/initializer.rb.tt +65 -0
- data/lib/reeve/audit/entry.rb +54 -0
- data/lib/reeve/audit/query.rb +84 -0
- data/lib/reeve/audit/recorder.rb +181 -0
- data/lib/reeve/audit/redactor.rb +67 -0
- data/lib/reeve/audit.rb +97 -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 +97 -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 +111 -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 +97 -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 +10 -3
- 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
|