forest_admin_agent 1.36.0 → 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: 59bcabb74959ab090658d106c743b18c6503f75b085ad32c9282288a2ece4963
4
- data.tar.gz: 66c2283f513b2f22d603b16f30480713992879a31fb46985f14dd7cf74b94c0a
3
+ metadata.gz: 8dfe78a2ed0c19256af6975b916fedd900f5ec28e5cb2ab5d5e712a8d28621f0
4
+ data.tar.gz: 7681368ebd2a38978c141d8dc509be092de45daab2ee1613426f4964b0128695
5
5
  SHA512:
6
- metadata.gz: de2234332df46b3fbe7ddfeaf7425df1f0e789d39e9c0cba722044508f03019cabffe700edf977160beb1a087c96d07deb67f65b3e0ca1c8f4e31706be03c909
7
- data.tar.gz: 8dd2c52e79c5253e7618ef9c70a93db8f4920bb56f96b9b7c2da5d6afb220fcc704d29f10019ecee4901d8d6e5755087dbb2067da3c024c2bf6dc3e7697a1c2a
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
 
@@ -1,3 +1,5 @@
1
+ require 'json'
2
+
1
3
  module ForestAdminAgent
2
4
  module Utils
3
5
  class ContextVariablesInjector
@@ -5,55 +7,131 @@ module ForestAdminAgent
5
7
  include ForestAdminAgent::Builder
6
8
 
7
9
  REGEX = /{{([^}]+)}}/
10
+ FULL_REFERENCE_REGEX = /\A{{([^}]+)}}\z/
11
+ COMMENT_MARKERS = ['--', '/*'].freeze
8
12
 
9
13
  def self.inject_context_in_value(value, context_variables)
14
+ return value unless value.is_a?(String)
15
+
16
+ # A value that is exactly one {{variable}} reference (no surrounding text) keeps the
17
+ # resolved object as-is, so a jsonb/array field gets typed-cast correctly by the
18
+ # datasource instead of being compared against a serialized string that can never match.
19
+ full_reference = FULL_REFERENCE_REGEX.match(value)
20
+ return context_variables.get_value(full_reference[1]) if full_reference
21
+
10
22
  inject_context_in_value_custom(value) do |context_variable_key|
11
- context_variables.get_value(context_variable_key).to_s
23
+ serialize_for_injection(context_variables.get_value(context_variable_key))
12
24
  end
13
25
  end
14
26
 
27
+ def self.serialize_for_injection(resolved_value)
28
+ return JSON.generate(resolved_value) if resolved_value.is_a?(Array) || resolved_value.is_a?(Hash)
29
+
30
+ resolved_value.to_s
31
+ end
32
+
15
33
  def self.inject_context_in_native_query(datasource, connection_name, query, context_variables)
16
34
  return query unless query.is_a?(String)
17
35
 
18
- query_with_context_variables_injected = query
19
- encountered_variables = {}
36
+ state = { binds: [], replacements: {}, reusable: nil }
20
37
 
21
- while (match = REGEX.match(query_with_context_variables_injected))
22
- 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
23
43
 
24
- next if encountered_variables.value?(context_variable_key)
44
+ [injected_query, state[:binds]]
45
+ end
25
46
 
26
- index = datasource.build_binding_symbol(connection_name, encountered_variables)
27
- query_with_context_variables_injected.gsub!(
28
- /{{#{context_variable_key}}}/,
29
- index
30
- )
31
- 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
32
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
33
92
 
34
- [query_with_context_variables_injected, encountered_variables]
93
+ i += 1
94
+ end
95
+
96
+ return :code unless quote
97
+
98
+ quote == "'" ? :string : :identifier
35
99
  end
36
100
 
37
- def self.inject_context_in_value_custom(value)
38
- return value unless value.is_a?(String)
101
+ def self.skip_sql_comment(text, index)
102
+ return text.index("\n", index)&.succ if text[index, 2] == '--'
39
103
 
40
- value_with_context_variables_injected = value
41
- encountered_variables = []
104
+ text.index('*/', index + 2)&.succ&.succ
105
+ end
42
106
 
43
- while (match = REGEX.match(value_with_context_variables_injected))
44
- context_variable_key = match[1]
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
45
112
 
46
- unless encountered_variables.include?(context_variable_key)
47
- value_with_context_variables_injected.gsub!(
48
- /{{#{context_variable_key}}}/,
49
- yield(context_variable_key)
50
- )
51
- end
113
+ def self.raise_unless_live_sql(key, text_before_match)
114
+ context = sql_context_before(text_before_match)
115
+ return if context == :code
52
116
 
53
- encountered_variables.push(context_variable_key)
54
- end
117
+ raise ForestAdminDatasourceToolkit::Exceptions::ForestException,
118
+ "The '{{#{key}}}' placeholder #{PLACEHOLDER_CONTEXT_ERRORS[context]}."
119
+ end
120
+
121
+ def self.inject_context_in_value_custom(value)
122
+ return value unless value.is_a?(String)
123
+
124
+ # Resolve every distinct {{key}} found in the ORIGINAL string upfront, then substitute
125
+ # in a single gsub pass. A resolved value can itself contain "{{...}}"-looking text (e.g.
126
+ # a tag whose data happens to include it); re-scanning a mutated string for placeholders
127
+ # (the previous while+gsub! approach) would misinterpret that text as a new reference, or
128
+ # loop forever if it matched an already-resolved key without ever changing the string.
129
+ keys = value.scan(REGEX).map(&:first).uniq
130
+ return value if keys.empty?
131
+
132
+ replacements = keys.to_h { |key| [key, yield(key)] }
55
133
 
56
- value_with_context_variables_injected
134
+ value.gsub(REGEX) { replacements[::Regexp.last_match(1)] }
57
135
  end
58
136
 
59
137
  def self.inject_context_in_filter(filter, context_variables)
@@ -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.0"
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.0"
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.0
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-24 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