Module: Legion::Data::AuditRecord
- Extended by:
- Logging::Helper
- Defined in:
- lib/legion/data/audit_record.rb
Constant Summary collapse
- GENESIS_HASH =
('0' * 64).freeze
Class Method Summary collapse
-
.append(chain_id:, content_type:, content_hash:, metadata: {}, sign: false) ⇒ Object
Append a new record to the named chain.
-
.compute_chain_hash(parent_hash, content_hash, timestamp, content_type) ⇒ Object
SHA-256 of “parent_hash:content_hash:unix_ts_us:content_type”.
-
.query_by_type(content_type:, since: nil, limit: 100) ⇒ Object
Return records filtered by content_type across all chains.
-
.verify(chain_id:) ⇒ Hash
Walk all records in the chain ordered by creation time and verify that each record’s stored chain_hash matches a freshly computed one.
-
.walk(chain_id:, since: nil, limit: 1000) ⇒ Object
Return all records for a chain as deserialised hashes.
Methods included from Logging::Helper
Class Method Details
.append(chain_id:, content_type:, content_hash:, metadata: {}, sign: false) ⇒ Object
Append a new record to the named chain. Returns the persisted record hash on success, or an error hash when the database is unavailable.
22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
# File 'lib/legion/data/audit_record.rb', line 22 def append(chain_id:, content_type:, content_hash:, metadata: {}, sign: false) return { error: 'db unavailable' } unless db_ready? conn = Legion::Data.connection conn.transaction do parent_hash = latest_chain_hash(conn, chain_id) ts = truncate_to_us(Time.now) ch = compute_chain_hash(parent_hash, content_hash, ts, content_type) sig = sign ? sign_record(ch) : nil = .empty? ? nil : Legion::JSON.dump() id = conn[:audit_records].insert( chain_id: chain_id, content_type: content_type, content_hash: content_hash, parent_hash: parent_hash, chain_hash: ch, signature: sig, metadata: , created_at: ts ) log.debug "AuditRecord append: chain=#{chain_id} type=#{content_type} id=#{id}" { id: id, chain_id: chain_id, chain_hash: ch, parent_hash: parent_hash } end end |
.compute_chain_hash(parent_hash, content_hash, timestamp, content_type) ⇒ Object
SHA-256 of “parent_hash:content_hash:unix_ts_us:content_type”.
The timestamp is normalised to microseconds-since-epoch. PostgreSQL TIMESTAMP columns have microsecond precision, so nanosecond values written by Ruby would be truncated on read, causing recomputed hashes to diverge. Microsecond normalisation keeps write-time and read-time hashes identical across all supported adapters.
114 115 116 117 |
# File 'lib/legion/data/audit_record.rb', line 114 def compute_chain_hash(parent_hash, content_hash, , content_type) ts_us = () Digest::SHA256.hexdigest("#{parent_hash}:#{content_hash}:#{ts_us}:#{content_type}") end |
.query_by_type(content_type:, since: nil, limit: 100) ⇒ Object
Return records filtered by content_type across all chains.
99 100 101 102 103 104 105 |
# File 'lib/legion/data/audit_record.rb', line 99 def query_by_type(content_type:, since: nil, limit: 100) return [] unless db_ready? ds = Legion::Data.connection[:audit_records].where(content_type: content_type) ds = ds.where { created_at >= since } if since ds.order(Sequel.desc(:created_at)).limit(limit).all.map { |r| deserialize(r) } end |
.verify(chain_id:) ⇒ Hash
Walk all records in the chain ordered by creation time and verify that each record’s stored chain_hash matches a freshly computed one.
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
# File 'lib/legion/data/audit_record.rb', line 54 def verify(chain_id:) return { valid: false, error: 'db unavailable' } unless db_ready? records = Legion::Data.connection[:audit_records] .where(chain_id: chain_id) .order(:created_at, :id) .all prev_hash = GENESIS_HASH records.each do |r| unless r[:parent_hash] == prev_hash log.warn "AuditRecord chain broken: chain=#{chain_id} id=#{r[:id]}" return { valid: false, broken_at: r[:id], reason: :parent_mismatch } end expected = compute_chain_hash(prev_hash, r[:content_hash], r[:created_at], r[:content_type]) unless r[:chain_hash] == expected log.warn "AuditRecord hash mismatch: chain=#{chain_id} id=#{r[:id]}" return { valid: false, broken_at: r[:id], reason: :hash_mismatch } end prev_hash = r[:chain_hash] end { valid: true, length: records.size } end |
.walk(chain_id:, since: nil, limit: 1000) ⇒ Object
Return all records for a chain as deserialised hashes.
86 87 88 89 90 91 92 |
# File 'lib/legion/data/audit_record.rb', line 86 def walk(chain_id:, since: nil, limit: 1000) return [] unless db_ready? ds = Legion::Data.connection[:audit_records].where(chain_id: chain_id) ds = ds.where { created_at >= since } if since ds.order(:created_at, :id).limit(limit).all.map { |r| deserialize(r) } end |