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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/activeadmin_mcp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
32 changes: 32 additions & 0 deletions lib/activeadmin_mcp/authorization.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 1 addition & 3 deletions lib/activeadmin_mcp/record_updater.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 26 additions & 5 deletions lib/activeadmin_mcp/request_handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
35 changes: 20 additions & 15 deletions lib/activeadmin_mcp/resource_registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
66 changes: 66 additions & 0 deletions spec/activeadmin_mcp/authorization_spec.rb
Original file line number Diff line number Diff line change
@@ -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
60 changes: 55 additions & 5 deletions spec/activeadmin_mcp/request_handler_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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] },
Expand All @@ -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" })

Expand All @@ -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)

Expand Down
13 changes: 13 additions & 0 deletions spec/activeadmin_mcp/resource_registry_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading