activeadmin-graphql 0.1.2 → 0.2.1

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.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/activeadmin-graphql.gemspec +5 -2
  4. data/app/controllers/active_admin/graphql_controller.rb +83 -31
  5. data/docs/graphql-api.md +28 -4
  6. data/lib/active_admin/graphql/integration.rb +33 -13
  7. data/lib/active_admin/graphql/key_value_pair_input.rb +20 -12
  8. data/lib/active_admin/graphql/policy_set_cache.rb +64 -0
  9. data/lib/active_admin/graphql/record_source.rb +24 -11
  10. data/lib/active_admin/graphql/resource_query_proxy/controller.rb +22 -6
  11. data/lib/active_admin/graphql/resource_query_proxy.rb +32 -4
  12. data/lib/active_admin/graphql/run_action_mutation_config.rb +2 -0
  13. data/lib/active_admin/graphql/run_action_mutation_dsl.rb +4 -0
  14. data/lib/active_admin/graphql/run_action_payload.rb +2 -1
  15. data/lib/active_admin/graphql/schema_builder/mutation_batch.rb +9 -1
  16. data/lib/active_admin/graphql/schema_builder/mutation_collection.rb +11 -2
  17. data/lib/active_admin/graphql/schema_builder/mutation_member.rb +11 -2
  18. data/lib/active_admin/graphql/schema_builder/query_type.rb +7 -0
  19. data/lib/active_admin/graphql/schema_builder/query_type_policies.rb +160 -0
  20. data/lib/active_admin/graphql/schema_builder/types_object.rb +27 -2
  21. data/lib/active_admin/graphql/schema_builder/wire.rb +11 -1
  22. data/lib/active_admin/graphql/schema_builder.rb +1 -0
  23. data/lib/active_admin/graphql/schema_field.rb +21 -1
  24. data/lib/active_admin/graphql/version.rb +1 -1
  25. data/lib/active_admin/graphql.rb +37 -16
  26. data/lib/active_admin/primary_key.rb +61 -39
  27. data/sig/active_admin/graphql.rbs +7 -0
  28. metadata +47 -16
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cc303bd74d84b05385c3a3af874e1dd66957029746729122a5d684ac9720a4cd
4
- data.tar.gz: c2ab55694cb7b62773fd22db7fb219850fb3901a3d5bf9565a9ac00b0804db6e
3
+ metadata.gz: '00998f8445b9a5f9a7d4a939029d6e6460527432d658711b713df1ad293abc9e'
4
+ data.tar.gz: fd5f948edd2277b99e68ee384ccf7d31c8ffa8c890bf2f277837106213498652
5
5
  SHA512:
6
- metadata.gz: 0c90194015f4ad4ff7ebf9125ad6c7b2b4c0cf800a622f7cafba0d9a88945bdc57956eca598b2991aa32ceeec66ea925c5e9e5749ab38ad56a042c22f1de6437
7
- data.tar.gz: f023d72351949ed1b0817ec01839ff1ce45aeae3d5ffba51a7651307fdf51c598171e085a5f0fe9b0b183bb62ff4da09e0810c57e4afb5c54ded43db1f9b12e2
6
+ metadata.gz: fb9a627765be0dfb70171b6c2b9be70828e35a86fb0be2c79df237b0dbadc0559376e176b04ab6a2de49d22404c9773a4f9bee1427d1007b139dc71114afb991
7
+ data.tar.gz: 7c2d075e817702fd05648cf0f6dee228938d7d09400be066f99d4d50b23eb4d301f8ee0ac1632974ea57317f8a2ee7bcee81f5282481e9c477c9e8fd312e5db1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.2.1 (2026-07-14)
6
+
7
+ - Add RBS type signatures to the published gem for Steep and other Ruby type checkers
8
+ - Add Ruby 4.0 to the supported compatibility matrix
9
+ - Return HTTP 400 with a clear error when the GraphQL request body or variables contain invalid JSON
10
+ - Return `null` for member queries when the record id does not exist
11
+ - Enforce read authorization on `belongs_to` association fields so denied parent records resolve as `null`
12
+ - Build GraphQL schemas safely when `schema_for` runs concurrently
13
+
14
+ ## 0.2.0 (2026-04-29)
15
+
16
+ - Add `activeadmin_policies` GraphQL policy surfaces:
17
+ - global `activeadmin_policies` for resources/pages
18
+ - per-object `activeadmin_policies` on resource objects
19
+ - preflight `activeadmin_policies_for(type_name:, ids:, path:)` for per-record checks before running queries/mutations/actions
20
+ - Switch policy payloads to allow-lists (`allowed_actions`, `allowed_member_actions`, `allowed_collection_actions`, `allowed_batch_actions`) and add namespace customization hooks (`graphql_policy_actions`, `graphql_policy_action_mapper`, `graphql_policy_extra`, `graphql_policy_transform`)
21
+ - Enforce authorization-by-default for custom GraphQL fields/mutations with explicit opt-out (`authorize: false` / mutation DSL `authorize false`)
22
+ - Add namespace defaults/settings and request specs covering policies, customization hooks, and auth-toggle behavior
23
+
3
24
  ## 0.1.2 (2026-03-30)
4
25
 
5
26
  - Fix TruffleRuby compatibility issue with JSON.dump
@@ -36,7 +36,8 @@ Gem::Specification.new do |spec|
36
36
  ].select { |f| File.file?(f) } +
37
37
  Dir["docs/**/*.md"] +
38
38
  Dir["app/**/*.rb"] +
39
- Dir["lib/**/*.rb"]
39
+ Dir["lib/**/*.rb"] +
40
+ Dir["sig/**/*"]
40
41
  end
41
42
 
42
43
  spec.require_paths = ["lib"]
@@ -51,11 +52,12 @@ Gem::Specification.new do |spec|
51
52
  spec.add_development_dependency "parallel_tests", "~> 4.7"
52
53
  spec.add_development_dependency "rails", ">= 6.1"
53
54
  spec.add_development_dependency "rspec", "~> 3"
55
+ spec.add_development_dependency "polyrun", ">= 2.2.0"
56
+ spec.add_development_dependency "prosopite", "~> 2.0"
54
57
  spec.add_development_dependency "rspec-rails", ">= 6"
55
58
  spec.add_development_dependency "rubocop-rails", "~> 2.34"
56
59
  spec.add_development_dependency "rubocop-rspec", "~> 3.8"
57
60
  spec.add_development_dependency "rubocop-thread_safety", "~> 0.7"
58
- spec.add_development_dependency "simplecov", "~> 0.22"
59
61
  spec.add_development_dependency "sprockets-rails", ">= 3.4"
60
62
  spec.add_development_dependency "sqlite3", ">= 1"
61
63
  spec.add_development_dependency "standard", "~> 1.52"
@@ -63,4 +65,5 @@ Gem::Specification.new do |spec|
63
65
  spec.add_development_dependency "standard-performance", "~> 1.8"
64
66
  spec.add_development_dependency "standard-rails", "~> 1.5"
65
67
  spec.add_development_dependency "standard-rspec", "~> 0.3"
68
+ spec.add_development_dependency "rbs", "~> 3"
66
69
  end
@@ -9,40 +9,18 @@ module ActiveAdmin
9
9
  class GraphqlController < ApplicationController
10
10
  protect_from_forgery with: :exception
11
11
 
12
+ rescue_from ActionDispatch::Http::Parameters::ParseError do
13
+ render json: {errors: [{message: "Invalid JSON"}]}, status: :bad_request
14
+ end
15
+
12
16
  before_action :ensure_graphql_enabled!
13
17
  before_action :authenticate_graphql!
14
18
 
15
19
  def execute
16
20
  schema = ActiveAdmin::GraphQL.schema_for(active_admin_namespace)
21
+ return render_multiplex(schema) if multiplex_request?
17
22
 
18
- if multiplex_request?
19
- operations = multiplex_operations
20
- max_n = active_admin_namespace.graphql_multiplex_max || 20
21
- if operations.size > max_n
22
- return render json: {errors: [{message: "Multiplex exceeds maximum of #{max_n}"}]},
23
- status: :content_too_large
24
- end
25
-
26
- results = schema.multiplex(
27
- operations.map do |op|
28
- {
29
- query: op[:query],
30
- variables: ensure_variables(op[:variables]),
31
- operation_name: op[:operation_name],
32
- context: graphql_context
33
- }
34
- end
35
- )
36
- render json: results.map(&:to_h), status: :ok
37
- else
38
- result = schema.execute(
39
- query: query_string,
40
- variables: ensure_variables(variables_hash),
41
- operation_name: operation_name,
42
- context: graphql_context
43
- )
44
- render json: result.to_h, status: :ok
45
- end
23
+ render_single(schema)
46
24
  end
47
25
 
48
26
  def active_admin_namespace
@@ -130,6 +108,8 @@ module ActiveAdmin
130
108
 
131
109
  def request_body_hash
132
110
  json = request_body_json
111
+ return nil if json.nil? || json == :invalid_json
112
+
133
113
  json if json.is_a?(Hash)
134
114
  end
135
115
 
@@ -145,13 +125,21 @@ module ActiveAdmin
145
125
  body.present? ? JSON.parse(body) : nil
146
126
  end
147
127
  rescue JSON::ParserError
148
- @request_body_json = nil
128
+ @request_body_json = :invalid_json
129
+ end
130
+
131
+ def invalid_request_json?
132
+ request_body_json == :invalid_json
149
133
  end
150
134
 
151
135
  def ensure_variables(raw)
152
136
  case raw
153
137
  when String
154
- raw.present? ? JSON.parse(raw) : {}
138
+ if raw.blank?
139
+ {}
140
+ else
141
+ JSON.parse(raw)
142
+ end
155
143
  when Hash
156
144
  raw
157
145
  when ActionController::Parameters
@@ -162,7 +150,71 @@ module ActiveAdmin
162
150
  {}
163
151
  end
164
152
  rescue JSON::ParserError
165
- {}
153
+ :invalid_json
154
+ end
155
+
156
+ def render_invalid_json!
157
+ render json: {errors: [{message: "Invalid JSON"}]}, status: :bad_request
158
+ end
159
+
160
+ def render_multiplex(schema)
161
+ return render_invalid_json! if invalid_request_json?
162
+
163
+ operations = multiplex_operations
164
+ return render_multiplex_limit_error!(operations.size) if exceeds_multiplex_limit?(operations)
165
+
166
+ payloads = build_multiplex_payloads(operations)
167
+ return if performed?
168
+
169
+ results = schema.multiplex(payloads)
170
+ render json: results.map(&:to_h), status: :ok
171
+ end
172
+
173
+ def render_single(schema)
174
+ return render_invalid_json! if invalid_request_json?
175
+
176
+ variables = ensure_variables(variables_hash)
177
+ return render_invalid_json! if variables == :invalid_json
178
+
179
+ result = schema.execute(
180
+ query: query_string,
181
+ variables: variables,
182
+ operation_name: operation_name,
183
+ context: graphql_context
184
+ )
185
+ render json: result.to_h, status: :ok
186
+ end
187
+
188
+ def exceeds_multiplex_limit?(operations)
189
+ operations.size > max_multiplex_operations
190
+ end
191
+
192
+ def max_multiplex_operations
193
+ active_admin_namespace.graphql_multiplex_max || 20
194
+ end
195
+
196
+ def render_multiplex_limit_error!(count)
197
+ max_n = max_multiplex_operations
198
+ msg = "Multiplex exceeds maximum of #{max_n} (received #{count})"
199
+ render json: {errors: [{message: msg}]}, status: :content_too_large
200
+ end
201
+
202
+ def build_multiplex_payloads(operations)
203
+ context = graphql_context
204
+ operations.map do |operation|
205
+ variables = ensure_variables(operation[:variables])
206
+ if variables == :invalid_json
207
+ render_invalid_json!
208
+ return []
209
+ end
210
+
211
+ {
212
+ query: operation[:query],
213
+ variables: variables,
214
+ operation_name: operation[:operation_name],
215
+ context: context
216
+ }
217
+ end
166
218
  end
167
219
  end
168
220
  end
data/docs/graphql-api.md CHANGED
@@ -46,10 +46,16 @@ Each namespace inherits these options (defaults in parentheses):
46
46
  * `graphql_path` — path segment under the namespace mount (`"graphql"`).
47
47
  * `graphql_multiplex_max` — maximum operations per multiplexed JSON array
48
48
  request (`20`).
49
- * `graphql_max_complexity` — passed to graphql-ruby `max_complexity` when set.
50
- * `graphql_max_depth` passed to `max_depth` when set.
51
- * `graphql_default_page_size` / `graphql_default_max_page_size` connection
52
- defaults when set.
49
+ * `graphql_max_complexity` — passed to graphql-ruby `max_complexity` (`300` by
50
+ default; set a higher value if legitimate queries are rejected). Set to
51
+ `nil` to disable the limit.
52
+ * `graphql_max_depth` — passed to `max_depth` (`18` by default). Set to `nil`
53
+ to disable.
54
+ * `graphql_batch_action_max_ids` — maximum length of the `ids` argument on
55
+ `*_batch_action` mutations (`5000` by default; set `0` to disable the cap).
56
+ * `graphql_default_page_size` / `graphql_default_max_page_size` — schema
57
+ connection defaults (`25` / `100`); needed for graphql-ruby complexity scoring
58
+ on connection fields when `max_complexity` is enabled.
53
59
  * `graphql_dataloader` — graphql-ruby dataloader plugin (`nil` = use
54
60
  `GraphQL::Dataloader`). Set to `GraphQL::Dataloader::AsyncDataloader` when
55
61
  the [`async` gem](https://github.com/socketry/async) is loaded if you want
@@ -155,6 +161,24 @@ separate route with CSRF disabled. Treat this endpoint like any other
155
161
  The namespace `authentication_method` runs before any GraphQL work, including
156
162
  schema introspection. Unauthenticated clients should not receive a schema.
157
163
 
164
+ If you need to turn off schema introspection entirely (even for authenticated
165
+ sessions or specific environments), call
166
+ [`schema.disable_introspection_entry_points`](https://graphql-ruby.org/schema/definition.html#schema-configuration)
167
+ from `graphql_configure_schema`. The hook receives the generated schema class,
168
+ so you can toggle introspection based on Rails environment or other app
169
+ settings:
170
+
171
+ ```ruby
172
+ ActiveAdmin.setup do |config|
173
+ config.namespace :admin do |admin|
174
+ admin.graphql = true
175
+ admin.graphql_configure_schema = lambda do |schema|
176
+ schema.disable_introspection_entry_points if Rails.env.production?
177
+ end
178
+ end
179
+ end
180
+ ```
181
+
158
182
  The GraphQL context exposes the same user as `current_active_admin_user` (from
159
183
  the namespace `current_user_method`), wrapped for authorization checks.
160
184
 
@@ -31,11 +31,18 @@ module ActiveAdmin
31
31
  ns.register :graphql_visibility, nil
32
32
  ns.register :graphql_visibility_profile, nil
33
33
  ns.register :graphql_schema_visible, nil
34
- ns.register :graphql_max_complexity, nil
35
- ns.register :graphql_max_depth, nil
36
- ns.register :graphql_default_page_size, nil
37
- ns.register :graphql_default_max_page_size, nil
34
+ ns.register :graphql_max_complexity, 300
35
+ ns.register :graphql_max_depth, 18
36
+ ns.register :graphql_batch_action_max_ids, 5000
37
+ ns.register :graphql_default_page_size, 25
38
+ ns.register :graphql_default_max_page_size, 100
38
39
  ns.register :graphql_configure_schema, nil
40
+ ns.register :graphql_policy_actions, nil
41
+ ns.register :graphql_policy_action_mapper, nil
42
+ ns.register :graphql_policy_extra, nil
43
+ ns.register :graphql_policy_transform, nil
44
+ ns.register :graphql_custom_field_authorization_default, true
45
+ ns.register :graphql_custom_mutation_authorization_default, true
39
46
  end
40
47
 
41
48
  def register_railtie!
@@ -114,18 +121,31 @@ module ActiveAdmin
114
121
  private
115
122
 
116
123
  def define_graphql_routes
117
- namespaces.each do |namespace|
118
- next unless namespace.graphql
124
+ graphql_namespaces.each do |namespace|
125
+ segment = graphql_segment_for(namespace)
126
+ defaults = graphql_route_defaults(namespace)
127
+ mount_graphql_route(namespace, segment, defaults)
128
+ end
129
+ end
130
+
131
+ def graphql_namespaces
132
+ namespaces.select(&:graphql)
133
+ end
119
134
 
120
- segment = namespace.graphql_path.to_s.delete_prefix("/").presence || "graphql"
121
- defaults = {active_admin_namespace: namespace.name}
135
+ def graphql_segment_for(namespace)
136
+ namespace.graphql_path.to_s.delete_prefix("/").presence || "graphql"
137
+ end
138
+
139
+ def graphql_route_defaults(namespace)
140
+ {active_admin_namespace: namespace.name}
141
+ end
122
142
 
123
- if namespace.root?
143
+ def mount_graphql_route(namespace, segment, defaults)
144
+ if namespace.root?
145
+ router.post segment, controller: "/active_admin/graphql", action: "execute", defaults: defaults
146
+ else
147
+ router.namespace namespace.name, **namespace.route_options.dup do
124
148
  router.post segment, controller: "/active_admin/graphql", action: "execute", defaults: defaults
125
- else
126
- router.namespace namespace.name, **namespace.route_options.dup do
127
- router.post segment, controller: "/active_admin/graphql", action: "execute", defaults: defaults
128
- end
129
149
  end
130
150
  end
131
151
  end
@@ -30,18 +30,26 @@ module ActiveAdmin
30
30
  end
31
31
  end
32
32
 
33
- def extract_pair(e)
34
- if e.respond_to?(:key) && e.respond_to?(:value) && !e.is_a?(Hash)
35
- [e.key, e.value]
36
- elsif e.is_a?(Hash)
37
- k = e["key"] || e[:key]
38
- v = e["value"] || e[:value]
39
- raise ::GraphQL::ExecutionError, "key/value pair missing key" if k.nil?
40
-
41
- [k, v]
42
- else
43
- raise ::GraphQL::ExecutionError, "invalid key/value pair entry: #{e.class}"
44
- end
33
+ def extract_pair(entry)
34
+ return extract_from_struct(entry) if key_value_struct?(entry)
35
+ return extract_from_hash(entry) if entry.is_a?(Hash)
36
+
37
+ raise ::GraphQL::ExecutionError, "invalid key/value pair entry: #{entry.class}"
38
+ end
39
+
40
+ def key_value_struct?(entry)
41
+ entry.respond_to?(:key) && entry.respond_to?(:value) && !entry.is_a?(Hash)
42
+ end
43
+
44
+ def extract_from_struct(entry)
45
+ [entry.key, entry.value]
46
+ end
47
+
48
+ def extract_from_hash(entry)
49
+ key = entry["key"] || entry[:key]
50
+ raise ::GraphQL::ExecutionError, "key/value pair missing key" if key.nil?
51
+
52
+ [key, entry["value"] || entry[:value]]
45
53
  end
46
54
  end
47
55
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module GraphQL
5
+ # Request-scoped memoization for ActiveAdmin policy sets on GraphQL objects.
6
+ class PolicySetCache
7
+ class << self
8
+ def fetch(context, subject_owner:, subject:)
9
+ namespace = context[:namespace]
10
+ unless namespace
11
+ return policy_builder(context).send(
12
+ :build_policy_set,
13
+ auth: context[:auth],
14
+ subject_owner: subject_owner,
15
+ subject: subject,
16
+ context: context
17
+ )
18
+ end
19
+
20
+ cache = context[:policy_set_cache] ||= {}
21
+ key = cache_key(subject_owner, subject)
22
+ cache[key] ||= policy_builder(context).send(
23
+ :build_policy_set,
24
+ auth: context[:auth],
25
+ subject_owner: subject_owner,
26
+ subject: subject,
27
+ context: context
28
+ )
29
+ end
30
+
31
+ def policy_builder(context)
32
+ context[:policy_schema_builder] ||= SchemaBuilder.new(context[:namespace])
33
+ end
34
+
35
+ def cache_key(subject_owner, subject)
36
+ owner_key = subject_owner_key(subject_owner)
37
+ "#{owner_key}:#{subject_key(subject)}"
38
+ end
39
+
40
+ private
41
+
42
+ def subject_owner_key(subject_owner)
43
+ if subject_owner.is_a?(ActiveAdmin::Page)
44
+ "page-owner:#{subject_owner.name}"
45
+ else
46
+ "resource-owner:#{subject_owner.resource_class.name}"
47
+ end
48
+ end
49
+
50
+ def subject_key(subject)
51
+ if subject.is_a?(Class)
52
+ "class:#{subject.name}"
53
+ elsif subject.is_a?(ActiveAdmin::Page)
54
+ "page:#{subject.name}"
55
+ elsif subject.is_a?(ActiveRecord::Base)
56
+ "record:#{subject.class.name}:#{PrimaryKey.graphql_id_value(subject)}"
57
+ else
58
+ "object:#{subject.object_id}"
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -10,21 +10,34 @@ module ActiveAdmin
10
10
  end
11
11
 
12
12
  def fetch(ids)
13
- keys = ids.map { |raw| raw.nil? ? nil : ActiveAdmin::PrimaryKey.dataloader_tuple(@model_class, raw) }
13
+ keys = normalized_keys(ids)
14
14
  uniq = keys.compact.uniq
15
- return ids.map { nil } if uniq.empty?
15
+ return Array.new(ids.size) { nil } if uniq.empty?
16
16
 
17
- if ActiveAdmin::PrimaryKey.composite?(@model_class)
18
- records = @model_class.where(@pk => uniq).to_a
19
- by_tuple = records.index_by { |r| ActiveAdmin::PrimaryKey.dataloader_tuple_from_record(r) }
20
- keys.map { |t| t.nil? ? nil : by_tuple[t] }
21
- else
22
- pk_sym = @pk.to_sym
23
- records = @model_class.where(pk_sym => uniq).to_a
24
- by_id = records.index_by { |r| r.public_send(pk_sym) }
25
- keys.map { |k| k.nil? ? nil : by_id[k] }
17
+ indexed = load_records(uniq)
18
+ keys.map { |key| key.nil? ? nil : indexed[key] }
19
+ end
20
+
21
+ def normalized_keys(ids)
22
+ ids.map do |raw|
23
+ raw.nil? ? nil : ActiveAdmin::PrimaryKey.dataloader_tuple(@model_class, raw)
26
24
  end
27
25
  end
26
+
27
+ def load_records(keys)
28
+ ActiveAdmin::PrimaryKey.composite?(@model_class) ? composite_index(keys) : single_column_index(keys)
29
+ end
30
+
31
+ def composite_index(keys)
32
+ records = @model_class.where(@pk => keys).to_a
33
+ records.index_by { |record| ActiveAdmin::PrimaryKey.dataloader_tuple_from_record(record) }
34
+ end
35
+
36
+ def single_column_index(keys)
37
+ pk_sym = @pk.to_sym
38
+ records = @model_class.where(pk_sym => keys).to_a
39
+ records.index_by { |record| record.public_send(pk_sym) }
40
+ end
28
41
  end
29
42
  end
30
43
  end
@@ -12,15 +12,24 @@ module ActiveAdmin
12
12
  end
13
13
 
14
14
  def param_hash_for(action, extra)
15
+ base_graphql_params(action)
16
+ .merge(extra.stringify_keys)
17
+ .yield_self { |params| merge_graph_params!(params) }
18
+ end
19
+
20
+ def base_graphql_params(action)
15
21
  {
16
22
  "action" => action,
17
23
  "controller" => "active_admin/graphql"
18
- }.merge(extra.stringify_keys).tap do |h|
19
- merge_ransack!(h)
20
- h["scope"] = @graph_params["scope"].to_s if @graph_params["scope"].present?
21
- h["order"] = @graph_params["order"].to_s if @graph_params["order"].present?
22
- merge_belongs_to!(h)
23
- end
24
+ }
25
+ end
26
+
27
+ def merge_graph_params!(hash)
28
+ merge_ransack!(hash)
29
+ assign_if_present!(hash, "scope", @graph_params["scope"])
30
+ assign_if_present!(hash, "order", @graph_params["order"])
31
+ merge_belongs_to!(hash)
32
+ hash
24
33
  end
25
34
 
26
35
  def merge_ransack!(h)
@@ -50,6 +59,13 @@ module ActiveAdmin
50
59
  h[key] = val.to_s if val.present?
51
60
  end
52
61
 
62
+ def assign_if_present!(hash, key, value)
63
+ return hash if value.blank?
64
+
65
+ hash[key] = value.to_s
66
+ hash
67
+ end
68
+
53
69
  def stub_controller!(controller, hash)
54
70
  user = @user
55
71
  params_obj = ActionController::Parameters.new(hash)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rack/mock"
4
+ require "stringio"
4
5
 
5
6
  require_relative "resource_query_proxy/controller"
6
7
 
@@ -26,6 +27,26 @@ module ActiveAdmin
26
27
  def find_member(id)
27
28
  extra = member_route_params_for_find(id)
28
29
  controller_for("show", extra).send(:find_resource)
30
+ rescue ActiveRecord::RecordNotFound
31
+ nil
32
+ end
33
+
34
+ def find_members(ids)
35
+ string_ids = Array(ids).map(&:to_s).uniq
36
+ return {} if string_ids.empty?
37
+
38
+ model = @aa_resource.resource_class
39
+ if ActiveAdmin::PrimaryKey.composite?(model)
40
+ return string_ids.index_with { |identifier| find_member(identifier) }
41
+ end
42
+
43
+ controller = controller_for("index")
44
+ relation = controller.send(:apply_authorization_scope, controller.send(:scoped_collection))
45
+ primary_key = model.primary_key.to_sym
46
+ indexed = relation.where(primary_key => string_ids).index_by do |record|
47
+ record.public_send(primary_key).to_s
48
+ end
49
+ string_ids.index_with { |identifier| indexed[identifier] }
29
50
  end
30
51
 
31
52
  def build_new(attributes)
@@ -49,10 +70,9 @@ module ActiveAdmin
49
70
  extra = {
50
71
  "batch_action" => batch_sym.to_s,
51
72
  "collection_selection" => Array(ids).map(&:to_s),
52
- # JSON.generate/to_json can attempt to append to a frozen literal on TruffleRuby.
53
- # Using JSON.dump with an explicit mutable buffer avoids mutating the gem's internal
54
- # literals while still serializing to the string that ActiveAdmin expects.
55
- "batch_action_inputs" => JSON.dump(inputs, +"")
73
+ # TruffleRuby freezes JSON's internal "{}" buffer, so Hash#to_json raises FrozenError.
74
+ # Dumping into a dedicated StringIO avoids mutating the shared literal.
75
+ "batch_action_inputs" => dump_batch_inputs(inputs)
56
76
  }
57
77
  c = controller_for("batch_action", extra)
58
78
  perform_controller_command!(c) { c.send(:batch_action) }
@@ -110,6 +130,14 @@ module ActiveAdmin
110
130
  stub_controller!(c, h)
111
131
  c
112
132
  end
133
+
134
+ def dump_batch_inputs(hash)
135
+ buffer = StringIO.new
136
+ JSON.dump(hash, buffer)
137
+ buffer.string
138
+ rescue FrozenError
139
+ JSON.generate(hash)
140
+ end
113
141
  end
114
142
  end
115
143
  end
@@ -12,6 +12,8 @@ module ActiveAdmin
12
12
  attr_accessor :resolve_proc
13
13
  # Optional block evaluated in the graphql-ruby +field+ DSL context (+argument+, …) for per-action fields.
14
14
  attr_accessor :arguments_proc
15
+ # Optional authorization toggle for this mutation configuration. +nil+ means namespace default.
16
+ attr_accessor :authorize
15
17
 
16
18
  def self.ensure_graphql_object_subclass!(type)
17
19
  unless type.is_a?(Class) && type < ::GraphQL::Schema::Object
@@ -27,6 +27,10 @@ module ActiveAdmin
27
27
  def arguments(&block)
28
28
  @mutation_config.arguments_proc = block
29
29
  end
30
+
31
+ def authorize(value = true)
32
+ @mutation_config.authorize = !!value
33
+ end
30
34
  end
31
35
  end
32
36
  end
@@ -21,7 +21,8 @@ module ActiveAdmin
21
21
  field :body, ::GraphQL::Types::String, null: true,
22
22
  description: "Response body text when the action rendered (e.g. JSON)."
23
23
 
24
- Result = Struct.new(:ok, :status, :location, :body, keyword_init: true)
24
+ # keyword_init required for Ruby 3.2 consumers; cop targets 3.4 default Struct behavior
25
+ Result = Struct.new(:ok, :status, :location, :body, keyword_init: true) # rubocop:disable Style/RedundantStructKeywordInit
25
26
  end
26
27
  end
27
28
  end
@@ -29,7 +29,9 @@ module ActiveAdmin
29
29
  define_method(fname.to_sym) do |batch_action:, ids:, inputs: nil, **kw|
30
30
  auth = context[:auth]
31
31
  mdl = aa_res.resource_class
32
- unless auth.authorized?(aa_res, ActiveAdmin::Authorization::READ, mdl)
32
+ cfg = aa_res.graphql_config.batch_run_action
33
+ auth_enabled = cfg.authorize.nil? ? (ns.graphql_custom_mutation_authorization_default != false) : cfg.authorize
34
+ if auth_enabled && !auth.authorized?(aa_res, ActiveAdmin::Authorization::READ, mdl)
33
35
  raise ::GraphQL::ExecutionError, "not authorized to read #{mdl.name}"
34
36
  end
35
37
 
@@ -38,6 +40,12 @@ module ActiveAdmin
38
40
  raise ::GraphQL::ExecutionError, "Unknown batch_action #{ba.inspect} for #{plural}"
39
41
  end
40
42
 
43
+ max_ids = ns.graphql_batch_action_max_ids
44
+ if max_ids.is_a?(Integer) && max_ids.positive? && ids.size > max_ids
45
+ raise ::GraphQL::ExecutionError,
46
+ "ids cannot exceed #{max_ids} entries (received #{ids.size})"
47
+ end
48
+
41
49
  proxy = ResourceQueryProxy.new(
42
50
  aa_resource: aa_res,
43
51
  user: context[:auth].user,