forest_admin_agent 1.45.1 → 1.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AUDIT_TRAIL.md +55 -26
- data/forest_admin_agent.gemspec +2 -0
- data/lib/forest_admin_agent/audit_trail/audit_record.rb +1 -1
- data/lib/forest_admin_agent/audit_trail/capture.rb +1 -7
- data/lib/forest_admin_agent/audit_trail/sql/audit_connection_base.rb +1 -5
- data/lib/forest_admin_agent/audit_trail/sql/migrations.rb +23 -6
- data/lib/forest_admin_agent/audit_trail/store.rb +79 -65
- data/lib/forest_admin_agent/auth/oidc_client_manager.rb +1 -2
- data/lib/forest_admin_agent/http/router.rb +4 -2
- data/lib/forest_admin_agent/routes/action/actions.rb +13 -4
- data/lib/forest_admin_agent/routes/capabilities/collections.rb +4 -0
- data/lib/forest_admin_agent/routes/resources/audit_trail.rb +25 -118
- data/lib/forest_admin_agent/routes/resources/audit_trail_correlation.rb +1 -3
- data/lib/forest_admin_agent/routes/resources/audit_trail_query_params.rb +131 -0
- data/lib/forest_admin_agent/routes/resources/audit_trail_route.rb +16 -39
- data/lib/forest_admin_agent/routes/resources/audit_trail_timeline.rb +96 -0
- data/lib/forest_admin_agent/routes/resources/audit_trail_withholding.rb +23 -9
- data/lib/forest_admin_agent/routes/resources/list.rb +2 -1
- data/lib/forest_admin_agent/routes/security/scope_invalidation.rb +2 -1
- data/lib/forest_admin_agent/serializer/forest_serializer.rb +51 -57
- data/lib/forest_admin_agent/serializer/forest_serializer_override.rb +1 -4
- data/lib/forest_admin_agent/services/ip_whitelist.rb +6 -8
- data/lib/forest_admin_agent/services/permissions.rb +81 -41
- data/lib/forest_admin_agent/services/sse_cache_invalidation.rb +19 -1
- data/lib/forest_admin_agent/utils/safe_file_cache.rb +52 -0
- data/lib/forest_admin_agent/utils/schema/schema_emitter.rb +1 -1
- data/lib/forest_admin_agent/version.rb +1 -1
- data/lib/forest_admin_agent.rb +2 -2
- metadata +33 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1f60fdc1a8849851e0aeb16b6ac0f42eabe7a01ebc54b432ae75ef2ba3b9ba00
|
|
4
|
+
data.tar.gz: 68772b2ffdd8247f2de2308d231d4225b61e033deeb8508cfe686325d4e52f41
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6b17eb054da746a8c13de4b164c8f3128b4b89f1d52bab2d7a5ba848c904f441afc70412c3e01a74090e7783cb2c9cc8ffff11ce31eca67828c3b5517befbecc
|
|
7
|
+
data.tar.gz: bbbbc65fcb9f9d0404de90f78c6f216561775ad7e4b36b85669b191c3c506d471feef4510c708f88b2a7b77c9a1de8dbb1656a439da665c8afb38838486beab1
|
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
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
require 'filecache'
|
|
2
1
|
require 'openid_connect'
|
|
3
2
|
require_relative 'oauth2/oidc_config'
|
|
4
3
|
require_relative 'oauth2/forest_provider'
|
|
@@ -25,7 +24,7 @@ module ForestAdminAgent
|
|
|
25
24
|
private
|
|
26
25
|
|
|
27
26
|
def setup_cache(env_secret, config_agent)
|
|
28
|
-
cache =
|
|
27
|
+
cache = ForestAdminAgent::Utils::SafeFileCache.new('auth_issuer', (config_agent[:cache_dir]).to_s, TTL)
|
|
29
28
|
cache.get_or_set env_secret do
|
|
30
29
|
oidc_config = retrieve_config(config_agent[:forest_server_url])
|
|
31
30
|
credentials = register(
|
|
@@ -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 } },
|
|
@@ -48,11 +48,11 @@ module ForestAdminAgent
|
|
|
48
48
|
|
|
49
49
|
def handle_request(args = {})
|
|
50
50
|
context = build(args)
|
|
51
|
+
ensure_served_action(context)
|
|
51
52
|
args = middleware_custom_action_approval_request_data(args)
|
|
52
53
|
filter_for_caller = get_record_selection(args, context)
|
|
53
|
-
get_record_selection(args, context, include_user_scope: false)
|
|
54
54
|
|
|
55
|
-
context.permissions.can_smart_action?(args, context.collection, filter_for_caller)
|
|
55
|
+
context.permissions.can_smart_action?(args, context.collection, filter_for_caller, action_name: @action_name)
|
|
56
56
|
|
|
57
57
|
raw_data = args.dig(:params, :data, :attributes, :values)
|
|
58
58
|
|
|
@@ -82,6 +82,7 @@ module ForestAdminAgent
|
|
|
82
82
|
|
|
83
83
|
def handle_hook_request(args = {})
|
|
84
84
|
context = build(args)
|
|
85
|
+
ensure_served_action(context)
|
|
85
86
|
forest_fields = args.dig(:params, :data, :attributes, :fields)
|
|
86
87
|
data = (if forest_fields
|
|
87
88
|
Schema::ForestValueConverter.make_form_data_from_fields(context.datasource,
|
|
@@ -117,6 +118,14 @@ module ForestAdminAgent
|
|
|
117
118
|
|
|
118
119
|
private
|
|
119
120
|
|
|
121
|
+
def ensure_served_action(context)
|
|
122
|
+
served_index = @route_collection.schema[:actions].keys.index(@action_name)
|
|
123
|
+
return if context.collection.schema[:actions].keys.index(@action_name) == served_index
|
|
124
|
+
|
|
125
|
+
raise ForestAdminAgent::Http::Exceptions::BadRequestError,
|
|
126
|
+
"The collection #{context.collection.name} does not have this smart action"
|
|
127
|
+
end
|
|
128
|
+
|
|
120
129
|
# Recorded as pending before the action runs and confirmed after, so an action that takes the process
|
|
121
130
|
# down with it still leaves evidence that it started. A failed run is worth recording too — "who tried
|
|
122
131
|
# to run this" is usually the interesting part — and an action answering with an Error result failed
|
|
@@ -211,11 +220,11 @@ module ForestAdminAgent
|
|
|
211
220
|
)[0])
|
|
212
221
|
end
|
|
213
222
|
|
|
214
|
-
def get_record_selection(args, context
|
|
223
|
+
def get_record_selection(args, context)
|
|
215
224
|
attributes = args.dig(:params, :data, :attributes)
|
|
216
225
|
|
|
217
226
|
# Match user filter + search + scope? + segment
|
|
218
|
-
scope =
|
|
227
|
+
scope = context.permissions.get_scope(context.collection)
|
|
219
228
|
filter = Filter.new(
|
|
220
229
|
condition_tree: ConditionTreeFactory.intersect(
|
|
221
230
|
[
|
|
@@ -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
|
},
|