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