From de9440a7c62ddba965fb111d5268e817db0b80c2 Mon Sep 17 00:00:00 2001 From: Olivier Bex-Chauvet Date: Wed, 4 Nov 2026 10:33:42 +0100 Subject: [PATCH] feat(agent): audit writes made from inside a smart action The audit trail registered its hooks with the public add_hook, which sits above the action layer: a create/update/delete made from an action's execute through context.datasource never reached them, so only the action row was recorded. Add an internal hook layer below `action` (parity with agent-nodejs) and register the audit hooks there. Co-Authored-By: Claude Opus 5.5 --- packages/forest_admin_agent/AUDIT_TRAIL.md | 5 +++ .../forest_admin_agent/audit_trail/capture.rb | 19 +++++----- .../audit_trail/capture_spec.rb | 2 +- .../collection_customizer.rb | 7 ++++ .../decorators/decorators_stack.rb | 5 ++- .../collection_customizer_spec.rb | 36 +++++++++++++++++++ 6 files changed, 62 insertions(+), 12 deletions(-) diff --git a/packages/forest_admin_agent/AUDIT_TRAIL.md b/packages/forest_admin_agent/AUDIT_TRAIL.md index e63c10d78..24b7351fb 100644 --- a/packages/forest_admin_agent/AUDIT_TRAIL.md +++ b/packages/forest_admin_agent/AUDIT_TRAIL.md @@ -340,6 +340,11 @@ the store. A missing `collection` or `recordId` returns **HTTP 400** (`Validatio ## Smart actions +Writes a smart action makes through Forest (`context.collection` or `context.datasource.get_collection(...)` +`create` / `update` / `delete`) are audited like any other write, under the same `correlationKey` as the +action: the audit hooks sit on an internal layer below smart actions. Writes that bypass Forest (raw SQL, a +direct ActiveRecord call, an external API) only show up through the action row below. + Running a smart action writes one row per selected record, in the same table: | column | value | diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/audit_trail/capture.rb b/packages/forest_admin_agent/lib/forest_admin_agent/audit_trail/capture.rb index a94a30f7d..99ec8897a 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/audit_trail/capture.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/audit_trail/capture.rb @@ -43,19 +43,18 @@ def instrument(collection_customizer) add_delete_hooks(collection_customizer, target) end - # The "after" hooks are prepended: `execute_after` stops at the first exception, so a customization - # raising in its own after hook would otherwise drop the record of a write that already happened. - # The "before" hooks stay appended, so they read the data, filter and patch every other customization - # has had its say on. + # Hooks sit on the internal layer, below smart actions, so writes made from an action's `execute` are + # audited too. The "after" hooks are prepended: `execute_after` stops at the first exception, so another + # internal hook raising would otherwise drop the record of a write that already happened. def add_create_hooks(collection_customizer, target) - collection_customizer.add_hook('Before', 'Create') do |context| + collection_customizer.add_internal_hook('Before', 'Create') do |context| # No record id yet — that is what the column being nullable is for. rows = [{ record_id: nil, new_values: pick(context.data, target[:columns]) }] @snapshots.push(context.data, ids: pending_rows(context.caller, 'create', target[:name], rows)) end - collection_customizer.add_hook('After', 'Create', prepend: true) do |context| + collection_customizer.add_internal_hook('After', 'Create', prepend: true) do |context| pending = @snapshots.pop_for(context.data) next unless pending @@ -66,7 +65,7 @@ def add_create_hooks(collection_customizer, target) end def add_update_hooks(collection_customizer, target) - collection_customizer.add_hook('Before', 'Update') do |context| + collection_customizer.add_internal_hook('Before', 'Update') do |context| records = @snapshots.take(context, target[:projection]) @snapshots.push( context.filter, @@ -83,7 +82,7 @@ def add_update_hooks(collection_customizer, target) ) end - collection_customizer.add_hook('After', 'Update', prepend: true) do |context| + collection_customizer.add_internal_hook('After', 'Update', prepend: true) do |context| pending = @snapshots.pop_for(context.filter) next unless pending @@ -92,7 +91,7 @@ def add_update_hooks(collection_customizer, target) end def add_delete_hooks(collection_customizer, target) - collection_customizer.add_hook('Before', 'Delete') do |context| + collection_customizer.add_internal_hook('Before', 'Delete') do |context| records = @snapshots.take(context, target[:projection]) @snapshots.push( context.filter, @@ -107,7 +106,7 @@ def add_delete_hooks(collection_customizer, target) ) end - collection_customizer.add_hook('After', 'Delete', prepend: true) do |context| + collection_customizer.add_internal_hook('After', 'Delete', prepend: true) do |context| pending = @snapshots.pop_for(context.filter) next unless pending diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/audit_trail/capture_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/audit_trail/capture_spec.rb index 5507785d2..80a5e7014 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/audit_trail/capture_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/audit_trail/capture_spec.rb @@ -59,7 +59,7 @@ def discard(ids) let(:collection_customizer) do customizer = double('CollectionCustomizer', name: 'companies', collection: collection) - allow(customizer).to receive(:add_hook) do |position, type, prepend: false, &block| + allow(customizer).to receive(:add_internal_hook) do |position, type, prepend: false, &block| registrations << [position, type, prepend] hooks["#{position}_#{type}"] = block end diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/collection_customizer.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/collection_customizer.rb index 6aa07cb29..80c9c1c3e 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/collection_customizer.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/collection_customizer.rb @@ -305,6 +305,13 @@ def add_hook(position, type, prepend: false, &handler) end end + # Like add_hook, but also fires for writes initiated from inside a smart action. Used by the audit trail. + def add_internal_hook(position, type, prepend: false, &handler) + push_customization do + @stack.internal_hook.get_collection(@name).add_hook(position, type, handler, prepend: prepend) + end + end + # Add a new segment on the collection. # @param name the name of the segment # @param definition a function used to generate a condition tree or a condition tree diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/decorators_stack.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/decorators_stack.rb index 0d75d44bc..f2a26e889 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/decorators_stack.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/decorators_stack.rb @@ -5,7 +5,7 @@ class DecoratorsStack attr_reader :datasource, :schema, :search, :early_computed, :late_computed, :action, :relation, :late_op_emulate, :early_op_emulate, :validation, :sort, :rename_field, :publication, :write, :chart, :hook, :segment, - :binary, :override, :lazy_join + :binary, :override, :lazy_join, :internal_hook def initialize(datasource) @customizations = [] @@ -75,6 +75,9 @@ def init_stack(datasource) last = @sort = DatasourceDecorator.new(last, Sort::SortCollectionDecorator) last = @chart = Chart::ChartDatasourceDecorator.new(last) + # Below `action` so writes emitted from inside a smart action also traverse it, and its after hooks + # complete before `hook`'s start: a customization raising in a public after hook can't preempt it. + last = @internal_hook = DatasourceDecorator.new(last, Hook::HookCollectionDecorator) last = @action = DatasourceDecorator.new(last, Action::ActionCollectionDecorator) last = @schema = DatasourceDecorator.new(last, Schema::SchemaCollectionDecorator) last = @write = Write::WriteDatasourceDecorator.new(last) diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/collection_customizer_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/collection_customizer_spec.rb index fdd0c201c..33c7878b0 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/collection_customizer_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/collection_customizer_spec.rb @@ -662,6 +662,42 @@ module ForestAdminDatasourceCustomizer end end + context 'when using add_internal_hook' do + let(:person_customizer) { described_class.new(@datasource_customizer, @datasource_customizer.stack, 'person') } + let(:internal_creates) { [] } + let(:public_creates) { [] } + + before do + @datasource_customizer.stack.apply_queued_customizations({}) + allow(@datasource_customizer.composite_datasource.get_collection('person')).to receive(:create) { |_caller, data| data } + person_customizer.add_internal_hook('Before', 'Create') { |context| internal_creates << context.data } + person_customizer.add_hook('Before', 'Create') { |context| public_creates << context.data } + + create_person = BaseAction.new(scope: Types::ActionScope::GLOBAL) do |context, result_builder| + context.datasource.get_collection('person').create({ 'name' => 'from action' }) + result_builder.success + end + described_class.new(@datasource_customizer, @datasource_customizer.stack, 'book') + .add_action('create person', create_person) + @datasource_customizer.stack.apply_queued_customizations({}) + end + + it 'fires for a write made from inside a smart action, unlike add_hook' do + @datasource_customizer.stack.datasource.get_collection('book') + .execute(caller, 'create person', {}, ForestAdminDatasourceToolkit::Components::Query::Filter.new) + + expect(internal_creates).to eq([{ 'name' => 'from action' }]) + expect(public_creates).to eq([]) + end + + it 'fires alongside add_hook for a write made through the agent' do + @datasource_customizer.stack.datasource.get_collection('person').create(caller, { 'name' => 'direct' }) + + expect(internal_creates).to eq([{ 'name' => 'direct' }]) + expect(public_creates).to eq([{ 'name' => 'direct' }]) + end + end + context 'when using add_segment' do it 'add a segment' do stack = @datasource_customizer.stack