Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions packages/forest_admin_agent/AUDIT_TRAIL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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

Expand All @@ -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,
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading