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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +293 -3
  3. data/README.md +276 -11
  4. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +52 -5
  5. data/lib/generators/reeve/install/templates/initializer.rb.tt +10 -0
  6. data/lib/generators/reeve/upgrade/templates/add_key_epochs_to_reeve_audit_entries.rb.tt +35 -0
  7. data/lib/generators/reeve/upgrade/templates/add_ledger_retention_to_reeve_audit_entries.rb.tt +37 -0
  8. data/lib/generators/reeve/upgrade/upgrade_generator.rb +19 -25
  9. data/lib/generators/reeve/upgrade/upgrade_notes.rb +62 -0
  10. data/lib/reeve/audit/chain/anchoring.rb +91 -0
  11. data/lib/reeve/audit/chain/canonical.rb +38 -7
  12. data/lib/reeve/audit/chain/checkpoint.rb +228 -0
  13. data/lib/reeve/audit/chain/checkpoint_check.rb +156 -0
  14. data/lib/reeve/audit/chain/entrypoints.rb +29 -0
  15. data/lib/reeve/audit/chain/epoch_rules.rb +159 -0
  16. data/lib/reeve/audit/chain/epochs.rb +83 -0
  17. data/lib/reeve/audit/chain/head.rb +55 -2
  18. data/lib/reeve/audit/chain/keyring.rb +6 -0
  19. data/lib/reeve/audit/chain/report.rb +232 -32
  20. data/lib/reeve/audit/chain/retention/archive.rb +98 -0
  21. data/lib/reeve/audit/chain/retention/archive_check.rb +179 -0
  22. data/lib/reeve/audit/chain/retention/check.rb +94 -0
  23. data/lib/reeve/audit/chain/retention/connection.rb +65 -0
  24. data/lib/reeve/audit/chain/retention/digest.rb +29 -0
  25. data/lib/reeve/audit/chain/retention/plan.rb +100 -0
  26. data/lib/reeve/audit/chain/retention/preflight.rb +48 -0
  27. data/lib/reeve/audit/chain/retention/purge.rb +155 -0
  28. data/lib/reeve/audit/chain/retention/purge_result.rb +134 -0
  29. data/lib/reeve/audit/chain/retention/records.rb +23 -0
  30. data/lib/reeve/audit/chain/rotation.rb +124 -0
  31. data/lib/reeve/audit/chain/schema.rb +59 -0
  32. data/lib/reeve/audit/chain/verification.rb +53 -10
  33. data/lib/reeve/audit/chain/verifier.rb +75 -22
  34. data/lib/reeve/audit/chain/walk.rb +24 -4
  35. data/lib/reeve/audit/chain.rb +61 -13
  36. data/lib/reeve/audit/chain_epoch.rb +24 -0
  37. data/lib/reeve/audit/chain_record.rb +14 -8
  38. data/lib/reeve/audit/file_anchor.rb +49 -0
  39. data/lib/reeve/audit/retention_record.rb +24 -0
  40. data/lib/reeve/audit.rb +30 -3
  41. data/lib/reeve/configuration.rb +25 -0
  42. data/lib/reeve/errors.rb +15 -0
  43. data/lib/reeve/railtie.rb +2 -1
  44. data/lib/reeve/tasks/descriptions.rb +22 -0
  45. data/lib/reeve/tasks/purge_options.rb +54 -0
  46. data/lib/reeve/tasks.rb +104 -7
  47. data/lib/reeve/testing/checks/contract_version.rb +2 -2
  48. data/lib/reeve/testing/checks/ledger_chain.rb +56 -2
  49. data/lib/reeve/testing/ledger.rb +19 -3
  50. data/lib/reeve/version.rb +1 -1
  51. 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