forest_admin_agent 1.44.0 → 1.44.1

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: 7685f99576db0d5421c02e2ccd0024cbdf8cdae97c50b81c273347058d01919c
4
+ data.tar.gz: ca1838fe30e5619908f3aa9364a2f98181d78bd86078c49fb4f3202b3dc606af
5
5
  SHA512:
6
- metadata.gz: ef2ffa8474a150773b31add6d108ea351d4be7aac9f176e14967576f0718998d7a025d82d894d53c82736115165f92dd50f655e43afc1cb579b41b2cdd054f2f
7
- data.tar.gz: f34499524fc80e0194f2341e14c0e5b373beca8a95fa1d4ae46a912bea35747d34d21819170acfee83f082b67703656bee4a7d152c08f2b834b5fb4916fecb5a
6
+ metadata.gz: c0eb71e1942b23f47f48c1bc211a2fbfdfd7eebe11e9c8e724feea421bca97df3864490a7d5d5f57bae497eff2705334026210d365131e6e41b79aa9ee62cd1f
7
+ data.tar.gz: 87e7c2a7af1f695f6cd92f61e8d71f21962ca16db50b6085525395bc920c9256cf78b2405702b7eeb9ac124bfa6ac00191b790edab8a9168f6013b2ec99c5970
data/AUDIT_TRAIL.md CHANGED
@@ -190,6 +190,30 @@ 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. Primary keys are the exception, read
203
+ back from the row's own id, so a scope on the id still matches the record it belongs to. This is also
204
+ what makes an update's partial diff safe to test: a diff that never carried the scoped column answers for
205
+ neither side, so both are withheld.
206
+
207
+ The record is read twice: once before the rows are fetched, to refuse a record that exists outside the
208
+ caller's scope without touching the audit database, and once after, so the answer that decides the
209
+ withholding is never older than the rows it applies to — a record deleted in between would otherwise have
210
+ answered "present and in scope" for rows that already carry its delete. The second read is skipped when no
211
+ scope is in effect.
212
+
213
+ **This covers the history route only.** `/state` reconstructs a gone record from the same rows and serves
214
+ it unfiltered, and the correlation routes check the record but not the values, so a caller the withholding
215
+ above protects against can still read those values one request away. Closing that is tracked separately.
216
+
193
217
  ### State route
194
218
 
195
219
  `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,77 @@ 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, entry.previous_values, scope, context) ? entry : blank(entry, :previous_values)
118
+ when 'create'
119
+ in_scope?(entry, entry.new_values, 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
+ kept = in_scope?(entry, entry.previous_values, scope, context) ? entry : blank(entry, :previous_values)
135
+
136
+ in_scope?(kept, kept.new_values, scope, context) ? kept : blank(kept, :new_values)
137
+ end
138
+
139
+ # Only a snapshot that answers every field the scope asks about, with what was really stored, is worth
140
+ # matching. The capture keeps the writable columns, so a scope on anything else — a read-only column, a
141
+ # relation — reads as nil there and would answer for a value the row never held: `status != 'private'`
142
+ # would match, and an ordered operator would raise on the nil. A redacted value answers no better.
143
+ def in_scope?(entry, values, scope, context)
144
+ snapshot = with_primary_keys(entry, values, context.collection)
145
+ answerable = scope.projection.all? do |field|
146
+ snapshot.key?(field) && snapshot[field] != ::ForestAdminAgent::AuditTrail::Recording::REDACTED
147
+ end
148
+
149
+ answerable && scope.match(snapshot, context.collection, context.caller.timezone)
150
+ end
151
+
152
+ # A read-only primary key never lands in the snapshot, so a scope on the id would redact a row that is
153
+ # squarely in scope. The row's own packed id carries those values.
154
+ def with_primary_keys(entry, values, collection)
155
+ keys = entry.record_id.nil? ? {} : Utils::Id.unpack_id(collection, entry.record_id, with_key: true)
156
+
157
+ keys.merge(values || {})
158
+ end
159
+
160
+ def blank(entry, *fields)
161
+ entry.dup.tap { |copy| fields.each { |field| copy[field] = {} } }
162
+ end
163
+
88
164
  # `availableUsers` rides along on the first fetch only — the front keeps the list it saw — and lists the
89
165
  # distinct authors of the entries the current filters match, whatever page was asked for. The identity
90
166
  # 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.1"
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.1"
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.1
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-23 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activesupport