activeadmin_mcp 0.0.4 → 0.1.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.
@@ -0,0 +1,78 @@
1
+ module ActiveadminMcp
2
+ # Validates and coerces a tool call's arguments against its ActionDefinition
3
+ # before anything reaches the controller.
4
+ #
5
+ # Undeclared arguments are dropped rather than passed through: the declaration
6
+ # is the contract, and forwarding unknown keys into a controller action would
7
+ # let a client reach parameters the application never opted in to.
8
+ class ActionParams
9
+ class CoercionError < StandardError; end
10
+
11
+ def initialize(definition)
12
+ @definition = definition
13
+ end
14
+
15
+ def call(arguments)
16
+ arguments ||= {}
17
+ result = { params: {} }
18
+
19
+ case @definition.kind
20
+ when :member
21
+ id = arguments["id"]
22
+ return { error: "id is required" } if id.nil? || id.to_s.empty?
23
+
24
+ result[:record_id] = id.to_s
25
+ when :batch
26
+ ids = Array(arguments["ids"]).reject { |id| id.to_s.empty? }
27
+ return { error: "ids is required" } if ids.empty?
28
+
29
+ result[:record_ids] = ids.map(&:to_s)
30
+ end
31
+
32
+ @definition.params.each do |name, spec|
33
+ value = arguments[name.to_s]
34
+
35
+ if value.nil? || value.to_s.empty?
36
+ return { error: "#{name} is required" } if spec[:required]
37
+
38
+ next
39
+ end
40
+
41
+ begin
42
+ coerced_value = coerce(value, spec[:type])
43
+ rescue CoercionError
44
+ return { error: "#{name} must be a #{spec[:type]}" }
45
+ end
46
+
47
+ if spec[:enum].is_a?(Array) && !spec[:enum].include?(coerced_value)
48
+ return { error: "#{name} must be one of: #{spec[:enum].join(', ')}" }
49
+ end
50
+
51
+ result[:params][name] = coerced_value
52
+ end
53
+
54
+ result
55
+ end
56
+
57
+ private
58
+
59
+ def coerce(value, type)
60
+ case type&.to_sym
61
+ when :integer
62
+ Integer(value)
63
+ when :number
64
+ Float(value)
65
+ when :boolean
66
+ case value
67
+ when true, "true", "1", 1 then true
68
+ when false, "false", "0", 0 then false
69
+ else raise CoercionError, "invalid boolean value"
70
+ end
71
+ else
72
+ value
73
+ end
74
+ rescue ArgumentError, TypeError => e
75
+ raise CoercionError, e.message
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,163 @@
1
+ module ActiveadminMcp
2
+ # Runs one opted-in ActiveAdmin action for an MCP client.
3
+ #
4
+ # Two gates gate every call, in this order, both before anything is
5
+ # dispatched:
6
+ #
7
+ # 1. ActiveAdmin's own authorization adapter - the same check `query` and
8
+ # `update` make today.
9
+ # 2. The action's optional `permission:` proc.
10
+ # 3. For batch actions only, the submitted ids are run back through the
11
+ # adapter's scope_collection, since gate 1 could only authorize the
12
+ # resource class.
13
+ #
14
+ # The proc can only ever narrow access. A record the MCP user cannot touch
15
+ # stays untouchable whether or not a proc is declared, and the controller's
16
+ # own before_action chain runs again during dispatch regardless.
17
+ class ActionRunner
18
+ def initialize(definition:, current_user:)
19
+ @definition = definition
20
+ @current_user = current_user
21
+ end
22
+
23
+ def call(arguments)
24
+ parsed = ActionParams.new(@definition).call(arguments)
25
+ return parsed if parsed[:error]
26
+
27
+ record = find_record(parsed[:record_id])
28
+ return { error: "Record not found: #{@definition.resource_name}##{parsed[:record_id]}" } if record_missing?(record, parsed)
29
+
30
+ subject = record || @definition.config.resource_class
31
+ return { error: "Not authorized to run #{@definition.tool_name}" } unless authorized?(subject)
32
+
33
+ dispatcher = ControllerDispatcher.new(config: @definition.config, current_user: @current_user)
34
+
35
+ refusal = permission_refusal(dispatcher, record, parsed)
36
+ return { error: refusal } if refusal
37
+
38
+ out_of_scope = unauthorized_batch_ids(parsed)
39
+ return { error: out_of_scope } if out_of_scope
40
+
41
+ dispatcher.call(
42
+ action: dispatch_action,
43
+ path: path_for(record, parsed),
44
+ verb: @definition.http_verb,
45
+ params: dispatch_params(parsed),
46
+ path_params: path_params(parsed)
47
+ )
48
+ end
49
+
50
+ private
51
+
52
+ def find_record(id)
53
+ return nil unless id
54
+
55
+ @definition.config.resource_class.find_by(id: id)
56
+ end
57
+
58
+ def record_missing?(record, parsed)
59
+ parsed.key?(:record_id) && record.nil?
60
+ end
61
+
62
+ def authorized?(subject)
63
+ Authorization.for(@definition.config, @current_user)
64
+ .authorized?(@definition.action_name, subject)
65
+ end
66
+
67
+ # Evaluated the way ActiveAdmin evaluates batch action `:if` procs, so
68
+ # current_admin_user, can? and the usual admin helpers are in scope.
69
+ # Returns a refusal message, or nil when the proc allows the call.
70
+ def permission_refusal(dispatcher, record, parsed)
71
+ permission = @definition.permission
72
+ return nil unless permission
73
+
74
+ controller = dispatcher.controller_with_mcp_user
75
+ outcome = ::MethodOrProcHelper.render_in_context(controller, permission, *permission_args(permission, record, parsed))
76
+
77
+ return outcome if outcome.is_a?(String)
78
+ return nil if outcome
79
+
80
+ "Not permitted to run #{@definition.tool_name}"
81
+ rescue StandardError => e
82
+ # Keep the detail server-side: the message can carry SQL, paths and other
83
+ # internals the MCP client has no business seeing.
84
+ warn("[activeadmin_mcp] permission proc for #{@definition.tool_name} raised #{e.class}: #{e.message}")
85
+ "Permission check failed for #{@definition.tool_name}"
86
+ end
87
+
88
+ # Gate 1 can only authorize the resource class for a batch action, because
89
+ # there is no single record. That leaves the submitted ids unchecked, so a
90
+ # client could name records the adapter's scope_collection excludes. Run the
91
+ # ids back through the scope and refuse the whole call if any falls outside
92
+ # it — narrowing silently would let a client believe it acted on records it
93
+ # never touched.
94
+ def unauthorized_batch_ids(parsed)
95
+ return nil unless @definition.kind == :batch
96
+
97
+ ids = Array(parsed[:record_ids])
98
+ return nil if ids.empty?
99
+
100
+ klass = @definition.config.resource_class
101
+ key = klass.primary_key
102
+ scoped = Authorization.for(@definition.config, @current_user)
103
+ .scope_collection(klass.where(key => ids), @definition.action_name)
104
+ permitted = scoped.pluck(key).map(&:to_s)
105
+ refused = ids - permitted
106
+ return nil if refused.empty?
107
+
108
+ "Not authorized to run #{@definition.tool_name} on #{@definition.resource_name} " \
109
+ "#{refused.join(', ')} (not found, or outside your permitted scope)"
110
+ rescue StandardError => e
111
+ warn("[activeadmin_mcp] scoping batch ids for #{@definition.tool_name} raised #{e.class}: #{e.message}")
112
+ "Not authorized to run #{@definition.tool_name}"
113
+ end
114
+
115
+ # render_in_context instance_execs the proc AND passes args along, so a
116
+ # zero-arity lambda would raise ArgumentError if we always handed it a
117
+ # record. Match what the proc actually accepts.
118
+ def permission_args(permission, record, parsed)
119
+ return [] if permission.respond_to?(:arity) && permission.arity.zero?
120
+
121
+ case @definition.kind
122
+ when :member then [record]
123
+ when :batch then [parsed[:record_ids]]
124
+ else []
125
+ end
126
+ end
127
+
128
+ # Batch actions enter through ActiveAdmin's own batch_action controller
129
+ # method, so its slicing of inputs to the declared form: keys applies
130
+ # exactly as it does in the admin UI.
131
+ def dispatch_action
132
+ @definition.kind == :batch ? :batch_action : @definition.action_name
133
+ end
134
+
135
+ def dispatch_params(parsed)
136
+ return parsed[:params] unless @definition.kind == :batch
137
+
138
+ {
139
+ batch_action: @definition.action_name.to_s,
140
+ collection_selection: parsed[:record_ids],
141
+ batch_action_inputs: JSON.generate(parsed[:params]),
142
+ }
143
+ end
144
+
145
+ def path_params(parsed)
146
+ parsed[:record_id] ? { id: parsed[:record_id] } : {}
147
+ end
148
+
149
+ def path_for(record, parsed)
150
+ config = @definition.config
151
+
152
+ case @definition.kind
153
+ when :member then config.route_member_action_path(@definition.action_name, record)
154
+ # RouteBuilder#batch_action_path calls `.permit!` on the params it is
155
+ # given, which a plain Hash (its own default argument) does not
156
+ # respond to. Pass ActionController::Parameters explicitly to avoid
157
+ # tripping over ActiveAdmin's own default.
158
+ when :batch then config.route_batch_action_path(ActionController::Parameters.new)
159
+ else config.route_collection_path
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,66 @@
1
+ module ActiveadminMcp
2
+ # Builds the JSON Schema an MCP client sees for one opted-in action.
3
+ #
4
+ # The binding/advisory split matters: a static `enum:` is enforced before we
5
+ # dispatch, while `suggestions:` is only ever a hint to the model. A
6
+ # suggestions proc runs application code on every tools/list call, so it is
7
+ # wrapped — a raising proc costs its suggestions, never the whole listing.
8
+ class ActionSchema
9
+ def initialize(definition)
10
+ @definition = definition
11
+ end
12
+
13
+ def to_h
14
+ properties = record_properties
15
+ required = properties.keys.map(&:to_s)
16
+
17
+ @definition.params.each do |name, spec|
18
+ properties[name] = property_for(spec)
19
+ required << name.to_s if spec[:required]
20
+ end
21
+
22
+ { type: "object", properties: properties, required: required.uniq }
23
+ end
24
+
25
+ private
26
+
27
+ def record_properties
28
+ case @definition.kind
29
+ when :member
30
+ { id: { type: "string", description: "Primary key of the record to act on" } }
31
+ when :batch
32
+ { ids: { type: "array", items: { type: "string" },
33
+ description: "Primary keys of the records to act on" } }
34
+ else
35
+ {}
36
+ end
37
+ end
38
+
39
+ def property_for(spec)
40
+ property = { type: (spec[:type] || :string).to_s }
41
+
42
+ descriptions = []
43
+ descriptions << spec[:hint] if spec[:hint]
44
+
45
+ property[:enum] = spec[:enum] if spec[:enum].is_a?(Array)
46
+
47
+ suggestions = resolve_suggestions(spec[:suggestions])
48
+ if suggestions&.any?
49
+ property[:examples] = suggestions
50
+ descriptions << "Suggested values: #{suggestions.join(', ')}"
51
+ end
52
+
53
+ property[:description] = descriptions.join(". ") unless descriptions.empty?
54
+ property
55
+ end
56
+
57
+ # Advisory only. A proc that blows up must not take the tool listing with it.
58
+ def resolve_suggestions(suggestions)
59
+ return nil unless suggestions.respond_to?(:call)
60
+
61
+ Array(suggestions.call)
62
+ rescue StandardError
63
+ nil
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,39 @@
1
+ module ActiveadminMcp
2
+ # Every patch this gem applies to ActiveAdmin lives in this one file, so the
3
+ # coupling has a single place to check when ActiveAdmin is upgraded.
4
+ #
5
+ # ActiveAdmin stores an action's option hash but exposes only the keys it
6
+ # uses itself (:method, :title, :form, :if, ...). MCP metadata is declared as
7
+ # an extra `mcp:` key on that same hash, which ActiveAdmin carries through
8
+ # untouched, so all we need is a reader to get it back out.
9
+ module ActiveAdminExt
10
+ module ActionOptions
11
+ def mcp_options
12
+ options = instance_variable_get(:@options)
13
+ options.is_a?(Hash) ? options[:mcp] : nil
14
+ end
15
+ end
16
+
17
+ # ActiveAdmin::BatchAction only exists once ActiveAdmin's before_load hooks
18
+ # have run, so this is called from ActiveAdmin.after_load rather than at
19
+ # require time.
20
+ def self.apply!
21
+ unless applicable?
22
+ # Without these readers every opted-in action silently vanishes from
23
+ # tools/list, because ActionCatalog can no longer see an mcp: option
24
+ # anywhere. Say so rather than shipping a feature that is quietly off.
25
+ warn("[activeadmin_mcp] ActiveAdmin::ControllerAction / ActiveAdmin::BatchAction not found: " \
26
+ "MCP action options cannot be read, so no opted-in actions will be exposed as tools.")
27
+ return false
28
+ end
29
+
30
+ ::ActiveAdmin::ControllerAction.include(ActionOptions)
31
+ ::ActiveAdmin::BatchAction.include(ActionOptions)
32
+ true
33
+ end
34
+
35
+ def self.applicable?
36
+ defined?(::ActiveAdmin::ControllerAction) && defined?(::ActiveAdmin::BatchAction) ? true : false
37
+ end
38
+ end
39
+ end
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module ActiveadminMcp
4
2
  # Applies a resource's ActiveAdmin authorization adapter to MCP tool calls, so
5
3
  # reads, listings and writes obey the same rules as the admin UI.
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module ActiveadminMcp
4
2
  class Configuration
5
3
  MOUNT_STRATEGIES = %i[prepend append none].freeze
@@ -0,0 +1,138 @@
1
+ module ActiveadminMcp
2
+ # Runs a real ActiveAdmin controller action out of band, so an MCP tool call
3
+ # goes through the same before_action chain, authorization and callbacks as a
4
+ # click in the admin UI.
5
+ #
6
+ # We synthesize the request rather than route one. Routing would mean issuing
7
+ # a second HTTP request against the app, which would have to authenticate as
8
+ # the MCP user by forging an admin session — a back door this gem should not
9
+ # have. Building the request by hand lets us inject the already-authenticated
10
+ # MCP user directly onto the controller instead.
11
+ class ControllerDispatcher
12
+ def initialize(config:, current_user:)
13
+ @config = config
14
+ @current_user = current_user
15
+ end
16
+
17
+ # Yields the controller once it has finished processing, so a caller that
18
+ # needs more than the redirect — the record a write built or loaded, and
19
+ # its validation errors — can read it off the controller itself. The block
20
+ # runs whether processing succeeded or raised, because a write that fails
21
+ # its validations re-renders the form, and that render is exactly the kind
22
+ # of thing a synthesized request can blow up on. Its return value is
23
+ # ignored and an exception inside it never reaches the caller.
24
+ def call(action:, path:, verb: :get, params: {}, path_params: {})
25
+ controller = controller_with_mcp_user
26
+ request = build_request(path: path, verb: verb, params: params, action: action, path_params: path_params)
27
+ response = ActionDispatch::Response.new
28
+
29
+ controller.set_request!(request)
30
+ controller.set_response!(response)
31
+
32
+ begin
33
+ controller.process(action)
34
+ capture(request, response)
35
+ rescue StandardError => e
36
+ # The exception text can carry internals — SQL fragments, table names,
37
+ # file paths. It belongs in the application's log, not in a tool result
38
+ # that goes to an MCP client.
39
+ warn("[activeadmin_mcp] #{@config.resource_class.name}##{action} raised #{e.class}: #{e.message}")
40
+ { error: "#{@config.resource_class.name}##{action} failed" }
41
+ ensure
42
+ inspect_controller(controller, action) { |processed| yield processed } if block_given?
43
+ end
44
+ end
45
+
46
+ # A controller instance for this resource with the MCP user injected, ready
47
+ # either to process a request or to serve as the evaluation context for an
48
+ # action's `permission:` proc. Public because listing-time permission checks
49
+ # need exactly the same context a dispatched call gets.
50
+ def controller_with_mcp_user
51
+ user = @current_user
52
+ controller = @config.controller.new
53
+
54
+ current_user_methods.each do |method_name|
55
+ controller.define_singleton_method(method_name) { user }
56
+ end
57
+
58
+ # The namespace's authentication_method (typically Devise's
59
+ # authenticate_admin_user!) would redirect us to a login page. The MCP
60
+ # request has already authenticated by bearer token, so it is a no-op
61
+ # here — this does NOT skip authorization, which still runs in full.
62
+ auth_method = @config.namespace.authentication_method
63
+ controller.define_singleton_method(auth_method) { true } if auth_method
64
+
65
+ # The synthesized request carries no session-bound CSRF token, so
66
+ # Rails' own forgery protection would refuse every non-GET action
67
+ # (member actions declared `method: :post`, and every batch action,
68
+ # which always dispatches as one). The MCP request has already
69
+ # authenticated by bearer token; this does NOT skip authorization,
70
+ # which still runs in full.
71
+ controller.define_singleton_method(:verified_request?) { true }
72
+
73
+ controller
74
+ end
75
+
76
+ private
77
+
78
+ # An application can point ActiveadminMcp at one current-user method and the
79
+ # ActiveAdmin namespace at another. Stub both, so an action body calling
80
+ # either gets the MCP user rather than nil. uniq keeps us from defining the
81
+ # same singleton method twice when they agree.
82
+ def current_user_methods
83
+ namespace_method = @config.namespace.current_user_method if @config.namespace.respond_to?(:current_user_method)
84
+
85
+ [
86
+ ActiveadminMcp.config.current_user_method,
87
+ namespace_method,
88
+ :current_active_admin_user,
89
+ ].select { |name| name.respond_to?(:to_sym) }.map(&:to_sym).uniq
90
+ end
91
+
92
+ def inspect_controller(controller, action)
93
+ yield controller
94
+ rescue StandardError => e
95
+ warn("[activeadmin_mcp] inspecting #{@config.resource_class.name}##{action} raised #{e.class}: #{e.message}")
96
+ end
97
+
98
+ def build_request(path:, verb:, params:, action:, path_params:)
99
+ env = Rack::MockRequest.env_for(
100
+ path,
101
+ method: verb.to_s.upcase,
102
+ params: params.transform_keys(&:to_s)
103
+ )
104
+ # Flash needs somewhere to live; without a session the action raises.
105
+ env["rack.session"] = {}
106
+ env["action_dispatch.request.path_parameters"] =
107
+ { controller: controller_path, action: action.to_s }.merge(path_params)
108
+
109
+ ActionDispatch::Request.new(env)
110
+ end
111
+
112
+ def controller_path
113
+ @config.controller.name.underscore.sub(/_controller\z/, "")
114
+ end
115
+
116
+ def capture(request, response)
117
+ result = { status: response.status }
118
+
119
+ if response.redirect?
120
+ result[:redirect_to] = response.location
121
+ else
122
+ # Deliberately not the body: admin HTML is large and almost entirely
123
+ # chrome, and would swamp the client's context for no benefit.
124
+ result[:rendered] = true
125
+ end
126
+
127
+ flash = extract_flash(request)
128
+ result[:flash] = flash if flash&.any?
129
+ result
130
+ end
131
+
132
+ def extract_flash(request)
133
+ request.flash.to_hash
134
+ rescue StandardError
135
+ nil
136
+ end
137
+ end
138
+ end
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module ActiveadminMcp
4
2
  class Engine < ::Rails::Engine
5
3
  isolate_namespace ActiveadminMcp
@@ -16,5 +14,11 @@ module ActiveadminMcp
16
14
  end
17
15
  end
18
16
  end
17
+
18
+ initializer "activeadmin_mcp.active_admin_ext" do
19
+ ActiveSupport.on_load(:after_initialize) do
20
+ ActiveAdmin.after_load { ActiveadminMcp::ActiveAdminExt.apply! } if defined?(::ActiveAdmin)
21
+ end
22
+ end
19
23
  end
20
24
  end
@@ -0,0 +1,149 @@
1
+ module ActiveadminMcp
2
+ # Describes the form behind a resource's create or update action richly
3
+ # enough that an MCP client stops guessing field types and allowed values
4
+ # from column names.
5
+ #
6
+ # The description is read from the resource's own `form do ... end` block
7
+ # when it declares one. When it does not, ActiveAdmin renders a bare
8
+ # `f.inputs` that Formtastic only expands at render time — there is nothing
9
+ # to introspect — so the description is derived from the resource's
10
+ # `permit_params` instead, which is what `create` and `update` enforce
11
+ # anyway. The payload says which of the two it is.
12
+ #
13
+ # Either way each field is annotated from the model: the column type it is
14
+ # stored in, and whether the model validates its presence.
15
+ class FormDescription
16
+ WRITE_ACTIONS = { "new" => :create, "edit" => :update }.freeze
17
+ REFUSALS = { create: "is not creatable", update: "is not editable" }.freeze
18
+
19
+ def initialize(resource:, current_user:)
20
+ @resource = resource
21
+ @current_user = current_user
22
+ @config = resource[:config]
23
+ end
24
+
25
+ def call(action: "new")
26
+ write_action = WRITE_ACTIONS[action.to_s]
27
+ return error(%(Unknown form action: #{action} (expected "new" or "edit"))) unless write_action
28
+
29
+ refusal = write_refusal(write_action)
30
+ return refusal if refusal
31
+
32
+ declared = declared_inputs
33
+ return describe(action, "form", declared) if declared
34
+
35
+ permitted = permitted_inputs
36
+ return permit_params_refusal unless permitted
37
+
38
+ describe(action, "permit_params", permitted)
39
+ end
40
+
41
+ private
42
+
43
+ def describe(action, source, inputs)
44
+ fields, groups = inputs.partition { |input| !input.key?(:nested) }
45
+
46
+ {
47
+ resource: @resource[:name],
48
+ action: action.to_s,
49
+ source: source,
50
+ attributes: fields.map { |field| attribute(field) },
51
+ nested: groups.map { |group| nested_group(group) },
52
+ }
53
+ end
54
+
55
+ # An association's own fields are reported as the form declared them and
56
+ # are not annotated from a model: they belong to the associated record, not
57
+ # to the one being written.
58
+ def nested_group(group)
59
+ { name: group[:name].to_s, attributes: group[:nested].map { |field| stringify(field) } }
60
+ end
61
+
62
+ def attribute(field)
63
+ described = stringify(field)
64
+ described[:type] = column_type(field[:name]) if column_type(field[:name])
65
+ described[:required] = true if !described.key?(:required) && presence_validated?(field[:name])
66
+ described
67
+ end
68
+
69
+ def stringify(field)
70
+ field.each_with_object({}) do |(key, value), described|
71
+ described[key] = value.is_a?(Symbol) ? value.to_s : value
72
+ end
73
+ end
74
+
75
+ def column_type(name)
76
+ @resource[:model].columns_hash[name.to_s]&.type&.to_s
77
+ end
78
+
79
+ def presence_validated?(name)
80
+ @resource[:model].validators_on(name).any? do |validator|
81
+ validator.is_a?(ActiveModel::Validations::PresenceValidator)
82
+ end
83
+ rescue StandardError
84
+ false
85
+ end
86
+
87
+ def declared_inputs
88
+ block = form_block
89
+ return nil unless block
90
+
91
+ FormFieldCollector.new.collect(&block)
92
+ rescue StandardError => e
93
+ # A form block that will not run outside a request describes nothing, but
94
+ # the resource's permitted params still can.
95
+ warn("[activeadmin_mcp] reading the #{@resource[:name]} form raised #{e.class}: #{e.message}")
96
+ nil
97
+ end
98
+
99
+ def form_block
100
+ return nil unless @config.respond_to?(:page_presenters)
101
+
102
+ @config.page_presenters[:form]&.block
103
+ end
104
+
105
+ # ActiveAdmin stores no list of the params it permits, only a method that
106
+ # filters against them, so the permitted names are recovered by offering it
107
+ # every column the model has and seeing which survive. A resource that
108
+ # never declared permit_params has no such method to answer, and is
109
+ # reported as unwritable rather than described.
110
+ def permitted_inputs
111
+ names = permitted_names
112
+ return nil unless names
113
+
114
+ names.map { |name| { name: name } }
115
+ end
116
+
117
+ def permitted_names
118
+ param_key = @config.param_key.to_sym
119
+ controller = @config.controller.new
120
+ controller.params = ActionController::Parameters.new(
121
+ param_key => @resource[:model].column_names.index_with { nil }
122
+ )
123
+ permitted = controller.send(:permitted_params)
124
+ scoped = permitted && permitted[param_key]
125
+ scoped&.keys&.map(&:to_sym)
126
+ rescue StandardError
127
+ nil
128
+ end
129
+
130
+ def write_refusal(write_action)
131
+ unless @config.defined_actions.include?(write_action)
132
+ return error("Resource #{REFUSALS[write_action]}: #{@resource[:name]}")
133
+ end
134
+
135
+ return if Authorization.for(@config, @current_user)
136
+ .authorized?(write_action, @config.resource_class)
137
+
138
+ error("Not authorized to #{write_action} #{@resource[:name]}")
139
+ end
140
+
141
+ def permit_params_refusal
142
+ error("Resource declares no permit_params, so nothing may be written: #{@resource[:name]}")
143
+ end
144
+
145
+ def error(message)
146
+ { error: message }
147
+ end
148
+ end
149
+ end