forest_admin_agent 1.44.4 → 1.44.5

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: 82246fa1590f7e734cedc309c456c3dca128ea82b325c3842ad6fa291a7051cf
4
- data.tar.gz: 7943c129e8985805327acb63b8f49448ef6fa0a323c501cb420c5030eed514be
3
+ metadata.gz: 91471f8fa4d7fe3b46034b7eaa57131fc2ba3c8f1ead4276f5fd5b0947b3e5fd
4
+ data.tar.gz: 476ea423b47775d4177f530f98a44747446ebfde525c9f36309c6c8f6459c755
5
5
  SHA512:
6
- metadata.gz: fa96c5af1acbbaca154093885dcc4336c7e1b699de8854200655e9f10ce62564e09232bdffc71d468f08a61d7169dcb1ad08b2389ae518f32cf1432e716ecfee
7
- data.tar.gz: 83bfa0a61d14812a660dafdfe2fc8ae8b07856dd7fb58caa6da56215d36e458a69efbc7eaf707bf1fc559416f5decb649b72301673bfbdabea00b4f878a806e7
6
+ metadata.gz: ec40d4d42efe1e7138b98faf8df7b70583e9c53f1b29515e928f27ed500753d0a88eb965c13a5bfb0d441f24e70e403b10b8cd69efdfc8bfdb38c8a89700ea46
7
+ data.tar.gz: e6bfdbf15fb6ab830a3a453434cbbd67a4111c89620ceddbae78597745df87fb08c42044d165c62b4baa8cc7d2c0781cbbbb61e9f63aad32abe2e082583a5301
data/AUDIT_TRAIL.md CHANGED
@@ -139,6 +139,16 @@ It deliberately does **not** match `operation`, `correlationKey`, `recordId`, `c
139
139
  A field masked by `redact` never matches — neither by its `[redacted]` mask nor by the value it hid, which
140
140
  was never recorded. A search must not confirm a value the trail refused to keep.
141
141
 
142
+ Nor one it withholds (see below). On a record gone for good under a caller's scope, `search` and `fields`
143
+ are matched against the values as served, never as captured — in SQL, which rows come back, `meta.count`
144
+ and `availableUsers` would each say whether a withheld value holds the term. The rows are read without those
145
+ two filters, in batches of 500 that each continue past the last row read, never at an offset, and bounded at
146
+ the instant the scan starts. They are matched and paged as they go, keeping only the page asked for. Only a
147
+ gone record's history pays that scan, and it is one record's history — the same rows the SQL search
148
+ would have scanned without an index. That holds too for a record deleted while the request was in flight: the second read
149
+ of the record decides the withholding, so the SQL-matched answer is discarded and the history scanned the
150
+ same way.
151
+
142
152
  `fields` matches whole keys, never paths, so a name holding a dot (`address.city`) is quoted before it
143
153
  reaches SQL. Both sides of the diff are searched, since a field the change added exists in `newValues`
144
154
  only and one it removed in `previousValues` only. The JSON test is per adapter (Postgres, SQLite, MySQL /
@@ -217,15 +227,37 @@ are logged, and the row keeps its operation, author and timestamp. This is also
217
227
  what makes an update's partial diff safe to test: a diff that never carried the scoped column answers for
218
228
  neither side, so both are withheld.
219
229
 
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.
230
+ A captured `nil` is tested the way the database tests a `NULL`: a negated comparison (`!=`, not in, does not
231
+ contain) never matches it. In memory `status != 'private'` holds for a nil status, while the scoped read that
232
+ guarded the live record left that record out, so without this a record the caller could never read alive
233
+ would become readable once deleted. A scope that asks for the nil itself (`status` is missing) still matches
234
+ it. On a datasource whose own `!=` keeps a NULL (Mongo's `$ne`), this is stricter than the live read, never
235
+ looser.
236
+
237
+ The record is read twice, and **the values are withheld if either read found it gone**. The first read
238
+ refuses a record that exists outside the caller's scope without touching the audit database, and it is the
239
+ only one that saw the record as it was while the rows were being chosen. The second, once the rows are in
240
+ hand, is the only one that can see a record deleted since — which would otherwise have answered "present and
241
+ in scope" for rows that already carry its delete.
242
+
243
+ Neither answer cancels the other, because an id outlives the record that held it. A record that took a freed
244
+ id between the two reads answers for itself and not for the life whose rows these are: if this caller cannot
245
+ read it, that is a **404**, the same as for a request starting a moment later; if they can, the earlier
246
+ life's values stay withheld all the same. The second read is skipped when no scope is in effect, and on the
247
+ correlation routes when the answer holds no rows.
248
+
249
+ Where the delete and the recreation both happened *before* the request, both reads see the live record and
250
+ its history is served whole, earlier lives included — the documented delete/recreate behaviour, and a
251
+ separate question from the one above.
252
+
253
+ **Every route that serves captured values applies this**, since a rule only one of them applies is one
254
+ lookup away from being no rule at all. The two correlation routes withhold row by row exactly as the history
255
+ route does. `/state` is nothing but those values reassembled, so the reconstruction is tested as a whole and
256
+ `data` is `null` when it fails — including when the scope asks about something the reconstruction cannot
257
+ answer. One difference there: a reconstruction can sit on the far side of a primary-key move the route cannot
258
+ see, so the requested id does not fill in a key the trail redacted, only one that was never captured at all
259
+ (read-only, so it cannot have moved). All of them take the second read of the record too, and skip it when
260
+ there is nothing to withhold — no scope in effect, or no rows in the answer.
229
261
 
230
262
  ### State route
231
263
 
@@ -239,7 +271,8 @@ after** the timestamp — an entry stamped exactly at it counts as part of that
239
271
 
240
272
  `timestamp` accepts an ISO-8601 instant, or the same wall-clock forms as the filters above read in the
241
273
  request `timezone`; it is required (**400** otherwise). `data` is `null` when the record did not exist at
242
- that instant — either created later, or deleted and never recreated.
274
+ that instant — either created later, or deleted and never recreated — and when the reconstruction of a gone
275
+ record falls outside the caller's scope.
243
276
 
244
277
  Walking back stops being able to help where the trail stops: only audited (writable) columns are
245
278
  reconstructed, and a `create` means the record did not exist before it, while a `delete` restores the whole
@@ -74,13 +74,15 @@ module ForestAdminAgent
74
74
  end
75
75
 
76
76
  def list_by_record(collection:, record_id:, skip: 0, limit: nil, user_ids: nil, start_timestamp: nil,
77
- end_timestamp: nil, fields: nil, search: nil, order: 'asc')
77
+ end_timestamp: nil, fields: nil, search: nil, order: 'asc', after: nil)
78
+ descending = order.to_s == 'desc'
78
79
  # `id` (insertion order) breaks ties on equal timestamps in both directions, keeping pages
79
80
  # deterministic and stable.
80
81
  relation = scope(collection, record_id, user_ids: user_ids, start_timestamp: start_timestamp,
81
82
  end_timestamp: end_timestamp, fields: fields, search: search)
82
- .order(timestamp: order.to_s == 'desc' ? :desc : :asc, id: :asc)
83
+ .order(timestamp: descending ? :desc : :asc, id: :asc)
83
84
  .offset(skip || 0)
85
+ relation = relation.where(*past_row_condition(after, descending)) if after
84
86
  relation = relation.limit(limit) unless limit.nil?
85
87
 
86
88
  relation.map { |row| from_row(row) }
@@ -167,6 +169,15 @@ module ForestAdminAgent
167
169
  relation
168
170
  end
169
171
 
172
+ # Rows strictly past `row` in the history's own order, so a scan keyed on the last row it read neither
173
+ # repeats nor skips one when entries are written between its reads, as an offset would.
174
+ def past_row_condition(row, descending)
175
+ at = as_time(row[:timestamp])
176
+ past = descending ? '<' : '>'
177
+
178
+ ["timestamp #{past} ? OR (timestamp = ? AND id > ?)", at, at, row[:id]]
179
+ end
180
+
170
181
  def as_time(value)
171
182
  value.is_a?(::Time) ? value : ::Time.iso8601(value.to_s)
172
183
  end
@@ -13,6 +13,7 @@ module ForestAdminAgent
13
13
 
14
14
  DEFAULT_PAGE_SIZE = 20
15
15
  MAX_PAGE_SIZE = 100
16
+ SCAN_BATCH_SIZE = 500
16
17
  DATE_ONLY = /\A\d{4}-\d{2}-\d{2}\z/
17
18
  # Wall-clock datetime, `T` or space separator, seconds optional: `YYYY-MM-DD[T ]HH:mm[:ss]`.
18
19
  DATE_TIME = /\A(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?\z/
@@ -39,9 +40,10 @@ module ForestAdminAgent
39
40
  def handle_request(args = {})
40
41
  context = build(args)
41
42
  context.permissions.can?(:read, context.collection)
42
- withholding_scope = assert_record_in_scope(context, context.collection, args[:params]['id'])
43
+ # Before the audit database is touched, for the 404 it raises — and for what it saw of the record
44
+ # while the rows below were still being chosen, which the read after them can no longer see.
45
+ gone_at_check = assert_record_in_scope(context, context.collection, args[:params]['id'])
43
46
 
44
- skip, limit = parse_pagination(args)
45
47
  filters = {
46
48
  collection: context.collection.name,
47
49
  # args[:params]['id'] is already Forest's packed id, the form the audit store keys on — plus any id
@@ -50,18 +52,15 @@ module ForestAdminAgent
50
52
  **parse_filters(args)
51
53
  }
52
54
 
53
- history = store.list_by_record(**filters, skip: skip, limit: limit, order: parse_sort(args))
54
- # `count` reflects the active filters (not the absolute total) and is independent of the page.
55
- count = store.count_by_record(**filters)
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)
55
+ data, count, authors = if gone_at_check && filters.slice(:search, :fields).any?
56
+ history_matched_after_withholding(context, args, filters, gone_at_check)
57
+ else
58
+ history_matched_in_store(context, args, filters, gone_at_check)
59
+ end
61
60
 
62
61
  {
63
62
  name: args[:params]['collection_name'],
64
- content: { data: data.map { |record| serialize_record(record) }, meta: meta(args, filters, count) }
63
+ content: { data: data.map { |record| serialize_record(record) }, meta: meta(args, count, authors) }
65
64
  }
66
65
  end
67
66
 
@@ -75,6 +74,9 @@ module ForestAdminAgent
75
74
  current = scoped_record(
76
75
  context, context.collection, args[:params]['id'], audited_projection(context.collection)
77
76
  )
77
+ # Nothing left to evaluate a scope against, so the reconstruction is tested in its own right below
78
+ # — whatever the read after the rows finds under this id by then.
79
+ gone_at_check = current.nil? ? context.permissions.get_scope(context.collection) : nil
78
80
 
79
81
  timestamp = parse_state_timestamp(args)
80
82
  entries = store.list_since(
@@ -84,136 +86,151 @@ module ForestAdminAgent
84
86
  )
85
87
  # Fully qualified: inside this class, `AuditTrail` is the route itself.
86
88
  state = ::ForestAdminAgent::AuditTrail::RecordState.at(current, entries)
87
-
88
- { name: args[:params]['collection_name'], content: { data: state } }
89
+ # Asked again now, for the same reason the history route asks, and the same way: the record read
90
+ # above can be deleted — or an id that was gone then be taken by somebody else's record — while the
91
+ # audit read is in flight.
92
+ withholding_scope = withholding_scope_for(context, context.collection, args[:params]['id'],
93
+ gone_at_check)
94
+
95
+ { name: args[:params]['collection_name'],
96
+ content: { data: answerable_state(state, withholding_scope, context, args[:params]['id']) } }
89
97
  end
90
98
 
91
99
  private
92
100
 
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?
101
+ # The history route withholds a gone record's captured values from a caller whose scope they fail, and
102
+ # this route is nothing but those values reassembled: without the same test they come back one request
103
+ # away. A reconstruction the scope cannot answer withholds too — absent is not the same as passing.
104
+ def answerable_state(state, scope, context, packed_id)
105
+ return state if state.nil? || scope.nil?
99
106
 
100
- assert_record_in_scope(context, context.collection, args[:params]['id'])
107
+ withholding = Withholding.new(context.collection, scope, context.caller.timezone)
108
+ # The reconstruction can sit on the far side of a primary-key move this route cannot see, so the
109
+ # requested id does not answer for a key the trail redacted — only for one never captured at all.
110
+ in_scope?(state, packed_id, withholding, id_answers_for_keys: false) ? state : nil
101
111
  end
102
112
 
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?
113
+ # `availableUsers` rides along on the first fetch only — the front keeps the list it saw — and lists the
114
+ # distinct authors of the entries the current filters match, whatever page was asked for. The identity
115
+ # comes from the rows, so someone since renamed or removed still reads as they were when they acted.
116
+ def meta(args, count, authors)
117
+ return { count: count } unless first_fetch?(args)
109
118
 
110
- entries.map { |entry| withhold(entry, scope, context) }
119
+ { count: count, availableUsers: authors.call.map { |author| available_user(author) } }
111
120
  end
112
121
 
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
122
+ def first_fetch?(args)
123
+ page = args.dig(:params, 'page')
128
124
 
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)
125
+ (page.is_a?(Hash) ? page['number'].to_i : 0) <= 1
141
126
  end
142
127
 
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
128
+ def available_user(author)
129
+ { id: author[:user_id], firstName: author[:user_first_name],
130
+ lastName: author[:user_last_name], email: author[:user_email] }
148
131
  end
149
132
 
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
133
+ def history_matched_in_store(context, args, filters, gone_at_check)
134
+ skip, limit = parse_pagination(args)
135
+ history = store.list_by_record(**filters, skip: skip, limit: limit, order: parse_sort(args))
136
+ # `count` reflects the active filters (not the absolute total) and is independent of the page.
137
+ count = store.count_by_record(**filters)
138
+ withholding = withholding_for(context, args, gone_at_check)
139
+
140
+ # Gone between the check and the read: this answer withholds, so the rows, count and authors matched
141
+ # in SQL above must not decide what is served either.
142
+ if withholding.scope && filters.slice(:search, :fields).any?
143
+ return history_matched_after_withholding(context, args, filters, gone_at_check, withholding)
144
+ end
145
+
146
+ [withhold_out_of_scope_values(history, withholding), count, -> { store.authors_by_record(**filters) }]
167
147
  end
168
148
 
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?
149
+ # Matched in SQL, `search` and `fields` would test the values as captured, so which rows come back, the
150
+ # count and the authors would still say what the withholding hides — one probe per character. They are
151
+ # matched against what is served instead, which means scanning the whole history here, in batches, so
152
+ # only the page asked for is kept. Only for a gone record: one in scope was the caller's to read whole.
153
+ def history_matched_after_withholding(context, args, filters, gone_at_check, withholding = nil)
154
+ skip, limit = parse_pagination(args)
155
+ value_filters = filters.slice(:search, :fields)
156
+ page = []
157
+ count = 0
158
+ latest = {}
178
159
 
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
160
+ each_withheld_batch(context, args, filters.except(*value_filters.keys), gone_at_check,
161
+ withholding) do |entry|
162
+ next unless matches_value_filters?(entry, **value_filters)
163
+
164
+ page << entry if count >= skip && page.size < limit
165
+ count += 1
166
+ next if entry.user_id.nil?
167
+
168
+ latest[entry.user_id] = entry if newer?(entry, latest[entry.user_id])
188
169
  end
170
+
171
+ [page, count, -> { latest.values.map { |entry| author_of(entry) } }]
189
172
  end
190
173
 
191
- def blank(entry, *fields)
192
- entry.dup.tap { |copy| fields.each { |field| copy[field] = {} } }
174
+ # Each batch continues past the last row read rather than at an offset, which entries written between
175
+ # batches would shift. Bounded at the instant the scan starts too, so an id taken by another record
176
+ # since cannot keep an oldest-first scan chasing its new rows.
177
+ def each_withheld_batch(context, args, filters, gone_at_check, withholding, &block)
178
+ order = parse_sort(args)
179
+ snapshot = filters.merge(end_timestamp: [filters[:end_timestamp], Time.now.utc.iso8601(3)].compact.min)
180
+ cursor = nil
181
+
182
+ loop do
183
+ rows = store.list_by_record(**snapshot, limit: SCAN_BATCH_SIZE, order: order, after: cursor)
184
+ withholding ||= withholding_for(context, args, gone_at_check)
185
+ withhold_out_of_scope_values(rows, withholding).each(&block)
186
+ break if rows.size < SCAN_BATCH_SIZE
187
+
188
+ cursor = rows.last.to_h.slice(:timestamp, :id)
189
+ end
193
190
  end
194
191
 
195
- # `availableUsers` rides along on the first fetch only — the front keeps the list it saw — and lists the
196
- # distinct authors of the entries the current filters match, whatever page was asked for. The identity
197
- # comes from the rows, so someone since renamed or removed still reads as they were when they acted.
198
- def meta(args, filters, count)
199
- return { count: count } unless first_fetch?(args)
192
+ # Asked again now, because the check above ran before these rows were read: a record deleted in
193
+ # between answered "present and in scope" for rows that already carry its delete.
194
+ def withholding_for(context, args, gone_at_check)
195
+ withholding_scope = withholding_scope_for(context, context.collection, args[:params]['id'],
196
+ gone_at_check)
200
197
 
201
- { count: count, availableUsers: available_users(filters) }
198
+ Withholding.new(context.collection, withholding_scope, context.caller.timezone)
202
199
  end
203
200
 
204
- def first_fetch?(args)
205
- page = args.dig(:params, 'page')
201
+ def matches_value_filters?(entry, search: nil, fields: nil)
202
+ (search.nil? || search_matches?(entry, search)) && (fields.nil? || touches_field?(entry, fields))
203
+ end
206
204
 
207
- (page.is_a?(Hash) ? page['number'].to_i : 0) <= 1
205
+ # `Sql::TextSearch`'s test, on the served values: the term JSON-escaped the way the values serialize,
206
+ # and a redacted mask removed before matching.
207
+ def search_matches?(entry, term)
208
+ text = term.downcase
209
+ escaped = text.to_json[1..-2]
210
+
211
+ ::ForestAdminAgent::AuditTrail::Sql::TextSearch::TEXT_COLUMNS.any? do |column|
212
+ entry[column].to_s.downcase.include?(text)
213
+ end || [entry.previous_values, entry.new_values].compact.any? do |values|
214
+ values.to_json.gsub(::ForestAdminAgent::AuditTrail::Recording::REDACTED, '').downcase.include?(escaped)
215
+ end
208
216
  end
209
217
 
210
- def available_users(filters)
211
- store.authors_by_record(**filters).map do |author|
212
- { id: author[:user_id], firstName: author[:user_first_name],
213
- lastName: author[:user_last_name], email: author[:user_email] }
218
+ def touches_field?(entry, fields)
219
+ [entry.previous_values, entry.new_values].compact.any? do |values|
220
+ fields.any? { |field| values.key?(field) }
214
221
  end
215
222
  end
216
223
 
224
+ # An author reads as their latest identity. Not simply the first or last row read: rows sharing a
225
+ # timestamp come id-ascending whichever way the history is sorted.
226
+ def newer?(entry, kept)
227
+ kept.nil? || ([entry.timestamp, entry.id] <=> [kept.timestamp, kept.id]).positive?
228
+ end
229
+
230
+ def author_of(entry)
231
+ ::ForestAdminAgent::AuditTrail::Store::AUTHOR_COLUMNS.to_h { |column| [column, entry[column]] }
232
+ end
233
+
217
234
  # An ISO-8601 instant, or the same wall-clock forms the history filters accept, read in the request
218
235
  # timezone.
219
236
  def parse_state_timestamp(args)
@@ -35,19 +35,20 @@ module ForestAdminAgent
35
35
  end
36
36
 
37
37
  def handle_history(args = {})
38
- collection, record_id = assert_scope(args)
38
+ context, collection, record_id, gone_at_check = assert_scope(args)
39
39
 
40
40
  history = store.list_by_correlation(
41
41
  collection: collection.name,
42
42
  record_id: record_id,
43
43
  correlation_key: args[:params]['correlation_key']
44
44
  )
45
+ data = withhold_gone_record(history, context, collection, record_id, gone_at_check)
45
46
 
46
- { name: collection.name, content: { data: history.map { |record| serialize_record(record) } } }
47
+ { name: collection.name, content: { data: data.map { |record| serialize_record(record) } } }
47
48
  end
48
49
 
49
50
  def handle_batch(args = {})
50
- collection, record_id = assert_scope(args)
51
+ context, collection, record_id, gone_at_check = assert_scope(args)
51
52
  correlation_keys = parse_correlation_keys(args)
52
53
 
53
54
  history = if correlation_keys.empty?
@@ -59,12 +60,25 @@ module ForestAdminAgent
59
60
  correlation_keys: correlation_keys
60
61
  )
61
62
  end
63
+ data = withhold_gone_record(history, context, collection, record_id, gone_at_check)
62
64
 
63
- { name: collection.name, content: { data: history.map { |record| serialize_record(record) } } }
65
+ { name: collection.name, content: { data: data.map { |record| serialize_record(record) } } }
64
66
  end
65
67
 
66
68
  private
67
69
 
70
+ # These routes serve the same rows as the per-record history route, so a gone record's captured values
71
+ # are tested against the caller's scope here too — otherwise what that route withholds comes back
72
+ # through a correlation lookup.
73
+ def withhold_gone_record(history, context, collection, record_id, gone_at_check)
74
+ # An empty answer has nothing to withhold, so it does not earn a second read of the record.
75
+ return history if history.empty?
76
+
77
+ scope = withholding_scope_for(context, collection, record_id, gone_at_check)
78
+
79
+ withhold_out_of_scope_values(history, Withholding.new(collection, scope, context.caller.timezone))
80
+ end
81
+
68
82
  def assert_scope(args)
69
83
  context = build(args)
70
84
  name = args.dig(:params, 'collection').to_s
@@ -75,9 +89,9 @@ module ForestAdminAgent
75
89
 
76
90
  collection = get_collection(context, name)
77
91
  context.permissions.can?(:read, collection)
78
- assert_record_in_scope(context, collection, record_id)
79
-
80
- [collection, record_id]
92
+ # Before the audit database is touched, for the 404 it raises — and for what it saw of the record
93
+ # while the rows were still being chosen, which the read after them can no longer see.
94
+ [context, collection, record_id, assert_record_in_scope(context, collection, record_id)]
81
95
  end
82
96
 
83
97
  def get_collection(context, name)
@@ -8,6 +8,7 @@ module ForestAdminAgent
8
8
  # and they all serialize audit records the same way.
9
9
  module AuditTrailRoute
10
10
  include ForestAdminDatasourceToolkit::Components::Query
11
+ include AuditTrailWithholding
11
12
 
12
13
  # The caller's scope when the record is gone for good, nil otherwise — no scope in effect, or a record
13
14
  # still there and in scope. A scope can't be evaluated against a record that no longer exists, so its
@@ -43,6 +44,29 @@ module ForestAdminAgent
43
44
  raise Http::Exceptions::NotFoundError, 'Record does not exists'
44
45
  end
45
46
 
47
+ # The scope to withhold with: the caller's own, if the record was gone at either look at it.
48
+ #
49
+ # Both looks count, and neither can clear the other. The first is the only one that saw the record as
50
+ # it was when the rows were chosen, so a replacement the second finds — an id freed by a delete and
51
+ # taken by another record since — answers for itself, never for the life whose rows these are. The
52
+ # second is the only one that can see a record deleted since, and it is where the 404 comes from when
53
+ # that replacement is one this caller cannot read.
54
+ def withholding_scope_for(context, collection, packed_id, gone_at_check)
55
+ gone_since = scope_if_gone_since(context, collection, packed_id)
56
+
57
+ gone_at_check || gone_since
58
+ end
59
+
60
+ # Second read of the record, once the rows are in hand, so the answer that decides the withholding is
61
+ # never older than what it decides on. Skipped without a scope in effect — there is nothing to withhold
62
+ # then, and nothing to ask. A record moved out of scope rather than deleted raises the 404 it would
63
+ # raise for a request starting a moment later.
64
+ def scope_if_gone_since(context, collection, packed_id)
65
+ return nil if context.permissions.get_scope(collection).nil?
66
+
67
+ assert_record_in_scope(context, collection, packed_id)
68
+ end
69
+
46
70
  # Camelize only the top-level keys — the row `id` included, which the front uses as the tiebreaker when
47
71
  # merging pages ordered by (timestamp, id). Value hashes keep the keys they were stored with: a record's
48
72
  # own column names, or an action answer's camelCase Forest names.
@@ -0,0 +1,142 @@
1
+ module ForestAdminAgent
2
+ module Routes
3
+ module Resources
4
+ # Blanks the captured values a caller's record-level scope does not cover, keeping the rows themselves:
5
+ # that something happened, by whom and when, stays visible either way. Shared by every route that serves
6
+ # captured values — the per-record history, the state reconstruction and the two correlation routes —
7
+ # since a rule only one of them applies is one lookup away from being no rule at all.
8
+ module AuditTrailWithholding
9
+ # Everything a withholding decision needs, and nothing a request context would drag along: the
10
+ # collection the rows belong to, the scope to test them against, and the timezone its date
11
+ # comparisons read in.
12
+ Withholding = Struct.new(:collection, :scope, :timezone)
13
+
14
+ NULL_EXCLUDING_OPERATORS = [
15
+ ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators::NOT_EQUAL,
16
+ ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators::NOT_IN,
17
+ ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators::NOT_CONTAINS
18
+ ].freeze
19
+
20
+ # A record that is gone for good bypasses the scope check — there is nothing left to check it against
21
+ # — but its rows still carry the column values captured while it existed. When those values would
22
+ # themselves have failed the caller's scope, withhold them; the row itself stays visible either way,
23
+ # so that it happened, by whom and when still reads.
24
+ def withhold_out_of_scope_values(entries, withholding)
25
+ return entries if withholding.scope.nil?
26
+
27
+ entries.map { |entry| withhold(entry, withholding) }
28
+ end
29
+
30
+ def withhold(entry, withholding)
31
+ case entry.operation
32
+ # `delete`'s previous_values and `create`'s new_values both capture every writable column.
33
+ when 'delete'
34
+ in_scope?(entry.previous_values, entry.record_id, withholding) ? entry : blank(entry, :previous_values)
35
+ when 'create'
36
+ in_scope?(entry.new_values, entry.record_id, withholding) ? entry : blank(entry, :new_values)
37
+ when 'update'
38
+ withhold_each_side(entry, withholding)
39
+ # `action`/`action_failed` rows hold a submitted form and a result summary, not column values, so
40
+ # the scope doesn't apply to them.
41
+ else
42
+ entry
43
+ end
44
+ end
45
+
46
+ # An update's two sides are a partial diff, so each is tested against its own values: a diff that never
47
+ # carried the scoped column answers for neither and is withheld by `in_scope?` anyway. Gating the sides
48
+ # separately releases the ones that can be proven in scope — "it used to be X" can't escape through a
49
+ # row whose new value is out of scope, since that side is tested on its own.
50
+ def withhold_each_side(entry, withholding)
51
+ # An update that moved a writable primary key files its row under the id the record ended up with,
52
+ # and keeps the one it had on `previous_record_id`. Each side is tested against the id it was true
53
+ # of, or the new state's id would decide whether the old state is in scope.
54
+ before = entry.previous_record_id || entry.record_id
55
+ kept = in_scope?(entry.previous_values, before, withholding) ? entry : blank(entry, :previous_values)
56
+
57
+ in_scope?(kept.new_values, after_id(kept), withholding) ? kept : blank(kept, :new_values)
58
+ end
59
+
60
+ # A pending row is filed under the id the record had *before* the write, since the write may not have
61
+ # landed: it says nothing about the state the update was moving to, so the new side gets no id to fill
62
+ # from and falls back on what it captured itself.
63
+ def after_id(entry)
64
+ entry.status == ::ForestAdminAgent::AuditTrail::Recording::PENDING ? nil : entry.record_id
65
+ end
66
+
67
+ # Only a snapshot that answers every field the scope asks about, with what was really stored, is worth
68
+ # matching. The capture keeps the writable columns, so a scope on anything else — a read-only column, a
69
+ # relation — reads as nil there and would answer for a value the row never held: `status != 'private'`
70
+ # would match, and an ordered operator would raise on the nil. A redacted value answers no better.
71
+ def in_scope?(values, packed_id, withholding, id_answers_for_keys: true)
72
+ snapshot = answerable_snapshot(values, packed_id, withholding.collection,
73
+ id_answers_for_keys: id_answers_for_keys)
74
+ return false unless withholding.scope.projection.all? { |field| snapshot.key?(field) }
75
+
76
+ matches_as_stored?(withholding.scope, snapshot, withholding)
77
+ rescue StandardError => e
78
+ # Key presence is not answerability: a column captured as nil has its key, and an ordered operator
79
+ # raises on it. Uncaught that would fail the whole page, and only for the callers a scope applies
80
+ # to. One withheld row is the smaller loss, and the same answer the field would have got had it
81
+ # been missing outright.
82
+ Facades::Container.logger&.log('Warn', "[ForestAdmin] Audit row not scope-checkable: #{e.message}")
83
+
84
+ false
85
+ end
86
+
87
+ # `scope.match`, except that a negated comparison never matches a NULL. In memory `status != 'private'`
88
+ # holds for a nil status, while the database leaves a NULL out of it: a record the caller could never
89
+ # read alive would become readable once deleted. Stricter than a datasource that matches NULL there
90
+ # (Mongo's `$ne`), never looser.
91
+ def matches_as_stored?(tree, snapshot, withholding)
92
+ if tree.is_a?(ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeBranch)
93
+ evaluate = ->(condition) { matches_as_stored?(condition, snapshot, withholding) }
94
+
95
+ return tree.aggregator == 'And' ? tree.conditions.all?(&evaluate) : tree.conditions.any?(&evaluate)
96
+ end
97
+ return false if snapshot[tree.field].nil? && NULL_EXCLUDING_OPERATORS.include?(tree.operator)
98
+
99
+ tree.match(snapshot, withholding.collection, withholding.timezone)
100
+ end
101
+
102
+ # What this side of the row can answer about. A redacted value answers nothing, so it is dropped rather
103
+ # than matched against the placeholder — leaving the field unanswered, which withholds. The packed id
104
+ # then fills in the primary keys: a read-only one never lands in the snapshot at all, and a writable one
105
+ # the trail redacts was just dropped, while the id the row was filed under proves what the key was.
106
+ # It only fills what the snapshot cannot answer: on the side of a row that captured the key itself,
107
+ # that value is the one that was true there.
108
+ #
109
+ # `id_answers_for_keys: false` keeps the id off the keys the trail redacted, for a caller holding an id
110
+ # that may not be the one its values were true under — a state reconstruction, which can sit on the far
111
+ # side of a primary-key move it cannot see. A key never captured at all is still filled: read-only, so
112
+ # it cannot have moved.
113
+ def answerable_snapshot(values, packed_id, collection, id_answers_for_keys: true)
114
+ captured = values || {}
115
+ redacted = captured.select { |_, value| value == ::ForestAdminAgent::AuditTrail::Recording::REDACTED }.keys
116
+ answered = captured.except(*redacted)
117
+ return answered if packed_id.nil?
118
+
119
+ decoded = decoded_keys(collection, packed_id)
120
+ decoded = decoded.except(*redacted) unless id_answers_for_keys
121
+
122
+ decoded.merge(answered)
123
+ end
124
+
125
+ def decoded_keys(collection, packed_id)
126
+ Utils::Id.unpack_id(collection, packed_id, with_key: true)
127
+ rescue StandardError => e
128
+ # An id written under a primary key of another shape costs the keys it would have filled, and
129
+ # nothing else: the snapshot still answers for the columns it captured, so a scope that never
130
+ # asks about the id is unaffected.
131
+ Facades::Container.logger&.log('Warn', "[ForestAdmin] Audit row id not decodable: #{e.message}")
132
+
133
+ {}
134
+ end
135
+
136
+ def blank(entry, *fields)
137
+ entry.dup.tap { |copy| fields.each { |field| copy[field] = {} } }
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -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.4"
9
+ LIANA_VERSION = "1.44.5"
10
10
 
11
11
  def self.generate(datasource)
12
12
  datasource.collections
@@ -1,3 +1,3 @@
1
1
  module ForestAdminAgent
2
- VERSION = "1.44.4"
2
+ VERSION = "1.44.5"
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.4
4
+ version: 1.44.5
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-10-01 00:00:00.000000000 Z
12
+ date: 2026-10-06 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activesupport
@@ -378,6 +378,7 @@ files:
378
378
  - lib/forest_admin_agent/routes/resources/audit_trail.rb
379
379
  - lib/forest_admin_agent/routes/resources/audit_trail_correlation.rb
380
380
  - lib/forest_admin_agent/routes/resources/audit_trail_route.rb
381
+ - lib/forest_admin_agent/routes/resources/audit_trail_withholding.rb
381
382
  - lib/forest_admin_agent/routes/resources/count.rb
382
383
  - lib/forest_admin_agent/routes/resources/csv.rb
383
384
  - lib/forest_admin_agent/routes/resources/delete.rb