forest_admin_agent 1.38.3 → 1.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/AUDIT_TRAIL.md +347 -0
  3. data/lib/forest_admin_agent/audit_trail/action_capture.rb +99 -0
  4. data/lib/forest_admin_agent/audit_trail/audit_record.rb +16 -0
  5. data/lib/forest_admin_agent/audit_trail/capture.rb +229 -0
  6. data/lib/forest_admin_agent/audit_trail/diff.rb +156 -0
  7. data/lib/forest_admin_agent/audit_trail/record_state.rb +43 -0
  8. data/lib/forest_admin_agent/audit_trail/recording.rb +57 -0
  9. data/lib/forest_admin_agent/audit_trail/snapshots.rb +85 -0
  10. data/lib/forest_admin_agent/audit_trail/sql/audit_connection_base.rb +53 -0
  11. data/lib/forest_admin_agent/audit_trail/sql/audit_log.rb +16 -0
  12. data/lib/forest_admin_agent/audit_trail/sql/field_filter.rb +58 -0
  13. data/lib/forest_admin_agent/audit_trail/sql/migrations.rb +53 -0
  14. data/lib/forest_admin_agent/audit_trail/sql/migrator.rb +117 -0
  15. data/lib/forest_admin_agent/audit_trail/sql/text_search.rb +78 -0
  16. data/lib/forest_admin_agent/audit_trail/store.rb +253 -0
  17. data/lib/forest_admin_agent/audit_trail.rb +68 -0
  18. data/lib/forest_admin_agent/builder/agent_factory.rb +20 -0
  19. data/lib/forest_admin_agent/http/correlation_id.rb +37 -0
  20. data/lib/forest_admin_agent/http/correlation_id_middleware.rb +29 -0
  21. data/lib/forest_admin_agent/http/router.rb +6 -0
  22. data/lib/forest_admin_agent/routes/action/actions.rb +76 -1
  23. data/lib/forest_admin_agent/routes/capabilities/collections.rb +11 -1
  24. data/lib/forest_admin_agent/routes/resources/audit_trail.rb +237 -0
  25. data/lib/forest_admin_agent/routes/resources/audit_trail_correlation.rb +101 -0
  26. data/lib/forest_admin_agent/routes/resources/audit_trail_route.rb +115 -0
  27. data/lib/forest_admin_agent/utils/caller_parser.rb +4 -0
  28. data/lib/forest_admin_agent/utils/schema/schema_emitter.rb +1 -1
  29. data/lib/forest_admin_agent/version.rb +1 -1
  30. data/lib/forest_admin_agent.rb +4 -0
  31. metadata +23 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8240dc695f68ad1315a072be614a48151c0f52a0a35596500face2c12bdc3847
4
- data.tar.gz: 267ec0985751c7c86e5df4c426862063bf1b5e41e06ba24d7ee14bf1a80ecf69
3
+ metadata.gz: 6d8e421dc1033f0430516e5bb4d8b6393d1d8a8b9c0d396db8ea8dfa942984c3
4
+ data.tar.gz: ec2f491f596a6eef622817e3cd687d83bd9f555dbefc7418121e5f41c64bb4df
5
5
  SHA512:
6
- metadata.gz: 2966283a4f8b14022cdd74a54c6e366cdfae74314cd64d77fafbb8c7c258fa860dcacafc0e1dcd842d7d590ed63884a2550aa16066696d8313d0427fa552ad3b
7
- data.tar.gz: c943da4d2edf12f3e4f2b34d10ee70184befe11fe276cb246f785051cb7b7fcfb6e885459c9e5431e0351c6621db30ee6e2cab5ad93ee5c9ed3b8cf152ce63cc
6
+ metadata.gz: 5f822cf9a20bf034e51ea355cde7a894d904214e095dabec83952d95ae2856cc8a79de43713a48e6914d046c52a2bc271d91ffe762bfdb833fe2fe790584a08c
7
+ data.tar.gz: ff1bb212d93c979e0ab69a802f8da3e839cc350b6e29cbfb08706e65d01882048266f44eb8d3c235db3e1af6f1df2efd0e4bde42509af6f3618a76a7a4a8ea58
data/AUDIT_TRAIL.md ADDED
@@ -0,0 +1,347 @@
1
+ # Audit trail
2
+
3
+ Capture who changed what (before/after) for every change Forest performs through its data layer, and
4
+ persist it into a SQL database. Built into the agent: it turns on as soon as an audit-trail **database
5
+ is configured**, and stays completely off otherwise.
6
+
7
+ Two parts, both internal:
8
+
9
+ - **Capture** (`ForestAdminAgent::AuditTrail::Capture`) — datasource-agnostic. It instruments every
10
+ collection through the customizer hooks, so it behaves the same whether the audited datasource is
11
+ ActiveRecord, Mongoid, etc.
12
+ - **Storage** (`ForestAdminAgent::AuditTrail::Store`) — ActiveRecord-backed. It creates the `forest`
13
+ schema and creates/evolves the `audit_logs` table through versioned migrations, and reads the
14
+ per-record history back for the routes below.
15
+
16
+ Storage uses ActiveRecord: outside Rails, add `gem 'activerecord'` (and the adapter gem) to your
17
+ Gemfile. Nothing is loaded and no connection is opened until the feature is configured.
18
+
19
+ ## Turn it on
20
+
21
+ ### Rails (forest_admin_rails)
22
+
23
+ ```ruby
24
+ # config/initializers/forest_admin_rails.rb
25
+ ForestAdminRails.configure do |config|
26
+ config.auth_secret = ENV['FOREST_AUTH_SECRET']
27
+ config.env_secret = ENV['FOREST_ENV_SECRET']
28
+
29
+ config.audit_trail = {
30
+ database: { # or an ActiveRecord URL: ENV['AUDIT_TRAIL_DATABASE_URL']
31
+ adapter: 'postgresql', host: ENV['AUDIT_DB_HOST'], port: ENV['AUDIT_DB_PORT'],
32
+ username: ENV['AUDIT_DB_USER'], password: ENV['AUDIT_DB_PASSWORD'], database: ENV['AUDIT_DB_NAME']
33
+ }
34
+ }
35
+ end
36
+ ```
37
+
38
+ ### Plain agent (no Rails)
39
+
40
+ ```ruby
41
+ ForestAdminAgent::Builder::AgentFactory.instance.setup(
42
+ auth_secret: ENV['FOREST_AUTH_SECRET'],
43
+ env_secret: ENV['FOREST_ENV_SECRET'],
44
+ # ...usual options...
45
+ audit_trail: { database: ENV['AUDIT_TRAIL_DATABASE_URL'] }
46
+ )
47
+ ```
48
+
49
+ | option | description |
50
+ | ------------ | ------------------------------------------------------------------------------------ |
51
+ | `database` | ActiveRecord URL or config hash. **Setting it activates the audit trail.** |
52
+ | `schema` | Postgres schema holding the table (default `forest`; ignored on other adapters) |
53
+ | `table_name` | default `audit_logs` |
54
+ | `redact` | `{ 'collection_name' => ['field', ...] }` — values masked while recording the change |
55
+ | `critical` | default `false`. `true` refuses an operation the audit trail cannot record — see below |
56
+
57
+ The store connects and migrates **at boot**, not on the first write: an audit database the agent cannot
58
+ reach stops it starting, rather than leaving it looking healthy while recording nothing. Every create /
59
+ update / delete performed through Forest then writes one row per record, and the **Historic** tab in the UI
60
+ reads from the same table.
61
+
62
+ ## The write protocol, and `critical`
63
+
64
+ Every operation is recorded twice: a `pending` row **before** the write, confirmed `done` **after** it. One
65
+ code path either way, so `status` always means the same thing.
66
+
67
+ | `critical` | a pending row that cannot be written |
68
+ | ---------- | ------------------------------------------------------------------------------------------ |
69
+ | `false` | is logged and dropped; the operation goes ahead unaudited (the default, today's behaviour) |
70
+ | `true` | **refuses the operation**. Nothing was written, so there is nothing to repair and no compensating write ever happens |
71
+
72
+ What this buys is **no unaudited write** — not that every row holds exact after-values. A row left `pending`
73
+ means the write may or may not have landed: that residue is evidence, and it is the point. Everything after
74
+ the pending insert stays best-effort in both modes, because by then the write has happened and raising would
75
+ report a failure for an operation that succeeded.
76
+
77
+ > **The guarantee is opt-in.** `critical` defaults to `false`, so on a default configuration a write can
78
+ > succeed with no audit row at all — an unreachable audit database costs rows, not writes. Configuring the
79
+ > database gets you a best-effort trail; `critical: true` is what makes "no unaudited write" true. The Node
80
+ > agent defaults the same way, so the two agree.
81
+
82
+ Consequences worth knowing:
83
+
84
+ - A write that turns out to change nothing has its pending row **discarded** rather than confirmed, so no-op
85
+ updates leave no trace.
86
+ - A record the agent cannot read back after the write keeps its row **pending**. Confirming from the patch
87
+ would claim values that may never have been written.
88
+ - A write nested inside another that fails and is rescued keeps its row **pending** too. Each snapshot is
89
+ matched to its own operation by the object the hook decorator hands to both of its hooks, so the outer write
90
+ settles its own rows rather than the failed inner one's. Where a customization replaced that object there is
91
+ nothing to match on: with one operation in flight it still pairs, with several the rows stay pending rather
92
+ than the wrong ones being marked done.
93
+ - One operation audits at most **1000** records — the same number as the Node agent's `MAX_SNAPSHOT_RECORDS`,
94
+ so a bulk operation is not audited on one agent and truncated on the other. Under `critical: false` a wider
95
+ selection is truncated, with `N records audited, M skipped` logged at `Warn`; a smart action over the cap is
96
+ recorded as one row attached to no record. Under `critical: true` both are **refused**: auditing a subset
97
+ while the operation touches every match is precisely the invariant that mode exists for.
98
+ - Pending rows stay visible in the history — they are evidence of an attempt, and `status` says so — but the
99
+ state reconstruction ignores them, since undoing a change that may never have happened would invent a state
100
+ the record was never in.
101
+
102
+ ## Routes
103
+
104
+ All routes live under `/forest/_audit-trail`, are registered only when `audit_trail[:database]` is
105
+ set, and require read permission on the target collection (`can?(:read, collection)`).
106
+
107
+ ### Record-history route
108
+
109
+ `GET /forest/_audit-trail/{collection}/{recordId}` returns the current page of history (newest first
110
+ by default) together with the filtered total:
111
+
112
+ ```json
113
+ { "data": [ /* current page rows */ ], "meta": { "count": 137 } }
114
+ ```
115
+
116
+ `meta.count` is the number of rows matching the active filters (not the absolute total) and is independent of
117
+ the page. `meta.availableUsers` rides along **on the first fetch only** — the front keeps the list it saw —
118
+ and holds the distinct authors of the entries the current filters match, as `{ id, firstName, lastName,
119
+ email }`, whatever page was asked for. The identity comes from the rows themselves, so someone since renamed
120
+ or removed still reads as they were when they acted. Optional filters (all combine with `AND`; omit them for the full history):
121
+
122
+ | query param | format | effect |
123
+ | ----------- | -------------------------------- | ----------------------------------------------- |
124
+ | `userIds` | comma-separated integers `12,45` | keep only entries whose `user_id` is in the list |
125
+ | `startDate` | `YYYY-MM-DD` or datetime (incl.) | keep entries from this lower bound onward |
126
+ | `endDate` | `YYYY-MM-DD` or datetime (incl.) | keep entries up to this upper bound |
127
+ | `fields` | comma-separated field names | keep only entries whose diff touched one of them |
128
+ | `search` | free text, trimmed | keep only entries the term matches |
129
+
130
+ `search` is matched case-insensitively, as a substring, against the action's name, the actor's first name,
131
+ last name and email, and the **keys and values of both value objects at any depth** — searching `Lyon` finds
132
+ `{"address": {"city": "Lyon"}}`, which is what only the agent can answer, since it is the only side holding
133
+ the recorded values. It is matched in SQL, not in memory, so it composes with pagination and `meta.count`
134
+ like every other filter.
135
+
136
+ It deliberately does **not** match `operation`, `correlationKey`, `recordId`, `collection`, `status` or
137
+ `timestamp`: machine identifiers nobody searches for, whose matches read as noise.
138
+
139
+ A field masked by `redact` never matches — neither by its `[redacted]` mask nor by the value it hid, which
140
+ was never recorded. A search must not confirm a value the trail refused to keep.
141
+
142
+ `fields` matches whole keys, never paths, so a name holding a dot (`address.city`) is quoted before it
143
+ reaches SQL. Both sides of the diff are searched, since a field the change added exists in `newValues`
144
+ only and one it removed in `previousValues` only. The JSON test is per adapter (Postgres, SQLite, MySQL /
145
+ MariaDB); on any other adapter the filter raises rather than silently returning everything.
146
+
147
+ `startDate` / `endDate` are read as **local wall-clock time** in the request `timezone` query param
148
+ (e.g. `Europe/Paris`, default `UTC`) and converted to a UTC instant before querying, so filtering
149
+ happens in SQL. Two shapes are accepted:
150
+
151
+ - **Bare day** `YYYY-MM-DD` — `startDate` snaps to `00:00:00.000`, `endDate` to `23:59:59.999`.
152
+ - **Datetime** `YYYY-MM-DD[T| ]HH:mm[:ss]` — `T` or space separator, seconds optional; when seconds
153
+ are omitted `endDate` is completed to `:59.999` and `startDate` stays at `:00.000`.
154
+
155
+ Both bounds are **inclusive**. Defensive parsing: non-numeric `userIds` tokens are dropped
156
+ (`12,abc,45` → `12,45`), and a `startDate` / `endDate` matching no accepted format returns **HTTP
157
+ 400** (`ValidationError`); an invalid `timezone` likewise returns **400**.
158
+
159
+ Pagination follows JSON:API: `page[number]` is 1-based (default `1`), `page[size]` defaults to `20`
160
+ and is capped at `100`; out-of-bound or non-numeric values fall back to the defaults rather than
161
+ erroring. Sorting follows JSON:API `sort` on `timestamp`: `sort=-timestamp` (or absent/unrecognized)
162
+ is newest first, `sort=timestamp` is oldest first. Ties on equal timestamps fall back to insertion
163
+ order (the auto-increment `id`), so paging is deterministic in either direction.
164
+
165
+ All routes serialize audit records the same way: top-level keys are camelCased — `id`, `recordId`, `userId`,
166
+ `userFirstName`, `userLastName`, `userEmail`, `actionName`, `status`, `correlationKey`, `previousValues`,
167
+ `newValues`. The row `id` is exposed because both agents order by `(timestamp, id)` and the front uses it as
168
+ the merge tiebreaker.
169
+
170
+ Inside the value objects: a record's column names pass through untouched, while an action answer's keys are
171
+ Forest's own and so are camelCase (`mimeType`, not `mime_type`) — the agent transforms them on write.
172
+
173
+ **A record that was renamed keeps one timeline** — on this agent. The Node agent files an update under the
174
+ record's new id too, but has no `previous_record_id` and so cannot walk back past the rename: the same record
175
+ shows a complete chain here and a history beginning at the rename there, until that column is ported.
176
+ An update that moves a writable primary key files its row
177
+ under the record's new id — the id later lookups use — and remembers the one it left. Both the history and the
178
+ state routes walk that back, so asking for the current id returns everything the record has ever been filed
179
+ under, rather than starting the story at the rename.
180
+
181
+ Each earlier id counts only **up to the moment it was left**, because a primary key a record abandons can be
182
+ taken by another record afterwards, and those rows are none of this record's business. What the trail cannot
183
+ separate is the opposite case: rows written under an id *before* the record that holds it now arrived — a
184
+ reused key, or a delete followed by a recreate — since the packed id is the only identity the trail has. That
185
+ is deliberate for delete/recreate (the state reconstruction walks into an earlier life on purpose) and the
186
+ same limitation for a reused key. Telling those apart needs a lineage of its own on every row, which is a
187
+ bigger change than this one.
188
+
189
+ A record that no longer exists keeps its history: only a record that still exists *outside* the
190
+ caller's permission scope is refused (404). Inspecting what was deleted is much of the point of an
191
+ audit trail, and the delete event itself is the last thing recorded.
192
+
193
+ ### State route
194
+
195
+ `GET /forest/_audit-trail/{collection}/{recordId}/state?timestamp=…` returns the record as it stood at
196
+ that instant, rebuilt by taking the record as it stands now and undoing every entry recorded **strictly
197
+ after** the timestamp — an entry stamped exactly at it counts as part of that state:
198
+
199
+ ```json
200
+ { "data": { "status": "paid", "address": { "city": "Paris" } } }
201
+ ```
202
+
203
+ `timestamp` accepts an ISO-8601 instant, or the same wall-clock forms as the filters above read in the
204
+ request `timezone`; it is required (**400** otherwise). `data` is `null` when the record did not exist at
205
+ that instant — either created later, or deleted and never recreated.
206
+
207
+ Walking back stops being able to help where the trail stops: only audited (writable) columns are
208
+ reconstructed, and a `create` means the record did not exist before it, while a `delete` restores the whole
209
+ row it recorded and the walk carries on into any earlier life of the same id.
210
+
211
+ ### Correlation route
212
+
213
+ `GET /forest/_audit-trail/correlation/{correlationKey}` returns `{ "data": [...] }` — the
214
+ operation(s) recorded under one `correlation_key` for a single record (usually one), oldest first, or
215
+ an empty array if none. Scoped through query params; same auth and gating as above.
216
+
217
+ | query param | required | effect |
218
+ | ------------ | -------- | ------------------------------------------------------------ |
219
+ | `collection` | yes | collection the record belongs to (also the permission scope) |
220
+ | `recordId` | yes | packed record id to scope the lookup |
221
+
222
+ A missing `collection` or `recordId` returns **HTTP 400** (`ValidationError`).
223
+
224
+ ### Batch correlation route
225
+
226
+ `GET /forest/_audit-trail/correlations` returns `{ "data": [...] }` — a **flat** list of every record
227
+ whose `correlation_key` is in `correlationKeys`, scoped to one record (the client groups by
228
+ `correlation_key`). Same auth and gating; empty array when nothing matches.
229
+
230
+ | query param | required | effect |
231
+ | ----------------- | -------- | ------------------------------------------------------------ |
232
+ | `correlationKeys` | yes\* | comma-separated keys; blank tokens are dropped |
233
+ | `collection` | yes | collection the record belongs to (also the permission scope) |
234
+ | `recordId` | yes | packed record id to scope the lookup |
235
+
236
+ \* To dodge any URL length limit, the same path also accepts **`POST`** with a JSON body
237
+ `{ "correlationKeys": [...], "collection": "...", "recordId": "..." }` (the body array takes
238
+ precedence over the query param). An empty/absent key list returns `{ "data": [] }` without hitting
239
+ the store. A missing `collection` or `recordId` returns **HTTP 400** (`ValidationError`).
240
+
241
+ ## Smart actions
242
+
243
+ Running a smart action writes one row per selected record, in the same table:
244
+
245
+ | column | value |
246
+ | ---------------- | -------------------------------------------------------------------------- |
247
+ | `operation` | `action` when it went through, `action_failed` when it raised or answered with an `Error` result |
248
+ | `previousValues` | the submitted form values (redacted with the same `redact` config) |
249
+ | `newValues` | what the action answered — an allowlist of the result: `type`, `message`, `name`, `mimeType`, `method`, `url`, `path`. Empty when it raised |
250
+ | `recordId` | each selected record — empty for a global action or a select-all selection |
251
+
252
+ The two value columns carry what went in and what came back, rather than a record's before and after. A
253
+ result also holds the file's contents, a webhook's body and headers, and arbitrary response headers: file
254
+ bytes have no business in an audit table and the other two routinely hold credentials, so the stored answer
255
+ is an allowlist — a field added to a result later is not recorded until someone decides it should be. `html`
256
+ is left out too, being operator-facing markup the message already summarises.
257
+
258
+ Because these are the same columns the field filter searches, filtering by a field named like a result key
259
+ (`message`, `type`) also matches action rows.
260
+
261
+ **Which** action ran is not stored: the Forest activity logs already record it, and
262
+ `correlationKey` is the join between the two.
263
+
264
+ A **global** action targets no record, and a **select-all** selection only tells the agent which ids were
265
+ *excluded*, so naming the targets would mean querying the whole selection: those runs are recorded once,
266
+ attached to no record.
267
+
268
+ Recording follows the same policy as a write: the row goes in before the action runs, so under
269
+ `critical: false` a failing audit database logs an error and the action goes ahead, while under
270
+ `critical: true` it refuses the run — nothing has happened yet, so there is nothing to repair. Everything
271
+ after that point, the answer included, is best-effort either way.
272
+
273
+ **A row attached to no record is not readable through any route today.** Every history route is scoped to a
274
+ record id, and the correlation routes reject an empty one, so global and over-cap action runs are recorded but
275
+ cannot be fetched. They are evidence in the table rather than something the UI can show — reaching them needs
276
+ a collection-level endpoint that does not exist yet.
277
+
278
+ The targeted records are read back through the caller's own filter rather than taken from the request: the ids
279
+ a client sends are a claim, and in a compliance record asserting that an operator acted on a record their
280
+ scope excludes is worse than a missing row.
281
+
282
+ `url` and `path` are sanitised before storage — credentials in the userinfo and anything in a query string or
283
+ fragment come off, since either can carry a signed one-time token that would otherwise sit permanently in the
284
+ one table nobody deletes from.
285
+
286
+ > **What an action changes is only audited when it goes through Forest.** `context.collection.update(...)`
287
+ > passes through the same hooks as any other write, so it produces the usual field-level rows sharing the
288
+ > action's `correlationKey`. A direct ORM write (`Customer.find(id).update!(...)`) is invisible to the
289
+ > agent, so nothing is recorded for it beyond the invocation row above.
290
+
291
+ ## What gets stored
292
+
293
+ `forest.audit_logs`, one row per audited change:
294
+
295
+ | column | description |
296
+ | ----------------- | ----------------------------------------------------------- |
297
+ | `id` | auto-increment primary key, exposed in the payload |
298
+ | `status` | `pending` before the write, `done` once confirmed |
299
+ | `timestamp` | when the change happened |
300
+ | `operation` | `create` / `update` / `delete` |
301
+ | `collection` | audited collection name |
302
+ | `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 |
303
+ | `previous_record_id` | set only on an update that moved a writable primary key: the id the row was filed under before |
304
+ | `user_id` | the Forest user who made the change |
305
+ | `user_first_name`, `user_last_name`, `user_email` | denormalised from the caller at write time: who acted then, not whoever holds that id today |
306
+ | `action_name` | smart-action rows only |
307
+ | `correlation_key` | per-request id; groups every change made within one request. Empty for a write outside any request — inventing one would make the row look like a single-row request of its own |
308
+ | `previous_values` | values before the change (JSON) |
309
+ | `new_values` | values after the change (JSON) |
310
+
311
+ `previous_values` / `new_values` store **only the parts that actually changed**: nested hashes and
312
+ arrays of hashes are diffed structurally, so a single sub-field change records just that leaf. Only
313
+ writable columns are audited — read-only, computed and DB-managed fields are never written by Forest.
314
+
315
+ The `correlation_key` is the agent's per-request id (`caller.request_id`), generated by the agent and echoed
316
+ back to the client in the `X-Forest-Correlation-Id` response header — so every change made in one request
317
+ shares a key, and the caller can tie it to its own activity log.
318
+
319
+ Outside Rails, mount `ForestAdminAgent::Http::CorrelationIdMiddleware` yourself: it resets the id at the start
320
+ of each request, and without it a pooled thread would hand its previous request's key to the next one.
321
+
322
+ The capture layer registers its **after** hooks ahead of any other customization's (`prepend: true`,
323
+ since `execute_after` stops at the first exception, and by then the write has already happened) and its
324
+ **before** hooks after them, so the snapshot sees the filter and patch everyone else has had their say on.
325
+
326
+ ## Concurrent writes to one record
327
+
328
+ The before/after values are captured around the write, not inside it: the customizer hooks bracket the
329
+ write as separate calls, and the data layer deliberately exposes no lock or transaction primitive since
330
+ it spans ActiveRecord, Mongoid, HTTP APIs and more.
331
+
332
+ So when two writes race on the same record, both snapshot the same state and the one that lands second
333
+ records a `previousValues` that had already been overwritten. `newValues` is always exact — it is the
334
+ patch that was written — and no row is ever lost; only the prior state of an overlapping write can be
335
+ stale. Exact before-images under concurrency need the database itself (triggers, or CDC), not an agent
336
+ hook.
337
+
338
+ ## Schema migrations & concurrency
339
+
340
+ The table is created and evolved through an ordered, append-only migration list, tracked in a companion table
341
+ named after the audited one — `forest.audit_logs_migration` beside `forest.audit_logs`. One tracker per audited
342
+ table, so two stores configured with different `table_name`s each keep their own schema history rather than
343
+ reading the other's as done.
344
+
345
+ On Postgres the migrations run inside a transaction-scoped advisory lock, so several agents booting at once
346
+ apply them one after another; the schema is created (and committed, idempotently) first, since the lock cannot
347
+ cover a schema that does not exist yet.
@@ -0,0 +1,99 @@
1
+ require 'uri'
2
+
3
+ module ForestAdminAgent
4
+ module AuditTrail
5
+ # Records smart-action runs into the same table as the field-level history: the submitted form on the
6
+ # `previous_values` side, what the action answered on the `new_values` side.
7
+ #
8
+ # {Capture} cannot see them — the customizer has no `Execute` hook — and an action's writes are only
9
+ # audited when they go through the Forest data layer, so a direct ORM write stays invisible. What lands
10
+ # here is the run itself: who ran which action, on which records, with which form, and how it ended.
11
+ #
12
+ # Same protocol as a write: {#pending} before the action, {#confirm} after. The route owns the gate, so a
13
+ # pending insert that fails refuses the run under `critical: true`.
14
+ class ActionCapture
15
+ include Recording
16
+
17
+ EXECUTED = 'action'.freeze
18
+ FAILED = 'action_failed'.freeze
19
+ # A global action, and a bulk run over a selection wider than the cap, name no single target: they get
20
+ # one row attached to no record rather than none at all.
21
+ NO_RECORD = ''.freeze
22
+ # What of the action's answer is worth keeping — an allowlist, not a denylist: a result also carries the
23
+ # file's contents, a webhook's body and headers and arbitrary response headers. File bytes have no
24
+ # business in an audit table and the other two routinely hold credentials, and an allowlist means a field
25
+ # added to a result later is not stored until someone decides it should be. `html` is left out too:
26
+ # operator-facing markup, sometimes large, and the message already says what happened.
27
+ RESULT_FIELDS = %i[type message name mime_type method url path].freeze
28
+ # Either can carry userinfo credentials or a signed one-time token, which would then sit permanently in
29
+ # the one table nobody deletes from.
30
+ URL_FIELDS = %i[url path].freeze
31
+
32
+ def initialize(store, redact = {})
33
+ @store = store
34
+ @redact = redact || {}
35
+ end
36
+
37
+ # One row per targeted record, provisionally an `action` — {#confirm} settles which it really was. Returns
38
+ # the row ids to confirm.
39
+ def pending(caller:, collection:, action_name:, form_values:, record_ids:)
40
+ return [] unless @store
41
+
42
+ timestamp = now
43
+ correlation_key = correlation_key_for(caller)
44
+ identity = identity_of(caller)
45
+ submitted = redact(form_values || {}, @redact[collection] || [])
46
+ ids = record_ids.empty? ? [NO_RECORD] : record_ids
47
+
48
+ @store.append_all(
49
+ ids.map do |record_id|
50
+ AuditRecord.new(
51
+ timestamp: timestamp, operation: EXECUTED, collection: collection, record_id: record_id,
52
+ status: PENDING, action_name: action_name, correlation_key: correlation_key,
53
+ previous_values: submitted, new_values: {}, **identity
54
+ )
55
+ end
56
+ )
57
+ end
58
+
59
+ # Best-effort: the action has already run, so a failure here loses the answer, never the run.
60
+ def confirm(ids, result: nil, failed: false)
61
+ return if ids.nil? || ids.empty?
62
+
63
+ audit_safely do
64
+ answer = summarize(result)
65
+
66
+ ids.each { |id| @store.confirm(id, operation: failed ? FAILED : EXECUTED, new_values: answer) }
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ # Keys of an action's answer are Forest's own, so they are camelCase on the wire — unlike a record's
73
+ # column names, which pass through untouched.
74
+ def summarize(result)
75
+ return {} unless result.is_a?(Hash)
76
+
77
+ result.slice(*RESULT_FIELDS).compact.to_h do |field, value|
78
+ [field.to_s.camelize(:lower), URL_FIELDS.include?(field) ? sanitize_url(value) : value]
79
+ end
80
+ end
81
+
82
+ # `userinfo = nil` is a no-op on URI, so the credentials come off textually; the parser then takes care
83
+ # of the query and fragment.
84
+ def sanitize_url(value)
85
+ # Both `https://user:pass@host` and the scheme-relative `//user:pass@host`, which parses fine and
86
+ # would otherwise keep its credentials.
87
+ bare = value.to_s.sub(%r{\A([a-z][a-z0-9+.-]*:)?//[^/@]*@}i, '\1//')
88
+ uri = URI.parse(bare)
89
+ uri.query = nil
90
+ uri.fragment = nil
91
+
92
+ uri.to_s
93
+ rescue StandardError
94
+ # Not something the parser accepts: keep the shape, drop everything that can carry a secret.
95
+ bare.to_s.split(/[?#]/).first.to_s
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,16 @@
1
+ module ForestAdminAgent
2
+ module AuditTrail
3
+ # One audited change. Mirrors the columns of `forest.audit_logs`.
4
+ #
5
+ # The actor's name and email are denormalised from the caller at write time: the row says who acted then,
6
+ # not whoever holds that user id today. `action_name` is set on smart-action rows only, and `status`
7
+ # follows the write protocol — inserted as {Recording::PENDING} before the write and confirmed
8
+ # {Recording::DONE} after, so a row left pending means the write may or may not have landed.
9
+ AuditRecord = Struct.new(
10
+ :id, :timestamp, :operation, :collection, :record_id, :previous_record_id, :status,
11
+ :user_id, :user_first_name, :user_last_name, :user_email, :action_name,
12
+ :correlation_key, :previous_values, :new_values,
13
+ keyword_init: true
14
+ )
15
+ end
16
+ end