legion-data 1.6.12 → 1.6.13
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 +16 -0
- data/lib/legion/data/audit_record.rb +172 -0
- data/lib/legion/data/migrations/058_add_audit_records.rb +42 -0
- data/lib/legion/data/model.rb +2 -1
- data/lib/legion/data/models/audit_record.rb +30 -0
- data/lib/legion/data/version.rb +1 -1
- data/lib/legion/data.rb +1 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c634e213a6abe156c780751bf6bda7556655bef64c038a298b703e28fba87fb2
|
|
4
|
+
data.tar.gz: 929b40145ab46640b779ea58b23f9ef0726a27090bb09da194cce6c1d8193194
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 74a88e2ce0029d80d46f79069aed8589fc9d4a21a484a7d01eff07f148448cbfe741ccd2a7e16f8ac897a9bd60830d068c55443c07aa8a2a34d58122a7888e65
|
|
7
|
+
data.tar.gz: e9a4c36697bc29c74dde6c763c61124d045686b414a7925d3cbb64291521c11ad5b748d81eeaecbd77d40e887eaef268ed523a80e33e00a056ad849fa3632f19
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Legion::Data Changelog
|
|
2
2
|
|
|
3
|
+
## [1.6.13] - 2026-03-28
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `Legion::Data::AuditRecord` — tamper-evident audit record primitive with SHA-256 hash chain (closes #7)
|
|
7
|
+
- `append(chain_id:, content_type:, content_hash:, metadata: {}, sign: false)` — inserts a new record, linking it to the previous tail via `parent_hash` and `chain_hash`
|
|
8
|
+
- `verify(chain_id:)` — walks the chain and re-derives every hash, returning `{ valid:, length: }` or `{ valid: false, broken_at:, reason: }` on tampering
|
|
9
|
+
- `walk(chain_id:, since: nil, limit: 1000)` — return deserialized records in chronological order
|
|
10
|
+
- `query_by_type(content_type:, since: nil, limit: 100)` — cross-chain query by content_type
|
|
11
|
+
- `compute_chain_hash(parent_hash, content_hash, timestamp, content_type)` — public for independent verification
|
|
12
|
+
- Multiple independent chains share a single `audit_records` table, keyed by `chain_id`
|
|
13
|
+
- Chain hash formula: `SHA256("parent_hash:content_hash:unix_ns:content_type")` — timezone-independent via nanosecond epoch
|
|
14
|
+
- Optional signing via `Legion::Crypt.sign` when `sign: true`; signature column is nil when signing is unavailable
|
|
15
|
+
- Migration 058: `audit_records` table with `chain_id`, `content_type`, `content_hash`, `parent_hash`, `chain_hash` (unique), `signature`, `metadata`, `created_at`; PostgreSQL `NO UPDATE/DELETE` rules for DB-level append-only enforcement
|
|
16
|
+
- `Legion::Data::Model::AuditRecord` — Sequel model with `before_update`/`before_destroy` immutability guards and `parsed_metadata` helper
|
|
17
|
+
- 29 new specs covering constant, hash computation, DB-unavailable guards, chain creation, chain verification, tamper detection, walk/query operations, and model immutability
|
|
18
|
+
|
|
3
19
|
## [1.6.12] - 2026-03-28
|
|
4
20
|
|
|
5
21
|
### Added
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Data
|
|
7
|
+
module AuditRecord
|
|
8
|
+
GENESIS_HASH = ('0' * 64).freeze
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
# Append a new record to the named chain. Returns the persisted record hash
|
|
12
|
+
# on success, or an error hash when the database is unavailable.
|
|
13
|
+
#
|
|
14
|
+
# @param chain_id [String] chain identifier (scopes the sequence)
|
|
15
|
+
# @param content_type [String] caller-defined type label
|
|
16
|
+
# @param content_hash [String] SHA-256 hex digest of the content being recorded
|
|
17
|
+
# @param metadata [Hash] optional structured context (serialised to JSON)
|
|
18
|
+
# @param sign [Boolean] when true, attempt signing via legion-crypt
|
|
19
|
+
def append(chain_id:, content_type:, content_hash:, metadata: {}, sign: false)
|
|
20
|
+
return { error: 'db unavailable' } unless db_ready?
|
|
21
|
+
|
|
22
|
+
conn = Legion::Data.connection
|
|
23
|
+
conn.transaction do
|
|
24
|
+
parent_hash = latest_chain_hash(conn, chain_id)
|
|
25
|
+
ts = Time.now
|
|
26
|
+
ch = compute_chain_hash(parent_hash, content_hash, ts, content_type)
|
|
27
|
+
sig = sign ? sign_record(ch) : nil
|
|
28
|
+
meta_json = metadata.empty? ? nil : Legion::JSON.dump(metadata)
|
|
29
|
+
|
|
30
|
+
id = conn[:audit_records].insert(
|
|
31
|
+
chain_id: chain_id,
|
|
32
|
+
content_type: content_type,
|
|
33
|
+
content_hash: content_hash,
|
|
34
|
+
parent_hash: parent_hash,
|
|
35
|
+
chain_hash: ch,
|
|
36
|
+
signature: sig,
|
|
37
|
+
metadata: meta_json,
|
|
38
|
+
created_at: ts
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
Legion::Logging.debug "AuditRecord append: chain=#{chain_id} type=#{content_type} id=#{id}" if defined?(Legion::Logging)
|
|
42
|
+
{ id: id, chain_id: chain_id, chain_hash: ch, parent_hash: parent_hash }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Walk all records in the chain ordered by creation time and verify that
|
|
47
|
+
# each record's stored chain_hash matches a freshly computed one.
|
|
48
|
+
#
|
|
49
|
+
# @param chain_id [String]
|
|
50
|
+
# @return [Hash] { valid: Boolean, length: Integer, broken_at: Integer? }
|
|
51
|
+
def verify(chain_id:)
|
|
52
|
+
return { valid: false, error: 'db unavailable' } unless db_ready?
|
|
53
|
+
|
|
54
|
+
records = Legion::Data.connection[:audit_records]
|
|
55
|
+
.where(chain_id: chain_id)
|
|
56
|
+
.order(:created_at, :id)
|
|
57
|
+
.all
|
|
58
|
+
|
|
59
|
+
prev_hash = GENESIS_HASH
|
|
60
|
+
records.each do |r|
|
|
61
|
+
unless r[:parent_hash] == prev_hash
|
|
62
|
+
Legion::Logging.warn "AuditRecord chain broken: chain=#{chain_id} id=#{r[:id]}" if defined?(Legion::Logging)
|
|
63
|
+
return { valid: false, broken_at: r[:id], reason: :parent_mismatch }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
expected = compute_chain_hash(prev_hash, r[:content_hash], r[:created_at], r[:content_type])
|
|
67
|
+
unless r[:chain_hash] == expected
|
|
68
|
+
Legion::Logging.warn "AuditRecord hash mismatch: chain=#{chain_id} id=#{r[:id]}" if defined?(Legion::Logging)
|
|
69
|
+
return { valid: false, broken_at: r[:id], reason: :hash_mismatch }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
prev_hash = r[:chain_hash]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
{ valid: true, length: records.size }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Return all records for a chain as deserialised hashes.
|
|
79
|
+
#
|
|
80
|
+
# @param chain_id [String]
|
|
81
|
+
# @param since [Time, nil] optional lower bound on created_at
|
|
82
|
+
# @param limit [Integer]
|
|
83
|
+
def walk(chain_id:, since: nil, limit: 1000)
|
|
84
|
+
return [] unless db_ready?
|
|
85
|
+
|
|
86
|
+
ds = Legion::Data.connection[:audit_records].where(chain_id: chain_id)
|
|
87
|
+
ds = ds.where { created_at >= since } if since
|
|
88
|
+
ds.order(:created_at, :id).limit(limit).all.map { |r| deserialize(r) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Return records filtered by content_type across all chains.
|
|
92
|
+
#
|
|
93
|
+
# @param content_type [String]
|
|
94
|
+
# @param since [Time, nil]
|
|
95
|
+
# @param limit [Integer]
|
|
96
|
+
def query_by_type(content_type:, since: nil, limit: 100)
|
|
97
|
+
return [] unless db_ready?
|
|
98
|
+
|
|
99
|
+
ds = Legion::Data.connection[:audit_records].where(content_type: content_type)
|
|
100
|
+
ds = ds.where { created_at >= since } if since
|
|
101
|
+
ds.order(Sequel.desc(:created_at)).limit(limit).all.map { |r| deserialize(r) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# SHA-256 of "parent_hash:content_hash:unix_ts_ns:content_type".
|
|
105
|
+
#
|
|
106
|
+
# The timestamp is normalised to nanoseconds-since-epoch so the hash is
|
|
107
|
+
# independent of time zone, string formatting, and database type.
|
|
108
|
+
# Exposed as a public method so callers can independently verify a hash
|
|
109
|
+
# without querying the database.
|
|
110
|
+
def compute_chain_hash(parent_hash, content_hash, timestamp, content_type)
|
|
111
|
+
ts_ns = normalise_timestamp_ns(timestamp)
|
|
112
|
+
Digest::SHA256.hexdigest("#{parent_hash}:#{content_hash}:#{ts_ns}:#{content_type}")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# Normalise a timestamp to integer nanoseconds-since-epoch regardless of
|
|
118
|
+
# whether the database returned a Time, DateTime, or String.
|
|
119
|
+
def normalise_timestamp_ns(timestamp)
|
|
120
|
+
case timestamp
|
|
121
|
+
when ::Time
|
|
122
|
+
(timestamp.to_r * 1_000_000_000).to_i
|
|
123
|
+
when ::DateTime
|
|
124
|
+
(timestamp.to_time.to_r * 1_000_000_000).to_i
|
|
125
|
+
else
|
|
126
|
+
ts = ::Time.parse(timestamp.to_s)
|
|
127
|
+
(ts.to_r * 1_000_000_000).to_i
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def latest_chain_hash(conn, chain_id)
|
|
132
|
+
last = conn[:audit_records]
|
|
133
|
+
.select(:chain_hash)
|
|
134
|
+
.where(chain_id: chain_id)
|
|
135
|
+
.order(Sequel.desc(:created_at), Sequel.desc(:id))
|
|
136
|
+
.first
|
|
137
|
+
last ? last[:chain_hash] : GENESIS_HASH
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def sign_record(chain_hash)
|
|
141
|
+
return nil unless defined?(Legion::Crypt) && Legion::Crypt.respond_to?(:sign)
|
|
142
|
+
|
|
143
|
+
Legion::Crypt.sign(chain_hash)
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
Legion::Logging.warn "AuditRecord signing failed: #{e.message}" if defined?(Legion::Logging)
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def deserialize(row)
|
|
150
|
+
{
|
|
151
|
+
id: row[:id],
|
|
152
|
+
chain_id: row[:chain_id],
|
|
153
|
+
content_type: row[:content_type],
|
|
154
|
+
content_hash: row[:content_hash],
|
|
155
|
+
parent_hash: row[:parent_hash],
|
|
156
|
+
chain_hash: row[:chain_hash],
|
|
157
|
+
signature: row[:signature],
|
|
158
|
+
metadata: row[:metadata] ? Legion::JSON.load(row[:metadata]) : {},
|
|
159
|
+
created_at: row[:created_at]
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def db_ready?
|
|
164
|
+
defined?(Legion::Data) && Legion::Data.connection&.table_exists?(:audit_records)
|
|
165
|
+
rescue StandardError => e
|
|
166
|
+
Legion::Logging.debug "AuditRecord#db_ready? check failed: #{e.message}" if defined?(Legion::Logging)
|
|
167
|
+
false
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Sequel.migration do
|
|
4
|
+
up do
|
|
5
|
+
next if table_exists?(:audit_records)
|
|
6
|
+
|
|
7
|
+
create_table(:audit_records) do
|
|
8
|
+
primary_key :id
|
|
9
|
+
String :chain_id, size: 255, null: false
|
|
10
|
+
String :content_type, size: 100, null: false
|
|
11
|
+
column :metadata, :text, null: true
|
|
12
|
+
String :content_hash, size: 64, null: false
|
|
13
|
+
String :parent_hash, size: 64, null: false
|
|
14
|
+
String :chain_hash, size: 64, null: false, unique: true
|
|
15
|
+
String :signature, size: 512, null: true
|
|
16
|
+
DateTime :created_at, null: false
|
|
17
|
+
|
|
18
|
+
index :chain_id, name: :idx_audit_records_chain_id
|
|
19
|
+
index :content_type, name: :idx_audit_records_content_type
|
|
20
|
+
index :created_at, name: :idx_audit_records_created_at
|
|
21
|
+
index %i[chain_id created_at], name: :idx_audit_records_chain_time
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
if database_type == :postgres
|
|
25
|
+
run <<~SQL
|
|
26
|
+
CREATE RULE no_update_audit_records AS ON UPDATE TO audit_records DO INSTEAD NOTHING;
|
|
27
|
+
CREATE RULE no_delete_audit_records AS ON DELETE TO audit_records DO INSTEAD NOTHING;
|
|
28
|
+
SQL
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
down do
|
|
33
|
+
next unless table_exists?(:audit_records)
|
|
34
|
+
|
|
35
|
+
if database_type == :postgres
|
|
36
|
+
run 'DROP RULE IF EXISTS no_update_audit_records ON audit_records;'
|
|
37
|
+
run 'DROP RULE IF EXISTS no_delete_audit_records ON audit_records;'
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
drop_table :audit_records
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/legion/data/model.rb
CHANGED
|
@@ -8,7 +8,8 @@ module Legion
|
|
|
8
8
|
|
|
9
9
|
def models
|
|
10
10
|
%w[extension function relationship task runner node setting digital_worker
|
|
11
|
-
apollo_entry apollo_relation apollo_expertise apollo_access_log audit_log
|
|
11
|
+
apollo_entry apollo_relation apollo_expertise apollo_access_log audit_log
|
|
12
|
+
audit_record]
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
def load
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Data
|
|
5
|
+
module Model
|
|
6
|
+
class AuditRecord < Sequel::Model(:audit_records)
|
|
7
|
+
# Enforce append-only semantics at the application layer.
|
|
8
|
+
# PostgreSQL enforces this at the DB layer via rules (migration 058);
|
|
9
|
+
# the application guard covers SQLite and MySQL.
|
|
10
|
+
|
|
11
|
+
def before_update
|
|
12
|
+
raise 'audit_records are immutable and cannot be updated'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def before_destroy
|
|
16
|
+
raise 'audit_records are immutable and cannot be deleted'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def parsed_metadata
|
|
20
|
+
return {} unless metadata
|
|
21
|
+
|
|
22
|
+
Legion::JSON.load(metadata)
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
Legion::Logging.warn "AuditRecord#parsed_metadata failed: #{e.message}" if defined?(Legion::Logging)
|
|
25
|
+
{}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/legion/data/version.rb
CHANGED
data/lib/legion/data.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: legion-data
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.6.
|
|
4
|
+
version: 1.6.13
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -106,6 +106,7 @@ files:
|
|
|
106
106
|
- lib/legion/data/archival.rb
|
|
107
107
|
- lib/legion/data/archival/policy.rb
|
|
108
108
|
- lib/legion/data/archiver.rb
|
|
109
|
+
- lib/legion/data/audit_record.rb
|
|
109
110
|
- lib/legion/data/connection.rb
|
|
110
111
|
- lib/legion/data/encryption/cipher.rb
|
|
111
112
|
- lib/legion/data/encryption/key_provider.rb
|
|
@@ -186,12 +187,14 @@ files:
|
|
|
186
187
|
- lib/legion/data/migrations/055_add_definition_to_functions.rb
|
|
187
188
|
- lib/legion/data/migrations/056_add_absorber_patterns.rb
|
|
188
189
|
- lib/legion/data/migrations/057_add_routing_key_to_runners.rb
|
|
190
|
+
- lib/legion/data/migrations/058_add_audit_records.rb
|
|
189
191
|
- lib/legion/data/model.rb
|
|
190
192
|
- lib/legion/data/models/apollo_access_log.rb
|
|
191
193
|
- lib/legion/data/models/apollo_entry.rb
|
|
192
194
|
- lib/legion/data/models/apollo_expertise.rb
|
|
193
195
|
- lib/legion/data/models/apollo_relation.rb
|
|
194
196
|
- lib/legion/data/models/audit_log.rb
|
|
197
|
+
- lib/legion/data/models/audit_record.rb
|
|
195
198
|
- lib/legion/data/models/digital_worker.rb
|
|
196
199
|
- lib/legion/data/models/extension.rb
|
|
197
200
|
- lib/legion/data/models/function.rb
|