From 8be8f56f366a9ecefd82554fb44a304058211673 Mon Sep 17 00:00:00 2001 From: Lucas Moura Date: Wed, 5 Aug 2026 12:43:51 -0400 Subject: [PATCH 1/2] Fix runtime compatibility found in real Mendix apps --- lib/mxrb/compiler/gallery_bundle_compiler.rb | 3 +- lib/mxrb/compiler/page_bundle_compiler.rb | 5 +- lib/mxrb/compiler/web_operation_compiler.rb | 23 +++++-- lib/mxrb/version.rb | 2 +- mxrb.gemspec | 2 +- script/frontend_browser_acceptance | 3 +- spec/frontend_browser_acceptance_spec.rb | 16 +++-- spec/gallery_bundle_compiler_spec.rb | 2 + spec/page_bundle_compiler_spec.rb | 4 +- spec/web_operation_compiler_spec.rb | 69 ++++++++++++++++++++ 10 files changed, 110 insertions(+), 19 deletions(-) diff --git a/lib/mxrb/compiler/gallery_bundle_compiler.rb b/lib/mxrb/compiler/gallery_bundle_compiler.rb index ab8ecc9..2c71c69 100644 --- a/lib/mxrb/compiler/gallery_bundle_compiler.rb +++ b/lib/mxrb/compiler/gallery_bundle_compiler.rb @@ -110,13 +110,14 @@ def primitives(values) end end - def primitive(type, value) # rubocop:disable Metrics/CyclomaticComplexity + def primitive(type, value) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity compiled = case type when 'Boolean' then value['PrimitiveValue'] == 'true' when 'Integer' then value['PrimitiveValue'].to_i when 'Enumeration' then value['PrimitiveValue'].to_s when 'TextTemplate' then raw(expression(translated_text(value['TextTemplate']))) when 'Widgets' then [] if array(value['Widgets']).empty? + when 'Object' then [] if array(value['Objects']).empty? end compiled.nil? ? :undefined : compiled end diff --git a/lib/mxrb/compiler/page_bundle_compiler.rb b/lib/mxrb/compiler/page_bundle_compiler.rb index 19270fc..9a51df7 100644 --- a/lib/mxrb/compiler/page_bundle_compiler.rb +++ b/lib/mxrb/compiler/page_bundle_compiler.rb @@ -1810,9 +1810,8 @@ def widget_imports (@generic_widgets || {}).sort.each do |name, path| imports.concat([ "import * as #{name}WidgetModule from \"../widgets/#{path}.mjs\";", - "const #{name} = Object.getOwnPropertyDescriptor(#{name}WidgetModule, " \ - "#{JSON.generate(name)})?.value || " \ - "Object.getOwnPropertyDescriptor(#{name}WidgetModule, \"default\")?.value;" + "const #{name} = #{name}WidgetModule[#{JSON.generate(name)}] || " \ + "#{name}WidgetModule.default;" ]) widgets << name end diff --git a/lib/mxrb/compiler/web_operation_compiler.rb b/lib/mxrb/compiler/web_operation_compiler.rb index 1c46fd7..7b6c911 100644 --- a/lib/mxrb/compiler/web_operation_compiler.rb +++ b/lib/mxrb/compiler/web_operation_compiler.rb @@ -241,7 +241,8 @@ def microflow_action_operation(page_name, widget, action, role_sets) { 'operationId' => self.class.operation_id(page_name, widget['Name']), 'operationType' => 'callMicroflow', 'parameters' => parameters, - 'constants' => { 'MicroflowName' => name }, 'allowedUserRoleSets' => role_sets + 'constants' => { 'MicroflowName' => name }, + 'allowedUserRoleSets' => microflow_operation_role_sets(role_sets, name) } end @@ -276,7 +277,7 @@ def operation(page_name, widget, role_sets, page_document) 'operationId' => self.class.operation_id(page_name, widget['Name']), 'operationType' => 'retrieveByMicroflow', 'parameters' => microflow_parameters(source.microflow_name) || {}, 'constants' => data_source_constants(page_name, widget, source.microflow_name), - 'allowedUserRoleSets' => role_sets + 'allowedUserRoleSets' => microflow_operation_role_sets(role_sets, source.microflow_name) } end @@ -340,7 +341,8 @@ def call_microflow_operation(page_name, widget, role_sets, name) { 'operationId' => self.class.operation_id(page_name, widget['Name']), 'operationType' => 'callMicroflow', 'parameters' => parameters, - 'constants' => { 'MicroflowName' => name }, 'allowedUserRoleSets' => role_sets + 'constants' => { 'MicroflowName' => name }, + 'allowedUserRoleSets' => microflow_operation_role_sets(role_sets, name) } end @@ -352,7 +354,7 @@ def retrieve_by_microflow_operation(page_name, widget, role_sets, name) 'operationId' => self.class.operation_id(page_name, widget['Name']), 'operationType' => 'retrieveByMicroflow', 'parameters' => parameters, 'constants' => data_source_constants(page_name, widget, name), - 'allowedUserRoleSets' => role_sets + 'allowedUserRoleSets' => microflow_operation_role_sets(role_sets, name) } end @@ -455,7 +457,9 @@ def nanoflow_action_operations(qualified_name, document) nested(document, 'Microflows$ActionActivity').filter_map do |activity| action = activity['Action'] || {} operation = nanoflow_server_operation(qualified_name, activity, action, entities) - operation&.merge('allowedUserRoleSets' => []) + name = operation&.dig('constants', 'MicroflowName') + role_sets = name ? microflow_operation_role_sets([], name) : [] + operation&.merge('allowedUserRoleSets' => role_sets) end end @@ -537,6 +541,15 @@ def allowed_user_role_sets(unit) # rubocop:disable Metrics/AbcSize end end # rubocop:enable Metrics/AbcSize + def microflow_operation_role_sets(container_role_sets, qualified_name) + flow = qualified_unit('Microflows$Microflow', qualified_name) + flow_role_sets = flow ? allowed_user_role_sets(flow) : [] + return container_role_sets if flow_role_sets.empty? + return flow_role_sets if container_role_sets.empty? + + container_role_sets & flow_role_sets + end + def constants(page_name, widget, constraint, entity) { 'PageName' => page_name, 'WidgetName' => "#{page_name}.#{widget['Name']}", diff --git a/lib/mxrb/version.rb b/lib/mxrb/version.rb index d542e27..6223bba 100644 --- a/lib/mxrb/version.rb +++ b/lib/mxrb/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Mxrb - VERSION = "0.1.0" + VERSION = "0.1.1" end diff --git a/mxrb.gemspec b/mxrb.gemspec index 025e157..38cf24a 100644 --- a/mxrb.gemspec +++ b/mxrb.gemspec @@ -2,7 +2,7 @@ Gem::Specification.new do |s| s.name = "mxrb" - s.version = "0.1.0" + s.version = "0.1.1" s.summary = "Pure-Ruby read/write engine for Mendix .mpr projects — no mxcli required" s.description = "mxrb reads and writes Mendix .mpr files (SQLite3) directly, providing a Ruby DSL to define entities, pages, microflows and modules without any dependency on the official mxcli tooling." s.authors = ["Lucas Moura"] diff --git a/script/frontend_browser_acceptance b/script/frontend_browser_acceptance index 0d708df..ef728eb 100755 --- a/script/frontend_browser_acceptance +++ b/script/frontend_browser_acceptance @@ -530,7 +530,8 @@ module MxrbFrontendBrowserAcceptance Array(page['styles']).each { @browser.wait_for_style(_1) } snapshot = @browser.snapshot failure_markers = ['Could not render widget', - 'An error occurred, please contact your system administrator.'] + 'An error occurred, please contact your system administrator.', + 'Executing runtime operation failed for security reasons'] render_errors = Array(snapshot['elements']).filter_map do |element| text = element['text'].to_s text if failure_markers.any? { text.include?(_1) } diff --git a/spec/frontend_browser_acceptance_spec.rb b/spec/frontend_browser_acceptance_spec.rb index 92e5b7d..ea520db 100644 --- a/spec/frontend_browser_acceptance_spec.rb +++ b/spec/frontend_browser_acceptance_spec.rb @@ -90,12 +90,16 @@ def run(browser, root, baseline: nil, update_baseline: false) it 'rejects the generic Mendix runtime error dialog' do Dir.mktmpdir do |root| - broken = snapshot.merge('elements' => [{ - 'tag' => 'p', 'text' => 'An error occurred, please contact your system administrator.' - }]) - - expect(run(fake_browser(snapshot: broken), root).fetch(:error)) - .to include('visible widget render failure') + messages = [ + 'An error occurred, please contact your system administrator.', + 'Executing runtime operation failed for security reasons: operation-id' + ] + + messages.each do |message| + broken = snapshot.merge('elements' => [{ 'tag' => 'p', 'text' => message }]) + expect(run(fake_browser(snapshot: broken), root).fetch(:error)) + .to include('visible widget render failure') + end end end end diff --git a/spec/gallery_bundle_compiler_spec.rb b/spec/gallery_bundle_compiler_spec.rb index c58322b..1231be0 100644 --- a/spec/gallery_bundle_compiler_spec.rb +++ b/spec/gallery_bundle_compiler_spec.rb @@ -135,6 +135,8 @@ def source(*units, widget_schema: schema) expect(compiler).not_to be_supported expect(compiler.send(:property_values, nil)).to eq({}) expect(compiler.send(:primitive, 'Widgets', 'Widgets' => [2, {}])).to eq(:undefined) + expect(compiler.send(:primitive, 'Object', 'Objects' => [2])).to eq([]) + expect(compiler.send(:primitive, 'Object', 'Objects' => [2, {}])).to eq(:undefined) expect(compiler.send(:translated_text, nil)).to eq('') end diff --git a/spec/page_bundle_compiler_spec.rb b/spec/page_bundle_compiler_spec.rb index bc1e7ac..6f11da8 100644 --- a/spec/page_bundle_compiler_spec.rb +++ b/spec/page_bundle_compiler_spec.rb @@ -872,8 +872,10 @@ imports = compiler.send(:widget_imports) expect(imports).to include( 'RadioButtonGroup', 'GroupBox', 'FileManager', 'MicroflowObjectProperty', - 'ListView', 'NanoflowObjectProperty', 'CustomWidgetModule' + 'ListView', 'NanoflowObjectProperty', 'CustomWidgetModule', + 'const Custom = CustomWidgetModule["Custom"] || CustomWidgetModule.default;' ) + expect(imports).not_to include('Object.getOwnPropertyDescriptor') end it 'executes custom image and generic action-property callbacks' do diff --git a/spec/web_operation_compiler_spec.rb b/spec/web_operation_compiler_spec.rb index 985ce83..9231eb1 100644 --- a/spec/web_operation_compiler_spec.rb +++ b/spec/web_operation_compiler_spec.rb @@ -256,6 +256,75 @@ def widget(source: true) .to eq([['Administrator']]) end + it 'authorizes layout microflow data sources from the microflow module roles' do + data_view = { + '$Type' => 'Forms$DataView', 'Name' => 'currentUser', 'DataSource' => { + '$Type' => 'Forms$MicroflowSource', + 'MicroflowSettings' => { 'Microflow' => 'Atlas_Core.DS_Account_CurrentUser' } + } + } + layout = unit(module_name: 'Atlas_Core', document: { + '$Type' => 'Forms$Layout', 'Name' => 'Atlas_TopBar', 'Widgets' => [data_view] + }) + flow = unit(module_name: 'Atlas_Core', document: { + '$Type' => 'Microflows$Microflow', 'Name' => 'DS_Account_CurrentUser', + 'AllowedModuleRoles' => [1, 'Atlas_Core.User'] + }) + security = { + 'UserRoles' => [2, + { 'Name' => 'Administrator', + 'ModuleRoles' => [1, 'Atlas_Core.User'] }, + { 'Name' => 'Unrelated', 'ModuleRoles' => [1, 'Demo.User'] }] + } + source = instance_double(Mxrb::Compiler::SourceModel) + allow(source).to receive(:units_of) do |type| + { 'Microflows$Microflow' => [flow] }.fetch(type, []) + end + allow(source).to receive(:documents).with('Security$ProjectSecurity').and_return([security]) + + expect(described_class.new(source).send(:page_operations, layout)).to contain_exactly( + include( + 'operationType' => 'retrieveByMicroflow', + 'allowedUserRoleSets' => [['Administrator']] + ) + ) + end + + it 'limits page and nanoflow microflow operations to the microflow roles' do + flow = unit(module_name: 'Demo', document: { + '$Type' => 'Microflows$Microflow', 'Name' => 'SecureAction', + 'AllowedModuleRoles' => [1, 'Demo.Executor'] + }) + security = { + 'UserRoles' => [2, + { 'Name' => 'Administrator', 'ModuleRoles' => [1, 'Demo.Executor'] }, + { 'Name' => 'Viewer', 'ModuleRoles' => [1, 'Demo.Viewer'] }] + } + source = instance_double(Mxrb::Compiler::SourceModel) + allow(source).to receive(:units_of).with('Microflows$Microflow').and_return([flow]) + allow(source).to receive(:documents).with('Security$ProjectSecurity').and_return([security]) + compiler = described_class.new(source) + + expect(compiler.send( + :microflow_operation_role_sets, [['Administrator']], 'Demo.Missing' + )).to eq([['Administrator']]) + + expect(compiler.send( + :call_microflow_operation, 'Demo.Home', { 'Name' => 'run' }, + [['Administrator'], ['Viewer']], 'Demo.SecureAction' + )).to include('allowedUserRoleSets' => [['Administrator']]) + + nanoflow = { + 'ObjectCollection' => { 'Objects' => [2, { + '$Type' => 'Microflows$ActionActivity', '$ID' => SecureRandom.uuid, + 'Action' => { '$Type' => 'Microflows$MicroflowCallAction', + 'MicroflowCall' => { 'Microflow' => 'Demo.SecureAction' } } + }] } + } + expect(compiler.send(:nanoflow_action_operations, 'Demo.ClientAction', nanoflow)) + .to contain_exactly(include('allowedUserRoleSets' => [['Administrator']])) + end + it 'covers defensive menu, data-action, association, and nanoflow operation paths' do # rubocop:disable Metrics/BlockLength expect(described_class.menu_operation_id('$ID' => SecureRandom.uuid)).to be_a(String) expect(described_class.menu_operation_id( From ffb56a444bf0c67d2776602122c15a4e6bdc43e5 Mon Sep 17 00:00:00 2001 From: Lucas Moura Date: Wed, 5 Aug 2026 12:47:04 -0400 Subject: [PATCH 2/2] Update sqlite-vec lockfile for 0.1.1 --- Gemfile.sqlite-vec.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.sqlite-vec.lock b/Gemfile.sqlite-vec.lock index 497fd4f..bf196de 100644 --- a/Gemfile.sqlite-vec.lock +++ b/Gemfile.sqlite-vec.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - mxrb (0.1.0) + mxrb (0.1.1) base64 (~> 0.2) bigdecimal (~> 3.1) bson (~> 5.2) @@ -126,7 +126,7 @@ CHECKSUMS json (2.21.1) sha256=13a43df75d95641443f5702dff350f237164a9d811ff0f2c2800d4d980220583 language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 - mxrb (0.1.0) + mxrb (0.1.1) onnxruntime (0.11.5-arm64-darwin) sha256=9f2f45b3ac16999c466ac352562195e03a011cc930b8750c0b1e43a49c1ca5d9 onnxruntime (0.11.5-x86_64-darwin) sha256=19c18e90eb101f69a3232dfdd1742e77d69e2df623b106b670221629d4b4cc15 onnxruntime (0.11.5-x86_64-linux) sha256=13f20de606a8fb32ba404bca9e1fc47f0eab4605c909252dbcf6db4fb106c9e6