Class: Textus::Port::AuditLog

Inherits:
Object
  • Object
show all
Defined in:
lib/textus/port/audit_log.rb

Overview

Append-only audit log adapter: writes and rotates the on-disk audit JSONL under the store root. An instantiable class — it holds collaborators (the root path + size/keep config), so each store binds its own instance. It already satisfied ADR 0109’s single-shape rule (every port is an instantiable class) before that ADR’s Clock/Publisher conversions, so it was unchanged by them.

Constant Summary collapse

DEFAULT_MAX_SIZE =
10_485_760
DEFAULT_KEEP =
5

Instance Method Summary collapse

Constructor Details

#initialize(root, max_size: DEFAULT_MAX_SIZE, keep: DEFAULT_KEEP) ⇒ AuditLog

Returns a new instance of AuditLog.



17
18
19
20
21
22
# File 'lib/textus/port/audit_log.rb', line 17

def initialize(root, max_size: DEFAULT_MAX_SIZE, keep: DEFAULT_KEEP)
  @root     = root
  @path     = Textus::Store::Geometry.new(root).audit_log_path
  @max_size = max_size
  @keep     = keep
end

Instance Method Details

#append(role:, verb:, key:, etag_before:, etag_after:, extras: nil) ⇒ Object



62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/textus/port/audit_log.rb', line 62

def append(role:, verb:, key:, etag_before:, etag_after:, extras: nil)
  FileUtils.mkdir_p(File.dirname(@path))
  File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |f|
    f.flock(File::LOCK_EX)
    next_seq = current_max_seq_unlocked + 1
    row = assemble_row(next_seq, { role: role, verb: verb, key: key,
                                   etag_before: etag_before, etag_after: etag_after }, extras)
    f.write(JSON.generate(row) + "\n")
    f.flush
    rotate!(f) if f.size > @max_size
  end
end

#last_writer_for(key) ⇒ Object



24
25
26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/textus/port/audit_log.rb', line 24

def last_writer_for(key)
  return nil unless File.exist?(@path)

  last_role = nil
  File.foreach(@path) do |line|
    parsed = parse_row(line.chomp)
    next unless parsed
    next unless parsed["key"] == key
    next unless %w[put delete key_delete].include?(parsed["verb"])

    last_role = parsed["role"]
  end
  last_role
end

#latest_seqObject



39
40
41
42
43
44
45
46
47
# File 'lib/textus/port/audit_log.rb', line 39

def latest_seq
  return scan_max_seq(@path) if File.exist?(@path) && File.size(@path).positive?

  # Active log is empty/missing — consult the most recent rotated file's sidecar.
  meta = read_meta(1)
  return meta["max_seq"] if meta

  0
end

#min_available_seqObject



49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/textus/port/audit_log.rb', line 49

def min_available_seq
  rotated_metas = (1..@keep).map { |n| read_meta(n) }.compact
  if rotated_metas.any?
    rotated_metas.map { |m| m["min_seq"] }.min
  elsif File.exist?(@path)
    File.foreach(@path) do |line|
      parsed = parse_row(line.chomp)
      return parsed["seq"] if parsed && parsed["seq"]
    end
    nil
  end
end

#scan(seq_since: nil, key: nil, role: nil, verb: nil, correlation_id: nil, limit: nil) ⇒ Object

Scan log files with optional filters. Returns parsed row hashes. Lane and timestamp filters are left to the caller (they need manifest resolution and Time parsing the port shouldn’t know about).



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/textus/port/audit_log.rb', line 78

def scan(seq_since: nil, key: nil, role: nil, verb: nil,
         correlation_id: nil, limit: nil)
  files = all_log_files
  return [] if files.empty?

  rows = []
  files.each do |file|
    File.foreach(file) do |line|
      parsed = parse_row(line.chomp)
      next unless parsed && matches?(parsed, seq_since:, key:, role:, verb:, correlation_id:)

      rows << parsed
      break if limit && rows.length >= limit
    end
    break if limit && rows.length >= limit
  end
  rows
end

#verify_integrityObject

Returns an array of integrity-violation descriptors for the on-disk log. Each entry is { “lineno” => Integer, “reason” => String, “detail” => String }. Empty array means the log is well-formed (or doesn’t exist yet).



100
101
102
103
104
105
106
107
108
# File 'lib/textus/port/audit_log.rb', line 100

def verify_integrity
  return [] unless File.exist?(@path)

  [].tap do |out|
    iterate_with_prev_seq do |line, lineno, prev_seq|
      check_line_integrity(line, lineno, prev_seq, out)
    end
  end
end