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 +4 -4
- data/AUDIT_TRAIL.md +37 -0
- data/lib/forest_admin_agent/routes/resources/audit_trail.rb +109 -2
- data/lib/forest_admin_agent/routes/resources/audit_trail_route.rb +8 -2
- data/lib/forest_admin_agent/utils/schema/schema_emitter.rb +1 -1
- data/lib/forest_admin_agent/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eb144d36d50f4763b0b8f2e61e701ee5fbfb7a97b66b4a9a6f67aaff26b8ac5d
|
|
4
|
+
data.tar.gz: c6f7817f4a8507b703c604b8c4c643884eb57b8d0902cd90437e6ffe1b201238
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
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
|
-
|
|
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
|
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
|
+
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-
|
|
12
|
+
date: 2026-09-24 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: activesupport
|