From b5aaa66f20689336045e98d8785d3de0817bb58e Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Wed, 19 Aug 2026 13:16:01 +0100 Subject: [PATCH] Enforce ActiveAdmin authorization on list_resources and query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read tools ignored the resource namespace's authorization adapter, so any authenticated MCP user could list and Ransack-query every registered resource regardless of their admin abilities — only `update` was gated. Route `list_resources` and `query` through the same adapter as the admin UI (extracted into a shared `Authorization` wrapper that `RecordUpdater` now also uses): unreadable resources are hidden and refused, and query results are scoped via `scope_collection`. `query` also strips the same sensitive attributes that `list_resources` already omits. With ActiveAdmin's default adapter every check passes, so applications without an authorization adapter are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 13 ++++ README.md | 14 ++-- lib/activeadmin_mcp.rb | 1 + lib/activeadmin_mcp/authorization.rb | 32 +++++++++ lib/activeadmin_mcp/record_updater.rb | 4 +- lib/activeadmin_mcp/request_handler.rb | 31 +++++++-- lib/activeadmin_mcp/resource_registry.rb | 35 +++++----- spec/activeadmin_mcp/authorization_spec.rb | 66 +++++++++++++++++++ spec/activeadmin_mcp/request_handler_spec.rb | 60 +++++++++++++++-- .../activeadmin_mcp/resource_registry_spec.rb | 13 ++++ 10 files changed, 237 insertions(+), 32 deletions(-) create mode 100644 lib/activeadmin_mcp/authorization.rb create mode 100644 spec/activeadmin_mcp/authorization_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index bce3a8b..78dbb1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- Enforce ActiveAdmin authorization on reads. `list_resources` and `query` + previously ignored the resource namespace's authorization adapter, so any + authenticated MCP user could list and Ransack-query every registered resource + regardless of their admin abilities. Both tools now run through the same + adapter as the admin UI: unreadable resources are hidden and refused, and + query results are scoped with `scope_collection`. `query` also now strips the + same sensitive attributes (`encrypted_password`, `password_digest`, + `reset_password_token`, `api_key`, `secret`) from returned records that + `list_resources` already omits. Applications using ActiveAdmin's default + authorization adapter are unaffected. + ## [0.1.0] - Unreleased Initial release. diff --git a/README.md b/README.md index be03e3f..b0912b7 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,17 @@ The server is a Rails engine mounted inside your application (by default at - **Queries use Ransack.** The `query` tool passes its arguments straight to [Ransack](https://activerecord-hackery.github.io/ransack/), the same search library ActiveAdmin uses for filtering. +- **Reads go through ActiveAdmin too.** `list_resources` and `query` run through + the same authorization adapter (CanCanCan, Pundit, etc.) as the authenticated + MCP user: resources the user cannot read are hidden from the listing and + refused by `query`, and every query is scoped with the adapter's + `scope_collection`, so the MCP user only ever sees the records they could see + in the admin UI. With ActiveAdmin's default adapter every check passes, so + applications without an authorization adapter are unaffected. - **Writes go through ActiveAdmin.** The `update` tool only writes fields allowed by the resource's `permit_params`, refuses resources that don't register the `update` action, and runs every change through your - authorization adapter (CanCanCan, Pundit, etc.) as the authenticated MCP - user. + authorization adapter as the authenticated MCP user. - **Authentication is optional but built in.** Enable Bearer-token auth and the installer adds an "MCP Tokens" management page to your ActiveAdmin panel. @@ -57,8 +63,8 @@ read/query setup without authentication. | Tool | Description | |------|-------------| -| `list_resources` | List every ActiveAdmin resource along with its attributes. | -| `query` | Query a resource using Ransack syntax (`limit` defaults to 25, capped at 100). | +| `list_resources` | List the ActiveAdmin resources the current user may read, along with their attributes. | +| `query` | Query a resource the current user may read, using Ransack syntax, scoped to the records they may access (`limit` defaults to 25, capped at 100). | | `update` | Update an existing record, honouring ActiveAdmin's permitted params and authorization. | ### Query examples diff --git a/lib/activeadmin_mcp.rb b/lib/activeadmin_mcp.rb index 00c020d..e727866 100644 --- a/lib/activeadmin_mcp.rb +++ b/lib/activeadmin_mcp.rb @@ -2,6 +2,7 @@ require_relative "activeadmin_mcp/version" require_relative "activeadmin_mcp/configuration" +require_relative "activeadmin_mcp/authorization" require_relative "activeadmin_mcp/resource_registry" require_relative "activeadmin_mcp/form_field_collector" require_relative "activeadmin_mcp/record_updater" diff --git a/lib/activeadmin_mcp/authorization.rb b/lib/activeadmin_mcp/authorization.rb new file mode 100644 index 0000000..685bd63 --- /dev/null +++ b/lib/activeadmin_mcp/authorization.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module ActiveadminMcp + # Applies a resource's ActiveAdmin authorization adapter to MCP tool calls, so + # reads, listings and writes obey the same rules as the admin UI. + # + # ActiveAdmin's default adapter authorizes every action and returns collections + # unchanged, so applications without an authorization adapter are unaffected. + # Applications that configure one (CanCanCan via `cancan_ability_class`, Pundit, + # a custom adapter, ...) get their policy enforced on every path. + class Authorization + READ = :read + + def self.for(config, current_user) + adapter_class = config.namespace.authorization_adapter + adapter_class = adapter_class.constantize if adapter_class.is_a?(String) + new(adapter_class.new(config, current_user)) + end + + def initialize(adapter) + @adapter = adapter + end + + def authorized?(action, subject) + @adapter.authorized?(action, subject) + end + + def scope_collection(collection, action = READ) + @adapter.scope_collection(collection, action) + end + end +end diff --git a/lib/activeadmin_mcp/record_updater.rb b/lib/activeadmin_mcp/record_updater.rb index 37a79c6..dcc02d6 100644 --- a/lib/activeadmin_mcp/record_updater.rb +++ b/lib/activeadmin_mcp/record_updater.rb @@ -53,9 +53,7 @@ def editable?(config) end def authorized?(config, record) - adapter_class = config.namespace.authorization_adapter - adapter_class = adapter_class.constantize if adapter_class.is_a?(String) - adapter_class.new(config, @current_user).authorized?(UPDATE, record) + Authorization.for(config, @current_user).authorized?(UPDATE, record) end # Resolves the fields we may write, accepting exactly what the admin form diff --git a/lib/activeadmin_mcp/request_handler.rb b/lib/activeadmin_mcp/request_handler.rb index 57dd224..f88dc74 100644 --- a/lib/activeadmin_mcp/request_handler.rb +++ b/lib/activeadmin_mcp/request_handler.rb @@ -44,12 +44,15 @@ def tools_list tools: [ { name: "list_resources", - description: "List all ActiveAdmin resources with their attributes", + description: "List the ActiveAdmin resources the authenticated user is authorized " \ + "to read, with their attributes", inputSchema: { type: "object", properties: {} }, }, { name: "query", - description: "Query an ActiveAdmin resource using Ransack syntax", + description: "Query an ActiveAdmin resource using Ransack syntax. Respects ActiveAdmin " \ + "authorization: the resource must be readable by the authenticated user, " \ + "and results are scoped to the records they may access.", inputSchema: { type: "object", properties: { @@ -93,18 +96,21 @@ def call_tool(params) end def tool_list_resources - { resources: ResourceRegistry.all } + entries = ResourceRegistry.resources.select { |entry| authorized_to_read?(entry) } + { resources: entries.map { |entry| ResourceRegistry.resource_info(entry) } } end def tool_query(args) resource = ResourceRegistry.find(args["resource"]) return { error: "Resource not found: #{args['resource']}" } unless resource + return { error: "Not authorized to query #{resource[:name]}" } unless authorized_to_read?(resource) limit = [args["limit"] || 25, 100].min q = args["q"] || {} - records = resource[:model].ransack(q).result.limit(limit) - { resource: resource[:name], count: records.size, records: records.as_json } + relation = resource[:model].ransack(q).result + records = authorization(resource).scope_collection(relation, Authorization::READ).limit(limit) + { resource: resource[:name], count: records.size, records: filter_sensitive(records.as_json) } end def tool_update(args) @@ -119,6 +125,21 @@ def tool_update(args) .call(id: args["id"], attributes: attributes) end + def authorized_to_read?(resource) + authorization(resource).authorized?(Authorization::READ, resource[:model]) + end + + def authorization(resource) + Authorization.for(resource[:config], @current_user) + end + + def filter_sensitive(records) + sensitive = ResourceRegistry.sensitive_attributes + Array(records).map do |record| + record.is_a?(Hash) ? record.except(*sensitive) : record + end + end + def success(id, result) { jsonrpc: "2.0", id: id, result: result } end diff --git a/lib/activeadmin_mcp/resource_registry.rb b/lib/activeadmin_mcp/resource_registry.rb index 43462a4..5068651 100644 --- a/lib/activeadmin_mcp/resource_registry.rb +++ b/lib/activeadmin_mcp/resource_registry.rb @@ -4,14 +4,28 @@ module ActiveadminMcp module ResourceRegistry class << self def all - discover.map { |r| resource_info(r) } + resources.map { |entry| resource_info(entry) } + end + + def resources + discover.map { |r| entry(r) } end def find(name) - resource = discover.find { |r| r.resource_class.name == name } - return unless resource + resources.find { |entry| entry[:name] == name } + end - { name: resource.resource_class.name, model: resource.resource_class, config: resource } + def resource_info(entry) + klass = entry[:model] + { + name: klass.name, + table: klass.table_name, + attributes: klass.column_names - sensitive_attributes, + } + end + + def sensitive_attributes + %w[encrypted_password password_digest reset_password_token api_key secret] end private @@ -26,17 +40,8 @@ def discover end || [] end - def resource_info(resource) - klass = resource.resource_class - { - name: klass.name, - table: klass.table_name, - attributes: klass.column_names - sensitive_attributes, - } - end - - def sensitive_attributes - %w[encrypted_password password_digest reset_password_token api_key secret] + def entry(resource) + { name: resource.resource_class.name, model: resource.resource_class, config: resource } end end end diff --git a/spec/activeadmin_mcp/authorization_spec.rb b/spec/activeadmin_mcp/authorization_spec.rb new file mode 100644 index 0000000..caac7d8 --- /dev/null +++ b/spec/activeadmin_mcp/authorization_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe ActiveadminMcp::Authorization do + # Records the arguments it is constructed and called with, so the specs can + # assert the wrapper wires ActiveAdmin's adapter contract correctly. + let(:adapter_class) do + Class.new do + attr_reader :resource, :user + + def initialize(resource, user) + @resource = resource + @user = user + end + + def authorized?(action, subject) + [:authorized?, action, subject] + end + + def scope_collection(collection, action) + [:scope_collection, collection, action] + end + end + end + + def config_for(adapter) + namespace = double("namespace", authorization_adapter: adapter) + double("config", namespace: namespace) + end + + describe ".for" do + it "instantiates the namespace's adapter class with the config and current user" do + config = config_for(adapter_class) + + authorization = described_class.for(config, :current_user) + + expect(authorization.authorized?(:read, :subject)).to eq([:authorized?, :read, :subject]) + end + + it "constantizes a string adapter class name" do + stub_const("StubAuthAdapter", adapter_class) + config = config_for("StubAuthAdapter") + + authorization = described_class.for(config, :current_user) + + expect(authorization.authorized?(:read, :subject)).to eq([:authorized?, :read, :subject]) + end + end + + describe "#scope_collection" do + it "delegates to the adapter with the given action" do + authorization = described_class.for(config_for(adapter_class), :user) + + expect(authorization.scope_collection(:collection, :update)) + .to eq([:scope_collection, :collection, :update]) + end + + it "defaults the action to :read" do + authorization = described_class.for(config_for(adapter_class), :user) + + expect(authorization.scope_collection(:collection)) + .to eq([:scope_collection, :collection, :read]) + end + end +end diff --git a/spec/activeadmin_mcp/request_handler_spec.rb b/spec/activeadmin_mcp/request_handler_spec.rb index 6b10a44..fae865d 100644 --- a/spec/activeadmin_mcp/request_handler_spec.rb +++ b/spec/activeadmin_mcp/request_handler_spec.rb @@ -80,10 +80,29 @@ def call_tool(name, arguments = {}) JSON.parse(text) end + # A stand-in ActiveAdmin authorization adapter. `scope_collection` mirrors + # the real adapters by returning the collection it is handed, so tests can + # assert on the relation the handler builds. + def adapter_class(authorized:) + Class.new do + define_method(:initialize) { |*| } + define_method(:authorized?) { |*| authorized } + def scope_collection(collection, *) = collection + end + end + + def resource_config(authorized: true) + namespace = double("namespace", authorization_adapter: adapter_class(authorized: authorized)) + double("config", namespace: namespace) + end + describe "list_resources" do - it "returns the registry's resources" do - resources = [{ name: "User", table: "users", attributes: %w[id email] }] - allow(ActiveadminMcp::ResourceRegistry).to receive(:all).and_return(resources) + it "returns info only for resources the user is authorized to read" do + readable = { name: "User", model: double, config: resource_config(authorized: true) } + hidden = { name: "Secret", model: double, config: resource_config(authorized: false) } + allow(ActiveadminMcp::ResourceRegistry).to receive(:resources).and_return([readable, hidden]) + allow(ActiveadminMcp::ResourceRegistry).to receive(:resource_info).with(readable) + .and_return(name: "User", table: "users", attributes: %w[id email]) expect(call_tool("list_resources")).to eq("resources" => [ { "name" => "User", "table" => "users", "attributes" => %w[id email] }, @@ -96,14 +115,16 @@ def call_tool(name, arguments = {}) let(:relation) { double("relation", limit: records) } let(:model) { double("model") } - before do + def stub_resource(authorized: true) allow(records).to receive(:as_json).and_return(records) allow(records).to receive(:size).and_return(records.length) allow(model).to receive(:ransack).and_return(double("search", result: relation)) allow(ActiveadminMcp::ResourceRegistry).to receive(:find) - .with("User").and_return(name: "User", model: model) + .with("User").and_return(name: "User", model: model, config: resource_config(authorized: authorized)) end + before { stub_resource } + it "returns matching records with a count" do result = call_tool("query", "resource" => "User", "q" => { "name_cont" => "john" }) @@ -125,6 +146,35 @@ def call_tool(name, arguments = {}) call_tool("query", "resource" => "User") end + it "scopes the relation through the authorization adapter before limiting" do + scoped = double("scoped relation") + authorization = instance_double(ActiveadminMcp::Authorization) + allow(ActiveadminMcp::Authorization).to receive(:for).and_return(authorization) + allow(authorization).to receive(:authorized?).and_return(true) + expect(authorization).to receive(:scope_collection).with(relation, :read).and_return(scoped) + expect(scoped).to receive(:limit).with(25).and_return(records) + + call_tool("query", "resource" => "User") + end + + it "returns an authorization error when the user cannot read the resource" do + stub_resource(authorized: false) + + expect(call_tool("query", "resource" => "User")) + .to eq("error" => "Not authorized to query User") + end + + it "strips sensitive attributes from the returned records" do + leaky = [{ "id" => 1, "email" => "a@b.com", "encrypted_password" => "x", "api_key" => "y" }] + allow(leaky).to receive(:as_json).and_return(leaky) + allow(leaky).to receive(:size).and_return(1) + allow(relation).to receive(:limit).and_return(leaky) + + result = call_tool("query", "resource" => "User") + + expect(result["records"]).to eq([{ "id" => 1, "email" => "a@b.com" }]) + end + it "returns an error when the resource is not found" do allow(ActiveadminMcp::ResourceRegistry).to receive(:find).with("Ghost").and_return(nil) diff --git a/spec/activeadmin_mcp/resource_registry_spec.rb b/spec/activeadmin_mcp/resource_registry_spec.rb index d6a3049..fbe7cf5 100644 --- a/spec/activeadmin_mcp/resource_registry_spec.rb +++ b/spec/activeadmin_mcp/resource_registry_spec.rb @@ -81,6 +81,19 @@ def stub_active_admin(models) end end + describe ".resources" do + it "returns the name, model class and resource config for each queryable resource" do + user = build_model(name: "User") + stub_active_admin([user]) + + entry = described_class.resources.first + + expect(entry[:name]).to eq("User") + expect(entry[:model]).to eq(user) + expect(entry[:config].resource_class).to eq(user) + end + end + describe ".find" do it "returns the name, model class and resource config for a known resource" do user = build_model(name: "User")