Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,10 @@ action, so a write from MCP is the same write the admin UI makes:
so a `slug` sent to a `Post` permitting only `title` and `body` never
reaches the record. A resource that declares no `permit_params` at all (like
`Tag`) is refused outright, with a message saying so — ActiveAdmin cannot
write such a resource through its own forms either.
write such a resource through its own forms either. A block-form
`permit_params` is evaluated in controller context as the authenticated MCP
user, so a block varying the writable set by user gets the same answer it
would give that user in admin.
- **Your callbacks run** — ActiveAdmin's `before_build`, `before_create`,
`before_save`, `after_update` and friends all fire, because the controller
action is what fires them. A `before_create` that fills in a `slug` the form
Expand Down Expand Up @@ -154,8 +157,14 @@ at render time, so there is nothing to read. The response's `source` says which
of the two you are looking at.

Either way every field is annotated from the model with the column type it is
stored in and whether the model validates its presence. `has_many` blocks are
reported under `nested` rather than flattened in with the record's own fields.
stored in and whether the model validates its presence. An associated record's
fields — declared with `has_many`, or with `inputs for: :author` — are reported
under `nested` rather than flattened in with the record's own, because a write
against the parent would drop them.

A form block that declares no inputs of its own is described from
`permit_params` too. A bare `f.inputs` is legal, and Formtastic only expands it
against the model at render time, so there is nothing in the block to read.

`action:` selects the gate, not the shape — ActiveAdmin uses one form block for
both. `"new"` (the default) requires the resource to register `create` and pass
Expand All @@ -164,7 +173,7 @@ never submit tells you nothing you can act on, so it is refused with the same
messages `create` and `update` use: `Author` is not creatable, and `Tag`
declares no permitted params.

Two limits worth knowing:
Three limits worth knowing:

- A `collection:` that is an `ActiveRecord::Relation` or a proc is omitted
rather than evaluated. Describing a form should not fire a query, and a
Expand All @@ -174,6 +183,15 @@ Two limits worth knowing:
- A field a form block declares but `permit_params` omits is described and then
silently dropped on write. This cannot arise on the `permit_params` fallback
path.
- On the `permit_params` fallback path, only attributes backed by a database
column are reported. ActiveAdmin keeps no list of the params it permits, only
a method that filters against them, so the names are recovered by offering
that method every column the model has and seeing which survive. A permitted
param that is not a column — `tag_ids`, or a `*_attributes` key from
`accepts_nested_attributes_for` — is never offered and so never appears,
even though `create` and `update` will happily write it. Declare a
`form do ... end` block on such a resource and `describe_form` reads it in
full, associations included.

### Running member, collection and batch actions

Expand Down
30 changes: 20 additions & 10 deletions lib/activeadmin_mcp/form_description.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ module ActiveadminMcp
# from column names.
#
# The description is read from the resource's own `form do ... end` block
# when it declares one. When it does not, ActiveAdmin renders a bare
# `f.inputs` that Formtastic only expands at render time — there is nothing
# to introspect — so the description is derived from the resource's
# `permit_params` instead, which is what `create` and `update` enforce
# anyway. The payload says which of the two it is.
# when it declares one with inputs in it. When it does not — no form block,
# or a block whose only `f.inputs` is the bare one Formtastic expands at
# render time — there is nothing to introspect, so the description is
# derived from the resource's `permit_params` instead, which is what
# `create` and `update` enforce anyway. The payload says which of the two
# it is.
#
# Either way each field is annotated from the model: the column type it is
# stored in, and whether the model validates its presence.
Expand All @@ -30,7 +31,7 @@ def call(action: "new")
return refusal if refusal

declared = declared_inputs
return describe(action, "form", declared) if declared
return describe(action, "form", declared) if declared&.any?

permitted = permitted_inputs
return permit_params_refusal unless permitted
Expand Down Expand Up @@ -104,9 +105,17 @@ def form_block

# ActiveAdmin stores no list of the params it permits, only a method that
# filters against them, so the permitted names are recovered by offering it
# every column the model has and seeing which survive. A resource that
# never declared permit_params has no such method to answer, and is
# reported as unwritable rather than described.
# every column the model has and seeing which survive. A permitted param
# that is not a column — `tag_ids`, `*_attributes` — is therefore never
# offered and never reported; a resource declaring those should declare a
# form block, which is read in full. A resource that never declared
# permit_params has no method to answer at all, and is reported as
# unwritable rather than described.
#
# The controller comes from the dispatcher with the MCP user injected,
# because ActiveAdmin instance_execs a block-form permit_params on the
# controller and a block varying the writable set by user raises NameError
# on a bare instance.
def permitted_inputs
names = permitted_names
return nil unless names
Expand All @@ -116,7 +125,8 @@ def permitted_inputs

def permitted_names
param_key = @config.param_key.to_sym
controller = @config.controller.new
controller = ControllerDispatcher.new(config: @config, current_user: @current_user)
.controller_with_mcp_user
controller.params = ActionController::Parameters.new(
param_key => @resource[:model].column_names.index_with { nil }
)
Expand Down
31 changes: 26 additions & 5 deletions lib/activeadmin_mcp/form_field_collector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,15 @@ def input(name, *_args, **options, &_block)
self
end

def inputs(*_args, **_opts, &block)
# An `inputs for: :author` block declares the associated record's fields,
# not the record being described, so it is recorded as a group of its own
# exactly as `has_many` is. Without the `for:` option the block is simply a
# way of grouping the record's own fields under a heading, and is descended
# into.
def inputs(*_args, **options, &block)
association = association_name(options[:for])
return nested(association, &block) if association

instance_exec(self, &block) if block
self
end
Expand All @@ -41,11 +49,10 @@ def inputs(*_args, **_opts, &block)
# can tell an association's fields from the record's own. Flattening them
# would advertise `body` as an attribute of the parent record.
def has_many(name, *_args, **_opts, &block)
return self unless name.respond_to?(:to_sym)
return self if declared?(name.to_sym)
association = association_name(name)
return self unless association

@inputs << { name: name.to_sym, nested: block ? self.class.new.collect(&block) : [] }
self
nested(association, &block)
end

def method_missing(_name, *_args, **_opts, &block)
Expand All @@ -59,6 +66,20 @@ def respond_to_missing?(_name, _include_private = false)

private

def nested(association, &block)
return self if declared?(association)

@inputs << { name: association, nested: block ? self.class.new.collect(&block) : [] }
self
end

# Formtastic accepts the association on its own, and also as an array
# pairing it with the object to build the fields from.
def association_name(declared)
name = declared.is_a?(Array) ? declared.first : declared
name.to_sym if name.respond_to?(:to_sym)
end

def declared?(name)
@inputs.any? { |input| input[:name] == name }
end
Expand Down
9 changes: 8 additions & 1 deletion lib/activeadmin_mcp/record_writer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,16 @@ def permit_refusal(permitted)

# Asks the resource's own controller what it would permit, or nil when it
# has nothing to say because permit_params was never declared.
#
# The controller comes from the dispatcher with the MCP user injected,
# because ActiveAdmin instance_execs a block-form permit_params on the
# controller: a block varying the writable set by user raises NameError
# on a bare instance, and the rescue below would report a resource that
# permits plenty as one that permits nothing.
def resolve_permitted(attributes)
param_key = @config.param_key.to_sym
controller = @config.controller.new
controller = ControllerDispatcher.new(config: @config, current_user: @current_user)
.controller_with_mcp_user
controller.params = ActionController::Parameters.new(param_key => attributes)
permitted = controller.send(:permitted_params)
scoped = permitted && permitted[param_key]
Expand Down
35 changes: 35 additions & 0 deletions spec/activeadmin_mcp/form_description_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,41 @@ def attribute(result, name)
end
end

# ActiveAdmin instance_execs a block-form permit_params on the controller,
# so the permitted set can only be resolved in controller context. Resolved
# anywhere else the block raises, and a resource that permits plenty is
# reported as permitting nothing at all.
describe "a resource whose permit_params is declared as a block" do
it "describes the attributes the block permits for the MCP user" do
expect(describe_form("Roster")[:attributes].map { |attribute| attribute[:name] }).to eq(
%w[name notes]
)
end

it "says the description was derived from the resource's permitted params" do
expect(describe_form("Roster")[:source]).to eq("permit_params")
end

it "does not mistake a block it could not evaluate for a resource that declared no permit_params" do
expect(describe_form("Roster")[:error]).to be_nil
end
end

# A bare f.inputs is expanded by Formtastic at render time against the
# model, so a form block declaring nothing of its own describes nothing —
# but the resource's permitted params still do.
describe "a resource whose form block declares no inputs of its own" do
it "falls back to the resource's permitted params rather than reporting an empty form" do
expect(describe_form("Bulletin")[:source]).to eq("permit_params")
end

it "describes the attributes those permitted params accept" do
expect(describe_form("Bulletin")[:attributes].map { |attribute| attribute[:name] }).to eq(
%w[headline body]
)
end
end

describe "choosing which form to describe" do
it "describes the new form by default" do
expect(describe_form("Shift")[:action]).to eq("new")
Expand Down
38 changes: 38 additions & 0 deletions spec/activeadmin_mcp/form_field_collector_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,44 @@ def collect(&block)
)
end

describe "an inputs block declared for an association" do
it "records the association as a nested group rather than flattening its fields into the parent" do
inputs = collect do |_f|
input :title
inputs for: :author do |a|
a.input :name
end
end

expect(inputs).to eq(
[
{ name: :title },
{ name: :author, nested: [{ name: :name }] },
]
)
end

it "records the association named by Formtastic's array form, which passes the object alongside it" do
inputs = collect do |_f|
inputs "Author", for: [:author, Object.new] do |a|
a.input :name
end
end

expect(inputs).to eq([{ name: :author, nested: [{ name: :name }] }])
end

it "still descends into an inputs block that names no association, which is how forms group their own fields" do
inputs = collect do |_f|
inputs "Details" do
input :title
end
end

expect(inputs).to eq([{ name: :title }])
end
end

describe "a collection: of allowed values" do
it "records a literal array of values" do
inputs = collect { |_f| input :status, as: :select, collection: %w[draft published] }
Expand Down
24 changes: 24 additions & 0 deletions spec/activeadmin_mcp/record_writer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
let(:volunteers) { ActiveadminMcp::ResourceRegistry.find("Volunteer") }
let(:notes) { ActiveadminMcp::ResourceRegistry.find("Note") }
let(:sightings) { ActiveadminMcp::ResourceRegistry.find("Sighting") }
let(:rosters) { ActiveadminMcp::ResourceRegistry.find("Roster") }

after do
Volunteer.delete_all
Sighting.delete_all
Roster.delete_all
AdminUser.delete_all
end

Expand Down Expand Up @@ -165,6 +167,28 @@ def writer(resource)
end
end

# ActiveAdmin instance_execs a block-form permit_params on the controller,
# so a block reading current_admin_user can only be resolved in controller
# context. Resolved anywhere else it raises, and a resource that permits
# plenty is refused as one that permits nothing.
describe "a resource whose permit_params is declared as a block" do
it "creates the record, writing the attributes the block permits for the MCP user" do
result = writer(rosters).create(attributes: { "name" => "Weekends", "notes" => "Two shifts" })

expect(result[:error]).to be_nil
expect(Roster.find(result[:id])).to have_attributes(name: "Weekends", notes: "Two shifts")
end

it "updates the record rather than refusing it as declaring no permit_params" do
roster = Roster.create!(name: "Weekends")

result = writer(rosters).update(id: roster.id, attributes: { "name" => "Weekdays" })

expect(result[:error]).to be_nil
expect(roster.reload.name).to eq("Weekdays")
end
end

# Neutralising the namespace's authentication_method must not neutralise
# authorization: a user the adapter refuses must not be able to write.
describe "when the authorization adapter denies everything" do
Expand Down
17 changes: 17 additions & 0 deletions spec/e2e/fixture_app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,23 @@ Each one exists to give a claim in the README something to bite on:
tool.
- `app/admin/authors.rb` registers `actions :index, :show`, so the suite can
prove `update` refuses a resource the admin UI would not let you edit.
- `app/admin/newsletters.rb` declares its `permit_params` in the **block**
form, and the block reads `current_admin_user`, so the suite can prove the
permitted set is resolved in controller context. Asked of a bare controller
instance the block raises `NameError`, which reads as "this resource
declared no `permit_params`" and refuses `create`, `update` and
`describe_form` outright. Its `secret_note` is permitted to nobody, so
there is something the block withholds as well as something it grants.
- `app/admin/bulletins.rb` declares a form block containing a bare `f.inputs`
and nothing else — legal, because Formtastic expands it against the model
at render time — so the suite can prove `describe_form` falls back to
`permit_params` rather than reporting a form with no fields at all.
- `app/admin/dispatches.rb` declares a form block whose second `f.inputs`
names an association with `for:`, so the suite can prove the associated
record's fields are reported as a `nested` group rather than flattened in
with the dispatch's own — where a client would read them as attributes a
write against a dispatch could set, and `create` and `update` would drop
them.
- `app/models/*.rb` allowlist `ransackable_attributes`, which Ransack 4
requires before it will filter on an attribute at all.
- `db/migrate/*.rb` create the `authors` and `posts` tables, and add the
Expand Down
14 changes: 14 additions & 0 deletions spec/e2e/fixture_app/app/admin/bulletins.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Declares a form block that declares no inputs of its own: `f.inputs` with no
# block is legal, and Formtastic expands it against the model at render time.
# There is nothing in it to read, so the e2e suite can prove `describe_form`
# falls back to the resource's permitted params rather than reporting a form
# whose every field is missing — which a client reads as "nothing may be
# written here".
ActiveAdmin.register Bulletin do
permit_params :headline, :body

form do |f|
f.inputs
f.actions
end
end
18 changes: 18 additions & 0 deletions spec/e2e/fixture_app/app/admin/dispatches.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Declares a form block whose second `f.inputs` names an association with
# `for:`, so the e2e suite can prove `describe_form` reports the associated
# record's fields as a nested group rather than flattening them in with the
# dispatch's own. `name` belongs to the author, and `create` and `update`
# would drop it from a write against a dispatch.
ActiveAdmin.register Dispatch do
permit_params :headline, :author_id

form do |f|
f.inputs "Dispatch" do
f.input :headline
end
f.inputs "Author", for: :author do |a|
a.input :name
end
f.actions
end
end
19 changes: 19 additions & 0 deletions spec/e2e/fixture_app/app/admin/newsletters.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Declares its `permit_params` in the BLOCK form, which ActiveAdmin
# instance_execs on the controller, and the block reads `current_admin_user`
# — the documented way an application varies the writable set by user. So the
# e2e suite can prove the permitted set is resolved in controller context: a
# caller asking a bare controller instance gets a NameError, reads that as
# "this resource declared no permit_params", and refuses `create`, `update`
# and `describe_form` outright.
#
# E2E_ADMIN_EMAIL is only set for the process that seeds the database, not for
# the running server, so the seeded admin's email is fixed here rather than
# read from the environment: it has to match AppBuilder::ADMIN_EMAIL.
#
# `secret_note` is permitted to nobody, so the suite has something the block
# withholds as well as something it grants.
ActiveAdmin.register Newsletter do
permit_params do
current_admin_user&.email == "admin@example.com" ? %i[title body] : %i[title]
end
end
6 changes: 6 additions & 0 deletions spec/e2e/fixture_app/app/models/bulletin.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class Bulletin < ApplicationRecord
# See the note in post.rb: Ransack 4 requires an explicit allowlist.
def self.ransackable_attributes(_auth_object = nil)
column_names
end
end
Loading