Skip to content
Merged
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
15 changes: 12 additions & 3 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 Down
5 changes: 5 additions & 0 deletions spec/e2e/fixture_app/app/admin/assignments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
# expand a bare `f.inputs` at render time, as ActiveAdmin's own default
# form does — so there is nothing in the block to read and the description
# has to come from the permitted params instead.
#
# `secret_note` is a column neither branch of the block permits, so the suite
# has something the block withholds as well as something it grants: an example
# that only ever writes a permitted attribute cannot tell a block that filters
# from one whose result is ignored.
ActiveAdmin.register Assignment do
permit_params do
current_admin_user ? %i[name notes] : %i[name]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddSecretNoteToAssignments < ActiveRecord::Migration[7.2]
def change
add_column :assignments, :secret_note, :string
end
end
16 changes: 16 additions & 0 deletions spec/e2e/fixture_app/db/seeds.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,19 @@
# something to refuse. Nothing mutates it: the examples that name it assert it
# is unchanged.
Tag.find_or_initialize_by(name: "fantasy").save!

# Written through the MCP tools by the examples covering a block-form
# `permit_params`, so seeded restoratively on a name no example rewrites. Two
# rows, so an example rewriting one can assert the other was left alone.
# `secret_note` is permitted by neither branch of that block, so an example
# can prove a write never reaches it.
{
"Saturday sort" => "Two volunteers",
"Sunday sort" => "One volunteer",
}.each do |name, notes|
Assignment.find_or_initialize_by(name: name).tap do |assignment|
assignment.notes = notes
assignment.secret_note = "Not for the rota"
assignment.save!
end
end
34 changes: 34 additions & 0 deletions spec/e2e/mcp_tools_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,40 @@ def posts
expect(reread["title"]).to eq("Small Gods")
end

# `resolve_permitted` gates update as well as create, and the block it
# resolves is the resource's only statement of what may be written.
context "for a resource whose permit_params is a block reading controller state" do
def assignment(name)
client.call_tool("query", resource: "Assignment", q: { name_eq: name })["records"].first
end

it "updates the named record, rather than refusing it as a resource that permits nothing" do
result = client.call_tool(
"update",
resource: "Assignment",
id: assignment("Saturday sort")["id"],
attributes: { notes: "Three volunteers" }
)

expect(result["error"]).to be_nil
expect(result["updated"]).to eq(["notes"])
expect(assignment("Saturday sort")["notes"]).to eq("Three volunteers")
expect(assignment("Sunday sort")["notes"]).to eq("One volunteer")
end

it "drops an attribute neither branch of the block permits" do
result = client.call_tool(
"update",
resource: "Assignment",
id: assignment("Saturday sort")["id"],
attributes: { notes: "Three volunteers", secret_note: "tampered" }
)

expect(result["updated"]).to eq(["notes"])
expect(assignment("Saturday sort")["secret_note"]).to eq("Not for the rota")
end
end

it "refuses to update a resource that declares no permitted params" do
tag_id = client.call_tool("query", resource: "Tag")["records"].first["id"]

Expand Down
Loading