Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
45ff852
feat(audit_trail): add audit trail plugin gem
bexchauveto Jun 18, 2026
45e4f95
chore(audit_trail): exclude gemspec from Gemspec/RequireMFA rubocop cop
bexchauveto Jun 18, 2026
6c39f2b
feat(audit_trail): add correlation-scoped record-history routes
bexchauveto Jun 18, 2026
0b2d7c2
fix(audit_trail): bind each SqlStore to its own model class
bexchauveto Jun 19, 2026
e02b5b2
fix(audit_trail): make migration DDL idempotent for non-pg race
bexchauveto Jun 19, 2026
9b4f979
fix(audit_trail): treat a blank schema as no schema
bexchauveto Jun 19, 2026
9fcfeda
refactor(audit_trail): move the plugin into the agent, gated on the d…
bexchauveto Aug 10, 2026
b3b7de9
fix(audit_trail): scope history reads to the caller, keep gem loadabl…
bexchauveto Aug 10, 2026
507bd63
fix(audit_trail): keep deleted records readable, one payload shape, s…
bexchauveto Aug 10, 2026
a47c4e7
fix(audit_trail): add audit_trail option to RPC agent
bexchauveto Aug 11, 2026
0b0c668
fix(audit_trail): register the history routes before the collection r…
bexchauveto Aug 12, 2026
52aeff1
feat(audit_trail): record smart action runs in the audit table
bexchauveto Aug 12, 2026
e6e5a6b
refactor(audit_trail): drop the action name, the activity logs alread…
bexchauveto Aug 12, 2026
e4ae108
fix(audit_trail): never let auditing break the request, and cover the…
bexchauveto Aug 12, 2026
bff6ea4
docs(audit_trail): state that a concurrent overwrite can stale previo…
bexchauveto Aug 12, 2026
7c02bc3
fix(audit_trail): record the write even when another after hook raises
bexchauveto Aug 12, 2026
a00c7f5
feat(audit_trail): reconstruct a record's state, filter history by field
bexchauveto Aug 13, 2026
674b24c
test(audit_trail): pin the SQL of the adapters no local database exer…
bexchauveto Aug 13, 2026
c5e6929
fix(audit_trail): drop an appended array element on revert, honour th…
bexchauveto Aug 13, 2026
17ba534
fix(audit_trail): read the record for /state through the caller's scope
bexchauveto Aug 13, 2026
6acc598
refactor(audit_trail): return only data from /state, matching the Nod…
bexchauveto Aug 13, 2026
cdf6adf
fix(audit_trail): insert the correlation middleware into the applicat…
bexchauveto Aug 13, 2026
9ac9ffc
fix(audit_trail): count an Error result as a failed action run
bexchauveto Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ Naming/PredicatePrefix:

Metrics/ParameterLists:
Exclude:
- 'packages/forest_admin_agent/lib/forest_admin_agent/audit_trail/capture.rb'
- 'packages/forest_admin_agent/lib/forest_admin_agent/audit_trail/store.rb'
- 'packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb'
- 'packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/base_collection.rb'
- 'packages/forest_admin_datasource_snowflake/lib/forest_admin_datasource_snowflake/datasource.rb'
Expand Down Expand Up @@ -357,6 +359,7 @@ Metrics/BlockLength:

Metrics/ClassLength:
Exclude:
- 'packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/audit_trail.rb'
- 'packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb'
- 'packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb'
- 'packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb'
Expand Down Expand Up @@ -454,6 +457,12 @@ Layout/LineLength:

RSpec/VerifiedDoubles:
Exclude:
- 'packages/forest_admin_agent/spec/lib/forest_admin_agent/audit_trail/action_capture_spec.rb'
- 'packages/forest_admin_agent/spec/lib/forest_admin_agent/audit_trail/capture_spec.rb'
- 'packages/forest_admin_agent/spec/lib/forest_admin_agent/audit_trail/sql/migrator_spec.rb'
- 'packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/action/actions_spec.rb'
- 'packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/audit_trail_spec.rb'
- 'packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/audit_trail_correlation_spec.rb'
- 'packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/composite_datasource_spec.rb'

RSpec/VerifiedDoubleReference:
Expand Down
231 changes: 231 additions & 0 deletions packages/forest_admin_agent/AUDIT_TRAIL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
# Audit trail

Capture who changed what (before/after) for every change Forest performs through its data layer, and
persist it into a SQL database. Built into the agent: it turns on as soon as an audit-trail **database
is configured**, and stays completely off otherwise.

Two parts, both internal:

- **Capture** (`ForestAdminAgent::AuditTrail::Capture`) — datasource-agnostic. It instruments every
collection through the customizer hooks, so it behaves the same whether the audited datasource is
ActiveRecord, Mongoid, etc.
- **Storage** (`ForestAdminAgent::AuditTrail::Store`) — ActiveRecord-backed. It creates the `forest`
schema and creates/evolves the `audit_logs` table through versioned migrations, and reads the
per-record history back for the routes below.

Storage uses ActiveRecord: outside Rails, add `gem 'activerecord'` (and the adapter gem) to your
Gemfile. Nothing is loaded and no connection is opened until the feature is configured.

## Turn it on

### Rails (forest_admin_rails)

```ruby
# config/initializers/forest_admin_rails.rb
ForestAdminRails.configure do |config|
config.auth_secret = ENV['FOREST_AUTH_SECRET']
config.env_secret = ENV['FOREST_ENV_SECRET']

config.audit_trail = {
database: { # or an ActiveRecord URL: ENV['AUDIT_TRAIL_DATABASE_URL']
adapter: 'postgresql', host: ENV['AUDIT_DB_HOST'], port: ENV['AUDIT_DB_PORT'],
username: ENV['AUDIT_DB_USER'], password: ENV['AUDIT_DB_PASSWORD'], database: ENV['AUDIT_DB_NAME']
}
}
end
```

### Plain agent (no Rails)

```ruby
ForestAdminAgent::Builder::AgentFactory.instance.setup(
auth_secret: ENV['FOREST_AUTH_SECRET'],
env_secret: ENV['FOREST_ENV_SECRET'],
# ...usual options...
audit_trail: { database: ENV['AUDIT_TRAIL_DATABASE_URL'] }
)
```

| option | description |
| ------------ | ------------------------------------------------------------------------------------ |
| `database` | ActiveRecord URL or config hash. **Setting it activates the audit trail.** |
| `schema` | Postgres schema holding the table (default `forest`; ignored on other adapters) |
| `table_name` | default `audit_logs` |
| `redact` | `{ 'collection_name' => ['field', ...] }` — values masked while recording the change |

On the first write or read the store ensures the schema exists and runs any pending migrations; every
create / update / delete performed through Forest then writes one row per record, and the **Historic**
tab in the UI reads from the same table.

## Routes

All routes live under `/forest/_audit-trail`, are registered only when `audit_trail[:database]` is
set, and require read permission on the target collection (`can?(:read, collection)`).

### Record-history route

`GET /forest/_audit-trail/{collection}/{recordId}` returns the current page of history (newest first
by default) together with the filtered total:

```json
{ "data": [ /* current page rows */ ], "meta": { "count": 137 } }
```

`meta.count` is the number of rows matching the active filters (not the absolute total) and is
independent of the page. Optional filters (all combine with `AND`; omit them for the full history):

| query param | format | effect |
| ----------- | -------------------------------- | ----------------------------------------------- |
| `userIds` | comma-separated integers `12,45` | keep only entries whose `user_id` is in the list |
| `startDate` | `YYYY-MM-DD` or datetime (incl.) | keep entries from this lower bound onward |
| `endDate` | `YYYY-MM-DD` or datetime (incl.) | keep entries up to this upper bound |
| `fields` | comma-separated field names | keep only entries whose diff touched one of them |

`fields` matches whole keys, never paths, so a name holding a dot (`address.city`) is quoted before it
reaches SQL. Both sides of the diff are searched, since a field the change added exists in `newValues`
only and one it removed in `previousValues` only. The JSON test is per adapter (Postgres, SQLite, MySQL /
MariaDB); on any other adapter the filter raises rather than silently returning everything.

`startDate` / `endDate` are read as **local wall-clock time** in the request `timezone` query param
(e.g. `Europe/Paris`, default `UTC`) and converted to a UTC instant before querying, so filtering
happens in SQL. Two shapes are accepted:

- **Bare day** `YYYY-MM-DD` — `startDate` snaps to `00:00:00.000`, `endDate` to `23:59:59.999`.
- **Datetime** `YYYY-MM-DD[T| ]HH:mm[:ss]` — `T` or space separator, seconds optional; when seconds
are omitted `endDate` is completed to `:59.999` and `startDate` stays at `:00.000`.

Both bounds are **inclusive**. Defensive parsing: non-numeric `userIds` tokens are dropped
(`12,abc,45` → `12,45`), and a `startDate` / `endDate` matching no accepted format returns **HTTP
400** (`ValidationError`); an invalid `timezone` likewise returns **400**.

Pagination follows JSON:API: `page[number]` is 1-based (default `1`), `page[size]` defaults to `20`
and is capped at `100`; out-of-bound or non-numeric values fall back to the defaults rather than
erroring. Sorting follows JSON:API `sort` on `timestamp`: `sort=-timestamp` (or absent/unrecognized)
is newest first, `sort=timestamp` is oldest first. Ties on equal timestamps fall back to insertion
order (the auto-increment `id`), so paging is deterministic in either direction.

All three routes serialize audit records the same way: top-level keys are camelCased
(`recordId`, `userId`, `correlationKey`, `previousValues`, `new_values` → `newValues`), while the
`previousValues` / `newValues` hashes keep the audited record's own column names.

A record that no longer exists keeps its history: only a record that still exists *outside* the
caller's permission scope is refused (404). Inspecting what was deleted is much of the point of an
audit trail, and the delete event itself is the last thing recorded.

### State route

`GET /forest/_audit-trail/{collection}/{recordId}/state?timestamp=…` returns the record as it stood at
that instant, rebuilt by taking the record as it stands now and undoing every entry recorded **strictly
after** the timestamp — an entry stamped exactly at it counts as part of that state:

```json
{ "data": { "status": "paid", "address": { "city": "Paris" } } }
```

`timestamp` accepts an ISO-8601 instant, or the same wall-clock forms as the filters above read in the
request `timezone`; it is required (**400** otherwise). `data` is `null` when the record did not exist at
that instant — either created later, or deleted and never recreated.

Walking back stops being able to help where the trail stops: only audited (writable) columns are
reconstructed, and a `create` means the record did not exist before it, while a `delete` restores the whole
row it recorded and the walk carries on into any earlier life of the same id.

### Correlation route

`GET /forest/_audit-trail/correlation/{correlationKey}` returns `{ "data": [...] }` — the
operation(s) recorded under one `correlation_key` for a single record (usually one), oldest first, or
an empty array if none. Scoped through query params; same auth and gating as above.

| query param | required | effect |
| ------------ | -------- | ------------------------------------------------------------ |
| `collection` | yes | collection the record belongs to (also the permission scope) |
| `recordId` | yes | packed record id to scope the lookup |

A missing `collection` or `recordId` returns **HTTP 400** (`ValidationError`).

### Batch correlation route

`GET /forest/_audit-trail/correlations` returns `{ "data": [...] }` — a **flat** list of every record
whose `correlation_key` is in `correlationKeys`, scoped to one record (the client groups by
`correlation_key`). Same auth and gating; empty array when nothing matches.

| query param | required | effect |
| ----------------- | -------- | ------------------------------------------------------------ |
| `correlationKeys` | yes\* | comma-separated keys; blank tokens are dropped |
| `collection` | yes | collection the record belongs to (also the permission scope) |
| `recordId` | yes | packed record id to scope the lookup |

\* To dodge any URL length limit, the same path also accepts **`POST`** with a JSON body
`{ "correlationKeys": [...], "collection": "...", "recordId": "..." }` (the body array takes
precedence over the query param). An empty/absent key list returns `{ "data": [] }` without hitting
the store. A missing `collection` or `recordId` returns **HTTP 400** (`ValidationError`).

## Smart actions

Running a smart action writes one row per selected record, in the same table:

| column | value |
| ----------- | -------------------------------------------------------------------------- |
| `operation` | `action` when it went through, `action_failed` when it raised or answered with an `Error` result |
| `newValues` | the submitted form values (redacted with the same `redact` config) |
| `recordId` | each selected record — empty for a global action or a select-all selection |

**Which** action ran is not stored: the Forest activity logs already record it, and
`correlationKey` is the join between the two.

A **global** action targets no record, and a **select-all** selection only tells the agent which ids were
*excluded*, so naming the targets would mean querying the whole selection: those runs are recorded once,
attached to no record. Recording is best-effort — a failing audit database logs an error rather than
breaking the action.

> **What an action changes is only audited when it goes through Forest.** `context.collection.update(...)`
> passes through the same hooks as any other write, so it produces the usual field-level rows sharing the
> action's `correlationKey`. A direct ORM write (`Customer.find(id).update!(...)`) is invisible to the
> agent, so nothing is recorded for it beyond the invocation row above.

## What gets stored

`forest.audit_logs`, one row per audited change:

| column | description |
| ----------------- | ----------------------------------------------------------- |
| `id` | auto-increment primary key |
| `timestamp` | when the change happened |
| `operation` | `create` / `update` / `delete` |
| `collection` | audited collection name |
| `record_id` | packed record id (primary keys joined by `\|`) |
| `user_id` | the Forest user who made the change |
| `correlation_key` | per-request id; groups every change made within one request |
| `previous_values` | values before the change (JSON) |
| `new_values` | values after the change (JSON) |

`previous_values` / `new_values` store **only the parts that actually changed**: nested hashes and
arrays of hashes are diffed structurally, so a single sub-field change records just that leaf. Only
writable columns are audited — read-only, computed and DB-managed fields are never written by Forest.

The `correlation_key` is the agent's per-request id (`caller.request_id`), generated by the agent and
echoed back to the client in the `X-Forest-Correlation-Id` response header — so every change made in
one request shares a key, and the caller can tie it to its own activity log.

The capture layer registers its **after** hooks ahead of any other customization's (`prepend: true`,
since `execute_after` stops at the first exception, and by then the write has already happened) and its
**before** hooks after them, so the snapshot sees the filter and patch everyone else has had their say on.

## Concurrent writes to one record

The before/after values are captured around the write, not inside it: the customizer hooks bracket the
write as separate calls, and the data layer deliberately exposes no lock or transaction primitive since
it spans ActiveRecord, Mongoid, HTTP APIs and more.

So when two writes race on the same record, both snapshot the same state and the one that lands second
records a `previousValues` that had already been overwritten. `newValues` is always exact — it is the
patch that was written — and no row is ever lost; only the prior state of an overlapping write can be
stale. Exact before-images under concurrency need the database itself (triggers, or CDC), not an agent
hook.

## Schema migrations & concurrency

The table is created/evolved through an ordered, append-only migration list tracked in a dedicated
`forest.audit_migrations` table. On Postgres the migrations run inside a transaction-scoped advisory
lock so several agents booting at once apply them one after another; the schema is created (and
committed, idempotently) first since the lock can't cover a not-yet-existing schema.
2 changes: 2 additions & 0 deletions packages/forest_admin_agent/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ source "https://rubygems.org"
gemspec

group :development, :test do
gem 'activerecord', '>= 6.1'
gem 'forest_admin_datasource_customizer', path: '../forest_admin_datasource_customizer'
gem 'forest_admin_datasource_toolkit', path: '../forest_admin_datasource_toolkit'
gem 'forest_admin_test_toolkit', path: '../forest_admin_test_toolkit'
Expand All @@ -13,4 +14,5 @@ group :development, :test do
gem 'simplecov', '~> 0.22', require: false
gem 'simplecov-html', '~> 0.12.3'
gem 'simplecov_json_formatter', '~> 0.1.4'
gem 'sqlite3', '>= 2.1'
end
2 changes: 2 additions & 0 deletions packages/forest_admin_agent/Gemfile-test
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ source "https://rubygems.org"
gemspec

group :development, :test do
gem 'activerecord', '>= 6.1'
gem 'forest_admin_datasource_customizer', path: '../forest_admin_datasource_customizer'
gem 'forest_admin_datasource_toolkit', path: '../forest_admin_datasource_toolkit'
gem 'forest_admin_test_toolkit', path: '../forest_admin_test_toolkit'
Expand All @@ -13,4 +14,5 @@ group :development, :test do
gem 'simplecov', '~> 0.22', require: false
gem 'simplecov-html', '~> 0.12.3'
gem 'simplecov_json_formatter', '~> 0.1.4'
gem 'sqlite3', '>= 2.1'
end
4 changes: 4 additions & 0 deletions packages/forest_admin_agent/lib/forest_admin_agent.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@

loader = Zeitwerk::Loader.for_gem
loader.inflector.inflect('oauth2' => 'OAuth2')
loader.inflector.inflect('sql' => 'Sql')
loader.inflector.inflect('sse_cache_invalidation' => 'SSECacheInvalidation')
# ActiveRecord is only needed by agents configuring an audit-trail database, and Rails eager loads
# every gem loader (Zeitwerk::Loader.eager_load_all), so these files must stay strictly autoloaded.
loader.do_not_eager_load("#{__dir__}/forest_admin_agent/audit_trail/sql")
loader.setup

module ForestAdminAgent
Expand Down
16 changes: 16 additions & 0 deletions packages/forest_admin_agent/lib/forest_admin_agent/audit_trail.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module ForestAdminAgent
# The audit trail is inert unless `config.audit_trail[:database]` was set: the agent factory builds
# the store during setup and stores it in the config, and everything (capture layers and routes)
# resolves it from here.
module AuditTrail
def self.options
config = Facades::Container.config_from_cache

(config && config[:audit_trail]) || {}
end

def self.store
options[:store]
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
module ForestAdminAgent
module AuditTrail
# Records smart-action invocations into the same table as the field-level history.
#
# {Capture} cannot see them: the customizer has no `Execute` hook, and an action's writes are only
# audited when it goes through the Forest data layer — a direct ORM write (`Customer.find(id).update!`)
# is invisible to the agent and is therefore not recorded at all. What lands here is the invocation
# itself: on which records, with which form values, and whether it raised. Which action it was lives in
# the Forest activity logs — `correlation_key` is the join.
class ActionCapture
include Recording

EXECUTED = 'action'.freeze
FAILED = 'action_failed'.freeze
# Bulk runs over a select-all selection, and global actions, target no id we can name without
# querying the whole selection: they get one row attached to no record rather than none at all.
NO_RECORD = ''.freeze

def initialize(store, redact = {})
@store = store
@redact = redact || {}
end

# No-op unless an audit database was configured, and best-effort: the action has already run.
def record(caller:, collection:, form_values:, record_ids:, failed: false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 5): record [qlty:function-parameters]

return unless @store

audit_safely do
timestamp = now
correlation_key = correlation_key_for(caller)
values = redact(form_values || {}, @redact[collection] || [])
ids = record_ids.empty? ? [NO_RECORD] : record_ids

ids.each do |record_id|
@store.append(
AuditRecord.new(
timestamp: timestamp,
operation: failed ? FAILED : EXECUTED,
collection: collection,
record_id: record_id,
user_id: caller&.id,
correlation_key: correlation_key,
previous_values: {},
new_values: values
)
)
end
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 7): record [qlty:function-complexity]

end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module ForestAdminAgent
module AuditTrail
# One audited change. Mirrors the columns of `forest.audit_logs`; only the actor's `user_id` is
# stored, the rest of the actor identity is correlated elsewhere through `correlation_key`.
AuditRecord = Struct.new(
:timestamp, :operation, :collection, :record_id, :user_id, :correlation_key,
:previous_values, :new_values,
keyword_init: true
)
end
end
Loading
Loading