Module: ForestAdminAgent::Routes::Resources::AuditTrailRoute

Includes:
ForestAdminDatasourceToolkit::Components::Query
Included in:
AuditTrail, AuditTrailCorrelation
Defined in:
lib/forest_admin_agent/routes/resources/audit_trail_route.rb

Overview

Behaviour shared by every audit-trail route: they all take a packed record id straight from the request, so the caller's permission scope has to be checked against that record before any history is returned (can?(:read, collection) alone only proves access to the collection — a role restricted to a subset of the records would otherwise read the history of any of them), and they all serialize audit records the same way.

Instance Method Summary collapse

Instance Method Details

#assert_record_in_scope(context, collection, packed_id) ⇒ Object



12
13
14
15
16
# File 'lib/forest_admin_agent/routes/resources/audit_trail_route.rb', line 12

def assert_record_in_scope(context, collection, packed_id)
  scoped_record(context, collection, packed_id)

  nil
end

#audited_projection(collection) ⇒ Object

What the audit trail actually records: primary keys, so a state can be identified, plus the writable columns. Reading read-only ones would hand them back at their present value inside an answer that claims to describe a past instant.



97
98
99
100
101
102
103
# File 'lib/forest_admin_agent/routes/resources/audit_trail_route.rb', line 97

def audited_projection(collection)
  writable = collection.schema[:fields].select do |_name, field|
    field.type == 'Column' && !field.is_read_only
  end.keys

  Projection.new((ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(collection) + writable).uniq)
end

#earlier_bound(one, other) ⇒ Object

Bounds compare as the trail orders, (timestamp, row id); nil is "no bound yet", which is later than any of them. Through <=>, since Array is not Comparable and <= on one raises.



84
85
86
87
88
89
90
91
92
# File 'lib/forest_admin_agent/routes/resources/audit_trail_route.rb', line 84

def earlier_bound(one, other)
  return other.slice(:until, :until_row) if one[:until].nil?
  return one.slice(:until, :until_row) if other[:until].nil?

  pair = ->(bound) { [bound[:until], bound[:until_row].to_i] }
  earlier = (pair[one] <=> pair[other]) <= 0 ? one : other

  earlier.slice(:until, :until_row)
end

#first_record(context, collection, condition_tree, projection) ⇒ Object



109
110
111
# File 'lib/forest_admin_agent/routes/resources/audit_trail_route.rb', line 109

def first_record(context, collection, condition_tree, projection)
  collection.list(context.caller, Filter.new(condition_tree: condition_tree), projection).first
end

#key_projection(collection) ⇒ Object



105
106
107
# File 'lib/forest_admin_agent/routes/resources/audit_trail_route.rb', line 105

def key_projection(collection)
  Projection.new(ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(collection))
end

#record_segments(collection, packed_id) ⇒ Object

Every id this record has been filed under, each with the moment it stopped being that id. Rows written before an update moved a writable primary key stay under the id they were true of, so a history query that asked for the current id alone would start at the rename and call that the whole story — and one that asked for the bare ids would sweep up whatever record holds an abandoned id now.

Breadth-first, and no depth limit: every hop adds an id not already seen and there are finitely many of those, so skipping what we hold is both the cycle guard and the terminator. A cap would have truncated a record renamed often enough, which reads exactly like missing history.



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/forest_admin_agent/routes/resources/audit_trail_route.rb', line 61

def record_segments(collection, packed_id)
  segments = [{ id: packed_id, until: nil, until_row: nil }]
  queue = segments.dup

  until queue.empty?
    segment = queue.shift

    store.renamed_from(collection: collection.name, record_id: segment[:id]).each do |previous|
      next if segments.any? { |seen| seen[:id] == previous[:id] }

      # Bounded by its own rename and by everything walked through to reach it: an id abandoned twice
      # only belongs to this record up to the earlier of them.
      found = earlier_bound(previous, segment).merge(id: previous[:id])
      segments << found
      queue << found
    end
  end

  segments
end

#scoped_record(context, collection, packed_id, projection = nil) ⇒ Object

The record as it stands, read through the caller's permission scope. nil when it no longer exists — a deleted record keeps its history readable, which is much of the point of an audit trail — and a 404 when it does exist outside that scope.

Authorizing and reading are the same query on purpose: a scoped check followed by an unscoped read would hand back a row the check never covered, the moment the two drifted apart.



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# File 'lib/forest_admin_agent/routes/resources/audit_trail_route.rb', line 24

def scoped_record(context, collection, packed_id, projection = nil)
  condition = ConditionTree::ConditionTreeFactory.match_records(
    collection, [Utils::Id.unpack_id(collection, packed_id, with_key: true)]
  )
  scope = context.permissions.get_scope(collection)
  in_scope = ConditionTree::ConditionTreeFactory.intersect([condition, scope])
  record = first_record(context, collection, in_scope, projection || key_projection(collection))

  return record if record
  # Nothing in scope: either gone for good, or someone else's record. Without a scope the query
  # above already answered the question.
  return nil if scope.nil? || first_record(context, collection, condition, key_projection(collection)).nil?

  raise Http::Exceptions::NotFoundError, 'Record does not exists'
end

#serialize_record(record) ⇒ Object

Camelize only the top-level keys — the row id included, which the front uses as the tiebreaker when merging pages ordered by (timestamp, id). Value hashes keep the keys they were stored with: a record's own column names, or an action answer's camelCase Forest names.



43
44
45
46
47
# File 'lib/forest_admin_agent/routes/resources/audit_trail_route.rb', line 43

def serialize_record(record)
  # `previous_record_id` stays out: it is how the agent follows a record across a rename, not something
  # the payload contract carries.
  record.to_h.except(:previous_record_id).transform_keys { |key| key.to_s.camelize(:lower) }
end

#storeObject



49
50
51
# File 'lib/forest_admin_agent/routes/resources/audit_trail_route.rb', line 49

def store
  ::ForestAdminAgent::AuditTrail.store
end