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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +293 -3
- data/README.md +276 -11
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +52 -5
- data/lib/generators/reeve/install/templates/initializer.rb.tt +10 -0
- data/lib/generators/reeve/upgrade/templates/add_key_epochs_to_reeve_audit_entries.rb.tt +35 -0
- data/lib/generators/reeve/upgrade/templates/add_ledger_retention_to_reeve_audit_entries.rb.tt +37 -0
- data/lib/generators/reeve/upgrade/upgrade_generator.rb +19 -25
- data/lib/generators/reeve/upgrade/upgrade_notes.rb +62 -0
- data/lib/reeve/audit/chain/anchoring.rb +91 -0
- data/lib/reeve/audit/chain/canonical.rb +38 -7
- data/lib/reeve/audit/chain/checkpoint.rb +228 -0
- data/lib/reeve/audit/chain/checkpoint_check.rb +156 -0
- data/lib/reeve/audit/chain/entrypoints.rb +29 -0
- data/lib/reeve/audit/chain/epoch_rules.rb +159 -0
- data/lib/reeve/audit/chain/epochs.rb +83 -0
- data/lib/reeve/audit/chain/head.rb +55 -2
- data/lib/reeve/audit/chain/keyring.rb +6 -0
- data/lib/reeve/audit/chain/report.rb +232 -32
- data/lib/reeve/audit/chain/retention/archive.rb +98 -0
- data/lib/reeve/audit/chain/retention/archive_check.rb +179 -0
- data/lib/reeve/audit/chain/retention/check.rb +94 -0
- data/lib/reeve/audit/chain/retention/connection.rb +65 -0
- data/lib/reeve/audit/chain/retention/digest.rb +29 -0
- data/lib/reeve/audit/chain/retention/plan.rb +100 -0
- data/lib/reeve/audit/chain/retention/preflight.rb +48 -0
- data/lib/reeve/audit/chain/retention/purge.rb +155 -0
- data/lib/reeve/audit/chain/retention/purge_result.rb +134 -0
- data/lib/reeve/audit/chain/retention/records.rb +23 -0
- data/lib/reeve/audit/chain/rotation.rb +124 -0
- data/lib/reeve/audit/chain/schema.rb +59 -0
- data/lib/reeve/audit/chain/verification.rb +53 -10
- data/lib/reeve/audit/chain/verifier.rb +75 -22
- data/lib/reeve/audit/chain/walk.rb +24 -4
- data/lib/reeve/audit/chain.rb +61 -13
- data/lib/reeve/audit/chain_epoch.rb +24 -0
- data/lib/reeve/audit/chain_record.rb +14 -8
- data/lib/reeve/audit/file_anchor.rb +49 -0
- data/lib/reeve/audit/retention_record.rb +24 -0
- data/lib/reeve/audit.rb +30 -3
- data/lib/reeve/configuration.rb +25 -0
- data/lib/reeve/errors.rb +15 -0
- data/lib/reeve/railtie.rb +2 -1
- data/lib/reeve/tasks/descriptions.rb +22 -0
- data/lib/reeve/tasks/purge_options.rb +54 -0
- data/lib/reeve/tasks.rb +104 -7
- data/lib/reeve/testing/checks/contract_version.rb +2 -2
- data/lib/reeve/testing/checks/ledger_chain.rb +56 -2
- data/lib/reeve/testing/ledger.rb +19 -3
- data/lib/reeve/version.rb +1 -1
- metadata +28 -2
data/lib/reeve/audit/chain.rb
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
140
|
-
|
|
189
|
+
schema.require_columns!
|
|
190
|
+
end
|
|
141
191
|
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
48
|
-
#
|
|
49
|
-
#
|
|
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
|
-
|
|
53
|
+
[self, ChainEpoch, RetentionRecord].each do |model|
|
|
54
|
+
next if model.connection_db_config.configuration_hash == ledger_database
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
63
|
-
#
|
|
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 =
|
|
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!
|
data/lib/reeve/configuration.rb
CHANGED
|
@@ -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`,
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 =
|
|
52
|
+
EXPECTED_VERSION = 4
|
|
53
53
|
|
|
54
54
|
def initialize(tool: nil, expected: EXPECTED_VERSION, ledger: nil)
|
|
55
55
|
super(tool: tool, ledger: ledger)
|