reeve 0.7.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 +117 -0
- data/README.md +236 -9
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +29 -4
- data/lib/generators/reeve/install/templates/initializer.rb.tt +10 -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 +10 -1
- data/lib/generators/reeve/upgrade/upgrade_notes.rb +15 -4
- data/lib/reeve/audit/chain/anchoring.rb +91 -0
- data/lib/reeve/audit/chain/canonical.rb +17 -1
- 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/epochs.rb +10 -4
- data/lib/reeve/audit/chain/head.rb +26 -1
- data/lib/reeve/audit/chain/keyring.rb +6 -0
- data/lib/reeve/audit/chain/report.rb +188 -31
- 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/verification.rb +31 -9
- data/lib/reeve/audit/chain/verifier.rb +64 -19
- data/lib/reeve/audit/chain/walk.rb +9 -3
- data/lib/reeve/audit/chain.rb +12 -3
- data/lib/reeve/audit/chain_record.rb +3 -2
- data/lib/reeve/audit/file_anchor.rb +49 -0
- data/lib/reeve/audit/retention_record.rb +24 -0
- data/lib/reeve/audit.rb +16 -0
- data/lib/reeve/configuration.rb +25 -0
- data/lib/reeve/errors.rb +15 -0
- data/lib/reeve/railtie.rb +2 -2
- data/lib/reeve/tasks/descriptions.rb +22 -0
- data/lib/reeve/tasks/purge_options.rb +54 -0
- data/lib/reeve/tasks.rb +82 -14
- 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 +21 -2
|
@@ -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
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
# What `bin/rails reeve:purge_ledger` did, or why it did nothing (specs/005 data-model,
|
|
7
|
+
# contracts/retention.md). A value, so a script can act on it without reading the text.
|
|
8
|
+
#
|
|
9
|
+
# :dry_run — what a run with CONFIRM=1 would remove; nothing removed
|
|
10
|
+
# :purged — removed through `through`, recorded as retention record `purge`
|
|
11
|
+
# :nothing_to_purge — no checkpoint above the current boundary qualifies
|
|
12
|
+
# :refused — nothing removed; `reason` says why
|
|
13
|
+
class PurgeResult
|
|
14
|
+
PREFIX = "reeve ledger purge:"
|
|
15
|
+
AGAIN = "run again with CONFIRM=1"
|
|
16
|
+
|
|
17
|
+
FIELDS = %i[status reason detail from through through_made_at removed_chained
|
|
18
|
+
removed_pre_chain archive archive_sha256 purge resumed warnings].freeze
|
|
19
|
+
|
|
20
|
+
REFUSALS = {
|
|
21
|
+
no_key: "no ledger key configured (config.ledger_key)",
|
|
22
|
+
no_anchor: "no anchor configured (config.ledger_anchor): a purge must stop at a " \
|
|
23
|
+
"checkpoint kept outside the database",
|
|
24
|
+
no_retention_connection: "no retention connection configured " \
|
|
25
|
+
"(config.ledger_retention_connection)",
|
|
26
|
+
other_database: "config.ledger_retention_connection reaches another database than " \
|
|
27
|
+
"the ledger's",
|
|
28
|
+
application_role: "config.ledger_retention_connection connects as the application's " \
|
|
29
|
+
"own database user; the purge must run on a role the application " \
|
|
30
|
+
"does not use",
|
|
31
|
+
no_checkpoint: "no checkpoint the anchor holds is at that sequence (nearest below: %s, " \
|
|
32
|
+
"above: %s)",
|
|
33
|
+
not_intact: "the ledger does not verify intact, and a purge never removes what it " \
|
|
34
|
+
"cannot prove: %s",
|
|
35
|
+
no_archive_choice: "give ARCHIVE=path to keep the purged entries, or NO_ARCHIVE=1 to " \
|
|
36
|
+
"delete them without one",
|
|
37
|
+
archive_exists: "the archive %s already exists; an archive is never overwritten",
|
|
38
|
+
archive_unwritable: "the archive %s cannot be written there",
|
|
39
|
+
purge_running: "another purge took the next retention record number; run again once " \
|
|
40
|
+
"it is done"
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
attr_reader(*FIELDS)
|
|
44
|
+
|
|
45
|
+
def self.refused(reason, detail: nil)
|
|
46
|
+
new(status: :refused, reason: reason, detail: detail)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.nothing(reason, current:)
|
|
50
|
+
new(status: :nothing_to_purge, reason: reason, through: current)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.from(plan, status, **fields)
|
|
54
|
+
new(status: status, from: plan.from, through: plan.through,
|
|
55
|
+
through_made_at: plan.checkpoint.made_at, removed_chained: plan.removed_chained,
|
|
56
|
+
removed_pre_chain: plan.removed_pre_chain, **fields)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.resumed(record, removed, warnings:)
|
|
60
|
+
new(status: :purged, from: record.previous_through + 1, through: record.through_sequence,
|
|
61
|
+
purge: record.purge_number, removed_chained: record.removed_chained,
|
|
62
|
+
removed_pre_chain: record.removed_pre_chain, resumed: removed,
|
|
63
|
+
archive_sha256: record.archive_sha256, warnings: warnings)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def initialize(status:, warnings: [], **fields)
|
|
67
|
+
@status = status
|
|
68
|
+
@warnings = warnings.freeze
|
|
69
|
+
(FIELDS - %i[status warnings]).each do |name|
|
|
70
|
+
instance_variable_set(:"@#{name}", fields[name])
|
|
71
|
+
end
|
|
72
|
+
freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def dry_run? = status == :dry_run
|
|
76
|
+
def purged? = status == :purged
|
|
77
|
+
def refused? = status == :refused
|
|
78
|
+
|
|
79
|
+
def to_s
|
|
80
|
+
[first_line, *warnings.map { |warning| "warning: #{warning}" }].join("\n")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def first_line
|
|
86
|
+
case status
|
|
87
|
+
when :dry_run then "#{PREFIX} WOULD PURGE — #{removal}; #{archive_text}; #{AGAIN}"
|
|
88
|
+
when :purged then "#{PREFIX} PURGED — #{purged_text}"
|
|
89
|
+
when :nothing_to_purge then "#{PREFIX} NOTHING TO PURGE — #{nothing_text}"
|
|
90
|
+
else "#{PREFIX} REFUSED — #{refusal}; nothing was removed"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def purged_text
|
|
95
|
+
record = "#{removal}; retention record #{purge}"
|
|
96
|
+
return "#{record} (resumed; #{n(resumed)} rows removed this run)" if resumed
|
|
97
|
+
|
|
98
|
+
"#{record}; #{archive_text}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def removal
|
|
102
|
+
made = through_made_at ? " (#{AnchorText.made_on(through_made_at)})" : ""
|
|
103
|
+
"sequences #{n(from || 1)}–#{n(through)} through checkpoint ##{n(through)}#{made}: " \
|
|
104
|
+
"#{n(removed_chained)} chained entries and #{n(removed_pre_chain)} pre-chain rows"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def archive_text
|
|
108
|
+
return "no archive written (NO_ARCHIVE=1)" if archive.nil?
|
|
109
|
+
return "archive to #{archive}" if dry_run?
|
|
110
|
+
|
|
111
|
+
"archive #{archive} (sha256 #{archive_sha256})"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def nothing_text
|
|
115
|
+
where = "above the current boundary (##{n(through)})"
|
|
116
|
+
return "no checkpoint old enough #{where}" unless reason == :at_or_below
|
|
117
|
+
|
|
118
|
+
"the ledger is already purged through that sequence #{where}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def refusal
|
|
122
|
+
template = REFUSALS.fetch(reason)
|
|
123
|
+
return template unless template.include?("%s")
|
|
124
|
+
|
|
125
|
+
format(template, *Array(detail))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def n(value)
|
|
129
|
+
Report.number(value.to_i)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
# The ledger's purges (specs/005), as verification and the purge read them.
|
|
7
|
+
module Retention
|
|
8
|
+
# Every retention record, oldest first, in one query. None for a ledger never purged,
|
|
9
|
+
# or one whose table has not been migrated: it verifies exactly as before.
|
|
10
|
+
module Records
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def read(entry_class:)
|
|
14
|
+
present = entry_class.connection_pool.with_connection do |connection|
|
|
15
|
+
connection.table_exists?(RetentionRecord.table_name)
|
|
16
|
+
end
|
|
17
|
+
present ? RetentionRecord.uncached { RetentionRecord.order(:purge_number).to_a } : []
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -16,8 +16,11 @@ module Reeve
|
|
|
16
16
|
# position; an unchained row has none and is named by `invocation_id` instead.
|
|
17
17
|
# `key_id` is set for unknown_key: the fingerprint of the key the verifier lacked.
|
|
18
18
|
# `epoch` is set for a break about a key epoch's record (specs/003), whose sequence is nil.
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
# `checkpoint` is the head sequence of the checkpoint a break is about (specs/004); such a
|
|
20
|
+
# break has no sequence of its own.
|
|
21
|
+
# `retention` is the number of the retention record a break is about (specs/005).
|
|
22
|
+
Break = Struct.new(:kind, :sequence, :detail, :invocation_id, :key_id, :epoch, :checkpoint,
|
|
23
|
+
:retention, keyword_init: true)
|
|
21
24
|
|
|
22
25
|
# One key epoch as verification found it (specs/003 FR-013). `entry_count` counts the
|
|
23
26
|
# walked rows that named it; `status` is :verified, :key_not_configured or :altered.
|
|
@@ -37,6 +40,8 @@ module Reeve
|
|
|
37
40
|
digest_mismatch missing duplicate unchained head_mismatch beyond_head
|
|
38
41
|
genesis_altered chain_record_missing unknown_key
|
|
39
42
|
epoch_missing epoch_mismatch epoch_altered
|
|
43
|
+
checkpoint_not_reached checkpoint_mismatch checkpoint_altered
|
|
44
|
+
retention_altered
|
|
40
45
|
].freeze
|
|
41
46
|
|
|
42
47
|
# The status rules (data-model), for a verification that had a key and found a
|
|
@@ -53,11 +58,15 @@ module Reeve
|
|
|
53
58
|
|
|
54
59
|
attr_reader :status, :covered, :breaks, :head_at_start, :head_at_end,
|
|
55
60
|
:uncovered_now, :uncovered_at_genesis, :unchained_now, :unchained_at_genesis,
|
|
56
|
-
:reason, :epochs, :before_first_epoch
|
|
61
|
+
:reason, :epochs, :before_first_epoch,
|
|
62
|
+
:checkpoints, :anchored_at, :beyond_anchor, :walked_from,
|
|
63
|
+
:retention, :awaiting_purge
|
|
57
64
|
|
|
58
65
|
def initialize(status:, covered: nil, breaks: [], head_at_start: nil, head_at_end: nil,
|
|
59
66
|
uncovered_now: 0, uncovered_at_genesis: nil, unchained_now: 0,
|
|
60
|
-
unchained_at_genesis: nil, reason: nil, epochs: [], before_first_epoch: nil
|
|
67
|
+
unchained_at_genesis: nil, reason: nil, epochs: [], before_first_epoch: nil,
|
|
68
|
+
checkpoints: [], anchored_at: nil, beyond_anchor: nil, walked_from: nil,
|
|
69
|
+
retention: nil, awaiting_purge: 0)
|
|
61
70
|
raise ArgumentError, "unknown status #{status.inspect}" unless STATUSES.include?(status)
|
|
62
71
|
|
|
63
72
|
@status = status
|
|
@@ -72,12 +81,16 @@ module Reeve
|
|
|
72
81
|
@reason = reason
|
|
73
82
|
@epochs = epochs.freeze
|
|
74
83
|
@before_first_epoch = before_first_epoch
|
|
84
|
+
@checkpoints = checkpoints.freeze
|
|
85
|
+
@anchored_at = anchored_at
|
|
86
|
+
@beyond_anchor = beyond_anchor
|
|
87
|
+
@walked_from = walked_from
|
|
88
|
+
@retention = retention
|
|
89
|
+
@awaiting_purge = awaiting_purge
|
|
75
90
|
freeze
|
|
76
91
|
end
|
|
77
92
|
|
|
78
|
-
def intact?
|
|
79
|
-
status == :intact
|
|
80
|
-
end
|
|
93
|
+
def intact? = status == :intact
|
|
81
94
|
|
|
82
95
|
# Never capped, whatever the display does with the list.
|
|
83
96
|
def totals
|
|
@@ -111,14 +124,23 @@ module Reeve
|
|
|
111
124
|
|
|
112
125
|
def listing_json
|
|
113
126
|
{ "breaks" => breaks.map { |b| break_json(b) }, "epochs" => epochs.map(&:as_json),
|
|
114
|
-
"before_first_epoch" => before_first_epoch
|
|
127
|
+
"before_first_epoch" => before_first_epoch, "checkpoints" => checkpoints.map(&:as_json),
|
|
128
|
+
"anchored_at" => point_json(anchored_at), "beyond_anchor" => beyond_anchor,
|
|
129
|
+
"walked_from" => point_json(walked_from), "retention" => retention&.as_json,
|
|
130
|
+
"awaiting_purge" => awaiting_purge }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def point_json(checkpoint)
|
|
134
|
+
checkpoint && { "head_sequence" => checkpoint.head_sequence,
|
|
135
|
+
"made_at" => checkpoint.made_at }
|
|
115
136
|
end
|
|
116
137
|
|
|
117
138
|
def break_json(found)
|
|
118
139
|
{
|
|
119
140
|
"kind" => found.kind.to_s, "sequence" => range_json(found.sequence),
|
|
120
141
|
"detail" => found.detail, "invocation_id" => found.invocation_id,
|
|
121
|
-
"key_id" => found.key_id, "epoch" => found.epoch
|
|
142
|
+
"key_id" => found.key_id, "epoch" => found.epoch, "checkpoint" => found.checkpoint,
|
|
143
|
+
"retention" => found.retention
|
|
122
144
|
}
|
|
123
145
|
end
|
|
124
146
|
|