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 +4 -4
- data/AUDIT_TRAIL.md +24 -0
- data/lib/forest_admin_agent/routes/resources/audit_trail.rb +78 -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: 7685f99576db0d5421c02e2ccd0024cbdf8cdae97c50b81c273347058d01919c
|
|
4
|
+
data.tar.gz: ca1838fe30e5619908f3aa9364a2f98181d78bd86078c49fb4f3202b3dc606af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
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
|
-
|
|
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.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-
|
|
12
|
+
date: 2026-09-23 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: activesupport
|