reeve 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +112 -0
- data/README.md +191 -17
- data/lib/generators/reeve/install/install_generator.rb +46 -0
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +69 -0
- data/lib/generators/reeve/install/templates/initializer.rb.tt +65 -0
- data/lib/reeve/audit/entry.rb +48 -0
- data/lib/reeve/audit/query.rb +84 -0
- data/lib/reeve/audit/recorder.rb +163 -0
- data/lib/reeve/audit/redactor.rb +67 -0
- data/lib/reeve/audit.rb +88 -0
- data/lib/reeve/authorization/adapter.rb +71 -0
- data/lib/reeve/authorization/adapters/plain.rb +65 -0
- data/lib/reeve/authorization/adapters/pundit.rb +84 -0
- data/lib/reeve/authorization/authorizer.rb +23 -0
- data/lib/reeve/authorization/current.rb +66 -0
- data/lib/reeve/authorization/declaration.rb +73 -0
- data/lib/reeve/authorization/guard.rb +97 -0
- data/lib/reeve/authorization/registry.rb +118 -0
- data/lib/reeve/authorization/scoper.rb +399 -0
- data/lib/reeve/authorization.rb +76 -0
- data/lib/reeve/configuration.rb +158 -0
- data/lib/reeve/context.rb +104 -0
- data/lib/reeve/decision.rb +111 -0
- data/lib/reeve/errors.rb +89 -0
- data/lib/reeve/fast_mcp.rb +24 -0
- data/lib/reeve/integrations/fast_mcp/context_builder.rb +48 -0
- data/lib/reeve/integrations/fast_mcp/tool_extension.rb +55 -0
- data/lib/reeve/invocation.rb +260 -0
- data/lib/reeve/minitest.rb +19 -0
- data/lib/reeve/rspec.rb +18 -0
- data/lib/reeve/scope_result.rb +104 -0
- data/lib/reeve/testing/assertions.rb +76 -0
- data/lib/reeve/testing/checks/audit_coverage.rb +46 -0
- data/lib/reeve/testing/checks/base.rb +155 -0
- data/lib/reeve/testing/checks/contract_version.rb +73 -0
- data/lib/reeve/testing/checks/cross_principal_leak.rb +132 -0
- data/lib/reeve/testing/checks/guard_declared.rb +33 -0
- data/lib/reeve/testing/checks/principal_required.rb +60 -0
- data/lib/reeve/testing/checks/redaction_holds.rb +142 -0
- data/lib/reeve/testing/checks/rule_present.rb +55 -0
- data/lib/reeve/testing/checks.rb +95 -0
- data/lib/reeve/testing/compliance_assertions.rb +34 -0
- data/lib/reeve/testing/compliance_suite.rb +23 -0
- data/lib/reeve/testing/ledger.rb +82 -0
- data/lib/reeve/testing/matchers/audit_every_call.rb +39 -0
- data/lib/reeve/testing/matchers/base.rb +84 -0
- data/lib/reeve/testing/matchers/deny_access_for.rb +38 -0
- data/lib/reeve/testing/matchers/pass_reeve_check.rb +32 -0
- data/lib/reeve/testing/matchers.rb +33 -0
- data/lib/reeve/testing/report.rb +58 -0
- data/lib/reeve/testing/result.rb +49 -0
- data/lib/reeve/testing.rb +60 -0
- data/lib/reeve/version.rb +1 -1
- data/lib/reeve.rb +9 -3
- metadata +52 -1
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
# Reeve::Checks::ContractVersion.new.call
|
|
15
|
+
class ContractVersion < Base
|
|
16
|
+
TABLE = "reeve_audit_entries"
|
|
17
|
+
|
|
18
|
+
# Contract version 1, as documented in contracts/audit-entry.md. This list is
|
|
19
|
+
# deliberately written out rather than read back off the model: a check that
|
|
20
|
+
# derives its expectation from the thing it is checking checks nothing.
|
|
21
|
+
COLUMNS = %w[
|
|
22
|
+
invocation_id occurred_at agent_id agent_name principal_type principal_id
|
|
23
|
+
tool_name arguments outcome rule detail record_type record_ids record_count
|
|
24
|
+
truncated derived guard duration_ms metadata
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
EXPECTED_VERSION = 1
|
|
28
|
+
|
|
29
|
+
def initialize(tool: nil, expected: EXPECTED_VERSION, ledger: nil)
|
|
30
|
+
super(tool: tool, ledger: ledger)
|
|
31
|
+
@expected = expected
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call
|
|
35
|
+
return ledger_unavailable("the audit-entry contract version") unless ledger.available?
|
|
36
|
+
|
|
37
|
+
recorded = ledger.contract_version
|
|
38
|
+
return version_mismatch(recorded) unless recorded == @expected
|
|
39
|
+
|
|
40
|
+
missing = COLUMNS - ledger.columns
|
|
41
|
+
return missing_columns(missing) unless missing.empty?
|
|
42
|
+
|
|
43
|
+
passed("the ledger implements audit-entry contract version #{@expected}",
|
|
44
|
+
version: @expected)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def version_mismatch(recorded)
|
|
50
|
+
failed(
|
|
51
|
+
"expected the ledger to implement audit-entry contract version #{@expected}, " \
|
|
52
|
+
"but it reports version #{recorded.inspect} — this reeve version cannot read " \
|
|
53
|
+
"that shape",
|
|
54
|
+
version: recorded
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def missing_columns(missing)
|
|
59
|
+
failed(
|
|
60
|
+
"expected the ledger to implement audit-entry contract version #{@expected}, " \
|
|
61
|
+
"but #{TABLE} is missing: #{missing.join(', ')} — run `rails g reeve:install` " \
|
|
62
|
+
"and migrate",
|
|
63
|
+
version: @expected, missing: missing
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def base_details
|
|
68
|
+
{ tool: nil }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
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
|
|
@@ -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
|