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.
- checksums.yaml +4 -4
- data/AUDIT_TRAIL.md +347 -0
- data/lib/forest_admin_agent/audit_trail/action_capture.rb +99 -0
- data/lib/forest_admin_agent/audit_trail/audit_record.rb +16 -0
- data/lib/forest_admin_agent/audit_trail/capture.rb +229 -0
- data/lib/forest_admin_agent/audit_trail/diff.rb +156 -0
- data/lib/forest_admin_agent/audit_trail/record_state.rb +43 -0
- data/lib/forest_admin_agent/audit_trail/recording.rb +57 -0
- data/lib/forest_admin_agent/audit_trail/snapshots.rb +85 -0
- data/lib/forest_admin_agent/audit_trail/sql/audit_connection_base.rb +53 -0
- data/lib/forest_admin_agent/audit_trail/sql/audit_log.rb +16 -0
- data/lib/forest_admin_agent/audit_trail/sql/field_filter.rb +58 -0
- data/lib/forest_admin_agent/audit_trail/sql/migrations.rb +53 -0
- data/lib/forest_admin_agent/audit_trail/sql/migrator.rb +117 -0
- data/lib/forest_admin_agent/audit_trail/sql/text_search.rb +78 -0
- data/lib/forest_admin_agent/audit_trail/store.rb +253 -0
- data/lib/forest_admin_agent/audit_trail.rb +68 -0
- data/lib/forest_admin_agent/builder/agent_factory.rb +20 -0
- data/lib/forest_admin_agent/http/correlation_id.rb +37 -0
- data/lib/forest_admin_agent/http/correlation_id_middleware.rb +29 -0
- data/lib/forest_admin_agent/http/router.rb +6 -0
- data/lib/forest_admin_agent/routes/action/actions.rb +76 -1
- data/lib/forest_admin_agent/routes/capabilities/collections.rb +11 -1
- data/lib/forest_admin_agent/routes/resources/audit_trail.rb +237 -0
- data/lib/forest_admin_agent/routes/resources/audit_trail_correlation.rb +101 -0
- data/lib/forest_admin_agent/routes/resources/audit_trail_route.rb +115 -0
- data/lib/forest_admin_agent/utils/caller_parser.rb +4 -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 +4 -0
- metadata +23 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d8e421dc1033f0430516e5bb4d8b6393d1d8a8b9c0d396db8ea8dfa942984c3
|
|
4
|
+
data.tar.gz: ec2f491f596a6eef622817e3cd687d83bd9f555dbefc7418121e5f41c64bb4df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|