forest_admin_agent 1.44.0 → 1.44.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 765a7dfcd6479eadd50782c2d72e2fa6d14e2f0cd5858cd56b53279fb319f52d
4
- data.tar.gz: 869b66b6a89dcbaf78887428fc8b9d5dd823629204c1c25cc6e7a3bf6645c0be
3
+ metadata.gz: eb144d36d50f4763b0b8f2e61e701ee5fbfb7a97b66b4a9a6f67aaff26b8ac5d
4
+ data.tar.gz: c6f7817f4a8507b703c604b8c4c643884eb57b8d0902cd90437e6ffe1b201238
5
5
  SHA512:
6
- metadata.gz: ef2ffa8474a150773b31add6d108ea351d4be7aac9f176e14967576f0718998d7a025d82d894d53c82736115165f92dd50f655e43afc1cb579b41b2cdd054f2f
7
- data.tar.gz: f34499524fc80e0194f2341e14c0e5b373beca8a95fa1d4ae46a912bea35747d34d21819170acfee83f082b67703656bee4a7d152c08f2b834b5fb4916fecb5a
6
+ metadata.gz: c76840f3a4d7823a6395b6926e5ba60cb018f8acc30f6166773334ce6e966292b410001ad262a26cf0ca36580bf08dfcd0c0451821f52552919c37ec242083a7
7
+ data.tar.gz: fd08b9086757654dd821a909376648fa0f1a7274b18118cf734573bfc99f1818fabc063f085c72d1bccd7d94a8852f299aa0cd23703a3588d55f81a33a12f03b
data/AUDIT_TRAIL.md CHANGED
@@ -190,6 +190,43 @@ A record that no longer exists keeps its history: only a record that still exist
190
190
  caller's permission scope is refused (404). Inspecting what was deleted is much of the point of an
191
191
  audit trail, and the delete event itself is the last thing recorded.
192
192
 
193
+ Those rows still carry the column values captured while the record existed, and a scope that can no
194
+ longer be evaluated against the record is evaluated against the values instead: a `delete` row keeps
195
+ its `previousValues` only if they match the scope, a `create` row its `newValues`, and an `update`
196
+ row — whose two sides are a partial diff — is tested side by side, so each is kept only if its own
197
+ values match. Action rows hold a submitted form and a result summary, not column values, so they are
198
+ untouched. The row itself is always returned: what happened, by whom and when stays visible either way.
199
+
200
+ A snapshot only answers for the writable columns it captured, so a scope reaching for anything else — a
201
+ read-only column, a relation, a value stored redacted — is not evaluated against it at all and the values
202
+ are withheld: `nil` there is a missing answer, not a passing one, and a redacted value answers no better.
203
+ Primary keys are the exception: the packed id fills in whatever the snapshot cannot answer — a read-only key
204
+ it never captured, or a writable one the trail redacts — so a scope on the id still matches the record it
205
+ belongs to. It fills in only what is missing: where a side captured the key itself, that value is the one
206
+ that was true there. Each side of an update is read against the id it was filed under, the row's own id and,
207
+ for the previous side of an update that moved the key, the id it moved from. A `pending` row is filed under
208
+ the id the record had *before* the write, which may not have landed, so its new side is given no id to fill
209
+ from and answers with what it captured or not at all.
210
+
211
+ Neither failure this can meet is fatal, and each costs only what it actually broke. An id that stopped
212
+ decoding when the primary key changed shape costs the keys it would have filled and nothing more, so a scope
213
+ that never asks about the id is still answered from the columns the row captured. A column captured as `nil`
214
+ that an ordered operator cannot compare withholds that row. Uncaught, either would have failed the whole
215
+ page, and only for the callers a scope applies to, since an unscoped caller never reaches this test. Both
216
+ are logged, and the row keeps its operation, author and timestamp. This is also
217
+ what makes an update's partial diff safe to test: a diff that never carried the scoped column answers for
218
+ neither side, so both are withheld.
219
+
220
+ The record is read twice: once before the rows are fetched, to refuse a record that exists outside the
221
+ caller's scope without touching the audit database, and once after, so the answer that decides the
222
+ withholding is never older than the rows it applies to — a record deleted in between would otherwise have
223
+ answered "present and in scope" for rows that already carry its delete. The second read is skipped when no
224
+ scope is in effect.
225
+
226
+ **This covers the history route only.** `/state` reconstructs a gone record from the same rows and serves
227
+ it unfiltered, and the correlation routes check the record but not the values, so a caller the withholding
228
+ above protects against can still read those values one request away. Closing that is tracked separately.
229
+
193
230
  ### State route
194
231
 
195
232
  `GET /forest/_audit-trail/{collection}/{recordId}/state?timestamp=…` returns the record as it stood at
@@ -39,7 +39,7 @@ module ForestAdminAgent
39
39
  def handle_request(args = {})
40
40
  context = build(args)
41
41
  context.permissions.can?(:read, context.collection)
42
- assert_record_in_scope(context, context.collection, args[:params]['id'])
42
+ withholding_scope = assert_record_in_scope(context, context.collection, args[:params]['id'])
43
43
 
44
44
  skip, limit = parse_pagination(args)
45
45
  filters = {
@@ -54,9 +54,14 @@ module ForestAdminAgent
54
54
  # `count` reflects the active filters (not the absolute total) and is independent of the page.
55
55
  count = store.count_by_record(**filters)
56
56
 
57
+ # Asked again now: the check above ran before these rows were read, so a record deleted in between
58
+ # answered "present and in scope" for rows that already carry its delete.
59
+ withholding_scope ||= scope_if_gone_since(context, args)
60
+ data = withhold_out_of_scope_values(history, withholding_scope, context)
61
+
57
62
  {
58
63
  name: args[:params]['collection_name'],
59
- content: { data: history.map { |record| serialize_record(record) }, meta: meta(args, filters, count) }
64
+ content: { data: data.map { |record| serialize_record(record) }, meta: meta(args, filters, count) }
60
65
  }
61
66
  end
62
67
 
@@ -85,6 +90,108 @@ module ForestAdminAgent
85
90
 
86
91
  private
87
92
 
93
+ # Second read of the record, once the rows are in hand, so the answer that decides the withholding is
94
+ # never older than what it decides on. Skipped without a scope in effect — there is nothing to withhold
95
+ # then, and nothing to ask. A record moved out of scope rather than deleted raises the 404 it would
96
+ # raise for a request starting a moment later.
97
+ def scope_if_gone_since(context, args)
98
+ return nil if context.permissions.get_scope(context.collection).nil?
99
+
100
+ assert_record_in_scope(context, context.collection, args[:params]['id'])
101
+ end
102
+
103
+ # A record that is gone for good bypasses the scope check — there is nothing left to check it against
104
+ # — but its rows still carry the column values captured while it existed. When those values would
105
+ # themselves have failed the caller's scope, withhold them; the row itself stays visible either way,
106
+ # so that it happened, by whom and when still reads.
107
+ def withhold_out_of_scope_values(entries, scope, context)
108
+ return entries if scope.nil?
109
+
110
+ entries.map { |entry| withhold(entry, scope, context) }
111
+ end
112
+
113
+ def withhold(entry, scope, context)
114
+ case entry.operation
115
+ # `delete`'s previous_values and `create`'s new_values both capture every writable column.
116
+ when 'delete'
117
+ in_scope?(entry.previous_values, entry.record_id, scope, context) ? entry : blank(entry, :previous_values)
118
+ when 'create'
119
+ in_scope?(entry.new_values, entry.record_id, scope, context) ? entry : blank(entry, :new_values)
120
+ when 'update'
121
+ withhold_each_side(entry, scope, context)
122
+ # `action`/`action_failed` rows hold a submitted form and a result summary, not column values, so
123
+ # the scope doesn't apply to them.
124
+ else
125
+ entry
126
+ end
127
+ end
128
+
129
+ # An update's two sides are a partial diff, so each is tested against its own values: a diff that never
130
+ # carried the scoped column answers for neither and is withheld by `in_scope?` anyway. Gating the sides
131
+ # separately releases the ones that can be proven in scope — "it used to be X" can't escape through a
132
+ # row whose new value is out of scope, since that side is tested on its own.
133
+ def withhold_each_side(entry, scope, context)
134
+ # An update that moved a writable primary key files its row under the id the record ended up with,
135
+ # and keeps the one it had on `previous_record_id`. Each side is tested against the id it was true
136
+ # of, or the new state's id would decide whether the old state is in scope.
137
+ before = entry.previous_record_id || entry.record_id
138
+ kept = in_scope?(entry.previous_values, before, scope, context) ? entry : blank(entry, :previous_values)
139
+
140
+ in_scope?(kept.new_values, after_id(kept), scope, context) ? kept : blank(kept, :new_values)
141
+ end
142
+
143
+ # A pending row is filed under the id the record had *before* the write, since the write may not have
144
+ # landed: it says nothing about the state the update was moving to, so the new side gets no id to fill
145
+ # from and falls back on what it captured itself.
146
+ def after_id(entry)
147
+ entry.status == ::ForestAdminAgent::AuditTrail::Recording::PENDING ? nil : entry.record_id
148
+ end
149
+
150
+ # Only a snapshot that answers every field the scope asks about, with what was really stored, is worth
151
+ # matching. The capture keeps the writable columns, so a scope on anything else — a read-only column, a
152
+ # relation — reads as nil there and would answer for a value the row never held: `status != 'private'`
153
+ # would match, and an ordered operator would raise on the nil. A redacted value answers no better.
154
+ def in_scope?(values, packed_id, scope, context)
155
+ snapshot = answerable_snapshot(values, packed_id, context.collection)
156
+ return false unless scope.projection.all? { |field| snapshot.key?(field) }
157
+
158
+ scope.match(snapshot, context.collection, context.caller.timezone)
159
+ rescue StandardError => e
160
+ # Key presence is not answerability: a column captured as nil has its key, and an ordered operator
161
+ # raises on it. Uncaught that would fail the whole page, and only for the callers a scope applies
162
+ # to. One withheld row is the smaller loss, and the same answer the field would have got had it
163
+ # been missing outright.
164
+ Facades::Container.logger&.log('Warn', "[ForestAdmin] Audit row not scope-checkable: #{e.message}")
165
+
166
+ false
167
+ end
168
+
169
+ # What this side of the row can answer about. A redacted value answers nothing, so it is dropped rather
170
+ # than matched against the placeholder — leaving the field unanswered, which withholds. The packed id
171
+ # then fills in the primary keys: a read-only one never lands in the snapshot at all, and a writable one
172
+ # the trail redacts was just dropped, while the id the row was filed under proves what the key was.
173
+ # It only fills what the snapshot cannot answer: on the side of a row that captured the key itself,
174
+ # that value is the one that was true there.
175
+ def answerable_snapshot(values, packed_id, collection)
176
+ answered = (values || {}).reject { |_, value| value == ::ForestAdminAgent::AuditTrail::Recording::REDACTED }
177
+ return answered if packed_id.nil?
178
+
179
+ begin
180
+ Utils::Id.unpack_id(collection, packed_id, with_key: true).merge(answered)
181
+ rescue StandardError => e
182
+ # An id written under a primary key of another shape costs the keys it would have filled, and
183
+ # nothing else: the snapshot still answers for the columns it captured, so a scope that never
184
+ # asks about the id is unaffected.
185
+ Facades::Container.logger&.log('Warn', "[ForestAdmin] Audit row id not decodable: #{e.message}")
186
+
187
+ answered
188
+ end
189
+ end
190
+
191
+ def blank(entry, *fields)
192
+ entry.dup.tap { |copy| fields.each { |field| copy[field] = {} } }
193
+ end
194
+
88
195
  # `availableUsers` rides along on the first fetch only — the front keeps the list it saw — and lists the
89
196
  # distinct authors of the entries the current filters match, whatever page was asked for. The identity
90
197
  # comes from the rows, so someone since renamed or removed still reads as they were when they acted.
@@ -9,10 +9,16 @@ module ForestAdminAgent
9
9
  module AuditTrailRoute
10
10
  include ForestAdminDatasourceToolkit::Components::Query
11
11
 
12
+ # The caller's scope when the record is gone for good, nil otherwise — no scope in effect, or a record
13
+ # still there and in scope. A scope can't be evaluated against a record that no longer exists, so its
14
+ # history is served, but the values its rows captured while it existed still have to be tested against
15
+ # that scope before being handed back.
12
16
  def assert_record_in_scope(context, collection, packed_id)
13
- scoped_record(context, collection, packed_id)
17
+ return nil if scoped_record(context, collection, packed_id)
14
18
 
15
- nil
19
+ # Nothing there: gone for good, since an out-of-scope record raised above. Without a scope in
20
+ # effect this reads nil, which is the same answer.
21
+ context.permissions.get_scope(collection)
16
22
  end
17
23
 
18
24
  # The record as it stands, read through the caller's permission scope. nil when it no longer exists
@@ -6,7 +6,7 @@ module ForestAdminAgent
6
6
  module Schema
7
7
  class SchemaEmitter
8
8
  LIANA_NAME = "agent-ruby"
9
- LIANA_VERSION = "1.44.0"
9
+ LIANA_VERSION = "1.44.2"
10
10
 
11
11
  def self.generate(datasource)
12
12
  datasource.collections
@@ -1,3 +1,3 @@
1
1
  module ForestAdminAgent
2
- VERSION = "1.44.0"
2
+ VERSION = "1.44.2"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forest_admin_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.44.0
4
+ version: 1.44.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthieu
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-09-15 00:00:00.000000000 Z
12
+ date: 2026-09-24 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activesupport