forest_admin_agent 1.44.4 → 1.45.0
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 +43 -10
- data/lib/forest_admin_agent/audit_trail/store.rb +13 -2
- data/lib/forest_admin_agent/routes/resources/audit_trail.rb +126 -109
- data/lib/forest_admin_agent/routes/resources/audit_trail_correlation.rb +21 -7
- data/lib/forest_admin_agent/routes/resources/audit_trail_route.rb +24 -0
- data/lib/forest_admin_agent/routes/resources/audit_trail_withholding.rb +142 -0
- data/lib/forest_admin_agent/utils/schema/generator_field.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 +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b82038f1c254bc91295ded423735b4f481a3c0b997d4c246d606396ad476acec
|
|
4
|
+
data.tar.gz: 8bae29e2cbb6d1af5b9ebf4228a918eaace4b5489e26aa1ba7a098856da3bb38
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 336ea0d4c27df5a65ed30f6d5e64fd2a4645ae229a9fb874ea21b2c5501cef641c3a18bd91736cb52ed3b0b085dffbe9aa2f17f59471efcf62ab0fcd3ca3dfb4
|
|
7
|
+
data.tar.gz: 637d76398dc40a32ec459bb3ae3bdd783c82665a272988185efa1229823639f2cc64b564dbc7be6f1248f31a39f23af241d29bf636d1f48c4b0a7e0306826a55
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
119
|
+
{ count: count, availableUsers: authors.call.map { |author| available_user(author) } }
|
|
111
120
|
end
|
|
112
121
|
|
|
113
|
-
def
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
#
|
|
170
|
-
#
|
|
171
|
-
#
|
|
172
|
-
# the
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
#
|
|
196
|
-
#
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
198
|
+
Withholding.new(context.collection, withholding_scope, context.caller.timezone)
|
|
202
199
|
end
|
|
203
200
|
|
|
204
|
-
def
|
|
205
|
-
|
|
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
|
-
|
|
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
|
|
211
|
-
|
|
212
|
-
{
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
@@ -68,6 +68,12 @@ module ForestAdminAgent
|
|
|
68
68
|
}
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
def relation_filterable?(relation, foreign_collection)
|
|
72
|
+
return false if relation.respond_to?(:is_filterable) && relation.is_filterable == false
|
|
73
|
+
|
|
74
|
+
foreign_collection_filterable?(foreign_collection)
|
|
75
|
+
end
|
|
76
|
+
|
|
71
77
|
def foreign_collection_filterable?(foreign_collection)
|
|
72
78
|
foreign_collection.schema[:fields].values.any? do |field|
|
|
73
79
|
field.type == 'Column' && FrontendFilterable.filterable?(field.filter_operators)
|
|
@@ -124,7 +130,7 @@ module ForestAdminAgent
|
|
|
124
130
|
{
|
|
125
131
|
type: key_field.column_type,
|
|
126
132
|
defaultValue: nil,
|
|
127
|
-
isFilterable:
|
|
133
|
+
isFilterable: relation_filterable?(relation, foreign_collection),
|
|
128
134
|
isPrimaryKey: false,
|
|
129
135
|
isRequired: false,
|
|
130
136
|
isReadOnly: relation.is_read_only || key_field.is_read_only,
|
|
@@ -142,7 +148,7 @@ module ForestAdminAgent
|
|
|
142
148
|
{
|
|
143
149
|
type: key_field.column_type,
|
|
144
150
|
defaultValue: key_field.default_value,
|
|
145
|
-
isFilterable:
|
|
151
|
+
isFilterable: relation_filterable?(relation, foreign_collection),
|
|
146
152
|
isPrimaryKey: false,
|
|
147
153
|
isRequired: key_field.validation.any? { |v| v[:operator] == 'Present' },
|
|
148
154
|
isReadOnly: key_field.is_read_only,
|
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.
|
|
4
|
+
version: 1.45.0
|
|
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-
|
|
12
|
+
date: 2026-10-07 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
|