forest_admin_agent 1.45.2 → 1.46.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3d274612ae79795715d531ee72fce9566b2f6bc0ca79fd2f60538498f8cef8e
4
- data.tar.gz: 54daad3999b1ff2bdcef2257c9638dd2896c160083f852f6e4501cef3288d478
3
+ metadata.gz: be21283660e21385c61e7a2b4312996bd27c516d5835483a9548967eda3c10a3
4
+ data.tar.gz: 8db034fa9e1562fd7552d2f8b4fae5ddc29944b71d1902a715d05d4531c15b47
5
5
  SHA512:
6
- metadata.gz: c585bcad5f56d230eeb0dfd382c0a5543bf0a01b97bfee5499ae70e8533055f17b1b1093156ea18daf53ef06fa6daf57c953107da0da94a6461cde8620173f13
7
- data.tar.gz: 361983398aa5072601e09aea9ddaf75209a9582edc1b28291be9aa144a4c85f512394ea4ef1e40d3449cdb97c39d798ba8564625ee44b7403a1bf99f94218c2d
6
+ metadata.gz: 691a6dc3a633103b92e6cea06db4c7adf5fb1bcf2d294bbcdac8afd1c4d27af6794f325ad8b4c286c5c06e9469df8428f1a0c6fce81f8ca8318f072367435add
7
+ data.tar.gz: 67b147dbe477ca883faa985b2802187e3e0c6971896e129c288a15c85a9dc7118fe811f7d497bee9ef534c233bf15b0e0e008bb5ac121cae118605a3ee17b32c
data/AUDIT_TRAIL.md CHANGED
@@ -13,8 +13,9 @@ Two parts, both internal:
13
13
  schema and creates/evolves the `audit_logs` table through versioned migrations, and reads the
14
14
  per-record history back for the routes below.
15
15
 
16
- Storage uses ActiveRecord: outside Rails, add `gem 'activerecord'` (and the adapter gem) to your
17
- Gemfile. Nothing is loaded and no connection is opened until the feature is configured.
16
+ Storage uses ActiveRecord on a PostgreSQL database. The agent depends on `activerecord` and `pg`, so
17
+ there is nothing to add to the Gemfile, even in an app whose main datasource is not SQL. Nothing is loaded
18
+ and no connection is opened until the feature is configured.
18
19
 
19
20
  ## Turn it on
20
21
 
@@ -122,6 +123,7 @@ or removed still reads as they were when they acted. Optional filters (all combi
122
123
  | query param | format | effect |
123
124
  | ----------- | -------------------------------- | ----------------------------------------------- |
124
125
  | `userIds` | comma-separated integers `12,45` | keep only entries whose `user_id` is in the list |
126
+ | `operations` | comma-separated, from `create`, `update`, `delete`, `action`, `action_failed` | keep only entries recorded under one of them |
125
127
  | `startDate` | `YYYY-MM-DD` or datetime (incl.) | keep entries from this lower bound onward |
126
128
  | `endDate` | `YYYY-MM-DD` or datetime (incl.) | keep entries up to this upper bound |
127
129
  | `fields` | comma-separated field names | keep only entries whose diff touched one of them |
@@ -164,7 +166,9 @@ happens in SQL. Two shapes are accepted:
164
166
 
165
167
  Both bounds are **inclusive**. Defensive parsing: non-numeric `userIds` tokens are dropped
166
168
  (`12,abc,45` → `12,45`), and a `startDate` / `endDate` matching no accepted format returns **HTTP
167
- 400** (`ValidationError`); an invalid `timezone` likewise returns **400**.
169
+ 400** (`ValidationError`); an invalid `timezone` likewise returns **400**. An unrecognized
170
+ `operations` value returns **400** rather than being dropped: a silently ignored filter would hand back
171
+ unfiltered rows into a list the caller believes is filtered.
168
172
 
169
173
  Pagination follows JSON:API: `page[number]` is 1-based (default `1`), `page[size]` defaults to `20`
170
174
  and is capped at `100`; out-of-bound or non-numeric values fall back to the defaults rather than
@@ -180,21 +184,10 @@ the merge tiebreaker.
180
184
  Inside the value objects: a record's column names pass through untouched, while an action answer's keys are
181
185
  Forest's own and so are camelCase (`mimeType`, not `mime_type`) — the agent transforms them on write.
182
186
 
183
- **A record that was renamed keeps one timeline** — on this agent. The Node agent files an update under the
184
- record's new id too, but has no `previous_record_id` and so cannot walk back past the rename: the same record
185
- shows a complete chain here and a history beginning at the rename there, until that column is ported.
186
- An update that moves a writable primary key files its row
187
- under the record's new id — the id later lookups use — and remembers the one it left. Both the history and the
188
- state routes walk that back, so asking for the current id returns everything the record has ever been filed
189
- under, rather than starting the story at the rename.
190
-
191
- Each earlier id counts only **up to the moment it was left**, because a primary key a record abandons can be
192
- taken by another record afterwards, and those rows are none of this record's business. What the trail cannot
193
- separate is the opposite case: rows written under an id *before* the record that holds it now arrived — a
194
- reused key, or a delete followed by a recreate — since the packed id is the only identity the trail has. That
195
- is deliberate for delete/recreate (the state reconstruction walks into an earlier life on purpose) and the
196
- same limitation for a reused key. Telling those apart needs a lineage of its own on every row, which is a
197
- bigger change than this one.
187
+ **A record whose primary key moved starts its history at the move**, as on the Node agent. An update that
188
+ moves a writable primary key files its row under the record's new id, and the rows written before stay under
189
+ the id they were true of; the history and state routes read the requested id only. Migration `002` drops the
190
+ `previous_record_id` column an earlier version kept for this.
198
191
 
199
192
  A record that no longer exists keeps its history: only a record that still exists *outside* the
200
193
  caller's permission scope is refused (404). Inspecting what was deleted is much of the point of an
@@ -213,10 +206,11 @@ are withheld: `nil` there is a missing answer, not a passing one, and a redacted
213
206
  Primary keys are the exception: the packed id fills in whatever the snapshot cannot answer — a read-only key
214
207
  it never captured, or a writable one the trail redacts — so a scope on the id still matches the record it
215
208
  belongs to. It fills in only what is missing: where a side captured the key itself, that value is the one
216
- that was true there. Each side of an update is read against the id it was filed under, the row's own id and,
217
- for the previous side of an update that moved the key, the id it moved from. A `pending` row is filed under
218
- the id the record had *before* the write, which may not have landed, so its new side is given no id to fill
219
- from and answers with what it captured or not at all.
209
+ that was true there. The row's id answers only for the side it was filed under, as on the Node agent: a
210
+ create, a delete, an update's new side. On an update's previous side it fills a key never captured (read-only,
211
+ so it cannot have moved) but not a redacted one (writable, so it may have), and that side is withheld from a
212
+ scope on a redacted key. A `pending` row is filed under the id the record had *before* the write, which may
213
+ not have landed, so its new side is given no id to fill from and answers with what it captured or not at all.
220
214
 
221
215
  Neither failure this can meet is fatal, and each costs only what it actually broke. An id that stopped
222
216
  decoding when the primary key changed shape costs the keys it would have filled and nothing more, so a scope
@@ -246,9 +240,17 @@ read it, that is a **404**, the same as for a request starting a moment later; i
246
240
  life's values stay withheld all the same. The second read is skipped when no scope is in effect, and on the
247
241
  correlation routes when the answer holds no rows.
248
242
 
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.
243
+ **An id freed and taken since has two lives.** Where the delete and the recreation both happened *before*
244
+ the request, both reads see the live record, and a live record in the caller's scope says nothing about the
245
+ rows filed under its id before it took the id: they are an earlier record's. The boundary is the id's latest
246
+ confirmed `delete` (the earlier record's last row) or `create` (the current record's first), whichever is
247
+ later; the `create` covers an id freed with no confirmed `delete`, by a primary-key move or a delete that was
248
+ never confirmed. A `pending` row marks no boundary, since it may never have landed. The rows before the
249
+ boundary go through the same withholding as a record gone for good — on the history route (with `search` and
250
+ `fields` matched against the served values, so the count and the authors cannot leak them either), on the
251
+ correlation routes, and on `/state`, whose reconstruction at an instant **strictly before** the boundary is
252
+ tested against the scope: at the boundary's own instant the state already includes a replacement `create`
253
+ sharing it. The lookup runs only for a scoped caller on a live record. Same rule as the Node agent.
252
254
 
253
255
  **Every route that serves captured values applies this**, since a rule only one of them applies is one
254
256
  lookup away from being no rule at all. The two correlation routes withhold row by row exactly as the history
@@ -278,6 +280,34 @@ Walking back stops being able to help where the trail stops: only audited (writa
278
280
  reconstructed, and a `create` means the record did not exist before it, while a `delete` restores the whole
279
281
  row it recorded and the walk carries on into any earlier life of the same id.
280
282
 
283
+ ### Timeline route
284
+
285
+ `GET /forest/_audit-trail` is the project-level timeline: the audit rows of every collection the caller can
286
+ read, in one walk, for the project's Activity Logs page. It mirrors the Node agent's route, and the capability
287
+ payload announces it as `canUseAuditTrailTimeline` and `canSortAuditTrailTimeline`, both true wherever the
288
+ trail is configured.
289
+
290
+ ```json
291
+ { "data": [...], "meta": { "cursor": { "before": "2026-01-02T03:04:05.000Z", "excludeIds": [42] },
292
+ "availableUsers": [...] } }
293
+ ```
294
+
295
+ - Values follow the per-record history's rule: a caller who can read a collection reads its rows' values,
296
+ whatever their permission level. Who may open the page at all is the team's Activity Logs setting, enforced
297
+ by Forest, not by the agent.
298
+ - A collection the caller sees only through a record-level scope is left out entirely: a scope cannot be
299
+ evaluated across the whole timeline, and a row alone would say that a record they cannot read was touched.
300
+ - `sort=timestamp` walks oldest first; anything else newest first. Ties follow `id` in the same direction.
301
+ - Paged by cursor, not page number, since the front merges this walk with the activity logs and no count spans
302
+ both. `before` (newest first) or `after` (oldest first) is an **inclusive** ISO instant, and `excludeIds`
303
+ drops the rows already served at it; the other direction's bound is ignored, and a bound that is not an ISO
304
+ instant is a **400**. The bound and the date filter on its side combine, the tighter winning. `meta.cursor`
305
+ is `null` on the last page, and when the walk would not progress.
306
+ - `page[size]` as on the history route. `userIds`, `operations`, `startDate`, `endDate`, `timezone` and
307
+ `search` as on the history route; no `fields`, since a column name means nothing across collections.
308
+ - `meta.availableUsers` on the first page only (neither `before` nor `after`): the distinct authors the
309
+ filters match across the readable collections. Later pages omit the key.
310
+
281
311
  ### Correlation route
282
312
 
283
313
  `GET /forest/_audit-trail/correlation/{correlationKey}` returns `{ "data": [...] }` — the
@@ -370,7 +400,6 @@ one table nobody deletes from.
370
400
  | `operation` | `create` / `update` / `delete` |
371
401
  | `collection` | audited collection name |
372
402
  | `record_id` | packed record id (primary keys joined by `\|`), TEXT and nullable — a create's pending row has none yet, and a composite id outgrows a varchar |
373
- | `previous_record_id` | set only on an update that moved a writable primary key: the id the row was filed under before |
374
403
  | `user_id` | the Forest user who made the change |
375
404
  | `user_first_name`, `user_last_name`, `user_email` | denormalised from the caller at write time: who acted then, not whoever holds that id today |
376
405
  | `action_name` | smart-action rows only |
@@ -33,6 +33,7 @@ admin work on any Ruby application."
33
33
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
34
34
  spec.require_paths = ["lib"]
35
35
 
36
+ spec.add_dependency "activerecord", ">= 6.1"
36
37
  spec.add_dependency "activesupport", ">= 6.1"
37
38
  spec.add_dependency "base64"
38
39
  spec.add_dependency "benchmark"
@@ -52,6 +53,7 @@ admin work on any Ruby application."
52
53
  spec.add_dependency "ld-eventsource", "~> 2.2"
53
54
  spec.add_dependency "mono_logger", "~> 1.1"
54
55
  spec.add_dependency "openid_connect", "~> 2.2"
56
+ spec.add_dependency "pg", "~> 1.1"
55
57
  spec.add_dependency "rake", "~> 13.0"
56
58
  spec.add_dependency "rack-cors", "~> 3.0"
57
59
  spec.add_dependency "zeitwerk", "~> 2.3"
@@ -7,7 +7,7 @@ module ForestAdminAgent
7
7
  # follows the write protocol — inserted as {Recording::PENDING} before the write and confirmed
8
8
  # {Recording::DONE} after, so a row left pending means the write may or may not have landed.
9
9
  AuditRecord = Struct.new(
10
- :id, :timestamp, :operation, :collection, :record_id, :previous_record_id, :status,
10
+ :id, :timestamp, :operation, :collection, :record_id, :status,
11
11
  :user_id, :user_first_name, :user_last_name, :user_email, :action_name,
12
12
  :correlation_key, :previous_values, :new_values,
13
13
  keyword_init: true
@@ -136,14 +136,8 @@ module ForestAdminAgent
136
136
  if delta[:new_values].empty?
137
137
  empty << pending[:ids][index]
138
138
  else
139
- before_id = record_id(record, pks)
140
- after_id = record_id(after, pks)
141
-
142
139
  confirm(pending[:ids][index],
143
- record_id: after_id,
144
- # Only when the key actually moved, so a history query can walk back to the rows filed
145
- # under the id this record used to have.
146
- previous_record_id: after_id == before_id ? nil : before_id,
140
+ record_id: record_id(after, pks),
147
141
  previous_values: redacted(target[:name], delta[:previous_values]),
148
142
  new_values: redacted(target[:name], delta[:new_values]))
149
143
  end
@@ -1,8 +1,4 @@
1
- begin
2
- require 'active_record'
3
- rescue LoadError
4
- raise LoadError, 'config.audit_trail needs the activerecord gem: add `gem "activerecord"` to your Gemfile.'
5
- end
1
+ require 'active_record'
6
2
 
7
3
  module ForestAdminAgent
8
4
  module AuditTrail
@@ -1,10 +1,9 @@
1
1
  module ForestAdminAgent
2
2
  module AuditTrail
3
3
  module Sql
4
- # The audit table's schema, as an ordered, append-only list. Nothing has shipped yet, so this is still a
5
- # single entry; once a release is out, never edit one — add another, since a database out there has
6
- # already recorded the earlier ones as applied. Every statement tolerates being replayed
7
- # (`if_not_exists`), which is what makes a lost race harmless.
4
+ # The audit table's schema, as an ordered, append-only list. Never edit a released entry — add another,
5
+ # since a database out there has already recorded the earlier ones as applied. Every statement tolerates
6
+ # being replayed (`if_not_exists` / `if_exists`), which is what makes a lost race harmless.
8
7
  module Migrations
9
8
  ALL = [
10
9
  {
@@ -26,8 +25,7 @@ module ForestAdminAgent
26
25
  t.text :user_first_name
27
26
  t.text :user_last_name
28
27
  t.text :user_email
29
- # Set only on an update that moved a writable primary key: the id the row was filed under
30
- # before. What lets a history query follow a record across a rename.
28
+ # Dropped by 002.
31
29
  t.text :previous_record_id
32
30
  # Smart-action rows only.
33
31
  t.text :action_name
@@ -45,6 +43,25 @@ module ForestAdminAgent
45
43
  connection.add_index(table, :correlation_key, name: "#{base}_correlation_key", if_not_exists: true)
46
44
  connection.add_index(table, :user_id, name: "#{base}_user_id", if_not_exists: true)
47
45
  end
46
+ },
47
+ {
48
+ # The agent no longer follows a record across a primary-key move, so nothing writes or reads the
49
+ # column. No released version wrote it to a row a client relies on.
50
+ name: '002-drop-previous-record-id',
51
+ up: lambda do |connection, table|
52
+ # Checked first as well: older ActiveRecord versions accept `if_exists` here but ignore it.
53
+ next unless connection.column_exists?(table, :previous_record_id)
54
+
55
+ connection.remove_column(table, :previous_record_id, if_exists: true)
56
+ end
57
+ },
58
+ {
59
+ # The timeline pages the whole table in (timestamp, id) order, which no other index serves.
60
+ name: '003-index-timestamp-id',
61
+ up: lambda do |connection, table|
62
+ connection.add_index(table, %i[timestamp id], name: "#{table.split(".").last}_timestamp_id",
63
+ if_not_exists: true)
64
+ end
48
65
  }
49
66
  ].freeze
50
67
  end
@@ -11,7 +11,7 @@ module ForestAdminAgent
11
11
  class Store
12
12
  DEFAULT_SCHEMA = 'forest'.freeze
13
13
  DEFAULT_TABLE = 'audit_logs'.freeze
14
- COLUMNS = %i[timestamp operation collection record_id previous_record_id status user_id user_first_name
14
+ COLUMNS = %i[timestamp operation collection record_id status user_id user_first_name
15
15
  user_last_name user_email action_name correlation_key previous_values new_values].freeze
16
16
  AUTHOR_COLUMNS = %i[user_id user_first_name user_last_name user_email].freeze
17
17
 
@@ -73,13 +73,14 @@ module ForestAdminAgent
73
73
  model.where(id: ids).delete_all unless ids.empty?
74
74
  end
75
75
 
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', after: nil)
76
+ def list_by_record(collection:, record_id:, skip: 0, limit: nil, user_ids: nil, operations: nil,
77
+ start_timestamp: nil, end_timestamp: nil, fields: nil, search: nil, order: 'asc', after: nil)
78
78
  descending = order.to_s == 'desc'
79
79
  # `id` (insertion order) breaks ties on equal timestamps in both directions, keeping pages
80
80
  # deterministic and stable.
81
- relation = scope(collection, record_id, user_ids: user_ids, start_timestamp: start_timestamp,
82
- end_timestamp: end_timestamp, fields: fields, search: search)
81
+ relation = scope(collection, record_id, user_ids: user_ids, operations: operations,
82
+ start_timestamp: start_timestamp, end_timestamp: end_timestamp,
83
+ fields: fields, search: search)
83
84
  .order(timestamp: descending ? :desc : :asc, id: :asc)
84
85
  .offset(skip || 0)
85
86
  relation = relation.where(*past_row_condition(after, descending)) if after
@@ -88,42 +89,52 @@ module ForestAdminAgent
88
89
  relation.map { |row| from_row(row) }
89
90
  end
90
91
 
91
- def count_by_record(collection:, record_id:, user_ids: nil, start_timestamp: nil,
92
+ def count_by_record(collection:, record_id:, user_ids: nil, operations: nil, start_timestamp: nil,
92
93
  end_timestamp: nil, fields: nil, search: nil)
93
- scope(collection, record_id, user_ids: user_ids, start_timestamp: start_timestamp,
94
+ scope(collection, record_id, user_ids: user_ids, operations: operations, start_timestamp: start_timestamp,
94
95
  end_timestamp: end_timestamp, fields: fields, search: search).count
95
96
  end
96
97
 
97
98
  # The distinct authors of the entries the current filters match, whatever page is being asked for. The
98
99
  # identity comes from the rows themselves, so a user who has since been renamed or removed still reads
99
100
  # as they were when they acted.
100
- def authors_by_record(collection:, record_id:, user_ids: nil, start_timestamp: nil,
101
+ def authors_by_record(collection:, record_id:, user_ids: nil, operations: nil, start_timestamp: nil,
101
102
  end_timestamp: nil, fields: nil, search: nil)
102
- scope(collection, record_id, user_ids: user_ids, start_timestamp: start_timestamp,
103
- end_timestamp: end_timestamp, fields: fields, search: search)
104
- .where.not(user_id: nil)
105
- .distinct
106
- .pluck(*AUTHOR_COLUMNS)
107
- .map { |values| AUTHOR_COLUMNS.zip(values).to_h }
108
- .uniq { |author| author[:user_id] }
103
+ authors(scope(collection, record_id, user_ids: user_ids, operations: operations,
104
+ start_timestamp: start_timestamp, end_timestamp: end_timestamp,
105
+ fields: fields, search: search))
109
106
  end
110
107
 
111
- # The ids this record was renamed from, each with the moment it stopped being that id. Walking those back
112
- # is what lets a history query reach rows written before a rename — they stay under the id they were
113
- # written with, since that is the id they were true of — and the moment bounds how far: the id it left may
114
- # have been taken by another record afterwards, whose rows are none of this record's business.
115
- def renamed_from(collection:, record_id:)
116
- model.where(collection: collection, record_id: record_id)
117
- .where.not(previous_record_id: nil)
118
- .pluck(:previous_record_id, :timestamp, :id)
119
- .group_by(&:first)
120
- .map do |id, rows|
121
- # The row id comes along as the tie-breaker: the trail orders itself by (timestamp, id), so a
122
- # bound that knew only the timestamp would mean something slightly different from "before".
123
- _, at, row = rows.max_by { |(_, timestamp, row_id)| [timestamp, row_id] }
124
-
125
- { id: id, until: as_iso(at), until_row: row }
126
- end
108
+ # The project-level timeline across `collections`, the ones the caller may read: newest first, or oldest
109
+ # first with `order: 'asc'`, ties broken by id in the same direction. `before` / `after` are the cursor's
110
+ # inclusive bound and `exclude_ids` the rows already served at it.
111
+ def list_timeline(collections:, limit:, order: 'desc', **filters)
112
+ return [] if collections.empty?
113
+
114
+ direction = order.to_s == 'asc' ? :asc : :desc
115
+
116
+ timeline_scope(collections, **filters).order(timestamp: direction, id: direction).limit(limit)
117
+ .map { |row| from_row(row) }
118
+ end
119
+
120
+ # The distinct authors of the timeline the filters match, whatever the cursor.
121
+ def timeline_users(collections:, **filters)
122
+ return [] if collections.empty?
123
+
124
+ authors(timeline_scope(collections, **filters))
125
+ end
126
+
127
+ # The id's latest confirmed `delete` or `create`, in the trail's (timestamp, id) order: where the life of
128
+ # the record holding the id now began. A pending row may never have landed, so it marks nothing. Nil when
129
+ # the id has had one life only — no boundary at all, or a `create` with nothing filed before it — so a
130
+ # record created through Forest does not pay for the earlier-life withholding it can never need.
131
+ def life_boundary_of(collection:, record_id:)
132
+ rows = model.where(collection: collection, record_id: record_id)
133
+ row = rows.where(operation: %w[delete create], status: Recording::DONE).order(timestamp: :desc, id: :desc).first
134
+ return nil if row.nil?
135
+ return nil if row.operation == 'create' && !rows.where(*before_row_condition(row)).exists?
136
+
137
+ from_row(row)
127
138
  end
128
139
 
129
140
  # Entries recorded strictly after `timestamp`, newest first: what a state reconstruction has to undo.
@@ -135,7 +146,7 @@ module ForestAdminAgent
135
146
  # cannot act on them.
136
147
  def list_since(collection:, record_id:, timestamp:)
137
148
  model.where(collection: collection, status: Recording::DONE)
138
- .where(*segments_condition(record_id))
149
+ .where(record_id: record_id)
139
150
  .where('timestamp > ?', as_time(timestamp))
140
151
  .order(timestamp: :desc, id: :desc)
141
152
  .map { |row| from_row(row) }
@@ -155,12 +166,28 @@ module ForestAdminAgent
155
166
 
156
167
  private
157
168
 
158
- # Every filter is an AND, so the count matches exactly what a page of this history holds.
159
- def scope(collection, record_id, user_ids: nil, start_timestamp: nil, end_timestamp: nil,
160
- fields: nil, search: nil)
161
- relation = model.where(collection: collection).where(*segments_condition(record_id))
162
- relation = relation.where(user_id: user_ids) if user_ids
169
+ def scope(collection, record_id, fields: nil, **filters)
170
+ relation = filtered(model.where(collection: collection, record_id: record_id), **filters)
163
171
  relation = relation.where(Sql::FieldFilter.new(model.connection).condition(fields)) if fields&.any?
172
+ relation
173
+ end
174
+
175
+ # Each cursor bound and the date filter on its side are both inclusive, so the tighter one wins.
176
+ def timeline_scope(collections, before: nil, after: nil, exclude_ids: nil, start_timestamp: nil,
177
+ end_timestamp: nil, **filters)
178
+ relation = filtered(model.where(collection: collections),
179
+ start_timestamp: [start_timestamp, after].compact.max_by { |bound| as_time(bound) },
180
+ end_timestamp: [end_timestamp, before].compact.min_by { |bound| as_time(bound) },
181
+ **filters)
182
+ relation = relation.where.not(id: exclude_ids) if exclude_ids&.any?
183
+ relation
184
+ end
185
+
186
+ # The filters the per-record history and the timeline share, so they cannot drift apart. Every one is an
187
+ # AND, so the count matches exactly what a page holds.
188
+ def filtered(relation, user_ids: nil, operations: nil, search: nil, start_timestamp: nil, end_timestamp: nil)
189
+ relation = relation.where(user_id: user_ids) if user_ids
190
+ relation = relation.where(operation: operations) if operations
164
191
  relation = relation.where(Sql::TextSearch.new(model.connection).condition(search)) if search
165
192
  # Compare as Time so ActiveRecord casts the bound to the datetime column's storage format
166
193
  # (raw ISO strings with a `Z` would compare lexically against the cast rows and never match).
@@ -169,6 +196,21 @@ module ForestAdminAgent
169
196
  relation
170
197
  end
171
198
 
199
+ def authors(relation)
200
+ relation.where.not(user_id: nil)
201
+ .distinct
202
+ .pluck(*AUTHOR_COLUMNS)
203
+ .map { |values| AUTHOR_COLUMNS.zip(values).to_h }
204
+ .uniq { |author| author[:user_id] }
205
+ end
206
+
207
+ # Rows strictly before `row` in the trail's (timestamp, id) order.
208
+ def before_row_condition(row)
209
+ at = as_time(row.timestamp)
210
+
211
+ ['timestamp < ? OR (timestamp = ? AND id < ?)', at, at, row.id]
212
+ end
213
+
172
214
  # Rows strictly past `row` in the history's own order, so a scan keyed on the last row it read neither
173
215
  # repeats nor skips one when entries are written between its reads, as an offset would.
174
216
  def past_row_condition(row, descending)
@@ -182,34 +224,6 @@ module ForestAdminAgent
182
224
  value.is_a?(::Time) ? value : ::Time.iso8601(value.to_s)
183
225
  end
184
226
 
185
- def as_iso(value)
186
- value.respond_to?(:iso8601) ? value.iso8601(3) : value.to_s
187
- end
188
-
189
- # One record's history is its current id plus every id it was renamed from, each earlier one only up to
190
- # the rename: `record_id = '7' OR (record_id = '1' AND timestamp <= …)`. A plain `IN` would hand over the
191
- # rows of whichever record holds that id now.
192
- #
193
- # Takes an id, several, or segments — `{ id:, until: }` — so a caller that has no rename to care about
194
- # simply passes the id.
195
- def segments_condition(record_id)
196
- binds = []
197
- sql = Array(record_id).map { |value| value.is_a?(Hash) ? value : { id: value, until: nil } }.map do |segment|
198
- binds << segment[:id]
199
- next 'record_id = ?' unless segment[:until]
200
-
201
- binds << as_time(segment[:until])
202
- # Same millisecond as the rename, and the id says which side of it a row falls on: another record
203
- # taking the abandoned key that fast would otherwise land in this record's history.
204
- next '(record_id = ? AND timestamp <= ?)' unless segment[:until_row]
205
-
206
- binds << as_time(segment[:until]) << segment[:until_row]
207
- '(record_id = ? AND (timestamp < ? OR (timestamp = ? AND id <= ?)))'
208
- end
209
-
210
- [sql.join(' OR '), *binds]
211
- end
212
-
213
227
  def model
214
228
  ensure_ready
215
229
  @model
@@ -50,12 +50,14 @@ module ForestAdminAgent
50
50
  { name: 'charts', handler: -> { Charts::Charts.new.routes } },
51
51
  { name: 'collections', handler: -> { Capabilities::Collections.new.routes } },
52
52
  { name: 'native_query', handler: -> { Resources::NativeQuery.new.routes } },
53
- # Both must come before the routes matching on `:collection_name`: Rails matches in
53
+ # All three must come before the routes matching on `:collection_name`: Rails matches in
54
54
  # definition order, so `/_audit-trail/correlations` would otherwise be read as
55
- # `/:collection_name/:id` and 404 on a collection named `_audit-trail`. Correlation first, so
55
+ # `/:collection_name/:id`, and the timeline's `/_audit-trail` as `/:collection_name`, and both 404 on a
56
+ # collection named `_audit-trail`. Correlation first, so
56
57
  # `/_audit-trail/correlations` wins over the per-record `/_audit-trail/:collection_name/:id`.
57
58
  { name: 'audit_trail_correlation', handler: -> { Resources::AuditTrailCorrelation.new.routes } },
58
59
  { name: 'audit_trail', handler: -> { Resources::AuditTrail.new.routes } },
60
+ { name: 'audit_trail_timeline', handler: -> { Resources::AuditTrailTimeline.new.routes } },
59
61
  { name: 'count', handler: -> { Resources::Count.new.routes } },
60
62
  { name: 'delete', handler: -> { Resources::Delete.new.routes } },
61
63
  { name: 'csv', handler: -> { Resources::Csv.new.routes } },
@@ -69,6 +69,10 @@ module ForestAdminAgent
69
69
  canUseProjectionViaHeaderOnList: true,
70
70
  canUseMultipleFieldsProjectionOnRelation: true,
71
71
  canUseAuditTrail: audit_trail_enabled?,
72
+ # The store always lists the timeline, both ways, so the cross-collection route is mounted
73
+ # wherever the trail is.
74
+ canUseAuditTrailTimeline: audit_trail_enabled?,
75
+ canSortAuditTrailTimeline: audit_trail_enabled?,
72
76
  checksRelationReadPermissions: !skip_relation_read_permissions?
73
77
  }
74
78
  },
@@ -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
@@ -92,6 +92,20 @@ module ForestAdminAgent
92
92
  is_allowed
93
93
  end
94
94
 
95
+ # The collections the caller may perform +action+ on, without raising. A sweep over every collection
96
+ # meets a denial for most roles, so a denial is retried against fresh permissions only when
97
+ # {#refetch_denied_reads?} says staleness could explain it, and then once for all of them together.
98
+ def allowed_collections(action, collections)
99
+ return collections unless permission_system?
100
+
101
+ allowed = select_allowed(action, collections, reload: false)
102
+ return allowed if allowed.size == collections.size
103
+ return allowed unless refetch_denied_reads?(collections.map(&:name), get_collections_permissions_data)
104
+
105
+ @read_permissions_refetched = true
106
+ select_allowed(action, collections, reload: true)
107
+ end
108
+
95
109
  # +root_collection_name+ is pinned to readable and never looked up: +browse+ already gates a
96
110
  # listing, +read+ a get, and the signed hash a chart.
97
111
  #
@@ -456,6 +470,13 @@ module ForestAdminAgent
456
470
  'is not exposed by this agent.'
457
471
  end
458
472
 
473
+ def select_allowed(action, collections, reload:)
474
+ user_data = get_user_data(caller.id, reload: reload)
475
+ collections_data = get_collections_permissions_data(force_fetch: reload)
476
+
477
+ collections.select { |collection| permission_allowed?(collections_data, collection, action, user_data) }
478
+ end
479
+
459
480
  def permission_allowed?(collections_data, collection, action, user_data)
460
481
  return false unless user_data_valid?(user_data)
461
482
 
@@ -6,7 +6,7 @@ module ForestAdminAgent
6
6
  module Schema
7
7
  class SchemaEmitter
8
8
  LIANA_NAME = "agent-ruby"
9
- LIANA_VERSION = "1.45.2"
9
+ LIANA_VERSION = "1.46.1"
10
10
 
11
11
  def self.generate(datasource)
12
12
  datasource.collections
@@ -1,3 +1,3 @@
1
1
  module ForestAdminAgent
2
- VERSION = "1.45.2"
2
+ VERSION = "1.46.1"
3
3
  end
@@ -6,8 +6,8 @@ loader = Zeitwerk::Loader.for_gem
6
6
  loader.inflector.inflect('oauth2' => 'OAuth2')
7
7
  loader.inflector.inflect('sql' => 'Sql')
8
8
  loader.inflector.inflect('sse_cache_invalidation' => 'SSECacheInvalidation')
9
- # ActiveRecord is only needed by agents configuring an audit-trail database, and Rails eager loads
10
- # every gem loader (Zeitwerk::Loader.eager_load_all), so these files must stay strictly autoloaded.
9
+ # Rails eager loads every gem loader (Zeitwerk::Loader.eager_load_all): kept autoloaded so ActiveRecord is only
10
+ # required once an audit-trail database is configured.
11
11
  loader.do_not_eager_load("#{__dir__}/forest_admin_agent/audit_trail/sql")
12
12
  loader.setup
13
13
 
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.45.2
4
+ version: 1.46.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthieu
@@ -9,8 +9,22 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-10-14 00:00:00.000000000 Z
12
+ date: 2026-10-16 00:00:00.000000000 Z
13
13
  dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: activerecord
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '6.1'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '6.1'
14
28
  - !ruby/object:Gem::Dependency
15
29
  name: activesupport
16
30
  requirement: !ruby/object:Gem::Requirement
@@ -277,6 +291,20 @@ dependencies:
277
291
  - - "~>"
278
292
  - !ruby/object:Gem::Version
279
293
  version: '2.2'
294
+ - !ruby/object:Gem::Dependency
295
+ name: pg
296
+ requirement: !ruby/object:Gem::Requirement
297
+ requirements:
298
+ - - "~>"
299
+ - !ruby/object:Gem::Version
300
+ version: '1.1'
301
+ type: :runtime
302
+ prerelease: false
303
+ version_requirements: !ruby/object:Gem::Requirement
304
+ requirements:
305
+ - - "~>"
306
+ - !ruby/object:Gem::Version
307
+ version: '1.1'
280
308
  - !ruby/object:Gem::Dependency
281
309
  name: rake
282
310
  requirement: !ruby/object:Gem::Requirement
@@ -378,7 +406,9 @@ files:
378
406
  - lib/forest_admin_agent/routes/request_context.rb
379
407
  - lib/forest_admin_agent/routes/resources/audit_trail.rb
380
408
  - lib/forest_admin_agent/routes/resources/audit_trail_correlation.rb
409
+ - lib/forest_admin_agent/routes/resources/audit_trail_query_params.rb
381
410
  - lib/forest_admin_agent/routes/resources/audit_trail_route.rb
411
+ - lib/forest_admin_agent/routes/resources/audit_trail_timeline.rb
382
412
  - lib/forest_admin_agent/routes/resources/audit_trail_withholding.rb
383
413
  - lib/forest_admin_agent/routes/resources/count.rb
384
414
  - lib/forest_admin_agent/routes/resources/csv.rb