Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
c04359a
feat(audit_trail): add audit trail plugin gem
bexchauveto Jun 18, 2026
05e4415
chore(audit_trail): exclude gemspec from Gemspec/RequireMFA rubocop cop
bexchauveto Jun 18, 2026
e6429a0
feat(audit_trail): add correlation-scoped record-history routes
bexchauveto Jun 18, 2026
28adb4e
fix(audit_trail): bind each SqlStore to its own model class
bexchauveto Jun 19, 2026
d4574fb
fix(audit_trail): make migration DDL idempotent for non-pg race
bexchauveto Jun 19, 2026
552b635
fix(audit_trail): treat a blank schema as no schema
bexchauveto Jun 19, 2026
4869ca2
refactor(audit_trail): move the plugin into the agent, gated on the d…
bexchauveto Aug 10, 2026
cbf1865
fix(audit_trail): scope history reads to the caller, keep gem loadabl…
bexchauveto Aug 10, 2026
12cd870
fix(audit_trail): keep deleted records readable, one payload shape, s…
bexchauveto Aug 10, 2026
444b7f0
fix(audit_trail): add audit_trail option to RPC agent
bexchauveto Aug 11, 2026
f138a1d
fix(audit_trail): register the history routes before the collection r…
bexchauveto Aug 12, 2026
545051f
feat(audit_trail): record smart action runs in the audit table
bexchauveto Aug 12, 2026
bff005b
refactor(audit_trail): drop the action name, the activity logs alread…
bexchauveto Aug 12, 2026
31ad68a
fix(audit_trail): never let auditing break the request, and cover the…
bexchauveto Aug 12, 2026
d994897
docs(audit_trail): state that a concurrent overwrite can stale previo…
bexchauveto Aug 12, 2026
7858c43
fix(audit_trail): record the write even when another after hook raises
bexchauveto Aug 12, 2026
cee3d21
feat(audit_trail): reconstruct a record's state, filter history by field
bexchauveto Aug 13, 2026
de72e58
test(audit_trail): pin the SQL of the adapters no local database exer…
bexchauveto Aug 13, 2026
c535e15
fix(audit_trail): drop an appended array element on revert, honour th…
bexchauveto Aug 13, 2026
3a5d3cb
fix(audit_trail): read the record for /state through the caller's scope
bexchauveto Aug 13, 2026
d044bfc
refactor(audit_trail): return only data from /state, matching the Nod…
bexchauveto Aug 13, 2026
1a2a56e
fix(audit_trail): insert the correlation middleware into the applicat…
bexchauveto Aug 13, 2026
52a5c92
fix(audit_trail): count an Error result as a failed action run
bexchauveto Aug 13, 2026
110d2a2
feat(audit_trail): record what an action answered, next to what was s…
bexchauveto Aug 14, 2026
d95e805
feat(audit_trail): pending/confirm protocol, denormalised identity, r…
bexchauveto Aug 17, 2026
4ab1ea7
refactor(audit_trail): one migration, tracked beside the table it builds
bexchauveto Aug 17, 2026
66bea98
fix(audit_trail): keep the critical invariant, and stop inventing wha…
bexchauveto Aug 18, 2026
f97981f
test(capabilities): cover canUseAuditTrail, and read it from one place
bexchauveto Aug 18, 2026
4c5cef4
feat(audit_trail): search the history by free text
bexchauveto Aug 18, 2026
55ef0eb
fix(audit_trail): follow a record's history across a primary-key change
bexchauveto Aug 18, 2026
22cdb7b
fix(audit_trail): eight review findings on the search and rename work
bexchauveto Aug 18, 2026
9bb4c5c
fix(audit_trail): bound a rename segment by (timestamp, row id), as t…
bexchauveto Aug 18, 2026
bf862d8
fix(audit_trail): compare rename bounds through <=>, since Array is n…
bexchauveto Aug 18, 2026
87b05e9
fix(audit_trail): one cap for both agents, and refuse an over-cap act…
bexchauveto Aug 18, 2026
fcaa198
fix(audit_trail): pair a snapshot with its own operation, not the new…
bexchauveto Aug 18, 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
12 changes: 12 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,9 @@ Naming/PredicatePrefix:

Metrics/ParameterLists:
Exclude:
- 'packages/forest_admin_agent/lib/forest_admin_agent/audit_trail/action_capture.rb'
- '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 +360,9 @@ Metrics/BlockLength:

Metrics/ClassLength:
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_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 +460,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
347 changes: 347 additions & 0 deletions packages/forest_admin_agent/AUDIT_TRAIL.md

Large diffs are not rendered by default.

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
68 changes: 68 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,68 @@
module ForestAdminAgent
# The audit trail is inert unless `config.audit_trail[:database]` was set: the agent factory builds the
# store during setup — connecting and migrating there rather than on first write — and everything (capture
# layers and routes) resolves it from here.
module AuditTrail
# One operation must not materialise an unbounded number of records: a "delete all" would otherwise read
# every matched row and, with the pending/confirm protocol, write each of them twice. Truncation is logged,
# never silent.
#
# Matches the Node agent's `MAX_SNAPSHOT_RECORDS`: the same feature behind the same config key, so a bulk
# operation must not be audited on one agent and truncated on the other. Change it in both or neither.
MAX_RECORDS_PER_OPERATION = 1000

# Auditing a subset while the write touches every match is the one thing `critical` exists to prevent, so
# over the cap the operation is refused instead — before anything is written, in both the write and the
# action path, which is why the message lives here rather than in either of them.
def self.refuse_over_cap!
raise ForestAdminDatasourceToolkit::Exceptions::ForestException,
'The audit trail is configured as critical and cannot record an operation touching more than ' \
"#{MAX_RECORDS_PER_OPERATION} records at once. Narrow the selection."
end

def self.log_truncation(kept, total)
skipped = total ? total - kept : 'further'

Facades::Container.logger.log(
'Warn',
"[ForestAdmin] Audit trail: #{kept} records audited, #{skipped} skipped " \
"(cap #{MAX_RECORDS_PER_OPERATION} per operation)"
)
end

def self.options
config = Facades::Container.config_from_cache

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

def self.store
options[:store]
end

# `critical: true` makes the pending insert a precondition of the write: if the audit trail cannot record
# that an operation is about to happen, the operation is refused. Nothing was written, so there is nothing
# to repair and no compensating write ever happens. Default false keeps today's behaviour, where a broken
# audit database costs rows rather than writes.
def self.critical?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

critical defaults to off, so the guarantee the pending/confirm protocol exists to provide is opt-in.

The two-phase protocol is what makes "no unaudited write" achievable, and with options[:critical] unset a failed pending insert is logged while the write proceeds unaudited. A customer who has configured an audit database — and therefore believes they have an audit trail — gets a best-effort one until they find a second flag. Configuring the database is already an explicit opt-in; requiring a second one to get the invariant puts the surprise in the wrong place.

Node defaults the same way, so this is a shared product call rather than a Ruby oversight — flagged in both places for that reason.

Whichever way it goes, AUDIT_TRAIL.md should say plainly that without critical a write can succeed with no audit row. It documents the protocol today without documenting that the guarantee is optional.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not changed, and deliberately not: you have named it a shared product call and Node defaults the same way, so flipping it here would trade a documented gap for a silent divergence in the direction that changes customer behaviour. That one belongs to @bexchauveto, with both agents moving together.

The part you asked for whichever way it goes is done in 87b05e9. AUDIT_TRAIL.md now says it outright rather than implying it:

The guarantee is opt-in. critical defaults to false, so on a default configuration a write can succeed with no audit row at all — an unreachable audit database costs rows, not writes. Configuring the database gets you a best-effort trail; critical: true is what makes "no unaudited write" true.

Worth adding for whoever makes the call: with critical: true the failure mode moves from "silently missing rows" to "refused operations" — an unreachable audit database stops writes, and a selection wider than the cap is refused outright (also newly true for smart actions, see the other thread). That is the right trade for a compliance deployment and the wrong one for someone who enabled the trail to get a nice Historic tab, which is the real argument for the default being a choice rather than a default.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Decision, from @bexchauveto: the default stays false, in both agents. The reasoning, since it turns on something that changed after you wrote this.

Your argument rests on the customer who configures an audit database, believes they have an audit trail, and silently gets a best-effort one. That case is now covered by something else: the store connects and migrates at boot. A wrong connection string, an unreachable host, a migration that cannot run — the agent refuses to start. The "records nothing forever while looking healthy" scenario, which is the one that makes an opt-in guarantee feel like a trap, no longer exists.

What critical still governs is the transient failure: the audit database goes away mid-life. And that is where fail-closed is at its worst. It is a second database, usually a different host, on a connection nobody load-tested; with critical: true as the default, a blip there turns the admin panel read-only. That is an outage in the product caused by the subsystem whose only job is to observe it, and it lands hardest exactly when an extra database is most likely to be unhappy — during an incident, while people are using Forest to fix production.

The asymmetry is what settles it. false costs rows in a table nobody is reading yet, and the pending/confirm protocol you asked for is what makes that gap detectable rather than invisible: pending rows in the table, errors in the log. true costs writes, immediately, to the people least able to work out why the panel stopped saving.

So the shape is: configuring the database buys a best-effort trail with a visible failure mode; critical: true buys the invariant, and the docs now say so in the words you asked for, including which reader should set it.

Two things I would take from you here:

  • If you think compliance deployments are the majority of adopters rather than a minority who read the flag, that flips the argument and both agents should move together. My read is the opposite — most people enabling this want the Historic tab — which is your own framing from the thread on recordless runs.
  • The one concession that removes the rest of your objection without putting writes at risk: make the degraded state visible somewhere other than the log — a count of pending rows, or a line in the health check. Happy to build that here if you want it; it is the part of "the customer does not know" that survives the boot check.

options[:critical] == true
end

def self.log_failure(error)
Facades::Container.logger.log('Error', "[ForestAdmin] Audit trail unavailable, skipping: #{error.message}")
end

# Runs the pending insert under the configured policy: refusing the operation when critical, logging and
# carrying on otherwise.
def self.gate
return yield if critical?

begin
yield
rescue StandardError => e
log_failure(e)
nil
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
require 'uri'

module ForestAdminAgent
module AuditTrail
# Records smart-action runs into the same table as the field-level history: the submitted form on the
# `previous_values` side, what the action answered on the `new_values` side.
#
# {Capture} cannot see them — the customizer has no `Execute` hook — and an action's writes are only
# audited when they go through the Forest data layer, so a direct ORM write stays invisible. What lands
# here is the run itself: who ran which action, on which records, with which form, and how it ended.
#
# Same protocol as a write: {#pending} before the action, {#confirm} after. The route owns the gate, so a
# pending insert that fails refuses the run under `critical: true`.
class ActionCapture
include Recording

EXECUTED = 'action'.freeze
FAILED = 'action_failed'.freeze
# A global action, and a bulk run over a selection wider than the cap, name no single target: they get
# one row attached to no record rather than none at all.
NO_RECORD = ''.freeze
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
# What of the action's answer is worth keeping — an allowlist, not a denylist: a result also carries the
# file's contents, a webhook's body and headers and arbitrary response headers. File bytes have no
# business in an audit table and the other two routinely hold credentials, and an allowlist means a field
# added to a result later is not stored until someone decides it should be. `html` is left out too:
# operator-facing markup, sometimes large, and the message already says what happened.
RESULT_FIELDS = %i[type message name mime_type method url path].freeze
# Either can carry userinfo credentials or a signed one-time token, which would then sit permanently in
# the one table nobody deletes from.
URL_FIELDS = %i[url path].freeze

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

# One row per targeted record, provisionally an `action` — {#confirm} settles which it really was. Returns
# the row ids to confirm.
def pending(caller:, collection:, action_name:, form_values:, record_ids:)

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): pending [qlty:function-parameters]

return [] unless @store

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

@store.append_all(
ids.map do |record_id|
AuditRecord.new(
timestamp: timestamp, operation: EXECUTED, collection: collection, record_id: record_id,
status: PENDING, action_name: action_name, correlation_key: correlation_key,
previous_values: submitted, new_values: {}, **identity
)
end
)
end

# Best-effort: the action has already run, so a failure here loses the answer, never the run.
def confirm(ids, result: nil, failed: false)
return if ids.nil? || ids.empty?

audit_safely do
answer = summarize(result)

ids.each { |id| @store.confirm(id, operation: failed ? FAILED : EXECUTED, new_values: answer) }
end
Comment thread
qltysh[bot] marked this conversation as resolved.

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 = 5): confirm [qlty:function-complexity]

end

private

# Keys of an action's answer are Forest's own, so they are camelCase on the wire — unlike a record's
# column names, which pass through untouched.
def summarize(result)
return {} unless result.is_a?(Hash)

result.slice(*RESULT_FIELDS).compact.to_h do |field, value|
[field.to_s.camelize(:lower), URL_FIELDS.include?(field) ? sanitize_url(value) : value]
end
end

# `userinfo = nil` is a no-op on URI, so the credentials come off textually; the parser then takes care
# of the query and fragment.
def sanitize_url(value)
# Both `https://user:pass@host` and the scheme-relative `//user:pass@host`, which parses fine and
# would otherwise keep its credentials.
bare = value.to_s.sub(%r{\A([a-z][a-z0-9+.-]*:)?//[^/@]*@}i, '\1//')
uri = URI.parse(bare)
uri.query = nil
uri.fragment = nil

uri.to_s
rescue StandardError
# Not something the parser accepts: keep the shape, drop everything that can carry a secret.
bare.to_s.split(/[?#]/).first.to_s
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module ForestAdminAgent
module AuditTrail
# One audited change. Mirrors the columns of `forest.audit_logs`.
#
# The actor's name and email are denormalised from the caller at write time: the row says who acted then,
# not whoever holds that user id today. `action_name` is set on smart-action rows only, and `status`
# follows the write protocol — inserted as {Recording::PENDING} before the write and confirmed
# {Recording::DONE} after, so a row left pending means the write may or may not have landed.
AuditRecord = Struct.new(
:id, :timestamp, :operation, :collection, :record_id, :previous_record_id, :status,
:user_id, :user_first_name, :user_last_name, :user_email, :action_name,
:correlation_key, :previous_values, :new_values,
keyword_init: true
)
end
end
Loading
Loading