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

Methods included from Logging::Helper

handle_exception

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.

Parameters:

  • chain_id (String)

    chain identifier (scopes the sequence)

  • content_type (String)

    caller-defined type label

  • content_hash (String)

    SHA-256 hex digest of the content being recorded

  • metadata (Hash) (defaults to: {})

    optional structured context (serialised to JSON)

  • sign (Boolean) (defaults to: false)

    when true, attempt signing via legion-crypt



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          = Time.now
    ch          = compute_chain_hash(parent_hash, content_hash, ts, content_type)
    sig         = sign ? sign_record(ch) : nil
    meta_json   = .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:     meta_json,
      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_ns:content_type”.

The timestamp is normalised to nanoseconds-since-epoch so the hash is independent of time zone, string formatting, and database type. Exposed as a public method so callers can independently verify a hash without querying the database.



113
114
115
116
# File 'lib/legion/data/audit_record.rb', line 113

def compute_chain_hash(parent_hash, content_hash, timestamp, content_type)
  ts_ns = normalise_timestamp_ns(timestamp)
  Digest::SHA256.hexdigest("#{parent_hash}:#{content_hash}:#{ts_ns}:#{content_type}")
end

.query_by_type(content_type:, since: nil, limit: 100) ⇒ Object

Return records filtered by content_type across all chains.

Parameters:

  • content_type (String)
  • since (Time, nil) (defaults to: nil)
  • limit (Integer) (defaults to: 100)


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.

Parameters:

  • chain_id (String)

Returns:

  • (Hash)

    { valid: Boolean, length: Integer, broken_at: Integer? }



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.

Parameters:

  • chain_id (String)
  • since (Time, nil) (defaults to: nil)

    optional lower bound on created_at

  • limit (Integer) (defaults to: 1000)


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