forest_admin_agent 1.45.1 → 1.46.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 +55 -26
- data/forest_admin_agent.gemspec +2 -0
- data/lib/forest_admin_agent/audit_trail/audit_record.rb +1 -1
- data/lib/forest_admin_agent/audit_trail/capture.rb +1 -7
- data/lib/forest_admin_agent/audit_trail/sql/audit_connection_base.rb +1 -5
- data/lib/forest_admin_agent/audit_trail/sql/migrations.rb +23 -6
- data/lib/forest_admin_agent/audit_trail/store.rb +79 -65
- data/lib/forest_admin_agent/auth/oidc_client_manager.rb +1 -2
- data/lib/forest_admin_agent/http/router.rb +4 -2
- data/lib/forest_admin_agent/routes/action/actions.rb +13 -4
- data/lib/forest_admin_agent/routes/capabilities/collections.rb +4 -0
- data/lib/forest_admin_agent/routes/resources/audit_trail.rb +25 -118
- data/lib/forest_admin_agent/routes/resources/audit_trail_correlation.rb +1 -3
- data/lib/forest_admin_agent/routes/resources/audit_trail_query_params.rb +131 -0
- data/lib/forest_admin_agent/routes/resources/audit_trail_route.rb +16 -39
- data/lib/forest_admin_agent/routes/resources/audit_trail_timeline.rb +96 -0
- data/lib/forest_admin_agent/routes/resources/audit_trail_withholding.rb +23 -9
- data/lib/forest_admin_agent/routes/resources/list.rb +2 -1
- data/lib/forest_admin_agent/routes/security/scope_invalidation.rb +2 -1
- data/lib/forest_admin_agent/serializer/forest_serializer.rb +51 -57
- data/lib/forest_admin_agent/serializer/forest_serializer_override.rb +1 -4
- data/lib/forest_admin_agent/services/ip_whitelist.rb +6 -8
- data/lib/forest_admin_agent/services/permissions.rb +81 -41
- data/lib/forest_admin_agent/services/sse_cache_invalidation.rb +19 -1
- data/lib/forest_admin_agent/utils/safe_file_cache.rb +52 -0
- data/lib/forest_admin_agent/utils/schema/schema_emitter.rb +1 -1
- data/lib/forest_admin_agent/version.rb +1 -1
- data/lib/forest_admin_agent.rb +2 -2
- metadata +33 -2
|
@@ -10,13 +10,9 @@ module ForestAdminAgent
|
|
|
10
10
|
class AuditTrail < AbstractAuthenticatedRoute
|
|
11
11
|
include ForestAdminAgent::Utils
|
|
12
12
|
include AuditTrailRoute
|
|
13
|
+
include AuditTrailQueryParams
|
|
13
14
|
|
|
14
|
-
DEFAULT_PAGE_SIZE = 20
|
|
15
|
-
MAX_PAGE_SIZE = 100
|
|
16
15
|
SCAN_BATCH_SIZE = 500
|
|
17
|
-
DATE_ONLY = /\A\d{4}-\d{2}-\d{2}\z/
|
|
18
|
-
# Wall-clock datetime, `T` or space separator, seconds optional: `YYYY-MM-DD[T ]HH:mm[:ss]`.
|
|
19
|
-
DATE_TIME = /\A(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?\z/
|
|
20
16
|
|
|
21
17
|
def setup_routes
|
|
22
18
|
return self unless store
|
|
@@ -46,9 +42,8 @@ module ForestAdminAgent
|
|
|
46
42
|
|
|
47
43
|
filters = {
|
|
48
44
|
collection: context.collection.name,
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
record_id: record_segments(context.collection, args[:params]['id']),
|
|
45
|
+
# Already Forest's packed id, the form the audit store keys on.
|
|
46
|
+
record_id: args[:params]['id'],
|
|
52
47
|
**parse_filters(args)
|
|
53
48
|
}
|
|
54
49
|
|
|
@@ -81,7 +76,7 @@ module ForestAdminAgent
|
|
|
81
76
|
timestamp = parse_state_timestamp(args)
|
|
82
77
|
entries = store.list_since(
|
|
83
78
|
collection: context.collection.name,
|
|
84
|
-
record_id:
|
|
79
|
+
record_id: args[:params]['id'],
|
|
85
80
|
timestamp: timestamp
|
|
86
81
|
)
|
|
87
82
|
# Fully qualified: inside this class, `AuditTrail` is the route itself.
|
|
@@ -89,15 +84,24 @@ module ForestAdminAgent
|
|
|
89
84
|
# Asked again now, for the same reason the history route asks, and the same way: the record read
|
|
90
85
|
# above can be deleted — or an id that was gone then be taken by somebody else's record — while the
|
|
91
86
|
# audit read is in flight.
|
|
92
|
-
|
|
93
|
-
gone_at_check)
|
|
87
|
+
withholding = withholding_of(context, context.collection, args[:params]['id'], gone_at_check)
|
|
94
88
|
|
|
95
89
|
{ name: args[:params]['collection_name'],
|
|
96
|
-
content: { data: answerable_state(state,
|
|
90
|
+
content: { data: answerable_state(state, state_scope(withholding, timestamp), context,
|
|
91
|
+
args[:params]['id']) } }
|
|
97
92
|
end
|
|
98
93
|
|
|
99
94
|
private
|
|
100
95
|
|
|
96
|
+
# The whole reconstruction belongs to an earlier record when it stands strictly before the boundary. At
|
|
97
|
+
# the boundary's own instant it already reflects every row there, a replacement `create` sharing it
|
|
98
|
+
# included, so it is the current record's.
|
|
99
|
+
def state_scope(withholding, timestamp)
|
|
100
|
+
return withholding.scope if withholding.boundary.nil?
|
|
101
|
+
|
|
102
|
+
withholding.scope if ::Time.iso8601(timestamp) < ::Time.iso8601(withholding.boundary.timestamp.to_s)
|
|
103
|
+
end
|
|
104
|
+
|
|
101
105
|
# The history route withholds a gone record's captured values from a caller whose scope they fail, and
|
|
102
106
|
# this route is nothing but those values reassembled: without the same test they come back one request
|
|
103
107
|
# away. A reconstruction the scope cannot answer withholds too — absent is not the same as passing.
|
|
@@ -125,11 +129,6 @@ module ForestAdminAgent
|
|
|
125
129
|
(page.is_a?(Hash) ? page['number'].to_i : 0) <= 1
|
|
126
130
|
end
|
|
127
131
|
|
|
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] }
|
|
131
|
-
end
|
|
132
|
-
|
|
133
132
|
def history_matched_in_store(context, args, filters, gone_at_check)
|
|
134
133
|
skip, limit = parse_pagination(args)
|
|
135
134
|
history = store.list_by_record(**filters, skip: skip, limit: limit, order: parse_sort(args))
|
|
@@ -137,8 +136,8 @@ module ForestAdminAgent
|
|
|
137
136
|
count = store.count_by_record(**filters)
|
|
138
137
|
withholding = withholding_for(context, args, gone_at_check)
|
|
139
138
|
|
|
140
|
-
# Gone between the check and the read
|
|
141
|
-
# in SQL above must not decide what is served either.
|
|
139
|
+
# Gone between the check and the read, or carrying an earlier record's rows: this answer withholds, so
|
|
140
|
+
# the rows, count and authors matched in SQL above must not decide what is served either.
|
|
142
141
|
if withholding.scope && filters.slice(:search, :fields).any?
|
|
143
142
|
return history_matched_after_withholding(context, args, filters, gone_at_check, withholding)
|
|
144
143
|
end
|
|
@@ -149,7 +148,8 @@ module ForestAdminAgent
|
|
|
149
148
|
# Matched in SQL, `search` and `fields` would test the values as captured, so which rows come back, the
|
|
150
149
|
# count and the authors would still say what the withholding hides — one probe per character. They are
|
|
151
150
|
# matched against what is served instead, which means scanning the whole history here, in batches, so
|
|
152
|
-
# only the page asked for is kept.
|
|
151
|
+
# only the page asked for is kept. Whenever a withholding scope applies: a gone record, or the rows an earlier
|
|
152
|
+
# record filed under a live one's id. Otherwise the history was the caller's to read whole.
|
|
153
153
|
def history_matched_after_withholding(context, args, filters, gone_at_check, withholding = nil)
|
|
154
154
|
skip, limit = parse_pagination(args)
|
|
155
155
|
value_filters = filters.slice(:search, :fields)
|
|
@@ -192,10 +192,7 @@ module ForestAdminAgent
|
|
|
192
192
|
# Asked again now, because the check above ran before these rows were read: a record deleted in
|
|
193
193
|
# between answered "present and in scope" for rows that already carry its delete.
|
|
194
194
|
def withholding_for(context, args, gone_at_check)
|
|
195
|
-
|
|
196
|
-
gone_at_check)
|
|
197
|
-
|
|
198
|
-
Withholding.new(context.collection, withholding_scope, context.caller.timezone)
|
|
195
|
+
withholding_of(context, context.collection, args[:params]['id'], gone_at_check)
|
|
199
196
|
end
|
|
200
197
|
|
|
201
198
|
def matches_value_filters?(entry, search: nil, fields: nil)
|
|
@@ -256,105 +253,15 @@ module ForestAdminAgent
|
|
|
256
253
|
args.dig(:params, 'sort').to_s == 'timestamp' ? 'asc' : 'desc'
|
|
257
254
|
end
|
|
258
255
|
|
|
259
|
-
# JSON:API pagination: 1-based page[number] (default 1) and page[size]
|
|
260
|
-
#
|
|
256
|
+
# JSON:API pagination: 1-based page[number] (default 1) and page[size]. Out-of-bound or non-numeric
|
|
257
|
+
# values fall back to the defaults rather than erroring.
|
|
261
258
|
def parse_pagination(args)
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
page = {} unless page.is_a?(Hash)
|
|
265
|
-
|
|
266
|
-
size = page['size'].to_i
|
|
267
|
-
size = DEFAULT_PAGE_SIZE if size < 1
|
|
268
|
-
size = MAX_PAGE_SIZE if size > MAX_PAGE_SIZE
|
|
269
|
-
|
|
270
|
-
number = page['number'].to_i
|
|
259
|
+
size = parse_page_size(args)
|
|
260
|
+
number = page_param(args)['number'].to_i
|
|
271
261
|
number = 1 if number < 1
|
|
272
262
|
|
|
273
263
|
[(number - 1) * size, size]
|
|
274
264
|
end
|
|
275
|
-
|
|
276
|
-
def request_timezone(args)
|
|
277
|
-
timezone = args.dig(:params, 'timezone').to_s
|
|
278
|
-
|
|
279
|
-
timezone.empty? ? 'UTC' : timezone
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
def parse_filters(args)
|
|
283
|
-
timezone = request_timezone(args)
|
|
284
|
-
|
|
285
|
-
{
|
|
286
|
-
user_ids: parse_user_ids(args.dig(:params, 'userIds')),
|
|
287
|
-
fields: parse_fields(args.dig(:params, 'fields')),
|
|
288
|
-
search: parse_search(args.dig(:params, 'search')),
|
|
289
|
-
start_timestamp: parse_date_boundary(args.dig(:params, 'startDate'), timezone, :start),
|
|
290
|
-
end_timestamp: parse_date_boundary(args.dig(:params, 'endDate'), timezone, :end)
|
|
291
|
-
}.compact
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
# Free text, trimmed; blank means no filter rather than a term that matches everything.
|
|
295
|
-
def parse_search(raw)
|
|
296
|
-
term = raw.to_s.strip
|
|
297
|
-
|
|
298
|
-
term.empty? ? nil : term
|
|
299
|
-
end
|
|
300
|
-
|
|
301
|
-
# Comma-separated field names, kept verbatim (a name may hold a dot). Empty after parsing → no filter.
|
|
302
|
-
def parse_fields(raw)
|
|
303
|
-
return nil if raw.nil?
|
|
304
|
-
|
|
305
|
-
names = (raw.is_a?(Array) ? raw : raw.to_s.split(',')).map { |name| name.to_s.strip }.reject(&:empty?)
|
|
306
|
-
names.empty? ? nil : names
|
|
307
|
-
end
|
|
308
|
-
|
|
309
|
-
# Comma-separated integer ids; non-numeric tokens are dropped. Empty after parsing → no filter.
|
|
310
|
-
def parse_user_ids(raw)
|
|
311
|
-
return nil if raw.nil? || raw.to_s.empty?
|
|
312
|
-
|
|
313
|
-
ids = raw.to_s.split(',').map(&:strip).grep(/\A\d+\z/).map(&:to_i)
|
|
314
|
-
ids.empty? ? nil : ids
|
|
315
|
-
end
|
|
316
|
-
|
|
317
|
-
# `startDate`/`endDate` accept a bare day (`YYYY-MM-DD`) or a wall-clock datetime
|
|
318
|
-
# (`YYYY-MM-DD[T ]HH:mm[:ss]`), read as local time in the request timezone and returned as a UTC
|
|
319
|
-
# ISO instant the store can compare against stored timestamps.
|
|
320
|
-
def parse_date_boundary(raw, timezone, boundary)
|
|
321
|
-
return nil if raw.nil? || raw.to_s.empty?
|
|
322
|
-
|
|
323
|
-
zone = Time.find_zone(timezone)
|
|
324
|
-
raise Http::Exceptions::ValidationError, "Invalid timezone: \"#{timezone}\"" if zone.nil?
|
|
325
|
-
|
|
326
|
-
instant = begin
|
|
327
|
-
local_instant(zone, raw.to_s, boundary)
|
|
328
|
-
rescue ArgumentError
|
|
329
|
-
nil
|
|
330
|
-
end
|
|
331
|
-
|
|
332
|
-
if instant.nil?
|
|
333
|
-
raise Http::Exceptions::ValidationError,
|
|
334
|
-
"Invalid date: \"#{raw}\" (expected YYYY-MM-DD or YYYY-MM-DDTHH:mm)"
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
instant.utc.iso8601(3)
|
|
338
|
-
end
|
|
339
|
-
|
|
340
|
-
def local_instant(zone, raw, boundary)
|
|
341
|
-
if DATE_ONLY.match?(raw)
|
|
342
|
-
day = zone.parse(raw)
|
|
343
|
-
# Bare day → start (00:00:00.000) or end (23:59:59.999) of that local day.
|
|
344
|
-
boundary == :end ? day.end_of_day : day.beginning_of_day
|
|
345
|
-
elsif (match = DATE_TIME.match(raw))
|
|
346
|
-
date, hours, minutes, seconds = match.captures
|
|
347
|
-
base = zone.parse("#{date}T#{hours}:#{minutes}")
|
|
348
|
-
if seconds
|
|
349
|
-
base.change(sec: seconds.to_i, usec: 0)
|
|
350
|
-
elsif boundary == :end
|
|
351
|
-
# Minutes-only end boundary stays inclusive to :59.999; start stays at :00.000.
|
|
352
|
-
base.change(sec: 59, usec: 999_000)
|
|
353
|
-
else
|
|
354
|
-
base
|
|
355
|
-
end
|
|
356
|
-
end
|
|
357
|
-
end
|
|
358
265
|
end
|
|
359
266
|
end
|
|
360
267
|
end
|
|
@@ -74,9 +74,7 @@ module ForestAdminAgent
|
|
|
74
74
|
# An empty answer has nothing to withhold, so it does not earn a second read of the record.
|
|
75
75
|
return history if history.empty?
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
withhold_out_of_scope_values(history, Withholding.new(collection, scope, context.caller.timezone))
|
|
77
|
+
withhold_out_of_scope_values(history, withholding_of(context, collection, record_id, gone_at_check))
|
|
80
78
|
end
|
|
81
79
|
|
|
82
80
|
def assert_scope(args)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
module ForestAdminAgent
|
|
2
|
+
module Routes
|
|
3
|
+
module Resources
|
|
4
|
+
# The query params every audit-trail listing reads the same way, the per-record history and the
|
|
5
|
+
# cross-collection timeline alike, so a filter cannot mean one thing on one route and another on the
|
|
6
|
+
# other. Mirrors the Node agent's `audit-trail/query-params.ts`.
|
|
7
|
+
module AuditTrailQueryParams
|
|
8
|
+
DEFAULT_PAGE_SIZE = 20
|
|
9
|
+
MAX_PAGE_SIZE = 100
|
|
10
|
+
DATE_ONLY = /\A\d{4}-\d{2}-\d{2}\z/
|
|
11
|
+
# Wall-clock datetime, `T` or space separator, seconds optional: `YYYY-MM-DD[T ]HH:mm[:ss]`.
|
|
12
|
+
DATE_TIME = /\A(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?\z/
|
|
13
|
+
OPERATIONS = %w[create update delete action action_failed].freeze
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# `?page=foo` reaches us as a bare String, which `dig` refuses to walk into.
|
|
18
|
+
def page_param(args)
|
|
19
|
+
page = args.dig(:params, 'page')
|
|
20
|
+
|
|
21
|
+
page.is_a?(Hash) ? page : {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# page[size] defaults to 20 and is capped at 100.
|
|
25
|
+
def parse_page_size(args)
|
|
26
|
+
size = page_param(args)['size'].to_i
|
|
27
|
+
|
|
28
|
+
size < 1 ? DEFAULT_PAGE_SIZE : [size, MAX_PAGE_SIZE].min
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def request_timezone(args)
|
|
32
|
+
timezone = args.dig(:params, 'timezone').to_s
|
|
33
|
+
|
|
34
|
+
timezone.empty? ? 'UTC' : timezone
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def parse_filters(args)
|
|
38
|
+
timezone = request_timezone(args)
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
user_ids: parse_user_ids(args.dig(:params, 'userIds')),
|
|
42
|
+
operations: parse_operations(args.dig(:params, 'operations')),
|
|
43
|
+
fields: parse_fields(args.dig(:params, 'fields')),
|
|
44
|
+
search: parse_search(args.dig(:params, 'search')),
|
|
45
|
+
start_timestamp: parse_date_boundary(args.dig(:params, 'startDate'), timezone, :start),
|
|
46
|
+
end_timestamp: parse_date_boundary(args.dig(:params, 'endDate'), timezone, :end)
|
|
47
|
+
}.compact
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Free text, trimmed; blank means no filter rather than a term that matches everything.
|
|
51
|
+
def parse_search(raw)
|
|
52
|
+
term = raw.to_s.strip
|
|
53
|
+
|
|
54
|
+
term.empty? ? nil : term
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Comma-separated field names, kept verbatim (a name may hold a dot). Empty after parsing → no filter.
|
|
58
|
+
def parse_fields(raw)
|
|
59
|
+
return nil if raw.nil?
|
|
60
|
+
|
|
61
|
+
names = (raw.is_a?(Array) ? raw : raw.to_s.split(',')).map { |name| name.to_s.strip }.reject(&:empty?)
|
|
62
|
+
names.empty? ? nil : names
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Comma-separated, from a closed set. An unrecognized value is rejected rather than dropped: a silently
|
|
66
|
+
# ignored filter returns unfiltered rows into a list the caller believes is filtered.
|
|
67
|
+
def parse_operations(raw)
|
|
68
|
+
tokens = raw.to_s.split(',').map(&:strip).reject(&:empty?)
|
|
69
|
+
unknown = tokens.find { |token| !OPERATIONS.include?(token) }
|
|
70
|
+
|
|
71
|
+
if unknown
|
|
72
|
+
raise Http::Exceptions::ValidationError,
|
|
73
|
+
"Invalid operation: \"#{unknown}\" (expected one of #{OPERATIONS.join(", ")})"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
tokens.empty? ? nil : tokens
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Comma-separated integer ids; non-numeric tokens are dropped. Empty after parsing → no filter.
|
|
80
|
+
def parse_user_ids(raw)
|
|
81
|
+
return nil if raw.nil? || raw.to_s.empty?
|
|
82
|
+
|
|
83
|
+
ids = raw.to_s.split(',').map(&:strip).grep(/\A\d+\z/).map(&:to_i)
|
|
84
|
+
ids.empty? ? nil : ids
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# `startDate`/`endDate` accept a bare day (`YYYY-MM-DD`) or a wall-clock datetime
|
|
88
|
+
# (`YYYY-MM-DD[T ]HH:mm[:ss]`), read as local time in the request timezone and returned as a UTC
|
|
89
|
+
# ISO instant the store can compare against stored timestamps.
|
|
90
|
+
def parse_date_boundary(raw, timezone, boundary)
|
|
91
|
+
return nil if raw.nil? || raw.to_s.empty?
|
|
92
|
+
|
|
93
|
+
zone = Time.find_zone(timezone)
|
|
94
|
+
raise Http::Exceptions::ValidationError, "Invalid timezone: \"#{timezone}\"" if zone.nil?
|
|
95
|
+
|
|
96
|
+
instant = begin
|
|
97
|
+
local_instant(zone, raw.to_s, boundary)
|
|
98
|
+
rescue ArgumentError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if instant.nil?
|
|
103
|
+
raise Http::Exceptions::ValidationError,
|
|
104
|
+
"Invalid date: \"#{raw}\" (expected YYYY-MM-DD or YYYY-MM-DDTHH:mm)"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
instant.utc.iso8601(3)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def local_instant(zone, raw, boundary)
|
|
111
|
+
if DATE_ONLY.match?(raw)
|
|
112
|
+
day = zone.parse(raw)
|
|
113
|
+
# Bare day → start (00:00:00.000) or end (23:59:59.999) of that local day.
|
|
114
|
+
boundary == :end ? day.end_of_day : day.beginning_of_day
|
|
115
|
+
elsif (match = DATE_TIME.match(raw))
|
|
116
|
+
date, hours, minutes, seconds = match.captures
|
|
117
|
+
base = zone.parse("#{date}T#{hours}:#{minutes}")
|
|
118
|
+
if seconds
|
|
119
|
+
base.change(sec: seconds.to_i, usec: 0)
|
|
120
|
+
elsif boundary == :end
|
|
121
|
+
# Minutes-only end boundary stays inclusive to :59.999; start stays at :00.000.
|
|
122
|
+
base.change(sec: 59, usec: 999_000)
|
|
123
|
+
else
|
|
124
|
+
base
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -71,54 +71,31 @@ module ForestAdminAgent
|
|
|
71
71
|
# merging pages ordered by (timestamp, id). Value hashes keep the keys they were stored with: a record's
|
|
72
72
|
# own column names, or an action answer's camelCase Forest names.
|
|
73
73
|
def serialize_record(record)
|
|
74
|
-
|
|
75
|
-
# the payload contract carries.
|
|
76
|
-
record.to_h.except(:previous_record_id).transform_keys { |key| key.to_s.camelize(:lower) }
|
|
74
|
+
record.to_h.transform_keys { |key| key.to_s.camelize(:lower) }
|
|
77
75
|
end
|
|
78
76
|
|
|
79
77
|
def store
|
|
80
78
|
::ForestAdminAgent::AuditTrail.store
|
|
81
79
|
end
|
|
82
80
|
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def record_segments(collection, packed_id)
|
|
92
|
-
segments = [{ id: packed_id, until: nil, until_row: nil }]
|
|
93
|
-
queue = segments.dup
|
|
94
|
-
|
|
95
|
-
until queue.empty?
|
|
96
|
-
segment = queue.shift
|
|
97
|
-
|
|
98
|
-
store.renamed_from(collection: collection.name, record_id: segment[:id]).each do |previous|
|
|
99
|
-
next if segments.any? { |seen| seen[:id] == previous[:id] }
|
|
100
|
-
|
|
101
|
-
# Bounded by its own rename and by everything walked through to reach it: an id abandoned twice
|
|
102
|
-
# only belongs to this record up to the earlier of them.
|
|
103
|
-
found = earlier_bound(previous, segment).merge(id: previous[:id])
|
|
104
|
-
segments << found
|
|
105
|
-
queue << found
|
|
106
|
-
end
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
segments
|
|
110
|
-
end
|
|
81
|
+
# How to withhold the rows served for this id. A record gone at either look has all its rows tested
|
|
82
|
+
# against the caller's scope. A live one in scope still has the rows filed before it took the id: a
|
|
83
|
+
# delete frees an id, and a confirmed `create` marks where a record took one freed without a confirmed
|
|
84
|
+
# delete — a primary-key move, or a delete that never confirmed. Those rows are another record's, which
|
|
85
|
+
# the current one's scope says nothing about.
|
|
86
|
+
def withholding_of(context, collection, packed_id, gone_at_check)
|
|
87
|
+
gone_scope = withholding_scope_for(context, collection, packed_id, gone_at_check)
|
|
88
|
+
return Withholding.new(collection, gone_scope, context.caller.timezone) if gone_scope
|
|
111
89
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def earlier_bound(one, other)
|
|
115
|
-
return other.slice(:until, :until_row) if one[:until].nil?
|
|
116
|
-
return one.slice(:until, :until_row) if other[:until].nil?
|
|
90
|
+
scope = context.permissions.get_scope(collection)
|
|
91
|
+
boundary = scope && store.life_boundary_of(collection: collection.name, record_id: packed_id)
|
|
117
92
|
|
|
118
|
-
|
|
119
|
-
|
|
93
|
+
Withholding.new(collection, boundary && scope, context.caller.timezone, boundary)
|
|
94
|
+
end
|
|
120
95
|
|
|
121
|
-
|
|
96
|
+
def available_user(author)
|
|
97
|
+
{ id: author[:user_id], firstName: author[:user_first_name],
|
|
98
|
+
lastName: author[:user_last_name], email: author[:user_email] }
|
|
122
99
|
end
|
|
123
100
|
|
|
124
101
|
# What the audit trail actually records: primary keys, so a state can be identified, plus the writable
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
module ForestAdminAgent
|
|
2
|
+
module Routes
|
|
3
|
+
module Resources
|
|
4
|
+
# The project-level timeline, mirroring the Node agent's `GET /_audit-trail`: every readable collection's
|
|
5
|
+
# audit rows in one walk, paged by an inclusive cursor rather than by page number, since the front merges
|
|
6
|
+
# it with the activity logs and no count spans both.
|
|
7
|
+
#
|
|
8
|
+
# Values are served as on the per-record history: a caller who can read a collection reads its rows'
|
|
9
|
+
# values. Who may open the project's Activity Logs page at all is Forest's to decide, not the agent's.
|
|
10
|
+
class AuditTrailTimeline < AbstractAuthenticatedRoute
|
|
11
|
+
include AuditTrailRoute
|
|
12
|
+
include AuditTrailQueryParams
|
|
13
|
+
|
|
14
|
+
def setup_routes
|
|
15
|
+
return self unless store
|
|
16
|
+
|
|
17
|
+
add_route('forest_audit_trail_timeline', 'get', '/_audit-trail', ->(args) { handle_request(args) })
|
|
18
|
+
|
|
19
|
+
self
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def handle_request(args = {})
|
|
23
|
+
context = build(args)
|
|
24
|
+
order = args.dig(:params, 'sort').to_s == 'timestamp' ? 'asc' : 'desc'
|
|
25
|
+
limit = parse_page_size(args)
|
|
26
|
+
cursor = parse_cursor(args, order)
|
|
27
|
+
# Parsed before the permission sweep, so a malformed filter reads as a 400 whatever the caller may
|
|
28
|
+
# see. No `fields`: a column name means nothing across collections.
|
|
29
|
+
filters = parse_filters(args).except(:fields)
|
|
30
|
+
collections = readable_collections(context).map(&:name)
|
|
31
|
+
|
|
32
|
+
# One row over the page: the only way to know whether a further page exists without a count.
|
|
33
|
+
fetched = store.list_timeline(**filters, **cursor, order: order, collections: collections,
|
|
34
|
+
limit: limit + 1)
|
|
35
|
+
page = fetched.first(limit)
|
|
36
|
+
meta = { cursor: fetched.size > limit ? next_cursor(page, cursor, order) : nil }
|
|
37
|
+
# Authors on the first page only, like the per-record route: they do not change along the walk, and
|
|
38
|
+
# later pages omit the key rather than send `[]`.
|
|
39
|
+
if cursor[:before].nil? && cursor[:after].nil?
|
|
40
|
+
meta[:availableUsers] = store.timeline_users(**filters, collections: collections)
|
|
41
|
+
.map { |author| available_user(author) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
{ content: { data: page.map { |record| serialize_record(record) }, meta: meta } }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# A record-level scope cannot be evaluated across a whole timeline without fetching every record it
|
|
50
|
+
# mentions, so a collection the caller only sees through one is left out entirely rather than served
|
|
51
|
+
# unfiltered: the row alone would say that a record they cannot read exists and was touched.
|
|
52
|
+
def readable_collections(context)
|
|
53
|
+
context.permissions.allowed_collections(:read, context.datasource.collections.values)
|
|
54
|
+
.select { |collection| context.permissions.get_scope(collection).nil? }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# `before` (`after` oldest first) is an inclusive bound, so the rows sharing the last row's timestamp are
|
|
58
|
+
# carried as `excludeIds` rather than skipped. When the previous page ended on the same timestamp its
|
|
59
|
+
# exclusions still apply, so a timestamp holding more rows than one page walks on instead of looping.
|
|
60
|
+
# Nil when the walk would not progress: staying on the same timestamp must exclude at least one more id.
|
|
61
|
+
def next_cursor(page, previous, order)
|
|
62
|
+
bound = order == 'asc' ? :after : :before
|
|
63
|
+
last = page.last
|
|
64
|
+
same_boundary = previous[bound] && same_instant?(previous[bound], last.timestamp)
|
|
65
|
+
carried = same_boundary ? previous[:exclude_ids] : []
|
|
66
|
+
tied = page.select { |row| same_instant?(row.timestamp, last.timestamp) }.map(&:id)
|
|
67
|
+
exclude_ids = (carried + tied).uniq
|
|
68
|
+
|
|
69
|
+
return nil if same_boundary && exclude_ids.size == carried.size
|
|
70
|
+
|
|
71
|
+
{ bound => last.timestamp, excludeIds: exclude_ids }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def same_instant?(one, other)
|
|
75
|
+
Time.iso8601(one.to_s) == Time.iso8601(other.to_s)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The bound is the timestamp of a row this route served, so it is an ISO instant; only the one for the
|
|
79
|
+
# walk's direction is read.
|
|
80
|
+
def parse_cursor(args, order)
|
|
81
|
+
bound = order == 'asc' ? :after : :before
|
|
82
|
+
raw = args.dig(:params, bound.to_s).to_s
|
|
83
|
+
exclude_ids = args.dig(:params, 'excludeIds').to_s.split(',').map(&:strip).grep(/\A\d+\z/).map(&:to_i)
|
|
84
|
+
|
|
85
|
+
{ bound => raw.empty? ? nil : parse_instant(raw), exclude_ids: exclude_ids }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_instant(raw)
|
|
89
|
+
Time.iso8601(raw).utc.iso8601(3)
|
|
90
|
+
rescue ArgumentError
|
|
91
|
+
raise Http::Exceptions::ValidationError, "Invalid cursor: \"#{raw}\" (expected an ISO 8601 instant)"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -7,9 +7,10 @@ module ForestAdminAgent
|
|
|
7
7
|
# since a rule only one of them applies is one lookup away from being no rule at all.
|
|
8
8
|
module AuditTrailWithholding
|
|
9
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,
|
|
11
|
-
#
|
|
12
|
-
|
|
10
|
+
# collection the rows belong to, the scope to test them against, the timezone its date comparisons read
|
|
11
|
+
# in, and — for a live record whose id another record held before — the row where its own life began,
|
|
12
|
+
# so only the rows before it are tested. No boundary means every row is.
|
|
13
|
+
Withholding = Struct.new(:collection, :scope, :timezone, :boundary)
|
|
13
14
|
|
|
14
15
|
NULL_EXCLUDING_OPERATORS = [
|
|
15
16
|
ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators::NOT_EQUAL,
|
|
@@ -24,7 +25,16 @@ module ForestAdminAgent
|
|
|
24
25
|
def withhold_out_of_scope_values(entries, withholding)
|
|
25
26
|
return entries if withholding.scope.nil?
|
|
26
27
|
|
|
27
|
-
entries.map { |entry| withhold(entry, withholding) }
|
|
28
|
+
entries.map { |entry| of_withheld_life?(entry, withholding.boundary) ? withhold(entry, withholding) : entry }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A boundary `delete` is the earlier record's last row, a boundary `create` the current record's first.
|
|
32
|
+
def of_withheld_life?(entry, boundary)
|
|
33
|
+
return true if boundary.nil?
|
|
34
|
+
return boundary.operation == 'delete' if entry.id == boundary.id
|
|
35
|
+
|
|
36
|
+
([::Time.iso8601(entry.timestamp.to_s), entry.id.to_i] <=>
|
|
37
|
+
[::Time.iso8601(boundary.timestamp.to_s), boundary.id.to_i]).negative?
|
|
28
38
|
end
|
|
29
39
|
|
|
30
40
|
def withhold(entry, withholding)
|
|
@@ -48,11 +58,15 @@ module ForestAdminAgent
|
|
|
48
58
|
# separately releases the ones that can be proven in scope — "it used to be X" can't escape through a
|
|
49
59
|
# row whose new value is out of scope, since that side is tested on its own.
|
|
50
60
|
def withhold_each_side(entry, withholding)
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
kept = in_scope?(entry.previous_values,
|
|
61
|
+
# The row is filed under the id the record ended up with, which says nothing about a writable key the
|
|
62
|
+
# update may have moved: on the previous side the id answers only for a key never captured, read-only
|
|
63
|
+
# and so unmoved. A redacted key stays unanswered, which over-withholds an update that kept its key —
|
|
64
|
+
# the safe side of a move the trail cannot see.
|
|
65
|
+
kept = if in_scope?(entry.previous_values, entry.record_id, withholding, id_answers_for_keys: false)
|
|
66
|
+
entry
|
|
67
|
+
else
|
|
68
|
+
blank(entry, :previous_values)
|
|
69
|
+
end
|
|
56
70
|
|
|
57
71
|
in_scope?(kept.new_values, after_id(kept), withholding) ? kept : blank(kept, :new_values)
|
|
58
72
|
end
|
|
@@ -61,13 +61,14 @@ module ForestAdminAgent
|
|
|
61
61
|
def handle_search_decorator(search_value, records, collection)
|
|
62
62
|
decorator = { decorators: [] }
|
|
63
63
|
unless search_value.nil?
|
|
64
|
+
search_value = search_value.downcase
|
|
64
65
|
records.each_with_index do |entry, index|
|
|
65
66
|
decorator[:decorators][index] = { id: Utils::Id.pack_id(collection, entry), search: [] }
|
|
66
67
|
# attributes method is defined on ActiveRecord::Base model
|
|
67
68
|
attributes = entry.respond_to?(:attributes) ? entry.attributes : entry
|
|
68
69
|
|
|
69
70
|
attributes.each do |field_key, field_value|
|
|
70
|
-
if !field_value.is_a?(Array) && field_value.to_s.downcase.include?(search_value
|
|
71
|
+
if !field_value.is_a?(Array) && field_value.to_s.downcase.include?(search_value)
|
|
71
72
|
decorator[:decorators][index][:search] << field_key
|
|
72
73
|
end
|
|
73
74
|
end
|
|
@@ -18,7 +18,8 @@ module ForestAdminAgent
|
|
|
18
18
|
|
|
19
19
|
def handle_request(args)
|
|
20
20
|
build(args)
|
|
21
|
-
|
|
21
|
+
rendering_id = args.dig(:params, 'renderingId')
|
|
22
|
+
Permissions.invalidate_rendering_cache(rendering_id.to_s.strip.empty? ? nil : rendering_id)
|
|
22
23
|
|
|
23
24
|
{ content: nil, status: 204 }
|
|
24
25
|
end
|