reeve 0.6.0 → 0.8.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 +293 -3
- data/README.md +276 -11
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +52 -5
- data/lib/generators/reeve/install/templates/initializer.rb.tt +10 -0
- data/lib/generators/reeve/upgrade/templates/add_key_epochs_to_reeve_audit_entries.rb.tt +35 -0
- data/lib/generators/reeve/upgrade/templates/add_ledger_retention_to_reeve_audit_entries.rb.tt +37 -0
- data/lib/generators/reeve/upgrade/upgrade_generator.rb +19 -25
- data/lib/generators/reeve/upgrade/upgrade_notes.rb +62 -0
- data/lib/reeve/audit/chain/anchoring.rb +91 -0
- data/lib/reeve/audit/chain/canonical.rb +38 -7
- data/lib/reeve/audit/chain/checkpoint.rb +228 -0
- data/lib/reeve/audit/chain/checkpoint_check.rb +156 -0
- data/lib/reeve/audit/chain/entrypoints.rb +29 -0
- data/lib/reeve/audit/chain/epoch_rules.rb +159 -0
- data/lib/reeve/audit/chain/epochs.rb +83 -0
- data/lib/reeve/audit/chain/head.rb +55 -2
- data/lib/reeve/audit/chain/keyring.rb +6 -0
- data/lib/reeve/audit/chain/report.rb +232 -32
- data/lib/reeve/audit/chain/retention/archive.rb +98 -0
- data/lib/reeve/audit/chain/retention/archive_check.rb +179 -0
- data/lib/reeve/audit/chain/retention/check.rb +94 -0
- data/lib/reeve/audit/chain/retention/connection.rb +65 -0
- data/lib/reeve/audit/chain/retention/digest.rb +29 -0
- data/lib/reeve/audit/chain/retention/plan.rb +100 -0
- data/lib/reeve/audit/chain/retention/preflight.rb +48 -0
- data/lib/reeve/audit/chain/retention/purge.rb +155 -0
- data/lib/reeve/audit/chain/retention/purge_result.rb +134 -0
- data/lib/reeve/audit/chain/retention/records.rb +23 -0
- data/lib/reeve/audit/chain/rotation.rb +124 -0
- data/lib/reeve/audit/chain/schema.rb +59 -0
- data/lib/reeve/audit/chain/verification.rb +53 -10
- data/lib/reeve/audit/chain/verifier.rb +75 -22
- data/lib/reeve/audit/chain/walk.rb +24 -4
- data/lib/reeve/audit/chain.rb +61 -13
- data/lib/reeve/audit/chain_epoch.rb +24 -0
- data/lib/reeve/audit/chain_record.rb +14 -8
- data/lib/reeve/audit/file_anchor.rb +49 -0
- data/lib/reeve/audit/retention_record.rb +24 -0
- data/lib/reeve/audit.rb +30 -3
- data/lib/reeve/configuration.rb +25 -0
- data/lib/reeve/errors.rb +15 -0
- data/lib/reeve/railtie.rb +2 -1
- data/lib/reeve/tasks/descriptions.rb +22 -0
- data/lib/reeve/tasks/purge_options.rb +54 -0
- data/lib/reeve/tasks.rb +104 -7
- data/lib/reeve/testing/checks/contract_version.rb +2 -2
- data/lib/reeve/testing/checks/ledger_chain.rb +56 -2
- data/lib/reeve/testing/ledger.rb +19 -3
- data/lib/reeve/version.rb +1 -1
- metadata +28 -2
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Reeve
|
|
6
|
+
module Audit
|
|
7
|
+
class Chain
|
|
8
|
+
module Retention
|
|
9
|
+
# Verifies an archive on its own (specs/005 FR-012, contracts/archive-v1.md): its boundary
|
|
10
|
+
# checkpoint is genuine, its start link is the genesis (or the boundary before it), and
|
|
11
|
+
# every chained row, rebuilt as an unsaved entry so it casts as the database did,
|
|
12
|
+
# recomputes its digest against the row before it, ending at the checkpoint's digest.
|
|
13
|
+
#
|
|
14
|
+
# Needs the entries table's columns, for the casts, but none of its rows.
|
|
15
|
+
class ArchiveCheck
|
|
16
|
+
BOUNDARY = "its boundary checkpoint"
|
|
17
|
+
|
|
18
|
+
def initialize(path, keyring:, entry_class:)
|
|
19
|
+
@path = path
|
|
20
|
+
@keyring = keyring
|
|
21
|
+
@entry_class = entry_class
|
|
22
|
+
@breaks = []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call
|
|
26
|
+
header = Archive.read_header(@path)
|
|
27
|
+
checkpoint = Checkpoint.parse(header.fetch("through_checkpoint"), position: 1)
|
|
28
|
+
return result(header, checkpoint) unless genuine?(checkpoint)
|
|
29
|
+
|
|
30
|
+
check_end(header, checkpoint, walk(header, start_link(header, checkpoint)))
|
|
31
|
+
result(header, checkpoint)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
attr_reader :keyring, :breaks
|
|
37
|
+
|
|
38
|
+
def genuine?(checkpoint)
|
|
39
|
+
signed = checkpoint.signed_by(keyring)
|
|
40
|
+
key = checkpoint.key_id
|
|
41
|
+
add(:unknown_key, nil, "#{BOUNDARY} is signed by key #{key}", key_id: key) if
|
|
42
|
+
signed == :key_not_configured
|
|
43
|
+
if signed == :altered
|
|
44
|
+
add(:checkpoint_altered, nil,
|
|
45
|
+
"#{BOUNDARY} does not match its signature")
|
|
46
|
+
end
|
|
47
|
+
signed == :valid
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Purge 0 starts at the genesis, which is recomputed under its own key and must be the
|
|
51
|
+
# one the checkpoint names; a later purge starts at the previous boundary's digest,
|
|
52
|
+
# which the first row's own digest then binds.
|
|
53
|
+
def start_link(header, checkpoint)
|
|
54
|
+
genesis = header.dig("start", "genesis")
|
|
55
|
+
return header.dig("start", "through_digest") unless genesis
|
|
56
|
+
|
|
57
|
+
digest = genesis["genesis_digest"]
|
|
58
|
+
secret = keyring.for(genesis["genesis_key_id"])
|
|
59
|
+
if secret.nil?
|
|
60
|
+
add(:unknown_key, 0, "the genesis is signed by key #{genesis['genesis_key_id']}",
|
|
61
|
+
key_id: genesis["genesis_key_id"])
|
|
62
|
+
elsif Canonical.genesis_digest(secret, Archive.genesis_values(genesis)) != digest ||
|
|
63
|
+
checkpoint.genesis_digest != digest
|
|
64
|
+
add(:genesis_altered, 0,
|
|
65
|
+
"the archive's genesis does not match its digest or its checkpoint")
|
|
66
|
+
end
|
|
67
|
+
digest
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Every row after the header: pre-chain rows counted, chained rows checked in order.
|
|
71
|
+
def walk(header, link)
|
|
72
|
+
expected = header["from_sequence"]
|
|
73
|
+
last = nil
|
|
74
|
+
@chained = @pre_chain = 0
|
|
75
|
+
File.foreach(@path).drop(1).each do |line|
|
|
76
|
+
entry = @entry_class.new(JSON.parse(line))
|
|
77
|
+
next @pre_chain += 1 if entry.chain_sequence.nil?
|
|
78
|
+
|
|
79
|
+
expected, link = visit(entry, expected, link)
|
|
80
|
+
last = entry
|
|
81
|
+
end
|
|
82
|
+
last
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def visit(entry, expected, link)
|
|
86
|
+
@chained += 1
|
|
87
|
+
sequence = entry.chain_sequence
|
|
88
|
+
if sequence != expected
|
|
89
|
+
kind = sequence < expected ? :duplicate : :missing
|
|
90
|
+
add(kind, sequence < expected ? sequence : expected..(sequence - 1),
|
|
91
|
+
"out of order in the archive")
|
|
92
|
+
end
|
|
93
|
+
check_digest(entry, link)
|
|
94
|
+
[sequence + 1, entry.chain_digest]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def check_digest(entry, link)
|
|
98
|
+
secret = keyring.for(entry.chain_key_id)
|
|
99
|
+
if secret.nil?
|
|
100
|
+
return add(:unknown_key, entry.chain_sequence, "signed by key #{entry.chain_key_id}",
|
|
101
|
+
key_id: entry.chain_key_id)
|
|
102
|
+
end
|
|
103
|
+
return if Canonical.entry_digest(secret, entry,
|
|
104
|
+
previous_digest: link) == entry.chain_digest
|
|
105
|
+
|
|
106
|
+
add(:digest_mismatch, entry.chain_sequence, Walk::MISMATCH)
|
|
107
|
+
rescue ArgumentError => e
|
|
108
|
+
add(:digest_mismatch, entry.chain_sequence, e.message)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def check_end(header, checkpoint, last)
|
|
112
|
+
reached = last&.chain_sequence == header["through_sequence"] &&
|
|
113
|
+
last&.chain_digest == checkpoint.head_digest
|
|
114
|
+
unless reached
|
|
115
|
+
add(:checkpoint_not_reached, nil, "the archive does not end at its boundary " \
|
|
116
|
+
"checkpoint " \
|
|
117
|
+
"##{Report.number(checkpoint.head_sequence)}")
|
|
118
|
+
end
|
|
119
|
+
return if @chained == header["chained"] && @pre_chain == header["pre_chain"]
|
|
120
|
+
|
|
121
|
+
add(:missing, nil, "the header counts #{header['chained']} chained and " \
|
|
122
|
+
"#{header['pre_chain']} " \
|
|
123
|
+
"pre-chain rows; the archive holds #{@chained} and #{@pre_chain}")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def add(kind, sequence, detail, key_id: nil)
|
|
127
|
+
breaks << Verification::Break.new(kind: kind, sequence: sequence, detail: detail,
|
|
128
|
+
key_id: key_id)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def result(header, checkpoint)
|
|
132
|
+
ArchiveResult.new(status: Verification.status_for(breaks), breaks: breaks,
|
|
133
|
+
covered: header["from_sequence"]..header["through_sequence"],
|
|
134
|
+
checkpoint: checkpoint, pre_chain: @pre_chain.to_i)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# What verifying an archive found, in words (`bin/rails reeve:verify_archive`).
|
|
139
|
+
ArchiveResult = Struct.new(:status, :breaks, :covered, :checkpoint, :pre_chain,
|
|
140
|
+
keyword_init: true) do
|
|
141
|
+
def intact? = status == :intact
|
|
142
|
+
|
|
143
|
+
def to_s
|
|
144
|
+
listed = breaks.reject { |found| found.kind == :unknown_key }
|
|
145
|
+
[summary, *listed.map { |found| line(found) }].join("\n")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def summary
|
|
151
|
+
word, found =
|
|
152
|
+
case status
|
|
153
|
+
when :intact then ["INTACT", "sequences #{range} verified, ending at #{ending}"]
|
|
154
|
+
when :broken then ["BROKEN", "#{n(breaks.size)} breaks in sequences #{range}"]
|
|
155
|
+
else ["UNVERIFIABLE", "signed by a key not configured; nothing in #{range} verified"]
|
|
156
|
+
end
|
|
157
|
+
"reeve archive: #{word} — #{found}; #{counted}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def range = "#{n(covered.first)}–#{n(covered.last)}"
|
|
161
|
+
|
|
162
|
+
def ending
|
|
163
|
+
"checkpoint ##{n(checkpoint.head_sequence)} (#{AnchorText.made_on(checkpoint.made_at)})"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def counted = "#{n(pre_chain)} pre-chain rows counted, not verified"
|
|
167
|
+
|
|
168
|
+
def line(found)
|
|
169
|
+
sequence = found.sequence
|
|
170
|
+
where = sequence.is_a?(Range) ? "##{sequence.first}–#{sequence.last}" : "##{sequence}"
|
|
171
|
+
" #{sequence.nil? ? '—' : where} #{found.kind} #{found.detail}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def n(value) = Report.number(value.to_i)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
module Retention
|
|
7
|
+
# Checks the retention records (specs/005 FR-009): numbered 0 to n, each signed under a
|
|
8
|
+
# key this verifier holds, each linked to the one before and record 0 to the genesis,
|
|
9
|
+
# each boundary following on from the last. Leaves the boundary the walk starts after:
|
|
10
|
+
# the last record of the unbroken run from record 0.
|
|
11
|
+
#
|
|
12
|
+
# A record whose key is not held cannot be checked; it still sets the boundary (the
|
|
13
|
+
# rows it accounts for are gone either way) and makes the run unverifiable, never
|
|
14
|
+
# intact and never broken. A record that fails ends the run: the walk starts at the one
|
|
15
|
+
# before it, so the rows the failing record claims to account for are reported missing.
|
|
16
|
+
class Check
|
|
17
|
+
Summary = Struct.new(:purge, :through_sequence, :purged_at, :removed_chained,
|
|
18
|
+
:removed_pre_chain, :archived, :status, keyword_init: true) do
|
|
19
|
+
def as_json(*)
|
|
20
|
+
to_h.transform_keys(&:to_s).merge("status" => status.to_s)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
Result = Struct.new(:boundary, :summaries, keyword_init: true) do
|
|
25
|
+
def as_json(*)
|
|
26
|
+
{ "through" => boundary&.through_sequence, "records" => summaries.map(&:as_json) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# `report` receives (kind, sequence, detail, invocation_id, key_id, retention:).
|
|
31
|
+
def initialize(keyring:, report:)
|
|
32
|
+
@keyring = keyring
|
|
33
|
+
@report = report
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call(records, genesis_digest:)
|
|
37
|
+
boundary = nil
|
|
38
|
+
previous = nil
|
|
39
|
+
summaries = records.each_with_index.map do |record, index|
|
|
40
|
+
status = judge(record, index, previous,
|
|
41
|
+
previous ? previous.retention_digest : genesis_digest)
|
|
42
|
+
boundary = record if boundary.equal?(previous) && status != :altered
|
|
43
|
+
previous = record
|
|
44
|
+
summary(record, status)
|
|
45
|
+
end
|
|
46
|
+
Result.new(boundary: boundary, summaries: summaries)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
attr_reader :keyring
|
|
52
|
+
|
|
53
|
+
def judge(record, index, previous, link)
|
|
54
|
+
if record.purge_number != index
|
|
55
|
+
return altered(record,
|
|
56
|
+
"is numbered #{record.purge_number}, not #{index}")
|
|
57
|
+
end
|
|
58
|
+
return altered(record, "does not follow on from the purge before it") unless follows?(
|
|
59
|
+
record, previous
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
secret = keyring.for(record.key_id)
|
|
63
|
+
if secret.nil?
|
|
64
|
+
@report.call(:unknown_key, nil, "signed by key #{record.key_id}", nil, record.key_id,
|
|
65
|
+
retention: record.purge_number)
|
|
66
|
+
return :key_not_configured
|
|
67
|
+
end
|
|
68
|
+
return :verified if Digest.call(secret, record, previous_digest: link) ==
|
|
69
|
+
record.retention_digest
|
|
70
|
+
|
|
71
|
+
altered(record, "does not match its digest; it was edited, or a record before it was")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def follows?(record, previous)
|
|
75
|
+
through = previous ? previous.through_sequence : 0
|
|
76
|
+
record.previous_through == through && record.through_sequence > through
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def altered(record, detail)
|
|
80
|
+
@report.call(:retention_altered, nil, detail, nil, nil, retention: record.purge_number)
|
|
81
|
+
:altered
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def summary(record, status)
|
|
85
|
+
Summary.new(purge: record.purge_number, through_sequence: record.through_sequence,
|
|
86
|
+
purged_at: record.purged_at, removed_chained: record.removed_chained,
|
|
87
|
+
removed_pre_chain: record.removed_pre_chain, archived: record.archived,
|
|
88
|
+
status: status)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
module Retention
|
|
7
|
+
# The guard on config.ledger_retention_connection (specs/005 FR-006; constitution II,
|
|
8
|
+
# 1.1.0): the purge deletes only through a connection that reaches the ledger's own
|
|
9
|
+
# database and is not the application's.
|
|
10
|
+
#
|
|
11
|
+
# "Not the application's" is told by the username, which is all a connection config
|
|
12
|
+
# says about the role behind it. SQLite has no roles at all, so there the guard can
|
|
13
|
+
# only say so.
|
|
14
|
+
class Connection
|
|
15
|
+
SQLITE_WARNING = "SQLite has no database roles, so the retention connection cannot be " \
|
|
16
|
+
"told apart from the application's; on a server database it must be"
|
|
17
|
+
|
|
18
|
+
SAME_DATABASE = %i[adapter database host port].freeze
|
|
19
|
+
|
|
20
|
+
def initialize(retention, entry_class:)
|
|
21
|
+
@retention = retention
|
|
22
|
+
@entry_class = entry_class
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# nil, or why the purge must refuse: :no_retention_connection, :other_database,
|
|
26
|
+
# :application_role.
|
|
27
|
+
def problem
|
|
28
|
+
return :no_retention_connection if @retention.nil?
|
|
29
|
+
return :other_database unless same_database?
|
|
30
|
+
|
|
31
|
+
if roles? && retention_config[:username].to_s == ledger_config[:username].to_s
|
|
32
|
+
:application_role
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def warnings
|
|
37
|
+
!@retention.nil? && !roles? ? [SQLITE_WARNING] : []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def pool
|
|
41
|
+
@retention.connection_pool
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def same_database?
|
|
47
|
+
SAME_DATABASE.all? { |key| retention_config[key].to_s == ledger_config[key].to_s }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def roles?
|
|
51
|
+
!ledger_config[:adapter].to_s.include?("sqlite")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def retention_config
|
|
55
|
+
@retention.connection_db_config.configuration_hash
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def ledger_config
|
|
59
|
+
@entry_class.connection_db_config.configuration_hash
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
module Retention
|
|
7
|
+
# A retention record's digest (specs/005 data-model): an HMAC under the signing key over
|
|
8
|
+
# the record's fields and the previous record's digest, or the genesis digest for purge
|
|
9
|
+
# 0, so the records are a chain of their own. Canonical's form, with a tag of its own.
|
|
10
|
+
module Digest
|
|
11
|
+
TAG = "reeve-ledger-retention-v1"
|
|
12
|
+
|
|
13
|
+
FIELDS = %w[
|
|
14
|
+
purge_number through_sequence through_digest previous_through removed_chained
|
|
15
|
+
removed_pre_chain archived archive_sha256 purged_at key_id
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def call(secret, record, previous_digest:)
|
|
21
|
+
pairs = FIELDS.map { |field| [field, Canonical.value(record, field)] }
|
|
22
|
+
Canonical.hmac(secret, TAG,
|
|
23
|
+
Canonical.dump([["previous_digest", previous_digest], *pairs]))
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
module Retention
|
|
7
|
+
# Which checkpoint a purge stops at (specs/005 FR-002): one the anchor holds, that
|
|
8
|
+
# verification checked, above the current boundary.
|
|
9
|
+
class Boundary
|
|
10
|
+
def initialize(entry_class, checkpoints, verification)
|
|
11
|
+
@entry_class = entry_class
|
|
12
|
+
@current = current_through(verification)
|
|
13
|
+
checked = checkpoints.zip(verification.checkpoints)
|
|
14
|
+
.select { |_, found| found.status == :checked }.map(&:first)
|
|
15
|
+
@candidates = checked.select { |checkpoint| checkpoint.head_sequence > @current }
|
|
16
|
+
.uniq(&:head_sequence).sort_by(&:head_sequence)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The checkpoint at `sequence`, or the PurgeResult that says why there is none.
|
|
20
|
+
def at(sequence)
|
|
21
|
+
return PurgeResult.nothing(:at_or_below, current: @current) if sequence <= @current
|
|
22
|
+
|
|
23
|
+
found = @candidates.find { |checkpoint| checkpoint.head_sequence == sequence }
|
|
24
|
+
found || PurgeResult.refused(:no_checkpoint, detail: nearest(sequence))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The highest checkpoint whose entry occurred before `cutoff`.
|
|
28
|
+
def older_than(cutoff)
|
|
29
|
+
times = occurred_at(@candidates.map(&:head_sequence))
|
|
30
|
+
older = @candidates.select { |checkpoint| times[checkpoint.head_sequence]&.<(cutoff) }
|
|
31
|
+
older.last || PurgeResult.nothing(:none_older, current: @current)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def current_through(verification)
|
|
37
|
+
boundary = verification.retention&.boundary
|
|
38
|
+
boundary ? boundary.through_sequence : 0
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def nearest(sequence)
|
|
42
|
+
below = @candidates.reverse.find { |checkpoint| checkpoint.head_sequence < sequence }
|
|
43
|
+
above = @candidates.find { |checkpoint| checkpoint.head_sequence > sequence }
|
|
44
|
+
[below, above].map do |checkpoint|
|
|
45
|
+
checkpoint ? "##{Report.number(checkpoint.head_sequence)}" : "none"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def occurred_at(sequences)
|
|
50
|
+
@entry_class.where(chain_sequence: sequences).pluck(:chain_sequence, :occurred_at).to_h
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# What one purge removes and records (data-model "reeve_audit_retentions").
|
|
55
|
+
class Plan
|
|
56
|
+
attr_reader :checkpoint, :from, :through, :previous_through, :previous_digest, :purge,
|
|
57
|
+
:removed_chained, :removed_pre_chain, :start
|
|
58
|
+
|
|
59
|
+
# `current` is the latest retention record, or nil before the first purge.
|
|
60
|
+
def initialize(entry_class, current, checkpoint)
|
|
61
|
+
@checkpoint = checkpoint
|
|
62
|
+
@through = checkpoint.head_sequence
|
|
63
|
+
current ? follow(current) : first
|
|
64
|
+
@from = @previous_through + 1
|
|
65
|
+
@removed_chained = entry_class.where(chain_sequence: from..through).count
|
|
66
|
+
@removed_pre_chain = entry_class.where("contract_version < 3").count
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def record_values(archive_sha256, key_id)
|
|
70
|
+
{
|
|
71
|
+
"purge_number" => purge, "through_sequence" => through,
|
|
72
|
+
"through_digest" => checkpoint.head_digest,
|
|
73
|
+
"previous_through" => previous_through, "removed_chained" => removed_chained,
|
|
74
|
+
"removed_pre_chain" => removed_pre_chain, "archived" => !archive_sha256.nil?,
|
|
75
|
+
"archive_sha256" => archive_sha256, "purged_at" => Time.now.utc.floor(6),
|
|
76
|
+
"key_id" => key_id
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def first
|
|
83
|
+
@previous_through = 0
|
|
84
|
+
@purge = 0
|
|
85
|
+
@previous_digest = ChainRecord.ledger.genesis_digest
|
|
86
|
+
@start = nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def follow(current)
|
|
90
|
+
@previous_through = current.through_sequence
|
|
91
|
+
@purge = current.purge_number + 1
|
|
92
|
+
@previous_digest = current.retention_digest
|
|
93
|
+
@start = { "through_sequence" => current.through_sequence,
|
|
94
|
+
"through_digest" => current.through_digest }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
module Retention
|
|
7
|
+
# What a purge needs before it reads anything (specs/005 FR-004, FR-006, FR-007): a key,
|
|
8
|
+
# an anchor, a retention connection that is not the application's, and somewhere to put
|
|
9
|
+
# the archive, or leave to go without one. Each missing piece is a refusal that names it.
|
|
10
|
+
class Preflight
|
|
11
|
+
attr_reader :keyring, :guard
|
|
12
|
+
|
|
13
|
+
def initialize(entry_class:, archive:, no_archive:)
|
|
14
|
+
@keyring = Keyring.from(Reeve.config)
|
|
15
|
+
@guard = Connection.new(Reeve.config.ledger_retention_connection,
|
|
16
|
+
entry_class: entry_class)
|
|
17
|
+
@archive = archive
|
|
18
|
+
@no_archive = no_archive
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def refusal
|
|
22
|
+
return PurgeResult.refused(:no_key) unless keyring.signing?
|
|
23
|
+
return PurgeResult.refused(:no_anchor) if Reeve.config.ledger_anchor.nil?
|
|
24
|
+
|
|
25
|
+
PurgeResult.refused(guard.problem) if guard.problem
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Only a new purge writes an archive; a resumed one already did.
|
|
29
|
+
def archive_refusal
|
|
30
|
+
return nil if @no_archive
|
|
31
|
+
return PurgeResult.refused(:no_archive_choice) if @archive.nil?
|
|
32
|
+
return PurgeResult.refused(:archive_exists, detail: @archive) if File.exist?(@archive)
|
|
33
|
+
return nil if writable_directory?
|
|
34
|
+
|
|
35
|
+
PurgeResult.refused(:archive_unwritable, detail: @archive)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def writable_directory?
|
|
41
|
+
directory = File.dirname(@archive)
|
|
42
|
+
File.directory?(directory) && File.writable?(directory)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
module Retention
|
|
7
|
+
# `bin/rails reeve:purge_ledger` (specs/005; constitution II, 1.1.0): removes the chain's
|
|
8
|
+
# entries up to a checkpoint the anchor holds, and the rows written before the chain,
|
|
9
|
+
# leaving a signed retention record so that what remains still verifies and the
|
|
10
|
+
# removal reads as a purge, not as tampering.
|
|
11
|
+
#
|
|
12
|
+
# In this order, and nothing removed until the last step: refuse what cannot be backed;
|
|
13
|
+
# pick the boundary; require the whole ledger to verify INTACT; stop here for a dry run;
|
|
14
|
+
# write the archive; commit the record; delete in batches over the retention
|
|
15
|
+
# connection. A purge stopped during the deletes leaves rows "awaiting purge", which a
|
|
16
|
+
# rerun removes without a second record.
|
|
17
|
+
class Purge
|
|
18
|
+
BATCH_SIZE = 1_000
|
|
19
|
+
|
|
20
|
+
DAY = 86_400
|
|
21
|
+
|
|
22
|
+
EXACTLY_ONE = "give exactly one of through: (THROUGH=) and older_than: (OLDER_THAN=)"
|
|
23
|
+
|
|
24
|
+
# `through` is a checkpoint's sequence; `older_than` a number of days. `archive` is a
|
|
25
|
+
# path, or nil with `no_archive`.
|
|
26
|
+
def initialize(entry_class:, through: nil, older_than: nil, archive: nil,
|
|
27
|
+
no_archive: false, confirm: false, batch_size: BATCH_SIZE)
|
|
28
|
+
raise ArgumentError, EXACTLY_ONE if through.nil? == older_than.nil?
|
|
29
|
+
|
|
30
|
+
@entry_class = entry_class
|
|
31
|
+
@through = through
|
|
32
|
+
@older_than = older_than
|
|
33
|
+
@archive = archive
|
|
34
|
+
@no_archive = no_archive
|
|
35
|
+
@confirm = confirm
|
|
36
|
+
@batch_size = batch_size
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call
|
|
40
|
+
@preflight = Preflight.new(entry_class: entry_class, archive: @archive,
|
|
41
|
+
no_archive: @no_archive)
|
|
42
|
+
refusal = @preflight.refusal
|
|
43
|
+
return refusal if refusal
|
|
44
|
+
|
|
45
|
+
verification = Chain.verify(entry_class: entry_class, checkpoints: checkpoints)
|
|
46
|
+
# INTACT first, a resume included: a forged record under a key nobody holds reads as
|
|
47
|
+
# unverifiable, and resuming from it would delete rows on the forger's word.
|
|
48
|
+
unless verification.intact?
|
|
49
|
+
return PurgeResult.refused(:not_intact, detail: verification.to_s.lines.first.chomp)
|
|
50
|
+
end
|
|
51
|
+
return resume(verification) if verification.awaiting_purge.positive?
|
|
52
|
+
|
|
53
|
+
purge(verification)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
attr_reader :entry_class
|
|
59
|
+
|
|
60
|
+
def keyring = @preflight.keyring
|
|
61
|
+
|
|
62
|
+
def guard = @preflight.guard
|
|
63
|
+
|
|
64
|
+
def checkpoints
|
|
65
|
+
(@checkpoints ||= Checkpoint.read_all(Reeve.config.ledger_anchor.checkpoints))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A resumed purge already wrote its archive; only a new one needs a path for it.
|
|
69
|
+
def purge(verification)
|
|
70
|
+
refusal = @preflight.archive_refusal
|
|
71
|
+
return refusal if refusal
|
|
72
|
+
|
|
73
|
+
current = verification.retention&.boundary
|
|
74
|
+
boundary = Boundary.new(entry_class, checkpoints, verification)
|
|
75
|
+
chosen = @through ? boundary.at(@through) : boundary.older_than(cutoff)
|
|
76
|
+
return chosen unless chosen.is_a?(Checkpoint)
|
|
77
|
+
|
|
78
|
+
plan = Plan.new(entry_class, current, chosen)
|
|
79
|
+
unless @confirm
|
|
80
|
+
return PurgeResult.from(plan, :dry_run, archive: @archive,
|
|
81
|
+
warnings: guard.warnings)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
commit(plan)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def cutoff = Time.now - (@older_than * DAY)
|
|
88
|
+
|
|
89
|
+
def commit(plan)
|
|
90
|
+
sha256 = @no_archive ? nil : Archive.write(@archive, plan, entry_class: entry_class)
|
|
91
|
+
number = insert_record(plan, sha256)
|
|
92
|
+
delete_through(plan.through)
|
|
93
|
+
PurgeResult.from(plan, :purged, archive: @archive, archive_sha256: sha256,
|
|
94
|
+
purge: number,
|
|
95
|
+
warnings: guard.warnings)
|
|
96
|
+
rescue ActiveRecord::RecordNotUnique
|
|
97
|
+
PurgeResult.refused(:purge_running)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The record names the boundary checkpoint, what the purge removes, and the archive,
|
|
101
|
+
# signed and linked to the record before it (data-model).
|
|
102
|
+
def insert_record(plan, sha256)
|
|
103
|
+
values = plan.record_values(sha256, keyring.signing_id)
|
|
104
|
+
values["retention_digest"] =
|
|
105
|
+
Digest.call(keyring.signing_secret, values, previous_digest: plan.previous_digest)
|
|
106
|
+
guard.pool.with_connection { |connection| insert(connection, values) }
|
|
107
|
+
values["purge_number"]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def insert(connection, values)
|
|
111
|
+
quoted = values.values.map { |value| connection.quote(value) }
|
|
112
|
+
columns = values.keys.join(", ")
|
|
113
|
+
connection.insert("INSERT INTO #{RetentionRecord.table_name} (#{columns}) " \
|
|
114
|
+
"VALUES (#{quoted.join(', ')})")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A purge whose record is written but whose rows are not all gone: finish it.
|
|
118
|
+
def resume(verification)
|
|
119
|
+
boundary = verification.retention.boundary
|
|
120
|
+
removed = delete_through(boundary.through_sequence)
|
|
121
|
+
PurgeResult.resumed(boundary, removed, warnings: guard.warnings)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def delete_through(through)
|
|
125
|
+
removed = 0
|
|
126
|
+
loop do
|
|
127
|
+
count = delete_batch(through)
|
|
128
|
+
removed += count
|
|
129
|
+
break if count < @batch_size
|
|
130
|
+
end
|
|
131
|
+
removed
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# One batch, in its own transaction: rows chained at or below the boundary, and rows
|
|
135
|
+
# written before the chain. Unchained contract-3+ rows are left (spec Clarifications).
|
|
136
|
+
def delete_batch(through)
|
|
137
|
+
guard.pool.with_connection do |connection|
|
|
138
|
+
ids = connection.select_values(doomed_ids(through)).map { |id| Integer(id) }
|
|
139
|
+
next 0 if ids.empty?
|
|
140
|
+
|
|
141
|
+
connection.delete("DELETE FROM #{entry_class.table_name} " \
|
|
142
|
+
"WHERE id IN (#{ids.join(', ')})")
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def doomed_ids(through)
|
|
147
|
+
"SELECT id FROM #{entry_class.table_name} WHERE (chain_sequence IS NOT NULL AND " \
|
|
148
|
+
"chain_sequence <= #{Integer(through)}) OR contract_version < 3 ORDER BY id " \
|
|
149
|
+
"LIMIT #{Integer(@batch_size)}"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|