activeadmin-graphql 0.1.2 → 0.2.0

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: cc303bd74d84b05385c3a3af874e1dd66957029746729122a5d684ac9720a4cd
4
- data.tar.gz: c2ab55694cb7b62773fd22db7fb219850fb3901a3d5bf9565a9ac00b0804db6e
3
+ metadata.gz: 15355b8548053d83cb751fc238ff2f9d4c910be902cf3aab25c2c91741a33fa9
4
+ data.tar.gz: 8d76e29dcfe43079b171d19b817d52f13327c286a234688dd32c7e04efcc1522
5
5
  SHA512:
6
- metadata.gz: 0c90194015f4ad4ff7ebf9125ad6c7b2b4c0cf800a622f7cafba0d9a88945bdc57956eca598b2991aa32ceeec66ea925c5e9e5749ab38ad56a042c22f1de6437
7
- data.tar.gz: f023d72351949ed1b0817ec01839ff1ce45aeae3d5ffba51a7651307fdf51c598171e085a5f0fe9b0b183bb62ff4da09e0810c57e4afb5c54ded43db1f9b12e2
6
+ metadata.gz: a8a5472cfd32ac45a662733ee46fe1b0f918ea96e71db5bdf25ff9191a3c808c7b9ec047b4e4e2e0b52269a2a3aba20ff7d5cce39607bdf28448f85139beff39
7
+ data.tar.gz: 873a5814296d27af34ac8a6742ada804d61e4f855c33d0b5a092793ec7260625c68c54d8831977b88e59a7d7299a5cb22f77b502ab81b504e2d523b01e505cae
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 (2026-04-29)
4
+
5
+ - Add `activeadmin_policies` GraphQL policy surfaces:
6
+ - global `activeadmin_policies` for resources/pages
7
+ - per-object `activeadmin_policies` on resource objects
8
+ - preflight `activeadmin_policies_for(type_name:, ids:, path:)` for per-record checks before running queries/mutations/actions
9
+ - 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`)
10
+ - Enforce authorization-by-default for custom GraphQL fields/mutations with explicit opt-out (`authorize: false` / mutation DSL `authorize false`)
11
+ - Add namespace defaults/settings and request specs covering policies, customization hooks, and auth-toggle behavior
12
+
3
13
  ## 0.1.2 (2026-03-30)
4
14
 
5
15
  - Fix TruffleRuby compatibility issue with JSON.dump
@@ -14,35 +14,9 @@ module ActiveAdmin
14
14
 
15
15
  def execute
16
16
  schema = ActiveAdmin::GraphQL.schema_for(active_admin_namespace)
17
+ return render_multiplex(schema) if multiplex_request?
17
18
 
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
19
+ render_single(schema)
46
20
  end
47
21
 
48
22
  def active_admin_namespace
@@ -164,5 +138,50 @@ module ActiveAdmin
164
138
  rescue JSON::ParserError
165
139
  {}
166
140
  end
141
+
142
+ def render_multiplex(schema)
143
+ operations = multiplex_operations
144
+ return render_multiplex_limit_error!(operations.size) if exceeds_multiplex_limit?(operations)
145
+
146
+ payloads = build_multiplex_payloads(operations)
147
+ results = schema.multiplex(payloads)
148
+ render json: results.map(&:to_h), status: :ok
149
+ end
150
+
151
+ def render_single(schema)
152
+ result = schema.execute(
153
+ query: query_string,
154
+ variables: ensure_variables(variables_hash),
155
+ operation_name: operation_name,
156
+ context: graphql_context
157
+ )
158
+ render json: result.to_h, status: :ok
159
+ end
160
+
161
+ def exceeds_multiplex_limit?(operations)
162
+ operations.size > max_multiplex_operations
163
+ end
164
+
165
+ def max_multiplex_operations
166
+ active_admin_namespace.graphql_multiplex_max || 20
167
+ end
168
+
169
+ def render_multiplex_limit_error!(count)
170
+ max_n = max_multiplex_operations
171
+ msg = "Multiplex exceeds maximum of #{max_n} (received #{count})"
172
+ render json: {errors: [{message: msg}]}, status: :content_too_large
173
+ end
174
+
175
+ def build_multiplex_payloads(operations)
176
+ context = graphql_context
177
+ operations.map do |op|
178
+ {
179
+ query: op[:query],
180
+ variables: ensure_variables(op[:variables]),
181
+ operation_name: op[:operation_name],
182
+ context: context
183
+ }
184
+ end
185
+ end
167
186
  end
168
187
  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
@@ -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
 
@@ -49,10 +50,9 @@ module ActiveAdmin
49
50
  extra = {
50
51
  "batch_action" => batch_sym.to_s,
51
52
  "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, +"")
53
+ # TruffleRuby freezes JSON's internal "{}" buffer, so Hash#to_json raises FrozenError.
54
+ # Dumping into a dedicated StringIO avoids mutating the shared literal.
55
+ "batch_action_inputs" => dump_batch_inputs(inputs)
56
56
  }
57
57
  c = controller_for("batch_action", extra)
58
58
  perform_controller_command!(c) { c.send(:batch_action) }
@@ -110,6 +110,14 @@ module ActiveAdmin
110
110
  stub_controller!(c, h)
111
111
  c
112
112
  end
113
+
114
+ def dump_batch_inputs(hash)
115
+ buffer = StringIO.new
116
+ JSON.dump(hash, buffer)
117
+ buffer.string
118
+ rescue FrozenError
119
+ JSON.generate(hash)
120
+ end
113
121
  end
114
122
  end
115
123
  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
@@ -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,
@@ -33,7 +33,9 @@ module ActiveAdmin
33
33
  define_method(fname.to_sym) do |action:, params: nil, **kw|
34
34
  auth = context[:auth]
35
35
  mdl = aa_res.resource_class
36
- unless auth.authorized?(aa_res, ActiveAdmin::Authorization::READ, mdl)
36
+ cfg = aa_res.graphql_config.collection_run_action
37
+ auth_enabled = cfg.authorize.nil? ? (ns.graphql_custom_mutation_authorization_default != false) : cfg.authorize
38
+ if auth_enabled && !auth.authorized?(aa_res, ActiveAdmin::Authorization::READ, mdl)
37
39
  raise ::GraphQL::ExecutionError, "not authorized to read #{mdl.name}"
38
40
  end
39
41
 
@@ -91,7 +93,14 @@ module ActiveAdmin
91
93
  define_method(fname) do |params: nil, **kw|
92
94
  auth = context[:auth]
93
95
  mdl = aa_res.resource_class
94
- unless auth.authorized?(aa_res, ActiveAdmin::Authorization::READ, mdl)
96
+ auth_enabled =
97
+ if per_cfg&.authorize.nil?
98
+ base_cfg = aa_res.graphql_config.collection_run_action
99
+ base_cfg.authorize.nil? ? (ns.graphql_custom_mutation_authorization_default != false) : base_cfg.authorize
100
+ else
101
+ per_cfg.authorize
102
+ end
103
+ if auth_enabled && !auth.authorized?(aa_res, ActiveAdmin::Authorization::READ, mdl)
95
104
  raise ::GraphQL::ExecutionError, "not authorized to read #{mdl.name}"
96
105
  end
97
106
 
@@ -34,7 +34,9 @@ module ActiveAdmin
34
34
  define_method(fname.to_sym) do |action:, id:, params: nil, **kw|
35
35
  auth = context[:auth]
36
36
  mdl = aa_res.resource_class
37
- unless auth.authorized?(aa_res, ActiveAdmin::Authorization::READ, mdl)
37
+ cfg = aa_res.graphql_config.member_run_action
38
+ auth_enabled = cfg.authorize.nil? ? (ns.graphql_custom_mutation_authorization_default != false) : cfg.authorize
39
+ if auth_enabled && !auth.authorized?(aa_res, ActiveAdmin::Authorization::READ, mdl)
38
40
  raise ::GraphQL::ExecutionError, "not authorized to read #{mdl.name}"
39
41
  end
40
42
 
@@ -94,7 +96,14 @@ module ActiveAdmin
94
96
  define_method(fname) do |id:, params: nil, **kw|
95
97
  auth = context[:auth]
96
98
  mdl = aa_res.resource_class
97
- unless auth.authorized?(aa_res, ActiveAdmin::Authorization::READ, mdl)
99
+ auth_enabled =
100
+ if per_cfg&.authorize.nil?
101
+ base_cfg = aa_res.graphql_config.member_run_action
102
+ base_cfg.authorize.nil? ? (ns.graphql_custom_mutation_authorization_default != false) : base_cfg.authorize
103
+ else
104
+ per_cfg.authorize
105
+ end
106
+ if auth_enabled && !auth.authorized?(aa_res, ActiveAdmin::Authorization::READ, mdl)
98
107
  raise ::GraphQL::ExecutionError, "not authorized to read #{mdl.name}"
99
108
  end
100
109
 
@@ -8,6 +8,7 @@ module ActiveAdmin
8
8
  include QueryTypeCollection
9
9
  include QueryTypeMember
10
10
  include QueryTypePages
11
+ include QueryTypePolicies
11
12
 
12
13
  def build_query_type(registered_resource_union:)
13
14
  builder = self
@@ -45,6 +46,12 @@ module ActiveAdmin
45
46
  )
46
47
 
47
48
  builder.add_page_query_fields!(self, builder: builder, ns: ns)
49
+ builder.add_activeadmin_policies_query_field!(
50
+ self,
51
+ builder: builder,
52
+ ns: ns,
53
+ aa_by_graphql_type_name: aa_by_graphql_type_name
54
+ )
48
55
  end
49
56
  end
50
57
  end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module GraphQL
5
+ class SchemaBuilder
6
+ module QueryTypePolicies
7
+ POLICY_ACTIONS = {
8
+ read: ActiveAdmin::Authorization::READ,
9
+ create: ActiveAdmin::Authorization::CREATE,
10
+ update: ActiveAdmin::Authorization::UPDATE,
11
+ destroy: ActiveAdmin::Authorization::DESTROY
12
+ }.freeze
13
+
14
+ def add_activeadmin_policies_query_field!(query_class, builder:, ns:, aa_by_graphql_type_name:)
15
+ resource_policy_type = Class.new(::GraphQL::Schema::Object) do
16
+ graphql_name "ActiveAdminResourcePolicies"
17
+ field :type_name, ::GraphQL::Types::String, null: false, camelize: false
18
+ field :activeadmin_policies, builder.send(:policy_set_type), null: false, camelize: false
19
+ end
20
+
21
+ page_policy_type = Class.new(::GraphQL::Schema::Object) do
22
+ graphql_name "ActiveAdminPagePolicies"
23
+ field :name, ::GraphQL::Types::String, null: false, camelize: false
24
+ field :activeadmin_policies, builder.send(:policy_set_type), null: false, camelize: false
25
+ end
26
+
27
+ record_policy_type = Class.new(::GraphQL::Schema::Object) do
28
+ graphql_name "ActiveAdminRecordPolicies"
29
+ field :id, ::GraphQL::Types::ID, null: false, camelize: false
30
+ field :activeadmin_policies, builder.send(:policy_set_type), null: false, camelize: false
31
+ end
32
+
33
+ matrix_type = Class.new(::GraphQL::Schema::Object) do
34
+ graphql_name "ActiveAdminPolicies"
35
+ field :resources, [resource_policy_type], null: false, camelize: false
36
+ field :pages, [page_policy_type], null: false, camelize: false
37
+ end
38
+
39
+ query_class.class_eval do
40
+ field :activeadmin_policies, matrix_type, null: false, camelize: false,
41
+ visibility: {kind: :query_activeadmin_policies}
42
+
43
+ field :activeadmin_policies_for, [record_policy_type], null: false, camelize: false,
44
+ visibility: {kind: :query_activeadmin_policies_for} do
45
+ argument :type_name, ::GraphQL::Types::String, required: true, camelize: false
46
+ argument :ids, [::GraphQL::Types::ID], required: true, camelize: false
47
+ argument :path, [KeyValuePairInput], required: false, camelize: false
48
+ end
49
+
50
+ define_method(:activeadmin_policies) do
51
+ auth = context[:auth]
52
+ resources = builder.send(:active_resources).map do |aa_res|
53
+ {
54
+ type_name: builder.send(:graphql_type_name_for, aa_res),
55
+ activeadmin_policies: builder.send(:build_policy_set, auth: auth, subject_owner: aa_res, subject: aa_res.resource_class, context: context)
56
+ }
57
+ end
58
+ pages = ns.resources.select { |r| r.is_a?(ActiveAdmin::Page) }.map do |page|
59
+ {
60
+ name: page.name,
61
+ activeadmin_policies: builder.send(:build_policy_set, auth: auth, subject_owner: page, subject: page, context: context)
62
+ }
63
+ end
64
+ {resources: resources, pages: pages}
65
+ end
66
+
67
+ define_method(:activeadmin_policies_for) do |type_name:, ids:, path: nil|
68
+ aa_res = aa_by_graphql_type_name[type_name.to_s]
69
+ raise ::GraphQL::ExecutionError, "unknown resource type_name #{type_name.inspect}" unless aa_res
70
+
71
+ proxy = ResourceQueryProxy.new(
72
+ aa_resource: aa_res,
73
+ user: context[:auth].user,
74
+ namespace: ns,
75
+ graph_params: KeyValuePairs.to_hash(path)
76
+ )
77
+
78
+ ids.map do |id|
79
+ record = proxy.find_member(id.to_s)
80
+ raise ::GraphQL::ExecutionError, "not found" unless record
81
+
82
+ {
83
+ id: id.to_s,
84
+ activeadmin_policies: builder.send(:build_policy_set, auth: context[:auth], subject_owner: aa_res, subject: record, context: context)
85
+ }
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def policy_action_pairs
94
+ hook = @namespace.graphql_policy_actions
95
+ if hook.respond_to?(:call)
96
+ raw = hook.call(@namespace)
97
+ return raw.to_h.transform_keys(&:to_sym) if raw.respond_to?(:to_h)
98
+ end
99
+ POLICY_ACTIONS
100
+ end
101
+
102
+ def allowed_base_actions(auth:, subject_owner:, subject:, context:)
103
+ action_mapper = @namespace.graphql_policy_action_mapper
104
+ policy_action_pairs.each_with_object([]) do |(name, default_action), out|
105
+ allowed = if action_mapper.respond_to?(:call)
106
+ action_mapper.call(context, subject_owner, subject, name, default_action)
107
+ else
108
+ auth.authorized?(subject_owner, default_action, subject)
109
+ end
110
+ out << name.to_s if allowed
111
+ end
112
+ end
113
+
114
+ def build_policy_set(auth:, subject_owner:, subject:, context:)
115
+ base = {
116
+ allowed_actions: allowed_base_actions(auth: auth, subject_owner: subject_owner, subject: subject, context: context),
117
+ allowed_member_actions: [],
118
+ allowed_collection_actions: [],
119
+ allowed_batch_actions: []
120
+ }
121
+
122
+ if subject_owner.is_a?(ActiveAdmin::Resource)
123
+ class_subject = subject_owner.resource_class
124
+ if subject.is_a?(Class)
125
+ if auth.authorized?(subject_owner, ActiveAdmin::Authorization::READ, class_subject)
126
+ base[:allowed_member_actions] = subject_owner.member_actions.map { |a| a.name.to_s }
127
+ base[:allowed_collection_actions] = subject_owner.collection_actions.map { |a| a.name.to_s }
128
+ base[:allowed_batch_actions] = subject_owner.batch_actions.map { |a| a.sym.to_s }
129
+ end
130
+ elsif auth.authorized?(subject_owner, ActiveAdmin::Authorization::READ, subject)
131
+ base[:allowed_member_actions] = subject_owner.member_actions.map { |a| a.name.to_s }
132
+ end
133
+ end
134
+
135
+ if (extra = @namespace.graphql_policy_extra).respond_to?(:call)
136
+ out = extra.call(context, subject_owner, subject, base)
137
+ base = out if out.is_a?(Hash)
138
+ end
139
+ if (transform = @namespace.graphql_policy_transform).respond_to?(:call)
140
+ out = transform.call(context, subject_owner, subject, base)
141
+ base = out if out.is_a?(Hash)
142
+ end
143
+ base
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -6,6 +6,19 @@ module ActiveAdmin
6
6
  module TypesObject
7
7
  private
8
8
 
9
+ def policy_set_type
10
+ @policy_set_type ||= Class.new(::GraphQL::Schema::Object) do
11
+ graphql_name "ActiveAdminPolicySet"
12
+ description "Allowed ActiveAdmin actions for this subject."
13
+
14
+ field :allowed_actions, [::GraphQL::Types::String], null: false, camelize: false
15
+ field :allowed_member_actions, [::GraphQL::Types::String], null: false, camelize: false
16
+ field :allowed_collection_actions, [::GraphQL::Types::String], null: false, camelize: false
17
+ field :allowed_batch_actions, [::GraphQL::Types::String], null: false, camelize: false
18
+ field :extra, ::GraphQL::Types::JSON, null: true, camelize: false
19
+ end
20
+ end
21
+
9
22
  def build_enum_type(aa_res, column_name, mapping)
10
23
  key = [aa_res.resource_class.name, column_name.to_s]
11
24
  return @enum_types[key] if @enum_types[key]
@@ -61,14 +74,17 @@ module ActiveAdmin
61
74
  attr_names = attributes_for(aa_res).map(&:to_s)
62
75
 
63
76
  type_class = Class.new(::GraphQL::Schema::Object) do
77
+ field_class ::ActiveAdmin::GraphQL::SchemaField
64
78
  graphql_name gname
65
79
  description "ActiveAdmin resource `#{aa_res.resource_name}`"
66
80
  implements ::ActiveAdmin::GraphQL::ResourceInterface
81
+
82
+ define_singleton_method(:activeadmin_graphql_resource) { aa_res }
67
83
  end
68
84
 
69
85
  pk_cols = ActiveAdmin::PrimaryKey.columns(model)
70
86
 
71
- type_class.field :id, ::GraphQL::Types::ID, null: false
87
+ type_class.field :id, ::GraphQL::Types::ID, null: false, authorize: false
72
88
  type_class.define_method(:id) { ActiveAdmin::PrimaryKey.graphql_id_value(object) }
73
89
 
74
90
  attr_names.each do |name|
@@ -78,11 +94,17 @@ module ActiveAdmin
78
94
  next unless col
79
95
 
80
96
  gql_t = graphql_scalar_for_column(aa_res, model, col)
81
- type_class.field(name.to_sym, gql_t, null: true, camelize: false)
97
+ type_class.field(name.to_sym, gql_t, null: true, camelize: false, authorize: false)
82
98
 
83
99
  type_class.define_method(name.to_sym) { object.public_send(name) }
84
100
  end
85
101
 
102
+ type_class.field :activeadmin_policies, policy_set_type, null: false, camelize: false, authorize: false
103
+ type_class.define_method(:activeadmin_policies) do
104
+ builder = context[:namespace] && ActiveAdmin::GraphQL::SchemaBuilder.new(context[:namespace])
105
+ builder.send(:build_policy_set, auth: context[:auth], subject_owner: aa_res, subject: object, context: context)
106
+ end
107
+
86
108
  if (ext = aa_res.graphql_config.extension_block)
87
109
  type_class.class_eval(&ext)
88
110
  end
@@ -17,6 +17,7 @@ require_relative "schema_builder/query_type_registered"
17
17
  require_relative "schema_builder/query_type_collection"
18
18
  require_relative "schema_builder/query_type_member"
19
19
  require_relative "schema_builder/query_type_pages"
20
+ require_relative "schema_builder/query_type_policies"
20
21
  require_relative "schema_builder/query_type"
21
22
  require_relative "schema_builder/resolvers"
22
23
  require_relative "schema_builder/mutation_action_types"
@@ -9,8 +9,10 @@ module ActiveAdmin
9
9
  # That hook runs from {#visible?}; it does not replace graphql-ruby's visibility system—it
10
10
  # composes with +super+ like any custom +Field+ class.
11
11
  class SchemaField < ::GraphQL::Schema::Field
12
- def initialize(visibility: nil, **kwargs, &block)
12
+ def initialize(visibility: nil, authorize: nil, authorize_action: nil, **kwargs, &block)
13
13
  @visibility = visibility
14
+ @authorize = authorize
15
+ @authorize_action = authorize_action
14
16
  super(**kwargs, &block)
15
17
  end
16
18
 
@@ -24,6 +26,24 @@ module ActiveAdmin
24
26
 
25
27
  !!hook.call(ctx, @visibility)
26
28
  end
29
+
30
+ def authorized?(object, args, ctx)
31
+ return false unless super
32
+
33
+ owner = self.owner
34
+ aa_resource = owner.respond_to?(:activeadmin_graphql_resource) ? owner.activeadmin_graphql_resource : nil
35
+ return true unless aa_resource
36
+
37
+ enabled = if @authorize.nil?
38
+ ctx[:namespace]&.graphql_custom_field_authorization_default != false
39
+ else
40
+ @authorize
41
+ end
42
+ return true unless enabled
43
+
44
+ action = @authorize_action || ActiveAdmin::Authorization::READ
45
+ !!ctx[:auth]&.authorized?(aa_resource, action, object)
46
+ end
27
47
  end
28
48
  end
29
49
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveAdmin
4
4
  module GraphQL
5
- VERSION = "0.1.2"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -28,27 +28,19 @@ module ActiveAdmin
28
28
 
29
29
  @graphql_features_loaded = true
30
30
  ActiveAdmin::Dependency["graphql"].spec!
31
- require "graphql"
32
- require "graphql/types/json"
33
- require "graphql/types/iso_8601_date_time"
34
- require "graphql/types/iso_8601_date"
35
- require_relative "graphql/resource_interface"
36
- require_relative "graphql/schema_field"
37
- require_relative "graphql/auth_context"
38
- require_relative "graphql/record_source"
39
- require_relative "graphql/resource_query_proxy"
40
- require_relative "graphql/run_action_payload"
41
- require_relative "graphql/run_action_mutation_config"
42
- require_relative "graphql/run_action_mutation_dsl"
43
- require_relative "graphql/key_value_pair_input"
44
- require_relative "graphql/schema_builder"
31
+ require_graphql_dependencies!
32
+ require_active_admin_graphql_components!
45
33
  end
46
34
 
47
35
  # @param namespace [ActiveAdmin::Namespace]
48
36
  # @return [Class] schema class (subclass of GraphQL::Schema)
37
+ #
38
+ # Cached per namespace for the process. In development, call
39
+ # {clear_schema_cache!} or {clear_schema_for!} after ActiveAdmin +unload!+
40
+ # (already wired in {Integration::ApplicationUnloadClearsGraphQLSchema}) or
41
+ # when admin registrations change without a full unload.
49
42
  def schema_for(namespace)
50
43
  cache_key = namespace.name
51
- SCHEMA_CACHE.delete(cache_key) if defined?(Rails) && Rails.env.development?
52
44
  SCHEMA_CACHE[cache_key] ||= SchemaBuilder.new(namespace).build
53
45
  end
54
46
 
@@ -59,6 +51,28 @@ module ActiveAdmin
59
51
  def clear_schema_for!(namespace)
60
52
  SCHEMA_CACHE.delete(namespace.name)
61
53
  end
54
+
55
+ def require_graphql_dependencies!
56
+ require "graphql"
57
+ require "graphql/types/json"
58
+ require "graphql/types/iso_8601_date_time"
59
+ require "graphql/types/iso_8601_date"
60
+ end
61
+
62
+ def require_active_admin_graphql_components!
63
+ %w[
64
+ graphql/resource_interface
65
+ graphql/schema_field
66
+ graphql/auth_context
67
+ graphql/record_source
68
+ graphql/resource_query_proxy
69
+ graphql/run_action_payload
70
+ graphql/run_action_mutation_config
71
+ graphql/run_action_mutation_dsl
72
+ graphql/key_value_pair_input
73
+ graphql/schema_builder
74
+ ].each { |path| require_relative path }
75
+ end
62
76
  end
63
77
  end
64
78
  end
@@ -23,28 +23,37 @@ unless defined?(ActiveAdmin::PrimaryKey)
23
23
 
24
24
  def composite_attribute_hash(model, id_param)
25
25
  cols = ordered_columns(model)
26
- h =
27
- case id_param
28
- when Hash
29
- id_param.stringify_keys.slice(*cols)
30
- when String
31
- parsed = JSON.parse(id_param)
32
- raise ArgumentError, "composite id must be a JSON object" unless parsed.is_a?(Hash)
33
-
34
- parsed.stringify_keys.slice(*cols)
35
- when Array
36
- cols.zip(id_param).to_h
37
- else
38
- raise ArgumentError, "unsupported composite id type"
39
- end
40
-
41
- missing = cols - h.keys
26
+ hash = normalize_composite_input(cols, id_param)
27
+ validate_composite_keys!(cols, hash)
28
+ hash
29
+ end
30
+
31
+ def normalize_composite_input(cols, id_param)
32
+ case id_param
33
+ when Hash
34
+ id_param.stringify_keys.slice(*cols)
35
+ when String
36
+ extract_composite_json(cols, id_param)
37
+ when Array
38
+ cols.zip(id_param).to_h
39
+ else
40
+ raise ArgumentError, "unsupported composite id type"
41
+ end
42
+ end
43
+
44
+ def extract_composite_json(cols, payload)
45
+ parsed = JSON.parse(payload)
46
+ raise ArgumentError, "composite id must be a JSON object" unless parsed.is_a?(Hash)
47
+
48
+ parsed.stringify_keys.slice(*cols)
49
+ end
50
+
51
+ def validate_composite_keys!(cols, hash)
52
+ missing = cols - hash.keys
42
53
  raise ArgumentError, "composite id missing keys: #{missing.join(", ")}" if missing.any?
43
54
 
44
- absent = cols.select { |c| h[c].nil? }
55
+ absent = cols.select { |c| hash[c].nil? }
45
56
  raise ArgumentError, "composite id missing values for: #{absent.join(", ")}" if absent.any?
46
-
47
- h
48
57
  end
49
58
 
50
59
  def find_attributes(model, id_param)
@@ -82,36 +91,49 @@ unless defined?(ActiveAdmin::PrimaryKey)
82
91
  blob = blob.stringify_keys
83
92
  cols = ordered_columns(model)
84
93
  if cols.size == 1
85
- id = blob["id"]
86
- raise ArgumentError, "id is required" if id.blank?
87
-
88
- {"id" => id.to_s}
89
- elsif blob["id"].present?
90
- find_attributes(model, blob["id"]).stringify_keys
91
- elsif cols.all? { |c| blob.key?(c) && blob[c].present? }
92
- cols.to_h { |c| [c, blob[c]] }
94
+ single_member_param(blob)
93
95
  else
94
- raise ArgumentError, "composite id requires id (JSON) or all primary key columns"
96
+ composite_member_param(model, blob, cols)
95
97
  end
96
98
  end
97
99
 
100
+ def single_member_param(blob)
101
+ id = blob["id"]
102
+ raise ArgumentError, "id is required" if id.blank?
103
+
104
+ {"id" => id.to_s}
105
+ end
106
+
107
+ def composite_member_param(model, blob, cols)
108
+ return find_attributes(model, blob["id"]).stringify_keys if blob["id"].present?
109
+ return cols.to_h { |c| [c, blob[c]] } if cols.all? { |c| blob.key?(c) && blob[c].present? }
110
+
111
+ raise ArgumentError, "composite id requires id (JSON) or all primary key columns"
112
+ end
113
+
98
114
  def field_kw_to_param_hash(model, id:, **kw)
99
115
  cols = ordered_columns(model)
100
116
  if cols.size == 1
101
- raise ArgumentError, "id is required" if id.blank?
102
-
103
- {"id" => id.to_s}
104
- elsif id.present?
105
- find_attributes(model, id).stringify_keys
117
+ single_field_hash(id)
106
118
  else
107
- out = cols.to_h { |c| [c, kw[c.to_sym] || kw[c]] }
108
- if cols.all? { |c| !out[c].nil? }
109
- out.transform_values(&:to_s)
110
- else
111
- raise ArgumentError, "composite id requires id (JSON) or all primary key fields as arguments"
112
- end
119
+ composite_field_hash(model, cols, id, kw)
113
120
  end
114
121
  end
122
+
123
+ def single_field_hash(id)
124
+ raise ArgumentError, "id is required" if id.blank?
125
+
126
+ {"id" => id.to_s}
127
+ end
128
+
129
+ def composite_field_hash(model, cols, id, kw)
130
+ return find_attributes(model, id).stringify_keys if id.present?
131
+
132
+ out = cols.to_h { |c| [c, kw[c.to_sym] || kw[c]] }
133
+ return out.transform_values(&:to_s) if cols.all? { |c| !out[c].nil? }
134
+
135
+ raise ArgumentError, "composite id requires id (JSON) or all primary key fields as arguments"
136
+ end
115
137
  end
116
138
  end
117
139
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeadmin-graphql
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -348,6 +348,7 @@ files:
348
348
  - lib/active_admin/graphql/schema_builder/query_type_collection.rb
349
349
  - lib/active_admin/graphql/schema_builder/query_type_member.rb
350
350
  - lib/active_admin/graphql/schema_builder/query_type_pages.rb
351
+ - lib/active_admin/graphql/schema_builder/query_type_policies.rb
351
352
  - lib/active_admin/graphql/schema_builder/query_type_registered.rb
352
353
  - lib/active_admin/graphql/schema_builder/resolvers.rb
353
354
  - lib/active_admin/graphql/schema_builder/resources.rb
@@ -383,7 +384,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
383
384
  - !ruby/object:Gem::Version
384
385
  version: '0'
385
386
  requirements: []
386
- rubygems_version: 4.0.3
387
+ rubygems_version: 4.0.4
387
388
  specification_version: 4
388
389
  summary: GraphQL API extension for ActiveAdmin (graphql-ruby).
389
390
  test_files: []