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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +117 -0
  3. data/README.md +236 -9
  4. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +29 -4
  5. data/lib/generators/reeve/install/templates/initializer.rb.tt +10 -0
  6. data/lib/generators/reeve/upgrade/templates/add_ledger_retention_to_reeve_audit_entries.rb.tt +37 -0
  7. data/lib/generators/reeve/upgrade/upgrade_generator.rb +10 -1
  8. data/lib/generators/reeve/upgrade/upgrade_notes.rb +15 -4
  9. data/lib/reeve/audit/chain/anchoring.rb +91 -0
  10. data/lib/reeve/audit/chain/canonical.rb +17 -1
  11. data/lib/reeve/audit/chain/checkpoint.rb +228 -0
  12. data/lib/reeve/audit/chain/checkpoint_check.rb +156 -0
  13. data/lib/reeve/audit/chain/entrypoints.rb +29 -0
  14. data/lib/reeve/audit/chain/epochs.rb +10 -4
  15. data/lib/reeve/audit/chain/head.rb +26 -1
  16. data/lib/reeve/audit/chain/keyring.rb +6 -0
  17. data/lib/reeve/audit/chain/report.rb +188 -31
  18. data/lib/reeve/audit/chain/retention/archive.rb +98 -0
  19. data/lib/reeve/audit/chain/retention/archive_check.rb +179 -0
  20. data/lib/reeve/audit/chain/retention/check.rb +94 -0
  21. data/lib/reeve/audit/chain/retention/connection.rb +65 -0
  22. data/lib/reeve/audit/chain/retention/digest.rb +29 -0
  23. data/lib/reeve/audit/chain/retention/plan.rb +100 -0
  24. data/lib/reeve/audit/chain/retention/preflight.rb +48 -0
  25. data/lib/reeve/audit/chain/retention/purge.rb +155 -0
  26. data/lib/reeve/audit/chain/retention/purge_result.rb +134 -0
  27. data/lib/reeve/audit/chain/retention/records.rb +23 -0
  28. data/lib/reeve/audit/chain/verification.rb +31 -9
  29. data/lib/reeve/audit/chain/verifier.rb +64 -19
  30. data/lib/reeve/audit/chain/walk.rb +9 -3
  31. data/lib/reeve/audit/chain.rb +12 -3
  32. data/lib/reeve/audit/chain_record.rb +3 -2
  33. data/lib/reeve/audit/file_anchor.rb +49 -0
  34. data/lib/reeve/audit/retention_record.rb +24 -0
  35. data/lib/reeve/audit.rb +16 -0
  36. data/lib/reeve/configuration.rb +25 -0
  37. data/lib/reeve/errors.rb +15 -0
  38. data/lib/reeve/railtie.rb +2 -2
  39. data/lib/reeve/tasks/descriptions.rb +22 -0
  40. data/lib/reeve/tasks/purge_options.rb +54 -0
  41. data/lib/reeve/tasks.rb +82 -14
  42. data/lib/reeve/testing/checks/ledger_chain.rb +56 -2
  43. data/lib/reeve/testing/ledger.rb +19 -3
  44. data/lib/reeve/version.rb +1 -1
  45. 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
- Break = Struct.new(:kind, :sequence, :detail, :invocation_id, :key_id, :epoch,
20
- keyword_init: true)
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