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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 17ce25b6562386687fec0e9647a28005e54f430a2186ffd841c345b8e5c98a55
4
- data.tar.gz: 6227a1417584976e995d4868d4a8ef6d5084a9e7cad49963f7155cb9a7e99b46
3
+ metadata.gz: c634e213a6abe156c780751bf6bda7556655bef64c038a298b703e28fba87fb2
4
+ data.tar.gz: 929b40145ab46640b779ea58b23f9ef0726a27090bb09da194cce6c1d8193194
5
5
  SHA512:
6
- metadata.gz: b066c7ff24ba343941e507616899e77eeb3804a6b83ee0b3954c5646e3ea097daf5402785ef27dc7bba01d5c8742fe7fb23b15d5134a5db058dec6999b5b4b26
7
- data.tar.gz: f27b514fec0fb8c2d9d8157f23a69f0c7f81d0eade28ec8f824ef44132d48d40db0c5ff1e97da9e7e8a647fd97314be5d4b3fe3edad8187ebfe42a541d73a281
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
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Data
5
- VERSION = '1.6.12'
5
+ VERSION = '1.6.13'
6
6
  end
7
7
  end
data/lib/legion/data.rb CHANGED
@@ -14,6 +14,7 @@ require_relative 'data/archiver'
14
14
  require_relative 'data/helper'
15
15
  require_relative 'data/rls'
16
16
  require_relative 'data/extract'
17
+ require_relative 'data/audit_record'
17
18
 
18
19
  module Legion
19
20
  module Data
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.12
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