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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/AUDIT_TRAIL.md +55 -26
  3. data/forest_admin_agent.gemspec +2 -0
  4. data/lib/forest_admin_agent/audit_trail/audit_record.rb +1 -1
  5. data/lib/forest_admin_agent/audit_trail/capture.rb +1 -7
  6. data/lib/forest_admin_agent/audit_trail/sql/audit_connection_base.rb +1 -5
  7. data/lib/forest_admin_agent/audit_trail/sql/migrations.rb +23 -6
  8. data/lib/forest_admin_agent/audit_trail/store.rb +79 -65
  9. data/lib/forest_admin_agent/auth/oidc_client_manager.rb +1 -2
  10. data/lib/forest_admin_agent/http/router.rb +4 -2
  11. data/lib/forest_admin_agent/routes/action/actions.rb +13 -4
  12. data/lib/forest_admin_agent/routes/capabilities/collections.rb +4 -0
  13. data/lib/forest_admin_agent/routes/resources/audit_trail.rb +25 -118
  14. data/lib/forest_admin_agent/routes/resources/audit_trail_correlation.rb +1 -3
  15. data/lib/forest_admin_agent/routes/resources/audit_trail_query_params.rb +131 -0
  16. data/lib/forest_admin_agent/routes/resources/audit_trail_route.rb +16 -39
  17. data/lib/forest_admin_agent/routes/resources/audit_trail_timeline.rb +96 -0
  18. data/lib/forest_admin_agent/routes/resources/audit_trail_withholding.rb +23 -9
  19. data/lib/forest_admin_agent/routes/resources/list.rb +2 -1
  20. data/lib/forest_admin_agent/routes/security/scope_invalidation.rb +2 -1
  21. data/lib/forest_admin_agent/serializer/forest_serializer.rb +51 -57
  22. data/lib/forest_admin_agent/serializer/forest_serializer_override.rb +1 -4
  23. data/lib/forest_admin_agent/services/ip_whitelist.rb +6 -8
  24. data/lib/forest_admin_agent/services/permissions.rb +81 -41
  25. data/lib/forest_admin_agent/services/sse_cache_invalidation.rb +19 -1
  26. data/lib/forest_admin_agent/utils/safe_file_cache.rb +52 -0
  27. data/lib/forest_admin_agent/utils/schema/schema_emitter.rb +1 -1
  28. data/lib/forest_admin_agent/version.rb +1 -1
  29. data/lib/forest_admin_agent.rb +2 -2
  30. 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
- # args[:params]['id'] is already Forest's packed id, the form the audit store keys on — plus any id
50
- # this record was filed under before a rename, each bounded by when it stopped being that id.
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: record_segments(context.collection, args[:params]['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
- withholding_scope = withholding_scope_for(context, context.collection, args[:params]['id'],
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, withholding_scope, context, args[:params]['id']) } }
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: this answer withholds, so the rows, count and authors matched
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. Only for a gone record: one in scope was the caller's to read whole.
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
- withholding_scope = withholding_scope_for(context, context.collection, args[:params]['id'],
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] (default 20, capped at
260
- # 100). Out-of-bound or non-numeric values fall back to the defaults rather than erroring.
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
- # `?page=foo` reaches us as a bare String, which `dig` refuses to walk into.
263
- page = args.dig(:params, 'page')
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
- 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))
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
- # `previous_record_id` stays out: it is how the agent follows a record across a rename, not something
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
- # Every id this record has been filed under, each with the moment it stopped being that id. Rows
84
- # written before an update moved a writable primary key stay under the id they were true of, so a
85
- # history query that asked for the current id alone would start at the rename and call that the whole
86
- # story — and one that asked for the bare ids would sweep up whatever record holds an abandoned id now.
87
- #
88
- # Breadth-first, and no depth limit: every hop adds an id not already seen and there are finitely many
89
- # of those, so skipping what we hold is both the cycle guard and the terminator. A cap would have
90
- # truncated a record renamed often enough, which reads exactly like missing history.
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
- # Bounds compare as the trail orders, (timestamp, row id); nil is "no bound yet", which is later than
113
- # any of them. Through `<=>`, since Array is not Comparable and `<=` on one raises.
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
- pair = ->(bound) { [bound[:until], bound[:until_row].to_i] }
119
- earlier = (pair[one] <=> pair[other]) <= 0 ? one : other
93
+ Withholding.new(collection, boundary && scope, context.caller.timezone, boundary)
94
+ end
120
95
 
121
- earlier.slice(:until, :until_row)
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, and the timezone its date
11
- # comparisons read in.
12
- Withholding = Struct.new(:collection, :scope, :timezone)
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
- # 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)
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.downcase)
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
- Permissions.invalidate_cache('forest.rendering')
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