reeve 0.5.0 → 0.7.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +281 -3
  3. data/README.md +151 -6
  4. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +63 -3
  5. data/lib/generators/reeve/install/templates/initializer.rb.tt +20 -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_chain_to_reeve_audit_entries.rb.tt +51 -0
  8. data/lib/generators/reeve/upgrade/upgrade_generator.rb +25 -2
  9. data/lib/generators/reeve/upgrade/upgrade_notes.rb +51 -0
  10. data/lib/reeve/audit/chain/canonical.rb +144 -0
  11. data/lib/reeve/audit/chain/epoch_rules.rb +159 -0
  12. data/lib/reeve/audit/chain/epochs.rb +77 -0
  13. data/lib/reeve/audit/chain/head.rb +122 -0
  14. data/lib/reeve/audit/chain/keyring.rb +53 -0
  15. data/lib/reeve/audit/chain/normalizer.rb +88 -0
  16. data/lib/reeve/audit/chain/report.rb +177 -0
  17. data/lib/reeve/audit/chain/rotation.rb +124 -0
  18. data/lib/reeve/audit/chain/schema.rb +59 -0
  19. data/lib/reeve/audit/chain/verification.rb +131 -0
  20. data/lib/reeve/audit/chain/verifier.rb +168 -0
  21. data/lib/reeve/audit/chain/walk.rb +137 -0
  22. data/lib/reeve/audit/chain.rb +207 -0
  23. data/lib/reeve/audit/chain_epoch.rb +24 -0
  24. data/lib/reeve/audit/chain_record.rb +66 -0
  25. data/lib/reeve/audit/isolated_recorder.rb +5 -0
  26. data/lib/reeve/audit/recorder.rb +32 -19
  27. data/lib/reeve/audit.rb +37 -6
  28. data/lib/reeve/configuration.rb +66 -2
  29. data/lib/reeve/errors.rb +15 -0
  30. data/lib/reeve/ledger_key.rb +47 -0
  31. data/lib/reeve/railtie.rb +16 -0
  32. data/lib/reeve/tasks.rb +91 -0
  33. data/lib/reeve/testing/checks/contract_version.rb +2 -1
  34. data/lib/reeve/testing/checks/ledger_chain.rb +116 -0
  35. data/lib/reeve/testing/checks.rb +29 -4
  36. data/lib/reeve/testing/compliance_assertions.rb +3 -2
  37. data/lib/reeve/testing/compliance_suite.rb +2 -2
  38. data/lib/reeve/testing/ledger.rb +11 -1
  39. data/lib/reeve/testing/report.rb +8 -0
  40. data/lib/reeve/testing.rb +1 -1
  41. data/lib/reeve/version.rb +1 -1
  42. data/lib/reeve.rb +2 -0
  43. metadata +24 -2
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ class Chain
6
+ # Checks the chain and reports every place it no longer holds (specs/002 FR-012–FR-015,
7
+ # research R6).
8
+ #
9
+ # The chain record is read first; its head is where the walk (Walk) stops. After the
10
+ # walk the record is checked against what was found — the genesis against its digest,
11
+ # the head against the last row — and the head is read again. Rows between the two
12
+ # reads are writes that committed during the walk: legitimate, and left for the next
13
+ # run. A row above the second read cannot be legitimate, because reeve commits a row
14
+ # and its head move together, so it is reported. Without that second read, a row
15
+ # forged past the head would never be examined by any run.
16
+ #
17
+ # == What it cannot say
18
+ #
19
+ # A failing digest means the row's content, its position or its predecessor changed;
20
+ # which one is not knowable from the digest, and the report does not guess. Rows
21
+ # written before the chain began are counted, never verified.
22
+ class Verifier
23
+ BATCH_SIZE = 1_000
24
+
25
+ RECORD_MISSING = "chained rows exist but the chain record does not"
26
+ UNCHAINED_ROW = "a contract-3+ row outside the chain, written after it started (by clock)"
27
+
28
+ def initialize(entry_class:, keyring:, batch_size: BATCH_SIZE)
29
+ @entry_class = entry_class
30
+ @keyring = keyring
31
+ @batch_size = batch_size
32
+ end
33
+
34
+ def call
35
+ return unwalked(:unverifiable, :no_key) if keyring.empty?
36
+
37
+ ChainRecord.require_same_database!(entry_class)
38
+
39
+ record = read_head
40
+ return unwalked(:no_chain) unless record&.head_sequence&.positive? || chained_rows?
41
+
42
+ verify_chain(record)
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :entry_class, :keyring, :batch_size, :breaks
48
+
49
+ # The epochs are read after the head: every row the walk visits committed, with any
50
+ # epoch it opened, before that read. They are checked against the head read *after*
51
+ # the walk, which is later than they were read, so an epoch opened by a rotation
52
+ # during the run is never mistaken for one placed beyond the head.
53
+ def verify_chain(record)
54
+ @breaks = []
55
+ epochs = EpochRules.read(keyring: keyring, entry_class: entry_class)
56
+ walk = walk(record, epochs)
57
+ check_record(record, walk)
58
+ head_at_end = record && read_head
59
+ check_beyond_head(head_at_end || record)
60
+ check_unchained(record)
61
+ epochs.check!(record, head_at_end || record, report: method(:add))
62
+
63
+ result(record, head_at_end, walk.last_sequence, epochs)
64
+ end
65
+
66
+ def walk(record, epochs)
67
+ Walk.new(entry_class: entry_class, keyring: keyring, batch_size: batch_size,
68
+ link: record&.genesis_digest, report: method(:add), epochs: epochs)
69
+ .call(record&.head_sequence)
70
+ end
71
+
72
+ # A seam as well as a read: the specs wrap it to land a write mid-walk.
73
+ def read_head
74
+ ChainRecord.uncached { ChainRecord.find_by(name: ChainRecord::LEDGER) }
75
+ end
76
+
77
+ def check_record(record, walk)
78
+ return add(:chain_record_missing, nil, RECORD_MISSING) if record.nil?
79
+
80
+ check_genesis(record)
81
+ head = record.head_sequence
82
+ last_digest = walk.last_sequence.zero? ? record.genesis_digest : walk.last_digest
83
+ return if walk.last_sequence == head && last_digest == record.head_digest
84
+
85
+ add(:head_mismatch, head,
86
+ "the head is at #{head}, the last chained row found is #{walk.last_sequence}")
87
+ end
88
+
89
+ def check_genesis(record)
90
+ key_id = record.genesis_key_id
91
+ secret = keyring.for(key_id)
92
+ if secret.nil?
93
+ return add(:unknown_key, 0, "the genesis is signed by key #{key_id}", nil, key_id)
94
+ end
95
+ return if Canonical.genesis_digest(secret, record) == record.genesis_digest
96
+
97
+ add(:genesis_altered, 0, "the genesis does not match its digest")
98
+ end
99
+
100
+ def check_beyond_head(record)
101
+ return if record.nil?
102
+
103
+ candidates = entry_class.where("chain_sequence > ?", record.head_sequence)
104
+ .order(:chain_sequence).pluck(:chain_sequence)
105
+ return if candidates.empty?
106
+
107
+ # Read the head once more, after the search. A row committed between the last read
108
+ # and the search moved the head with it, so the head now covers it; a row above
109
+ # this read cannot have been written by reeve.
110
+ head = read_head&.head_sequence || record.head_sequence
111
+ detail = "numbered past the head (#{Report.number(head)} at the end of the walk); " \
112
+ "reeve did not write it"
113
+ candidates.select { |sequence| sequence > head }
114
+ .each { |sequence| add(:beyond_head, sequence, detail) }
115
+ end
116
+
117
+ # Contract-3 rows with no digest, beyond the number that already existed when the
118
+ # chain began (research R5). Which ones are new is known only by clock, and the
119
+ # detail says so.
120
+ def check_unchained(record)
121
+ return if count_unchained <= (record ? record.pre_chain_unchained_count : 0)
122
+
123
+ scope = entry_class.where(UNCHAINED)
124
+ scope = scope.where("occurred_at > ?", record.genesis_at) if record
125
+ scope.order(:occurred_at).pluck(:invocation_id).each do |invocation_id|
126
+ add(:unchained, nil, UNCHAINED_ROW, invocation_id)
127
+ end
128
+ end
129
+
130
+ # `epoch` names the key epoch a break is about when it has no sequence (specs/003).
131
+ def add(kind, sequence, detail = nil, invocation_id = nil, key_id = nil, epoch: nil)
132
+ found = { kind: kind, sequence: sequence, detail: detail, invocation_id: invocation_id }
133
+ breaks << Verification::Break.new(**found, key_id: key_id, epoch: epoch)
134
+ end
135
+
136
+ def result(record, head_at_end, last_sequence, epochs)
137
+ Verification.new(
138
+ status: Verification.status_for(breaks), breaks: breaks,
139
+ covered: covered(record, last_sequence),
140
+ epochs: epochs.summaries, before_first_epoch: epochs.before_first_epoch,
141
+ head_at_start: record&.head_sequence, head_at_end: head_at_end&.head_sequence,
142
+ uncovered_now: count_uncovered, uncovered_at_genesis: record&.pre_chain_count,
143
+ unchained_now: count_unchained, unchained_at_genesis: record&.pre_chain_unchained_count
144
+ )
145
+ end
146
+
147
+ def covered(record, last_sequence)
148
+ last = record ? record.head_sequence : last_sequence
149
+ last.positive? ? 1..last : nil
150
+ end
151
+
152
+ # An answer given before any walk: no key to walk with, or no chain to walk.
153
+ def unwalked(status, reason = nil)
154
+ Verification.new(status: status, reason: reason,
155
+ uncovered_now: count_uncovered, unchained_now: count_unchained)
156
+ end
157
+
158
+ def chained_rows?
159
+ entry_class.where.not(chain_sequence: nil).exists?
160
+ end
161
+
162
+ def count_uncovered = (@count_uncovered ||= entry_class.where("contract_version < 3").count)
163
+
164
+ def count_unchained = (@count_unchained ||= entry_class.where(UNCHAINED).count)
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ class Chain
6
+ # The verifier's row-by-row pass (research R6): every chained row up to a sequence, in
7
+ # order, a batch at a time.
8
+ #
9
+ # Each row's digest is recomputed against its predecessor's *recorded* digest, not a
10
+ # recomputed one, so one edited row is one break rather than a cascade to the end of
11
+ # the ledger. Gaps and repeated sequences are reported where they occur.
12
+ #
13
+ # Keyset pagination on (chain_sequence, id), never OFFSET, and never inside a
14
+ # transaction: memory stays flat whatever the ledger's size, and no writer waits.
15
+ class Walk
16
+ MISMATCH = "does not verify at its position — its content, its sequence number or " \
17
+ "its predecessor changed"
18
+
19
+ attr_reader :last_sequence, :last_digest
20
+
21
+ # `link` is the digest the first row must link to (the genesis), or nil when there
22
+ # is no chain record to take it from. `report` receives (kind, sequence, detail).
23
+ # `epochs` is the EpochRules the rows are checked against (specs/003 FR-011).
24
+ def initialize(entry_class:, keyring:, batch_size:, link:, report:, epochs:)
25
+ @entry_class = entry_class
26
+ @keyring = keyring
27
+ @batch_size = batch_size
28
+ @report = report
29
+ @epochs = epochs
30
+ @expected = 1
31
+ @last_sequence = 0
32
+ @link = link
33
+ @verify_first_link = !link.nil?
34
+ end
35
+
36
+ # Walks up to `limit` (the head read at the start), or every chained row if nil.
37
+ def call(limit)
38
+ each_chained(limit) { |entry| visit(entry) }
39
+ self
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :entry_class, :keyring, :batch_size, :epochs
45
+
46
+ def visit(entry)
47
+ sequence = entry.chain_sequence
48
+ return revisit(entry) if sequence == @last_sequence
49
+
50
+ report_gap(@expected..(sequence - 1)) if sequence > @expected
51
+ verify(entry, @link)
52
+ advance(entry)
53
+ end
54
+
55
+ def report_gap(range)
56
+ count = range.size
57
+ @report.call(:missing, range, "#{count} #{count == 1 ? 'entry' : 'entries'} not found")
58
+ end
59
+
60
+ # A second row claiming the sequence just seen: reported, and checked against the
61
+ # same predecessor the first one was.
62
+ def revisit(entry)
63
+ @report.call(:duplicate, entry.chain_sequence)
64
+ verify(entry, @previous_link)
65
+ end
66
+
67
+ def advance(entry)
68
+ @previous_link = @link
69
+ @link = entry.chain_digest
70
+ @last_sequence = entry.chain_sequence
71
+ @last_digest = entry.chain_digest
72
+ @expected = entry.chain_sequence + 1
73
+ end
74
+
75
+ def verify(entry, previous)
76
+ # A row whose key or epoch contradicts the epoch records is a break on its own; its
77
+ # digest would only repeat it.
78
+ return if epoch_break?(entry)
79
+
80
+ # With no chain record there is no genesis for the first row to link to. That is
81
+ # reported once, as chain_record_missing, not again here as a mismatch.
82
+ return if !@verify_first_link && entry.chain_sequence == 1
83
+
84
+ key_id = entry.chain_key_id
85
+ sequence = entry.chain_sequence
86
+ secret = keyring.for(key_id)
87
+ if secret.nil?
88
+ return @report.call(:unknown_key, sequence, "signed by key #{key_id}", nil, key_id)
89
+ end
90
+
91
+ expected = Canonical.entry_digest(secret, entry, previous_digest: previous)
92
+ @report.call(:digest_mismatch, sequence, MISMATCH) unless expected == entry.chain_digest
93
+ rescue ArgumentError => e
94
+ @report.call(:digest_mismatch, entry.chain_sequence, e.message)
95
+ end
96
+
97
+ def epoch_break?(entry)
98
+ kind, detail = epochs.judge(entry)
99
+ return false unless kind
100
+
101
+ @report.call(kind, entry.chain_sequence, detail)
102
+ true
103
+ end
104
+
105
+ def each_chained(limit, &block)
106
+ after = nil
107
+ loop do
108
+ batch = batch_after(after, limit)
109
+ batch.each(&block)
110
+ break if batch.size < batch_size
111
+
112
+ after = [batch.last.chain_sequence, batch.last.id]
113
+ end
114
+ end
115
+
116
+ def batch_after(after, limit)
117
+ entry_class.uncached { batch_scope(after, limit).to_a }
118
+ end
119
+
120
+ # Resumes after (sequence, id). The `>=` is what the index can use; the OR beneath it
121
+ # only breaks ties between rows sharing that sequence. Written as the OR alone, the
122
+ # condition is the same set of rows, but PostgreSQL applied it as a filter and scanned
123
+ # every batch from the first row: the walk was quadratic, and a million rows took 331 s
124
+ # against SC-004's 300. SQLite derived the bound on its own, which hid it there.
125
+ def batch_scope(after, limit)
126
+ scope = entry_class.where.not(chain_sequence: nil)
127
+ scope = scope.where("chain_sequence <= ?", limit) if limit
128
+ if after
129
+ scope = scope.where("chain_sequence >= ?", after[0])
130
+ .where("chain_sequence > ? OR id > ?", after[0], after[1])
131
+ end
132
+ scope.order(:chain_sequence, :id).limit(batch_size)
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ # The ledger's chain (specs/002): every row reeve writes is linked to the one before it
6
+ # by a keyed digest, so a row changed, removed or reordered outside reeve is reported by
7
+ # verification.
8
+ #
9
+ # Reeve::Audit::Chain.new(entry_class: Reeve::Audit::Entry).append!(row) # write
10
+ # Reeve::Audit::Chain.verify # check
11
+ #
12
+ # `append!` is the only door a chained row comes through. Recorder and IsolatedRecorder
13
+ # use it; a host with its own recorder uses it too (FR-007), or its rows are reported as
14
+ # unchained.
15
+ #
16
+ # == How a write is serialized (research R3)
17
+ #
18
+ # UPDATE the chain record's head_sequence by one -- the lock, taken first
19
+ # SELECT the new head_sequence and the old head_digest
20
+ # INSERT the row with its sequence, key id and digest
21
+ # UPDATE the head_digest to the row's digest
22
+ #
23
+ # all inside one savepoint on the entry class's own connection. The first statement is
24
+ # a write, so on PostgreSQL and MySQL it takes the row lock and on SQLite the database
25
+ # write lock: every writer queues at the same point with the same SQL. Reading first
26
+ # would be refused outright by SQLite (a shared lock cannot be upgraded while another
27
+ # writer waits), and SELECT ... FOR UPDATE is ignored there.
28
+ #
29
+ # Because the row and the head move commit together, a rolled-back write consumes no
30
+ # sequence — and a committed row can never sit beyond a committed head, which is what
31
+ # lets the verifier call such a row forged.
32
+ #
33
+ # == The cost, stated
34
+ #
35
+ # Under the default Recorder inside a transaction the host opened, the chain record
36
+ # stays locked until that transaction ends, so every other guarded call waits behind it.
37
+ # IsolatedRecorder holds it for one insert.
38
+ class Chain
39
+ TABLE = "reeve_audit_chains"
40
+
41
+ COLUMNS = %w[chain_sequence chain_key_id chain_digest chain_epoch].freeze
42
+
43
+ EPOCHS_TABLE = "reeve_audit_chain_epochs"
44
+
45
+ # The first contract whose rows name their key epoch.
46
+ EPOCH_CONTRACT = 4
47
+
48
+ # A contract-3 row outside the chain: the walk only reads rows with a sequence, so a
49
+ # row without one is counted here or nowhere. Written once and used by both the
50
+ # genesis (the baseline it records) and the verifier (the count it compares), because
51
+ # the baseline means something only while the two count the same rows. It keys on the
52
+ # missing sequence, not the missing digest: a forged row with a digest and no sequence
53
+ # once sat outside every count.
54
+ UNCHAINED = "contract_version >= 3 AND chain_sequence IS NULL"
55
+
56
+ # Verifies the whole ledger. With `keys:`, verifies with those keys alone — how an
57
+ # auditor checks a ledger with their own copy of the key.
58
+ def self.verify(keys: nil, entry_class: Entry, batch_size: Verifier::BATCH_SIZE)
59
+ keyring = keys ? Keyring.new(signing: nil, retired: keys) : Keyring.from(Reeve.config)
60
+ Verifier.new(entry_class: entry_class, keyring: keyring, batch_size: batch_size).call
61
+ end
62
+
63
+ # Makes the configured ledger key the key of the latest epoch (specs/003 FR-006) and
64
+ # returns a Rotation saying what happened. `bin/rails reeve:rotate_ledger_key` runs it.
65
+ def self.rotate_key!(accept_unverifiable: false, entry_class: Entry)
66
+ new(entry_class: entry_class).rotate_key!(accept_unverifiable: accept_unverifiable)
67
+ end
68
+
69
+ def initialize(entry_class: Entry, keyring: nil)
70
+ @entry_class = entry_class
71
+ @keyring = keyring
72
+ end
73
+
74
+ # Writes `row` as the next link and returns the entry. The row carries every column
75
+ # but the three chain ones; it is normalized here (research R2).
76
+ #
77
+ # Raises LedgerKeyError with no signing key, and whatever the database raises if the
78
+ # write fails. A replayed invocation_id returns the existing entry and consumes
79
+ # nothing. What a failure means for the caller — fail the call or warn — is the
80
+ # caller's decision, not this method's.
81
+ def append!(row)
82
+ require_columns!
83
+ signer = keyring
84
+ raise LedgerKeyError unless signer.signing?
85
+
86
+ normalized = normalizer.call(row)
87
+ with_connection do |connection|
88
+ insert_once(normalized) do
89
+ head = Head.new(connection: connection, entry_class: entry_class)
90
+ head.start!(signer)
91
+ schema.explaining do
92
+ entry_class.transaction(requires_new: true) { link!(head, normalized, signer) }
93
+ end
94
+ end
95
+ end
96
+ end
97
+
98
+ # Writes `row` at its contract version with no chain columns, around the chain: no lock,
99
+ # no sequence. For a caller that has decided to keep running without a key (Recorder
100
+ # under audit_failure_mode :warn). Verification reports such a row as unchained.
101
+ def append_unchained!(row)
102
+ require_columns!
103
+ normalized = normalizer.call(row)
104
+ with_connection do
105
+ insert_once(normalized) do
106
+ entry_class.transaction(requires_new: true) { entry_class.create!(normalized) }
107
+ end
108
+ end
109
+ end
110
+
111
+ # Starts the chain if it has not started — the genesis needs the key the epoch will
112
+ # open with — then decides under the head's lock, so a write and a rotation never
113
+ # interleave. A refusal writes nothing.
114
+ def rotate_key!(accept_unverifiable: false)
115
+ signer = keyring
116
+ return Rotation.refused(:no_key) unless signer.signing?
117
+
118
+ require_columns!
119
+ with_connection do |connection|
120
+ head = Head.new(connection: connection, entry_class: entry_class)
121
+ head.start!(signer)
122
+ schema.explaining do
123
+ entry_class.transaction(requires_new: true) do
124
+ Rotation.decide(Epochs.new(connection: connection), signer,
125
+ head_sequence: head.lock!, accept_unverifiable: accept_unverifiable)
126
+ end
127
+ end
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ attr_reader :entry_class
134
+
135
+ def keyring
136
+ @keyring || Keyring.from(Reeve.config)
137
+ end
138
+
139
+ def normalizer
140
+ @normalizer ||= Chain::Normalizer.new(entry_class)
141
+ end
142
+
143
+ # The digest is computed over the model instance, not the hash: ActiveRecord fills in
144
+ # column defaults (`truncated: false`) and casts on assignment (an Integer principal_id
145
+ # becomes a String), and the instance is exactly what the database will be given.
146
+ # Digesting the hash a caller passed would sign values that are never stored.
147
+ #
148
+ # A row at contract 4 or later names the key epoch its key opened, opening the next one
149
+ # if the key has none (specs/003 FR-004); a contract-3 row from a custom recorder is
150
+ # chained exactly as 0.6.0 chained it.
151
+ def link!(head, normalized, signer)
152
+ sequence, previous = head.advance!
153
+ chain = { chain_sequence: sequence, chain_key_id: signer.signing_id, chain_epoch: nil }
154
+ if normalized[:contract_version].to_i >= EPOCH_CONTRACT
155
+ chain[:chain_epoch] = Epochs.new(connection: head.connection)
156
+ .for_signing!(signer, sequence: sequence)
157
+ end
158
+ entry = entry_class.new(normalized.merge(chain))
159
+ entry.chain_digest = Canonical.entry_digest(signer.signing_secret, entry,
160
+ previous_digest: previous)
161
+ entry.save!
162
+ head.point_at!(entry.chain_digest)
163
+ entry
164
+ end
165
+
166
+ # A replayed invocation_id returns the row already written, whether the pre-check sees
167
+ # it or the insert collides with it.
168
+ def insert_once(normalized)
169
+ existing = replayed(normalized)
170
+ return existing if existing
171
+
172
+ yield
173
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
174
+ replayed(normalized) || raise(e)
175
+ end
176
+
177
+ # A table one contract behind the gem fails here, naming the fix, rather than as an
178
+ # "unknown attribute" or "no such column" from deep inside the write.
179
+ def require_columns!
180
+ schema.require_columns!
181
+ end
182
+
183
+ def schema
184
+ @schema ||= Schema.new(entry_class)
185
+ end
186
+
187
+ def replayed(normalized)
188
+ id = normalized[:invocation_id]
189
+ id && entry_class.find_by(invocation_id: id)
190
+ end
191
+
192
+ # Checked out for one append and returned, never leased to the thread: from Rails 7.2
193
+ # `entry_class.connection` pins a connection to the thread for good, which would undo
194
+ # IsolatedRecorder's promise to hold none between writes. ConnectionPool#with_connection
195
+ # exists on every Rails this gem supports, and reuses a connection the thread already
196
+ # holds — so the transaction and the INSERT run on the same one.
197
+ #
198
+ # The connection is passed down as an argument, never kept on the instance. A Chain
199
+ # holds no per-call state, so one instance can be shared by every thread that writes:
200
+ # an earlier version stored it in an instance variable, and two threads appending
201
+ # through one Chain drove a single PostgreSQL connection at once.
202
+ def with_connection(&block)
203
+ entry_class.connection_pool.with_connection(&block)
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ # One key epoch: the key that began signing the ledger at `first_sequence` (specs/003
6
+ # data-model "reeve_audit_chain_epochs").
7
+ #
8
+ # Written by Chain — when a key first signs, or by `bin/rails reeve:rotate_ledger_key` —
9
+ # through the ledger's own connection, inside the write that needs it. This model only
10
+ # reads them, for verification, and refuses to change one: an epoch is written once.
11
+ # As with ChainRecord, that protects against the library, not against raw SQL; the
12
+ # verifier reports an epoch changed outside reeve.
13
+ class ChainEpoch < ActiveRecord::Base
14
+ self.table_name = "reeve_audit_chain_epochs"
15
+
16
+ before_update { raise ActiveRecord::ReadOnlyRecord, "a key epoch is written once" }
17
+ before_destroy { throw :abort }
18
+
19
+ def readonly?
20
+ persisted? || super
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ # The ledger's chain record: one row holding the genesis and the head (specs/002
6
+ # data-model "reeve_audit_chains").
7
+ #
8
+ # The genesis is link 0 — when the chain started, how many rows preceded it, and the
9
+ # digest the first chained row links to. It is written once, with the first chained
10
+ # row, and the model refuses to change it afterwards. The head is the latest sequence
11
+ # and digest; Chain advances it with SQL of its own, inside the same transaction as
12
+ # the row it points at, so it is never updated through this model.
13
+ #
14
+ # As with Entry, this protects against the library, not against raw SQL. A role that
15
+ # can UPDATE this table can rewind the head; that is one of the stated limits of the
16
+ # chain, and the verifier's job is to report what it can.
17
+ class ChainRecord < ActiveRecord::Base
18
+ self.table_name = "reeve_audit_chains"
19
+
20
+ # Always this. A name rather than a primary key, because ids change on restore.
21
+ LEDGER = "ledger"
22
+
23
+ GENESIS_COLUMNS = %w[
24
+ name genesis_at pre_chain_count pre_chain_unchained_count genesis_key_id genesis_digest
25
+ ].freeze
26
+
27
+ attr_readonly(*GENESIS_COLUMNS)
28
+
29
+ # attr_readonly ignores the change silently on Rails 7.0 and raises from 7.1; this
30
+ # makes every version refuse it out loud.
31
+ before_update do
32
+ changed = changed_attribute_names_to_save & GENESIS_COLUMNS
33
+ unless changed.empty?
34
+ raise ActiveRecord::ReadOnlyRecord,
35
+ "the genesis of the ledger chain is written once; refused to change " \
36
+ "#{changed.join(', ')}"
37
+ end
38
+ end
39
+
40
+ before_destroy { throw :abort }
41
+
42
+ def self.ledger
43
+ find_by(name: LEDGER)
44
+ end
45
+
46
+ # The record is written through the ledger's own connection (Chain::Head) and read
47
+ # through this model's — and so are the key epochs (ChainEpoch, specs/003 FR-019a). A
48
+ # host that moves the ledger to a database of its own and leaves either model behind
49
+ # would have verification read the wrong rows and report tampering on every run;
50
+ # refusing, with the fix, is the honest answer.
51
+ def self.require_same_database!(entry_class)
52
+ ledger_database = entry_class.connection_db_config.configuration_hash
53
+ [self, ChainEpoch].each do |model|
54
+ next if model.connection_db_config.configuration_hash == ledger_database
55
+
56
+ short = model.name.split("::").last
57
+ raise ConfigurationError,
58
+ "#{model.name} is connected to a different database than #{entry_class.name}. " \
59
+ "Connect ChainRecord and ChainEpoch the same way as the ledger (for example " \
60
+ "with the same connects_to) so the chain is read where it is written; #{short} " \
61
+ "is the one that is not."
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -34,6 +34,11 @@ module Reeve
34
34
  # and it also survives a rollback of an invocation the host *meant* to undo entirely.
35
35
  # A ledger of what was attempted is the intended reading of Constitution II, but it is
36
36
  # a choice, and it should be made rather than inherited.
37
+ #
38
+ # It is also the answer to the chain's one real cost (specs/002). Every chained write
39
+ # locks the ledger's chain record; under Recorder inside a host transaction that lock is
40
+ # held until the host commits, so every other guarded call waits behind it. Here the
41
+ # chain record is written on this connection too, and the lock is held for one insert.
37
42
  class IsolatedRecorder < Recorder
38
43
  UNSUPPORTED_ADAPTERS = %w[sqlite sqlite3].freeze
39
44