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
- #assert_record_in_scope(context, collection, packed_id) ⇒ Object
-
#audited_projection(collection) ⇒ Object
What the audit trail actually records: primary keys, so a state can be identified, plus the writable columns.
-
#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.
- #first_record(context, collection, condition_tree, projection) ⇒ Object
- #key_projection(collection) ⇒ Object
-
#record_segments(collection, packed_id) ⇒ Object
Every id this record has been filed under, each with the moment it stopped being that id.
-
#scoped_record(context, collection, packed_id, projection = nil) ⇒ Object
The record as it stands, read through the caller's permission scope.
-
#serialize_record(record) ⇒ Object
Camelize only the top-level keys — the row
idincluded, which the front uses as the tiebreaker when merging pages ordered by (timestamp, id). - #store ⇒ Object
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..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 |
#store ⇒ Object
49 50 51 |
# File 'lib/forest_admin_agent/routes/resources/audit_trail_route.rb', line 49 def store ::ForestAdminAgent::AuditTrail.store end |