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
@@ -36,9 +36,16 @@ module Reeve
36
36
  # stays locked until that transaction ends, so every other guarded call waits behind it.
37
37
  # IsolatedRecorder holds it for one insert.
38
38
  class Chain
39
+ extend Entrypoints
40
+
39
41
  TABLE = "reeve_audit_chains"
40
42
 
41
- COLUMNS = %w[chain_sequence chain_key_id chain_digest].freeze
43
+ COLUMNS = %w[chain_sequence chain_key_id chain_digest chain_epoch].freeze
44
+
45
+ EPOCHS_TABLE = "reeve_audit_chain_epochs"
46
+
47
+ # The first contract whose rows name their key epoch.
48
+ EPOCH_CONTRACT = 4
42
49
 
43
50
  # A contract-3 row outside the chain: the walk only reads rows with a sequence, so a
44
51
  # row without one is counted here or nowhere. Written once and used by both the
@@ -50,9 +57,22 @@ module Reeve
50
57
 
51
58
  # Verifies the whole ledger. With `keys:`, verifies with those keys alone — how an
52
59
  # auditor checks a ledger with their own copy of the key.
53
- def self.verify(keys: nil, entry_class: Entry, batch_size: Verifier::BATCH_SIZE)
54
- keyring = keys ? Keyring.new(signing: nil, retired: keys) : Keyring.from(Reeve.config)
55
- Verifier.new(entry_class: entry_class, keyring: keyring, batch_size: batch_size).call
60
+ #
61
+ # `checkpoints:` (specs/004) are checkpoint lines or Checkpoints to check the ledger
62
+ # against; nil means the configured anchor's, and [] none. They are never read from the
63
+ # database. A line that does not parse raises CheckpointError before anything is walked.
64
+ #
65
+ # `from: :latest` walks from the checkpoint with the highest sequence instead of the
66
+ # genesis (FR-015), after checking that checkpoint on its own; a Checkpoint names one.
67
+ def self.verify(keys: nil, entry_class: Entry, checkpoints: nil, from: nil, **options)
68
+ Verifier.new(entry_class: entry_class, keyring: Keyring.verifying(keys), from: from,
69
+ checkpoints: Checkpoint.read_all(checkpoints), **options).call
70
+ end
71
+
72
+ # Makes the configured ledger key the key of the latest epoch (specs/003 FR-006) and
73
+ # returns a Rotation saying what happened. `bin/rails reeve:rotate_ledger_key` runs it.
74
+ def self.rotate_key!(accept_unverifiable: false, entry_class: Entry)
75
+ new(entry_class: entry_class).rotate_key!(accept_unverifiable: accept_unverifiable)
56
76
  end
57
77
 
58
78
  def initialize(entry_class: Entry, keyring: nil)
@@ -77,7 +97,9 @@ module Reeve
77
97
  insert_once(normalized) do
78
98
  head = Head.new(connection: connection, entry_class: entry_class)
79
99
  head.start!(signer)
80
- entry_class.transaction(requires_new: true) { link!(head, normalized, signer) }
100
+ schema.explaining do
101
+ entry_class.transaction(requires_new: true) { link!(head, normalized, signer) }
102
+ end
81
103
  end
82
104
  end
83
105
  end
@@ -95,6 +117,26 @@ module Reeve
95
117
  end
96
118
  end
97
119
 
120
+ # Starts the chain if it has not started — the genesis needs the key the epoch will
121
+ # open with — then decides under the head's lock, so a write and a rotation never
122
+ # interleave. A refusal writes nothing.
123
+ def rotate_key!(accept_unverifiable: false)
124
+ signer = keyring
125
+ return Rotation.refused(:no_key) unless signer.signing?
126
+
127
+ require_columns!
128
+ with_connection do |connection|
129
+ head = Head.new(connection: connection, entry_class: entry_class)
130
+ head.start!(signer)
131
+ schema.explaining do
132
+ entry_class.transaction(requires_new: true) do
133
+ Rotation.decide(Epochs.new(connection: connection), signer,
134
+ head_sequence: head.lock!, accept_unverifiable: accept_unverifiable)
135
+ end
136
+ end
137
+ end
138
+ end
139
+
98
140
  private
99
141
 
100
142
  attr_reader :entry_class
@@ -111,10 +153,18 @@ module Reeve
111
153
  # column defaults (`truncated: false`) and casts on assignment (an Integer principal_id
112
154
  # becomes a String), and the instance is exactly what the database will be given.
113
155
  # Digesting the hash a caller passed would sign values that are never stored.
156
+ #
157
+ # A row at contract 4 or later names the key epoch its key opened, opening the next one
158
+ # if the key has none (specs/003 FR-004); a contract-3 row from a custom recorder is
159
+ # chained exactly as 0.6.0 chained it.
114
160
  def link!(head, normalized, signer)
115
161
  sequence, previous = head.advance!
116
- entry = entry_class.new(normalized.merge(chain_sequence: sequence,
117
- chain_key_id: signer.signing_id))
162
+ chain = { chain_sequence: sequence, chain_key_id: signer.signing_id, chain_epoch: nil }
163
+ if normalized[:contract_version].to_i >= EPOCH_CONTRACT
164
+ chain[:chain_epoch] = Epochs.new(connection: head.connection)
165
+ .for_signing!(signer, sequence: sequence)
166
+ end
167
+ entry = entry_class.new(normalized.merge(chain))
118
168
  entry.chain_digest = Canonical.entry_digest(signer.signing_secret, entry,
119
169
  previous_digest: previous)
120
170
  entry.save!
@@ -136,13 +186,11 @@ module Reeve
136
186
  # A table one contract behind the gem fails here, naming the fix, rather than as an
137
187
  # "unknown attribute" or "no such column" from deep inside the write.
138
188
  def require_columns!
139
- missing = COLUMNS - entry_class.column_names
140
- return if missing.empty?
189
+ schema.require_columns!
190
+ end
141
191
 
142
- raise ConfigurationError,
143
- "#{entry_class.table_name} is missing #{missing.join(', ')}: the ledger is at an " \
144
- "older audit-entry contract than this reeve writes (#{CONTRACT_VERSION}). Run " \
145
- "`bin/rails generate reeve:upgrade` and `bin/rails db:migrate`."
192
+ def schema
193
+ @schema ||= Schema.new(entry_class)
146
194
  end
147
195
 
148
196
  def replayed(normalized)
@@ -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
@@ -44,17 +44,23 @@ module Reeve
44
44
  end
45
45
 
46
46
  # The record is written through the ledger's own connection (Chain::Head) and read
47
- # through this model's. A host that moves the ledger to a database of its own and
48
- # leaves this model behind would have verification read the wrong record and report
49
- # tampering on every run; refusing, with the fix, is the honest answer.
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.
50
51
  def self.require_same_database!(entry_class)
51
52
  ledger_database = entry_class.connection_db_config.configuration_hash
52
- return if connection_db_config.configuration_hash == ledger_database
53
+ [self, ChainEpoch, RetentionRecord].each do |model|
54
+ next if model.connection_db_config.configuration_hash == ledger_database
53
55
 
54
- raise ConfigurationError,
55
- "Reeve::Audit::ChainRecord is connected to a different database than " \
56
- "#{entry_class.name}. Connect ChainRecord the same way as the ledger (for " \
57
- "example with the same connects_to) so the chain is read where it is written."
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, ChainEpoch and RetentionRecord the same way as the ledger " \
60
+ "(for example " \
61
+ "with the same connects_to) so the chain is read where it is written; #{short} " \
62
+ "is the one that is not."
63
+ end
58
64
  end
59
65
  end
60
66
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ # The anchor reeve ships (specs/004 FR-006a, research R6): a file it only ever appends to,
6
+ # one checkpoint per line.
7
+ #
8
+ # config.ledger_anchor = Reeve::Audit::FileAnchor.new("/mnt/ledger-anchor/production.checkpoints")
9
+ #
10
+ # A building block, not a guarantee. A checkpoint is worth exactly as much as the store's
11
+ # refusal to let the database's users change or delete it, and a file on the database host
12
+ # refuses nothing. Point it at storage those users cannot reach, such as a mount backed by
13
+ # an object-locked bucket or a path a separate shipper collects.
14
+ #
15
+ # Append mode puts every write at the end of the file even with several processes writing,
16
+ # and the lock keeps each line whole; the file is never opened any other way for writing.
17
+ class FileAnchor
18
+ attr_reader :path
19
+
20
+ def initialize(path)
21
+ @path = path.to_s
22
+ freeze
23
+ end
24
+
25
+ def publish(line)
26
+ if line.include?("\n")
27
+ raise ArgumentError,
28
+ "a checkpoint is one line; this one has a line break in it"
29
+ end
30
+
31
+ File.open(path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |file|
32
+ file.flock(File::LOCK_EX)
33
+ file.write("#{line}\n")
34
+ file.fsync
35
+ end
36
+ end
37
+
38
+ def checkpoints
39
+ return [] unless File.exist?(path)
40
+
41
+ File.readlines(path, chomp: true).reject { |line| line.strip.empty? }
42
+ end
43
+
44
+ def inspect
45
+ "#<#{self.class.name} #{path}>"
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ # One purge (specs/005 data-model "reeve_audit_retentions"): which checkpoint the ledger
6
+ # was purged up to, what was removed, and the archive written, signed and chained to the
7
+ # purge before it.
8
+ #
9
+ # Written by `bin/rails reeve:purge_ledger` through the retention connection, never this
10
+ # model; the model only reads, for verification, and refuses to change one. As with
11
+ # ChainEpoch, that protects against the library, not against raw SQL; the verifier reports
12
+ # a record changed outside reeve.
13
+ class RetentionRecord < ActiveRecord::Base
14
+ self.table_name = "reeve_audit_retentions"
15
+
16
+ before_update { raise ActiveRecord::ReadOnlyRecord, "a retention record is written once" }
17
+ before_destroy { throw :abort }
18
+
19
+ def readonly?
20
+ persisted? || super
21
+ end
22
+ end
23
+ end
24
+ end
data/lib/reeve/audit.rb CHANGED
@@ -59,8 +59,14 @@ module Reeve
59
59
  module Audit
60
60
  # The version of the audit-entry shape, as documented in
61
61
  # specs/001-guardrails-core/contracts/audit-entry.md (FR-015). Adding a nullable
62
- # column is a MINOR change and leaves this alone; removing or renaming a column, or
63
- # changing what a value means, is MAJOR and bumps it.
62
+ # column is a MINOR change and leaves this alone — unless what its absence means, or
63
+ # what an existing value covers, differs between rows written before and after it;
64
+ # removing or renaming a column, or changing what a value means, is MAJOR and bumps it.
65
+ #
66
+ # 4 — entries name their key epoch (specs/003). One nullable column, `chain_epoch`, but
67
+ # it is inside the digest, so `chain_digest` on a contract-4 row covers something a
68
+ # contract-3 digest does not, and a NULL epoch means "before key epochs" on a contract-3
69
+ # row where a chained contract-4 row always has one.
64
70
  #
65
71
  # 3 — entries are chained (specs/002). The three chain columns are nullable, so the
66
72
  # shape change is additive, but what a NULL digest means changed: on a contract-3 row
@@ -71,7 +77,7 @@ module Reeve
71
77
  # was written NULL on every row regardless of what was passed, so anything mapping
72
78
  # version 1 rows could reasonably have read the column as "always empty". The shape
73
79
  # did not change; what a value means did.
74
- CONTRACT_VERSION = 3
80
+ CONTRACT_VERSION = 4
75
81
 
76
82
  TABLE_NAME = "reeve_audit_entries"
77
83
 
@@ -108,11 +114,32 @@ require_relative "audit/chain/keyring"
108
114
  require_relative "audit/chain/normalizer"
109
115
  require_relative "audit/chain/canonical"
110
116
  require_relative "audit/chain_record"
117
+ require_relative "audit/chain_epoch"
118
+ require_relative "audit/retention_record"
111
119
  require_relative "audit/chain/verification"
112
120
  require_relative "audit/chain/report"
113
121
  require_relative "audit/chain/head"
122
+ require_relative "audit/chain/epochs"
123
+ require_relative "audit/chain/rotation"
124
+ require_relative "audit/chain/checkpoint"
125
+ require_relative "audit/chain/epoch_rules"
126
+ require_relative "audit/chain/checkpoint_check"
127
+ require_relative "audit/chain/anchoring"
128
+ require_relative "audit/chain/retention/records"
129
+ require_relative "audit/chain/retention/digest"
130
+ require_relative "audit/chain/retention/check"
131
+ require_relative "audit/chain/retention/connection"
132
+ require_relative "audit/chain/retention/purge_result"
133
+ require_relative "audit/chain/retention/plan"
134
+ require_relative "audit/chain/retention/archive"
135
+ require_relative "audit/chain/retention/archive_check"
136
+ require_relative "audit/chain/retention/preflight"
137
+ require_relative "audit/chain/retention/purge"
138
+ require_relative "audit/chain/entrypoints"
139
+ require_relative "audit/chain/schema"
114
140
  require_relative "audit/chain/walk"
115
141
  require_relative "audit/chain/verifier"
116
142
  require_relative "audit/chain"
143
+ require_relative "audit/file_anchor"
117
144
 
118
145
  Reeve::Audit.install!
@@ -25,6 +25,7 @@ module Reeve
25
25
  principal_resolver unguarded_tools audit_failure_mode redact_arguments
26
26
  max_recorded_ids policy_adapter default_action audit_recorder logger
27
27
  compliance_principals compliance_tools inventory ledger_key retired_ledger_keys
28
+ ledger_anchor ledger_retention_connection
28
29
  ].freeze
29
30
 
30
31
  # Shown by #to_h and #inspect as fingerprints. A configuration object gets printed —
@@ -48,6 +49,8 @@ module Reeve
48
49
  @inventory = nil
49
50
  @ledger_key = nil
50
51
  @retired_ledger_keys = []
52
+ @ledger_anchor = nil
53
+ @ledger_retention_connection = nil
51
54
  end
52
55
 
53
56
  # The key that signs the audit ledger's chain (specs/002). Held in configuration, never
@@ -203,6 +206,28 @@ module Reeve
203
206
  recorder.nil? ? nil : require_protocol!(:audit_recorder, recorder, [:record])
204
207
  end
205
208
 
209
+ # Where ledger checkpoints are kept (specs/004): an object with #publish(line) and
210
+ # #checkpoints, such as Reeve::Audit::FileAnchor. It must be storage the database's users
211
+ # cannot change; nil (the default) means checkpoints are made only on request and stored by
212
+ # whoever asked.
213
+ def ledger_anchor=(anchor)
214
+ @ledger_anchor =
215
+ anchor.nil? ? nil : require_protocol!(:ledger_anchor, anchor, %i[publish checkpoints])
216
+ end
217
+
218
+ # The database connection `bin/rails reeve:purge_ledger` deletes through (specs/005): an
219
+ # abstract ActiveRecord class the host connects to the ledger's database under a role of
220
+ # its own, the only one with DELETE on the ledger. Never the application's connection.
221
+ def ledger_retention_connection=(retention)
222
+ @ledger_retention_connection =
223
+ if retention.nil?
224
+ nil
225
+ else
226
+ require_protocol!(:ledger_retention_connection, retention,
227
+ %i[connection_pool connection_db_config])
228
+ end
229
+ end
230
+
206
231
  def logger=(logger)
207
232
  @logger = logger.nil? ? nil : require_protocol!(:logger, logger, [:warn])
208
233
  end
data/lib/reeve/errors.rb CHANGED
@@ -85,6 +85,21 @@ module Reeve
85
85
  end
86
86
  end
87
87
 
88
+ # Raised when a ledger checkpoint cannot be read (specs/004 research R5): a line that is
89
+ # not a checkpoint, or one in a format this version does not know. Not a break — it says
90
+ # nothing about the ledger — but never skipped either: a store whose lines do not read
91
+ # cannot be counted as anchoring anything. `position` is the line's place in its list,
92
+ # from 1, so the operator can find it; the line itself is not repeated.
93
+ class CheckpointError < Error
94
+ attr_reader :position
95
+
96
+ def initialize(reason, position: nil)
97
+ @position = position
98
+ where = position ? "checkpoint #{position}" : "a checkpoint"
99
+ super("#{where} cannot be read: #{reason}")
100
+ end
101
+ end
102
+
88
103
  # Raised when the ledger write fails and audit_failure_mode is :fail (FR-012).
89
104
  #
90
105
  # It carries a rule like any other denial, because the failure is not recorded anywhere
data/lib/reeve/railtie.rb CHANGED
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Reeve
4
- # Registers `bin/rails reeve:verify_ledger`, and does nothing else.
4
+ # Registers the ledger tasks (`bin/rails reeve:verify_ledger`, `reeve:rotate_ledger_key`,
5
+ # `reeve:checkpoint_ledger`, `reeve:purge_ledger`, `reeve:verify_archive`), and does nothing else.
5
6
  #
6
7
  # Loaded by lib/reeve.rb only when Rails::Railtie is already defined, so `require "reeve"`
7
8
  # in a plain Ruby process still loads nothing beyond reeve's own files and its short stdlib
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ # The ledger's Rake tasks, as `bin/rails -T` lists them (Reeve::Tasks.define).
5
+ module Tasks
6
+ # Each task's description, and the method of this module it runs.
7
+ DESCRIPTIONS = {
8
+ verify_ledger: "Verify the audit ledger's chain " \
9
+ "(exit 0 intact, 1 broken, 2 could not verify; LIMIT=, FORMAT=json)",
10
+ checkpoint_ledger: "Checkpoint the audit ledger's chain to config.ledger_anchor " \
11
+ "(exit 0 made, 1 refused or the anchor failed)",
12
+ purge_ledger: "Purge the audit ledger's oldest entries up to a checkpoint (THROUGH= or " \
13
+ "OLDER_THAN=Nd; ARCHIVE=path or NO_ARCHIVE=1; nothing removed without " \
14
+ "CONFIRM=1)",
15
+ verify_archive: "Verify an archive reeve:purge_ledger wrote, on its own " \
16
+ "(ARCHIVE=path; exit 0 intact, 1 broken, 2 could not verify)",
17
+ rotate_ledger_key: "Make config.ledger_key the ledger's current key epoch (exit 0 " \
18
+ "rotated or current, 1 refused; ACCEPT_UNVERIFIABLE=1 if the old key " \
19
+ "is lost)"
20
+ }.freeze
21
+ end
22
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Tasks
5
+ # The environment `bin/rails reeve:purge_ledger` reads (specs/005, contracts/retention.md
6
+ # "Rake"), as keyword arguments for Reeve::Audit::Chain.purge!. Anything it does not accept
7
+ # is an ArgumentError naming what it does.
8
+ module PurgeOptions
9
+ module_function
10
+
11
+ def from(env)
12
+ through = env.fetch("THROUGH", "")
13
+ older = env.fetch("OLDER_THAN", "")
14
+ {
15
+ through: through.empty? ? nil : whole_number("THROUGH", through),
16
+ older_than: older.empty? ? nil : days("OLDER_THAN", older),
17
+ archive: env["ARCHIVE"].to_s.empty? ? nil : env["ARCHIVE"],
18
+ no_archive: flag("NO_ARCHIVE", env), confirm: flag("CONFIRM", env)
19
+ }.tap { |options| require_one_archive_choice!(options) }
20
+ end
21
+
22
+ def require_one_archive_choice!(options)
23
+ return unless options[:archive] && options[:no_archive]
24
+
25
+ raise ArgumentError, "give ARCHIVE=path or NO_ARCHIVE=1, not both"
26
+ end
27
+
28
+ def whole_number(name, raw)
29
+ Integer(raw, 10).tap { |value| raise ArgumentError unless value.positive? }
30
+ rescue ArgumentError
31
+ raise ArgumentError,
32
+ "#{name} must be a checkpoint's sequence (a whole number), got #{raw.inspect}"
33
+ end
34
+
35
+ def days(name, raw)
36
+ match = /\A(\d+)d\z/.match(raw)
37
+ unless match
38
+ raise ArgumentError,
39
+ "#{name} must be a number of days such as 365d, got #{raw.inspect}"
40
+ end
41
+
42
+ Integer(match[1], 10)
43
+ end
44
+
45
+ def flag(name, env)
46
+ raw = env.fetch(name, "")
47
+ return false if raw.empty?
48
+ return true if raw == "1"
49
+
50
+ raise ArgumentError, "#{name} must be 1 or unset, got #{raw.inspect}"
51
+ end
52
+ end
53
+ end
54
+ end
data/lib/reeve/tasks.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "tasks/purge_options"
5
+ require_relative "tasks/descriptions"
4
6
 
5
7
  module Reeve
6
8
  # `bin/rails reeve:verify_ledger` (specs/002 FR-017, contracts/ledger-chain.md "Rake").
@@ -14,6 +16,8 @@ module Reeve
14
16
  module Tasks
15
17
  EXIT = { intact: 0, broken: 1, unverifiable: 2, no_chain: 2 }.freeze
16
18
  FORMATS = %w[text json].freeze
19
+ ROTATE_EXIT = { rotated: 0, current: 0, refused: 1 }.freeze
20
+ PURGE_EXIT = { dry_run: 0, purged: 0, nothing_to_purge: 0, refused: 1 }.freeze
17
21
 
18
22
  module_function
19
23
 
@@ -22,6 +26,11 @@ module Reeve
22
26
  #
23
27
  # LIMIT caps the breaks listed in the text report (default 100; the totals are never
24
28
  # capped). FORMAT=json writes the whole result, every break, and ignores LIMIT.
29
+ #
30
+ # Checks the ledger against config.ledger_anchor's checkpoints, if one is configured
31
+ # (specs/004). CHECKPOINTS=path reads them from a file of checkpoint lines instead, and
32
+ # CHECKPOINTS= (empty) verifies without any. A checkpoint that cannot be read is exit 2.
33
+ # FROM=latest walks from the highest checkpoint instead of the genesis.
25
34
  def verify_ledger(env: ENV, out: $stdout)
26
35
  format = env.fetch("FORMAT", "text")
27
36
  unless FORMATS.include?(format)
@@ -30,9 +39,98 @@ module Reeve
30
39
 
31
40
  limit = limit_from(env)
32
41
  require "reeve/audit"
33
- result = Reeve::Audit::Chain.verify
42
+ result = Reeve::Audit::Chain.verify(checkpoints: checkpoints_from(env), from: from_env(env))
34
43
  out.puts(format == "json" ? JSON.generate(result.as_json) : result.to_s(limit: limit))
35
44
  EXIT.fetch(result.status)
45
+ rescue Reeve::CheckpointError => e
46
+ out.puts("reeve ledger: #{e.message}; nothing was verified")
47
+ EXIT.fetch(:unverifiable)
48
+ end
49
+
50
+ # Makes the configured ledger key the latest epoch's key (specs/003, contracts/
51
+ # key-rotation.md), writes what happened to `out`, and returns the exit status: 0 rotated
52
+ # or already current, 1 refused.
53
+ #
54
+ # ACCEPT_UNVERIFIABLE=1 opens the epoch even though the outgoing epoch's key is not
55
+ # configured here — for a key that is lost, and only then.
56
+ def rotate_ledger_key(env: ENV, out: $stdout)
57
+ accept = accept_unverifiable_from(env)
58
+ require "reeve/audit"
59
+ rotation = Reeve::Audit::Chain.rotate_key!(accept_unverifiable: accept)
60
+ out.puts(rotation.to_s)
61
+ status = ROTATE_EXIT.fetch(rotation.status)
62
+ return status unless rotation.rotated? && Reeve.config.ledger_anchor
63
+
64
+ checkpoint_after_rotation(out)
65
+ end
66
+
67
+ # Pins the epoch just opened, which no entry names yet (specs/004 FR-008). A failed anchor
68
+ # fails the task but does not undo the rotation: the epoch is open either way.
69
+ def checkpoint_after_rotation(out)
70
+ result = Reeve::Audit::Chain.checkpoint!
71
+ out.puts(result.to_s)
72
+ result.made? && !result.anchor_failed? ? 0 : 1
73
+ end
74
+
75
+ # Makes a checkpoint of the ledger's chain and hands it to config.ledger_anchor (specs/004,
76
+ # contracts/anchoring.md), writes what happened and the checkpoint's line to `out`, and
77
+ # returns the exit status: 0 made (and published, if there is an anchor), 1 refused or the
78
+ # anchor failed. The line is printed either way, so a failed store does not lose it.
79
+ def checkpoint_ledger(env: ENV, out: $stdout)
80
+ require "reeve/audit"
81
+ result = Reeve::Audit::Chain.checkpoint!
82
+ out.puts(result.to_s)
83
+ result.made? && !result.anchor_failed? ? 0 : 1
84
+ end
85
+
86
+ def from_env(env)
87
+ raw = env.fetch("FROM", "")
88
+ return nil if raw.empty?
89
+ return :latest if raw == "latest"
90
+
91
+ raise ArgumentError, "FROM must be latest or unset, got #{raw.inspect}"
92
+ end
93
+
94
+ # Purges the ledger's oldest entries up to a checkpoint (specs/005, contracts/retention.md),
95
+ # writes what it did to `out`, and returns the exit status: 0 for a dry run, a purge, or
96
+ # nothing to purge; 1 refused. Nothing is removed without CONFIRM=1.
97
+ def purge_ledger(env: ENV, out: $stdout)
98
+ options = PurgeOptions.from(env)
99
+ require "reeve/audit"
100
+ result = Reeve::Audit::Chain.purge!(**options)
101
+ out.puts(result.to_s)
102
+ PURGE_EXIT.fetch(result.status)
103
+ end
104
+
105
+ # Verifies an archive a purge wrote, on its own (specs/005 FR-012); exit codes as
106
+ # verify_ledger's. ARCHIVE=path names it.
107
+ def verify_archive(env: ENV, out: $stdout)
108
+ path = env.fetch("ARCHIVE", "")
109
+ raise ArgumentError, "ARCHIVE must name an archive file" if path.empty?
110
+
111
+ require "reeve/audit"
112
+ result = Reeve::Audit::Chain.verify_archive(path)
113
+ out.puts(result.to_s)
114
+ EXIT.fetch(result.status)
115
+ end
116
+
117
+ # nil (the configured anchor's) unless CHECKPOINTS is set.
118
+ def checkpoints_from(env)
119
+ return nil unless env.key?("CHECKPOINTS")
120
+
121
+ path = env["CHECKPOINTS"].to_s
122
+ return [] if path.empty?
123
+ raise Reeve::CheckpointError, "no checkpoint file at #{path}" unless File.file?(path)
124
+
125
+ Reeve::Audit::FileAnchor.new(path).checkpoints
126
+ end
127
+
128
+ def accept_unverifiable_from(env)
129
+ raw = env.fetch("ACCEPT_UNVERIFIABLE", "")
130
+ return false if raw.empty?
131
+ return true if raw == "1"
132
+
133
+ raise ArgumentError, "ACCEPT_UNVERIFIABLE must be 1 or unset, got #{raw.inspect}"
36
134
  end
37
135
 
38
136
  def limit_from(env)
@@ -42,18 +140,17 @@ module Reeve
42
140
  raise ArgumentError, "LIMIT must be a whole number of breaks to list, got #{raw.inspect}"
43
141
  end
44
142
 
45
- # The Rake task, defined on the current Rake application. Depends on :environment, so
46
- # the host's initializer — and with it the ledger key — is loaded first.
143
+ # The Rake tasks, defined on the current Rake application. Each depends on :environment,
144
+ # so the host's initializer — and with it the ledger key — is loaded first.
47
145
  #
48
146
  # The DSL is borrowed by a throwaway object, so Reeve::Tasks itself never gains `task`,
49
147
  # `sh` and the rest of Rake's methods.
50
148
  def define
51
149
  Object.new.extend(Rake::DSL).instance_exec do
52
150
  namespace :reeve do
53
- desc "Verify the audit ledger's chain " \
54
- "(exit 0 intact, 1 broken, 2 could not verify; LIMIT=, FORMAT=json)"
55
- task verify_ledger: :environment do
56
- exit Reeve::Tasks.verify_ledger
151
+ DESCRIPTIONS.each do |name, description|
152
+ desc description
153
+ task(name => :environment) { exit Reeve::Tasks.public_send(name) }
57
154
  end
58
155
  end
59
156
  end
@@ -46,10 +46,10 @@ module Reeve
46
46
  invocation_id occurred_at agent_id agent_name principal_type principal_id
47
47
  tool_name arguments outcome rule detail record_type record_ids record_count
48
48
  truncated derived guard duration_ms metadata contract_version
49
- chain_sequence chain_key_id chain_digest
49
+ chain_sequence chain_key_id chain_digest chain_epoch
50
50
  ].freeze
51
51
 
52
- EXPECTED_VERSION = 3
52
+ EXPECTED_VERSION = 4
53
53
 
54
54
  def initialize(tool: nil, expected: EXPECTED_VERSION, ledger: nil)
55
55
  super(tool: tool, ledger: ledger)