forest_admin_agent 1.36.1 → 1.36.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a13562c23233f9b3e5083c0d0f8d4478fd09eeaf6f9581b9e7b38b3020c96507
4
- data.tar.gz: f6055e0ab36bdddc899de5e44d792c63185c1d0f040234f94812ac73672d562a
3
+ metadata.gz: 8dfe78a2ed0c19256af6975b916fedd900f5ec28e5cb2ab5d5e712a8d28621f0
4
+ data.tar.gz: 7681368ebd2a38978c141d8dc509be092de45daab2ee1613426f4964b0128695
5
5
  SHA512:
6
- metadata.gz: 75c550acaf51c3cd42c001d6629fdb777d730428d1dedbb1369dea0545e792b9a58e96b62825e8b428fa37458482a631bdfd78f6380f9faff0346c02b4b82d42
7
- data.tar.gz: 0e78923aee30934275076f58ad3e526a34a5c7c744e864a178b83c7a34146f5e5a2c121f7f4b5c2dd33051bea050b14080afb4d1ca5f2ee196876054286ca718
6
+ metadata.gz: d64027002baa55f8ec4a4d9e6b4f8ba049bc304df687e3da6605cd245c998b0716a4fc59ac81666811503aeabab5e80806322511277c13b7ea8b18f947e6c78b
7
+ data.tar.gz: 6afcca1e58e83c2edea354af8bff76aed1d9adeecba94289dc3d37718a4dc8305fe0ed104c00edd7f884532dc858c1c0b116d7fa928540bda157822ad4bd2ae3
@@ -16,14 +16,10 @@ module ForestAdminAgent
16
16
 
17
17
  def execute_query(datasource, query, connection_name, permissions, caller, context_variables)
18
18
  query = query.strip
19
- query, context_variables = inject_context_variables(datasource, connection_name, query, permissions, caller,
20
- context_variables)
21
-
22
- datasource.execute_native_query(
23
- connection_name,
24
- query,
25
- context_variables.values
26
- )
19
+ query, binds = inject_context_variables(datasource, connection_name, query, permissions, caller,
20
+ context_variables)
21
+
22
+ datasource.execute_native_query(connection_name, query, binds)
27
23
  end
28
24
 
29
25
  def parse_query_segment(collection, args, permissions, caller)
@@ -36,7 +36,7 @@ module ForestAdminAgent
36
36
 
37
37
  context.permissions.can_chart?(args[:params])
38
38
 
39
- query.gsub!('?', args[:params][:record_id].to_s) if args[:params][:record_id]
39
+ query, context_variables = inject_record_id(query, args[:params])
40
40
  type = validate_and_get_type(args[:params][:type])
41
41
  result = execute_query(
42
42
  context.datasource,
@@ -44,7 +44,7 @@ module ForestAdminAgent
44
44
  args[:params][:connectionName],
45
45
  context.permissions,
46
46
  context.caller,
47
- args[:params][:contextVariables]
47
+ context_variables
48
48
  )
49
49
 
50
50
  { content: Serializer::ForestChartSerializer.serialize(send(:"make_#{type}", result)) }
@@ -52,6 +52,19 @@ module ForestAdminAgent
52
52
 
53
53
  private
54
54
 
55
+ # The developer's query template uses a literal '?' to mean "the record this chart is
56
+ # scoped to". Substituting record_id as text into the query (the previous approach) let
57
+ # an attacker inject arbitrary SQL through it - route it through the same {{...}} bind
58
+ # machinery used for context variables instead, so it's a real parameter, never SQL text.
59
+ def inject_record_id(query, params)
60
+ context_variables = params[:contextVariables]
61
+ context_variables = {} unless context_variables.is_a?(Hash)
62
+
63
+ return [query, context_variables] unless params[:record_id]
64
+
65
+ [query.gsub('?', '{{__record_id__}}'), context_variables.merge('__record_id__' => params[:record_id])]
66
+ end
67
+
55
68
  def validate_and_get_type(type)
56
69
  chart_types = %w[Value Objective Pie Line Leaderboard]
57
70
  unless chart_types.include?(type)
@@ -148,6 +148,12 @@ module ForestAdminAgent
148
148
  team = get_team(caller.rendering_id)
149
149
  user = get_user_data(caller.id)
150
150
 
151
+ if team.nil? || user.nil?
152
+ raise ForestAdminDatasourceToolkit::Exceptions::ForestException,
153
+ "Unable to resolve the caller's team or user data while computing the permission " \
154
+ "scope for '#{collection.name}'."
155
+ end
156
+
151
157
  context_variables = ContextVariables.new(team, user)
152
158
 
153
159
  ContextVariablesInjector.inject_context_in_filter(scope, context_variables)
@@ -10,14 +10,22 @@ module ForestAdminAgent
10
10
  USER_VALUE_TEAM_PREFIX = 'currentUser.team.'.freeze
11
11
 
12
12
  def initialize(team, user, request_context_variables = nil)
13
- @team = team.transform_keys(&:to_sym)
14
- @user = user.transform_keys(&:to_sym)
15
- @request_context_variables = request_context_variables
13
+ @team = (team || {}).transform_keys(&:to_sym)
14
+ @user = (user || {}).transform_keys(&:to_sym)
15
+ @request_context_variables = request_context_variables.is_a?(Hash) ? request_context_variables : {}
16
16
  end
17
17
 
18
18
  def get_value(context_variable_key)
19
19
  return get_current_user_data(context_variable_key) if context_variable_key.start_with?(USER_VALUE_PREFIX)
20
20
 
21
+ if request_context_variables.empty?
22
+ ForestAdminAgent::Facades::Container.logger&.log(
23
+ 'Warn',
24
+ "Context variable '#{context_variable_key}' could not be resolved: no request context " \
25
+ 'variables were provided.'
26
+ )
27
+ end
28
+
21
29
  request_context_variables[context_variable_key]
22
30
  end
23
31
 
@@ -8,6 +8,7 @@ module ForestAdminAgent
8
8
 
9
9
  REGEX = /{{([^}]+)}}/
10
10
  FULL_REFERENCE_REGEX = /\A{{([^}]+)}}\z/
11
+ COMMENT_MARKERS = ['--', '/*'].freeze
11
12
 
12
13
  def self.inject_context_in_value(value, context_variables)
13
14
  return value unless value.is_a?(String)
@@ -32,23 +33,89 @@ module ForestAdminAgent
32
33
  def self.inject_context_in_native_query(datasource, connection_name, query, context_variables)
33
34
  return query unless query.is_a?(String)
34
35
 
35
- query_with_context_variables_injected = query
36
- encountered_variables = {}
36
+ state = { binds: [], replacements: {}, reusable: nil }
37
37
 
38
- while (match = REGEX.match(query_with_context_variables_injected))
39
- context_variable_key = match[1]
38
+ injected_query = query.gsub(REGEX) do
39
+ key = ::Regexp.last_match(1)
40
+ raise_unless_live_sql(key, ::Regexp.last_match.pre_match)
41
+ resolve_bind_symbol(key, datasource, connection_name, context_variables, state)
42
+ end
40
43
 
41
- next if encountered_variables.value?(context_variable_key)
44
+ [injected_query, state[:binds]]
45
+ end
42
46
 
43
- index = datasource.build_binding_symbol(connection_name, encountered_variables)
44
- query_with_context_variables_injected.gsub!(
45
- /{{#{context_variable_key}}}/,
46
- index
47
- )
48
- encountered_variables[index] = context_variables.get_value(context_variable_key)
47
+ # A repeated key's bind can be reused (true, e.g. postgres' $N) or needs a fresh slot per
48
+ # occurrence (false, e.g. "?", purely positional) - unknown until the first repeat, since
49
+ # build_binding_symbol can't declare it upfront. Reusing when unsafe drops a bind value a
50
+ # positional driver needs; always assuming fresh costs an extra RPC round-trip.
51
+ def self.resolve_bind_symbol(key, datasource, connection_name, context_variables, state)
52
+ if state[:replacements].key?(key)
53
+ if state[:reusable].nil?
54
+ probe = datasource.build_binding_symbol(connection_name, state[:binds])
55
+ state[:reusable] = (probe != state[:replacements][key])
56
+ end
57
+
58
+ state[:binds] << context_variables.get_value(key) unless state[:reusable]
59
+ state[:replacements][key]
60
+ else
61
+ symbol = datasource.build_binding_symbol(connection_name, state[:binds])
62
+ state[:binds] << context_variables.get_value(key)
63
+ state[:replacements][key] = symbol
49
64
  end
65
+ end
66
+
67
+ # A placeholder inside a quoted string, a "quoted identifier", or a SQL comment can never
68
+ # be a real bind, so scan everything before the match for one. Both quote styles share the
69
+ # same escape convention (a doubled quote character, never a boundary) so one `quote`
70
+ # variable tracks whichever is currently open. Comments are skipped wholesale rather than
71
+ # scanned char by char - a naive quote count would misfire on an apostrophe inside one (e.g.
72
+ # -- user's note).
73
+ def self.sql_context_before(text)
74
+ quote = nil
75
+ i = 0
76
+
77
+ while i < text.length
78
+ if quote
79
+ if text[i] == quote && text[i + 1] == quote
80
+ i += 1
81
+ elsif text[i] == quote
82
+ quote = nil
83
+ end
84
+ elsif /['"]/.match?(text[i])
85
+ quote = text[i]
86
+ elsif COMMENT_MARKERS.include?(text[i, 2])
87
+ i = skip_sql_comment(text, i)
88
+ return :comment if i.nil?
89
+
90
+ next
91
+ end
92
+
93
+ i += 1
94
+ end
95
+
96
+ return :code unless quote
97
+
98
+ quote == "'" ? :string : :identifier
99
+ end
100
+
101
+ def self.skip_sql_comment(text, index)
102
+ return text.index("\n", index)&.succ if text[index, 2] == '--'
103
+
104
+ text.index('*/', index + 2)&.succ&.succ
105
+ end
106
+
107
+ PLACEHOLDER_CONTEXT_ERRORS = {
108
+ string: 'is inside a quoted string literal, which native query bindings do not support',
109
+ comment: 'is inside a SQL comment, which native query bindings do not support',
110
+ identifier: 'is inside a quoted identifier - a bind can only ever be a value, never a column or table name'
111
+ }.freeze
112
+
113
+ def self.raise_unless_live_sql(key, text_before_match)
114
+ context = sql_context_before(text_before_match)
115
+ return if context == :code
50
116
 
51
- [query_with_context_variables_injected, encountered_variables]
117
+ raise ForestAdminDatasourceToolkit::Exceptions::ForestException,
118
+ "The '{{#{key}}}' placeholder #{PLACEHOLDER_CONTEXT_ERRORS[context]}."
52
119
  end
53
120
 
54
121
  def self.inject_context_in_value_custom(value)
@@ -6,7 +6,7 @@ module ForestAdminAgent
6
6
  module Schema
7
7
  class SchemaEmitter
8
8
  LIANA_NAME = "agent-ruby"
9
- LIANA_VERSION = "1.36.1"
9
+ LIANA_VERSION = "1.36.2"
10
10
 
11
11
  def self.generate(datasource)
12
12
  datasource.collections
@@ -1,3 +1,3 @@
1
1
  module ForestAdminAgent
2
- VERSION = "1.36.1"
2
+ VERSION = "1.36.2"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forest_admin_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.36.1
4
+ version: 1.36.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthieu
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-07-31 00:00:00.000000000 Z
12
+ date: 2026-08-04 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activesupport