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 +4 -4
- data/lib/forest_admin_agent/routes/query_handler.rb +4 -8
- data/lib/forest_admin_agent/routes/resources/native_query.rb +15 -2
- data/lib/forest_admin_agent/services/permissions.rb +6 -0
- data/lib/forest_admin_agent/utils/context_variables.rb +11 -3
- data/lib/forest_admin_agent/utils/context_variables_injector.rb +79 -12
- data/lib/forest_admin_agent/utils/schema/schema_emitter.rb +1 -1
- data/lib/forest_admin_agent/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8dfe78a2ed0c19256af6975b916fedd900f5ec28e5cb2ab5d5e712a8d28621f0
|
|
4
|
+
data.tar.gz: 7681368ebd2a38978c141d8dc509be092de45daab2ee1613426f4964b0128695
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
encountered_variables = {}
|
|
36
|
+
state = { binds: [], replacements: {}, reusable: nil }
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
44
|
+
[injected_query, state[:binds]]
|
|
45
|
+
end
|
|
42
46
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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)
|
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.
|
|
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-
|
|
12
|
+
date: 2026-08-04 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: activesupport
|