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
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,8 @@ def can_smart_action?(request, collection, filter, allow_fetch: true)
smart_action_approval = SmartActionChecker.new(
request[:params],
collection,
collection_actions[action['name'].to_sym],
# The schema scope lets the checker skip select-all resolution for global actions.
collection_actions[action['name'].to_sym].merge(scope: collection.schema[:actions][action['name']]&.scope),
caller,
user_data[:roleId],
filter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,24 @@ def initialize(message = 'Custom action requires approval', details: {})
end
end

class ApprovalSelectionTooLargeError < UnprocessableError
def initialize(max)
super(
"This action requires approval and cannot be triggered on more than #{max} records at once. " \
'Please refine your selection.'
)
end
end

class SmartActionChecker
include ForestAdminAgent::Utils
include ForestAdminDatasourceToolkit::Utils
include ForestAdminDatasourceToolkit::Components::Query
include ForestAdminDatasourceToolkit::Components::Query::ConditionTree

# The Forest server's cap on approval record ids; max_records_for_approval may only lower it.
MAX_RECORDS_FOR_APPROVAL = 500

attr_reader :parameters, :collection, :smart_action, :caller, :role_id, :filter, :attributes

def initialize(parameters, collection, smart_action, caller, role_id, filter)
Expand Down Expand Up @@ -66,9 +78,19 @@ def can_trigger?
end
elsif smart_action[:approvalRequired].include?(role_id) && smart_action[:triggerEnabled].include?(role_id)
if condition_by_role_id(smart_action[:approvalRequiredConditions]).nil? || match_conditions(:approvalRequiredConditions)
# Global actions target no specific records — never resolve.
if attributes[:all_records] && smart_action[:scope] != ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope::GLOBAL
record_ids = resolve_select_all_record_ids
end

raise CustomActionRequiresApprovalError.new(
'This action requires to be approved.',
details: { user_approval_enabled: smart_action[:userApprovalEnabled] }
details: {
user_approval_enabled: smart_action[:userApprovalEnabled],
# camelCase: sent verbatim in the error data, the key the frontend reads.
roleIdsAllowedToApprove: smart_action[:userApprovalEnabled],
**(record_ids ? { recordIds: record_ids } : {})
}
)
elsif condition_by_role_id(smart_action[:triggerConditions]).nil? || match_conditions(:triggerConditions)
return true
Expand All @@ -78,6 +100,27 @@ def can_trigger?
raise CustomActionTriggerForbiddenError, 'You don\'t have the permission to trigger this action.'
end

# Fetching cap+1 distinguishes "over the cap" from "exactly the cap".
def resolve_select_all_record_ids
configured = ForestAdminAgent::Facades::Container.config_from_cache[:max_records_for_approval]
# Anything but a positive Integer gets the default (a negative LIMIT means unlimited on
# some datasources, and a String would raise on comparison).
max = if configured.is_a?(Integer) && configured.positive?
[configured, MAX_RECORDS_FOR_APPROVAL].min
else
MAX_RECORDS_FOR_APPROVAL
end
records = collection.list(
caller,
filter.override(page: Page.new(offset: 0, limit: max + 1)),
Projection.new.with_pks(collection)
)

raise ApprovalSelectionTooLargeError, max if records.size > max

Utils::Id.pack_ids(collection, records)
end

def match_conditions(condition_name)
pks = Schema.primary_keys(collection)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1352,6 +1352,44 @@ module Services
@permissions.can_smart_action?(args, @datasource.collections['Book'], Filter.new)
end.to raise_error(ForestAdminAgent::Http::Exceptions::BadRequestError, 'The collection Book does not have this smart action')
end

it 'hands the collection schema action scope to the smart action checker' do
args[:headers]['REQUEST_PATH'] = '/forest/_actions/Book/0/make-photocopy'
args[:headers]['REQUEST_METHOD'] = 'POST'
args[:params] = {
data: {
attributes: {
'ids' => [],
'collection_name' => 'Book',
'all_records' => true,
'all_records_ids_excluded' => [],
'smart_action_id' => 'make-photocopy',
'signed_approval_request' => nil
},
'type' => 'custom-action-requests'
}
}

collection = @datasource.collections['Book']
schema_action = instance_double(ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction, scope: 'global')
allow(collection).to receive(:schema).and_return({ actions: { 'make-photocopy' => schema_action } })

@permissions.cache.set('forest.has_permission', { enable: true })
allow(@permissions).to receive_messages(
get_user_data: { roleId: 15 },
get_collections_permissions_data: { Book: { actions: { 'make-photocopy': { triggerEnabled: [15] } } } },
find_action_from_endpoint: { 'name' => 'make-photocopy' }
)

checker = instance_double(SmartActionChecker, can_execute?: true)
allow(SmartActionChecker).to receive(:new).and_return(checker)

expect(@permissions.can_smart_action?(args, collection, Filter.new)).to be true
# The schema scope must reach the checker: without it a global action would be resolved.
expect(SmartActionChecker).to have_received(:new).with(
anything, collection, hash_including(scope: 'global', triggerEnabled: [15]), anything, 15, anything
)
end
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,134 @@ module Services
end.to raise_error(CustomActionRequiresApprovalError, 'This action requires to be approved.')
end

it 'includes the approver role ids under the key the frontend reads' do
smart_action[:triggerEnabled] = [1]
smart_action[:approvalRequired] = [1]
smart_action[:approvalRequiredConditions] = []
smart_action[:userApprovalEnabled] = [7, 16]

smart_action_checker = described_class.new(parameters, @datasource.get_collection('Book'), smart_action,
QueryStringParser.parse_caller(args), 1, Filter.new)
expect { smart_action_checker.can_execute? }.to raise_error(CustomActionRequiresApprovalError) do |error|
expect(error.details[:roleIdsAllowedToApprove]).to eq([7, 16])
expect(error.details[:user_approval_enabled]).to eq([7, 16])
end
end

it 'includes the resolved record ids in the error on a "select all" trigger' do
smart_action[:triggerEnabled] = [1]
smart_action[:approvalRequired] = [1]
smart_action[:approvalRequiredConditions] = []
smart_action[:userApprovalEnabled] = [7]
parameters[:data][:attributes][:all_records] = true

collection = @datasource.get_collection('Book')
allow(collection).to receive(:list).and_return([{ 'id' => 1 }, { 'id' => 2 }, { 'id' => 3 }])

smart_action_checker = described_class.new(parameters, collection, smart_action,
QueryStringParser.parse_caller(args), 1, Filter.new)
expect { smart_action_checker.can_execute? }.to raise_error(CustomActionRequiresApprovalError) do |error|
expect(error.details[:recordIds]).to eq(%w[1 2 3])
expect(error.details[:roleIdsAllowedToApprove]).to eq([7])
end
expect(collection).to have_received(:list).with(
anything,
have_attributes(page: have_attributes(offset: 0, limit: 501)),
anything
)
end

it 'rejects a "select all" trigger matching more records than max_records_for_approval' do
smart_action[:triggerEnabled] = [1]
smart_action[:approvalRequired] = [1]
smart_action[:approvalRequiredConditions] = []
parameters[:data][:attributes][:all_records] = true

collection = @datasource.get_collection('Book')
allow(collection).to receive(:list).and_return([{ 'id' => 1 }, { 'id' => 2 }, { 'id' => 3 }])
config = ForestAdminAgent::Facades::Container.config_from_cache.merge(max_records_for_approval: 2)
allow(ForestAdminAgent::Facades::Container).to receive(:config_from_cache).and_return(config)

smart_action_checker = described_class.new(parameters, collection, smart_action,
QueryStringParser.parse_caller(args), 1, Filter.new)
expect { smart_action_checker.can_execute? }
.to raise_error(ApprovalSelectionTooLargeError, /more than 2 records/)
end

it 'clamps max_records_for_approval to the Forest server cap of 500' do
smart_action[:triggerEnabled] = [1]
smart_action[:approvalRequired] = [1]
smart_action[:approvalRequiredConditions] = []
parameters[:data][:attributes][:all_records] = true

collection = @datasource.get_collection('Book')
allow(collection).to receive(:list).and_return([{ 'id' => 1 }])
config = ForestAdminAgent::Facades::Container.config_from_cache.merge(max_records_for_approval: 1000)
allow(ForestAdminAgent::Facades::Container).to receive(:config_from_cache).and_return(config)

smart_action_checker = described_class.new(parameters, collection, smart_action,
QueryStringParser.parse_caller(args), 1, Filter.new)
expect { smart_action_checker.can_execute? }.to raise_error(CustomActionRequiresApprovalError)
expect(collection).to have_received(:list).with(
anything,
have_attributes(page: have_attributes(limit: 501)),
anything
)
end

it 'falls back to the default cap when max_records_for_approval is invalid' do
smart_action[:triggerEnabled] = [1]
smart_action[:approvalRequired] = [1]
smart_action[:approvalRequiredConditions] = []
parameters[:data][:attributes][:all_records] = true

collection = @datasource.get_collection('Book')
allow(collection).to receive(:list).and_return([{ 'id' => 1 }])

[-5, 'lots', true].each do |invalid|
config = ForestAdminAgent::Facades::Container.config_from_cache.merge(max_records_for_approval: invalid)
allow(ForestAdminAgent::Facades::Container).to receive(:config_from_cache).and_return(config)

smart_action_checker = described_class.new(parameters, collection, smart_action,
QueryStringParser.parse_caller(args), 1, Filter.new)
expect { smart_action_checker.can_execute? }.to raise_error(CustomActionRequiresApprovalError)
end

expect(collection).to have_received(:list)
.with(anything, have_attributes(page: have_attributes(limit: 501)), anything)
.exactly(3).times
end

it 'does not resolve record ids on a "select all" trigger of a global action' do
smart_action[:triggerEnabled] = [1]
smart_action[:approvalRequired] = [1]
smart_action[:approvalRequiredConditions] = []
smart_action[:scope] = 'global'
parameters[:data][:attributes][:all_records] = true

collection = @datasource.get_collection('Book')
allow(collection).to receive(:list)

smart_action_checker = described_class.new(parameters, collection, smart_action,
QueryStringParser.parse_caller(args), 1, Filter.new)
expect { smart_action_checker.can_execute? }.to raise_error(CustomActionRequiresApprovalError) do |error|
expect(error.details).not_to have_key(:recordIds)
end
expect(collection).not_to have_received(:list)
end

it 'omits record ids from the error on an explicit selection' do
smart_action[:triggerEnabled] = [1]
smart_action[:approvalRequired] = [1]
smart_action[:approvalRequiredConditions] = []

smart_action_checker = described_class.new(parameters, @datasource.get_collection('Book'), smart_action,
QueryStringParser.parse_caller(args), 1, Filter.new)
expect { smart_action_checker.can_execute? }.to raise_error(CustomActionRequiresApprovalError) do |error|
expect(error.details).not_to have_key(:recordIds)
end
end

it 'throws when the user try to trigger the action with approvalRequired and match approvalRequiredConditions' do
smart_action[:triggerEnabled] = [1]
smart_action[:approvalRequired] = [1]
Expand Down
2 changes: 2 additions & 0 deletions packages/forest_admin_rails/lib/forest_admin_rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ module ForestAdminRails
# { database: <ActiveRecord config or URL>, schema:, table_name:, redact: } — setting `database`
# turns the audit trail on: every change is captured and the `/_audit-trail` routes are registered.
setting :audit_trail, default: nil
# Max records a "select all" approval may target; clamped to the Forest server's cap (500).
setting :max_records_for_approval, default: 500
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

if defined?(Rails::Railtie)
# logic for cors middleware,... here // or it might be into Engine
Expand Down
Loading