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,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.
|
data/lib/reeve/rspec.rb
ADDED
|
@@ -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
|