administrate-mcp 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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +61 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +267 -0
  5. data/app/controllers/administrate/mcp/json_rpc_controller.rb +83 -0
  6. data/app/controllers/administrate/mcp/o_auth_controller.rb +199 -0
  7. data/app/lib/administrate/mcp/actions.rb +170 -0
  8. data/app/lib/administrate/mcp/admin_dashboard_tool.rb +114 -0
  9. data/app/lib/administrate/mcp/authentication.rb +146 -0
  10. data/app/lib/administrate/mcp/base_tool.rb +93 -0
  11. data/app/lib/administrate/mcp/clean_old_feedbacks.rb +26 -0
  12. data/app/lib/administrate/mcp/dashboard_registry.rb +102 -0
  13. data/app/lib/administrate/mcp/fast_search.rb +47 -0
  14. data/app/lib/administrate/mcp/field_serializer.rb +284 -0
  15. data/app/lib/administrate/mcp/o_auth_service.rb +103 -0
  16. data/app/lib/administrate/mcp/report_improvement.rb +58 -0
  17. data/app/lib/administrate/mcp/server_builder.rb +70 -0
  18. data/app/lib/administrate/mcp/tools/admin_resource_list.rb +194 -0
  19. data/app/lib/administrate/mcp/tools/admin_resource_list_resources.rb +107 -0
  20. data/app/lib/administrate/mcp/tools/admin_resource_show.rb +130 -0
  21. data/app/lib/administrate/mcp/tools/report_improvement.rb +43 -0
  22. data/app/lib/administrate/mcp/tools/sidekiq_retries.rb +50 -0
  23. data/app/lib/administrate/mcp/tools/sidekiq_stats.rb +75 -0
  24. data/app/models/administrate/mcp/api_key.rb +59 -0
  25. data/app/models/administrate/mcp/application_record.rb +23 -0
  26. data/app/models/administrate/mcp/feedback.rb +20 -0
  27. data/app/models/administrate/mcp/o_auth_access_grant.rb +76 -0
  28. data/app/models/administrate/mcp/o_auth_access_token.rb +84 -0
  29. data/app/models/administrate/mcp/o_auth_application.rb +64 -0
  30. data/app/views/administrate/mcp/o_auth/authorize.html.erb +63 -0
  31. data/config/routes.rb +6 -0
  32. data/db/migrate/20260101000001_create_administrate_model_context_protocol_api_keys.rb +20 -0
  33. data/db/migrate/20260101000002_create_administrate_model_context_protocol_feedbacks.rb +19 -0
  34. data/db/migrate/20260101000003_create_administrate_model_context_protocol_authorization_tables.rb +52 -0
  35. data/docs/admin-integration.md +56 -0
  36. data/docs/authentication.md +116 -0
  37. data/docs/configuration.md +220 -0
  38. data/docs/dashboards.md +50 -0
  39. data/docs/development.md +19 -0
  40. data/docs/oauth.md +54 -0
  41. data/docs/routes.md +30 -0
  42. data/lib/administrate/mcp/authorization/base.rb +45 -0
  43. data/lib/administrate/mcp/authorization/permissive.rb +13 -0
  44. data/lib/administrate/mcp/authorization/pundit.rb +32 -0
  45. data/lib/administrate/mcp/cloudflare_access.rb +140 -0
  46. data/lib/administrate/mcp/configuration.rb +156 -0
  47. data/lib/administrate/mcp/dashboard_extension.rb +25 -0
  48. data/lib/administrate/mcp/engine.rb +21 -0
  49. data/lib/administrate/mcp/errors.rb +21 -0
  50. data/lib/administrate/mcp/loopback_uri.rb +18 -0
  51. data/lib/administrate/mcp/rack_attack.rb +39 -0
  52. data/lib/administrate/mcp/routes.rb +48 -0
  53. data/lib/administrate/mcp/version.rb +7 -0
  54. data/lib/administrate/mcp.rb +35 -0
  55. data/lib/administrate-mcp.rb +3 -0
  56. metadata +160 -0
@@ -0,0 +1,220 @@
1
+ # Configuration
2
+
3
+ [Back to README](../README.md)
4
+
5
+ Configure the engine in an initializer, `config/initializers/administrate_mcp.rb`. It must run
6
+ before the engine's models load, because the `admin` association reads `admin_class_name`.
7
+
8
+ ```ruby
9
+ Administrate::MCP.configure do |c|
10
+ c.server_name = 'acme_admin'
11
+ c.server_version = '1.0.0'
12
+
13
+ c.admin_class_name = 'Administrator'
14
+ c.current_admin = ->(controller) { controller.send(:warden)&.authenticate(scope: :administrator) }
15
+ c.admin_active = ->(admin) { admin.admin? && admin.anonymized_at.nil? }
16
+ c.sign_in = lambda do |controller|
17
+ controller.send(:store_location_for, :administrator, controller.request.fullpath)
18
+ controller.redirect_to(controller.main_app.new_administrator_session_path)
19
+ end
20
+
21
+ # These procs are also called without a request, when the engine builds a record URL outside a
22
+ # request cycle, so guard the dereference.
23
+ c.issuer = ->(request) { request&.subdomain == 'admin-mcp' ? request.base_url : 'https://admin-mcp.acme.com' }
24
+ c.admin_origin = ->(request) { request&.subdomain == 'admin' ? request.base_url : 'https://admin.acme.com' }
25
+ c.admin_url_options = { subdomain: 'admin' }
26
+
27
+ c.authorization = Administrate::MCP::Authorization::Pundit.new
28
+ c.default_required_roles = [:full_access]
29
+
30
+ c.tool_paths = [Rails.root.join('app/mcp_tools')]
31
+
32
+ c.instrument = lambda do |tool_name:, admin:, &block|
33
+ Datadog::Tracing.trace('call', resource: tool_name, service: 'mcp_tool') { block.call }
34
+ end
35
+ c.on_tool_call = lambda do |tool_name:, admin:, arguments:, scopes:|
36
+ Admin::AuditOperation.call!(administrator_id: admin.id, request_method: 'POST',
37
+ request_url: "mcp://tools/#{tool_name}", params: arguments)
38
+ end
39
+ c.on_feedback = ->(feedback) { SlackNotifier.notify(notification_type: :mcp_feedback, blocks: blocks_for(feedback)) }
40
+ end
41
+ ```
42
+
43
+ ## The full configuration object
44
+
45
+ ```ruby
46
+ class Configuration
47
+ SKIPPED_FIELD_CLASSES = ['Administrate::Field::Password'].freeze
48
+
49
+ HAS_MANY_FIELD_CLASSES = ['Administrate::Field::HasMany'].freeze
50
+
51
+ FIELD_SERIALIZERS = {
52
+ 'Administrate::Field::HasMany' => :has_many,
53
+ 'Administrate::Field::Polymorphic' => :polymorphic,
54
+ 'Administrate::Field::BelongsTo' => :belongs_to,
55
+ 'Administrate::Field::HasOne' => :belongs_to,
56
+ 'Administrate::Field::DateTime' => :datetime,
57
+ 'Administrate::Field::Date' => :datetime,
58
+ 'Administrate::Field::Time' => :datetime,
59
+ 'Administrate::Field::Enum' => :enum,
60
+ 'Administrate::Field::Select' => :enum,
61
+ 'Administrate::Field::Shrine' => :attachment,
62
+ 'Administrate::Field::String' => :scalar,
63
+ 'Administrate::Field::Text' => :scalar,
64
+ 'Administrate::Field::Email' => :scalar,
65
+ 'Administrate::Field::Url' => :scalar,
66
+ 'Administrate::Field::Number' => :scalar,
67
+ 'Administrate::Field::Boolean' => :scalar
68
+ }.freeze
69
+
70
+ attr_accessor :server_name,
71
+ :server_version,
72
+ :issuer,
73
+ :admin_origin,
74
+ :current_admin,
75
+ :admin_active,
76
+ :identity_fallback,
77
+ :oauth,
78
+ :sign_in,
79
+ :admin_class_name,
80
+ :authorization,
81
+ :default_required_roles,
82
+ :tool_paths,
83
+ :dashboard_paths,
84
+ :instrument,
85
+ :on_tool_call,
86
+ :on_feedback,
87
+ :on_error,
88
+ :allow_localhost_redirects,
89
+ :default_client_name,
90
+ :sidekiq_stats_provider,
91
+ :admin_route_namespace,
92
+ :admin_url_options,
93
+ :skipped_field_classes,
94
+ :has_many_field_classes,
95
+ :field_serializers
96
+
97
+ attr_reader :api_key_token_prefix
98
+
99
+ def assign_identity
100
+ @server_name = 'administrate_mcp'
101
+ @server_version = VERSION
102
+ @issuer = nil
103
+ @admin_origin = nil
104
+ @current_admin = ->(_controller) {}
105
+ @admin_active = ->(_admin) { true }
106
+ @identity_fallback = ->(_request) {}
107
+ @oauth = true
108
+ @sign_in = nil
109
+ @admin_class_name = 'Administrator'
110
+ @authorization = default_authorization
111
+ @default_required_roles = []
112
+ @tool_paths = []
113
+ @dashboard_paths = nil
114
+ end
115
+
116
+ def assign_hooks
117
+ @instrument = ->(tool_name:, admin:, &block) { block.call }
118
+ @on_tool_call = ->(tool_name:, admin:, arguments:, scopes:) {}
119
+ @on_feedback = ->(feedback) {}
120
+ @on_error = ->(exception) {}
121
+ @allow_localhost_redirects = true
122
+ @api_key_token_prefix = 'amcp_'
123
+ @default_client_name = 'MCP Client'
124
+ @sidekiq_stats_provider = nil
125
+ end
126
+
127
+ def assign_fields
128
+ @admin_route_namespace = :admin
129
+ @admin_url_options = {}
130
+ @skipped_field_classes = SKIPPED_FIELD_CLASSES.dup
131
+ @has_many_field_classes = HAS_MANY_FIELD_CLASSES.dup
132
+ @field_serializers = FIELD_SERIALIZERS.dup
133
+ end
134
+
135
+ def api_key_token_prefix=(prefix); end # raises ArgumentError on a blank prefix
136
+
137
+ def register_field(class_name, as: nil, &serializer); end
138
+ def skip_field(*class_names); end
139
+ def register_has_many_field(*class_names); end
140
+
141
+ def issuer_for(request = nil); end
142
+ def admin_origin_for(request = nil); end
143
+ def dashboard_directories; end
144
+ end
145
+ ```
146
+
147
+ | Entry | Default | What it does |
148
+ | --------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
149
+ | `server_name` | `'administrate_mcp'` | Name reported in the MCP handshake |
150
+ | `server_version` | the gem version | Version reported in the handshake |
151
+ | `issuer` | `nil` | MCP origin. String or a proc taking the request |
152
+ | `admin_origin` | `nil` | Admin origin, where the consent screen lives. String or proc |
153
+ | `current_admin` | returns `nil` | Proc taking the OAuth controller, returning the signed-in admin |
154
+ | `admin_active` | `true` | Proc taking the authenticated admin. Return false and the call is refused with 401 `inactive_admin`, so a credential does not outlive the person's admin status |
155
+ | `identity_fallback` | returns `nil` | Proc taking the request, returning an `Authentication::Identity` or nil, consulted when no credential the engine issued matched |
156
+ | `oauth` | `true` | Whether the engine serves its own OAuth 2.1 server. False draws no OAuth routes and never looks an access token up |
157
+ | `sign_in` | `nil` (renders 401) | Proc taking the OAuth controller, sending an anonymous visitor to sign in |
158
+ | `admin_class_name` | `'Administrator'` | Class the `admin_id` column points at |
159
+ | `authorization` | Pundit if defined, else Permissive | Adapter: `authorize!`, `authorized?`, `authorize_roles!` |
160
+ | `default_required_roles` | `[]` | Roles a tool requires unless it declares its own |
161
+ | `tool_paths` | `[]` | Directories scanned for extra `BaseTool` subclasses |
162
+ | `dashboard_paths` | `app/dashboards` | Where dashboards are found |
163
+ | `instrument` | yields | Around hook `(tool_name:, admin:, &block)` |
164
+ | `on_tool_call` | no-op | Audit hook `(tool_name:, admin:, arguments:, scopes:)`, after the permission checks |
165
+ | `on_feedback` | no-op | Called with each new `Feedback` record |
166
+ | `on_error` | no-op | Called with an exception the engine swallowed, so you can report it |
167
+ | `allow_localhost_redirects` | `true` | Whether loopback OAuth redirect URIs are accepted. Inert when `oauth` is false |
168
+ | `api_key_token_prefix` | `'amcp_'` | Prefix that marks a bearer token as an API key. Cannot be blank |
169
+ | `default_client_name` | `'MCP Client'` | Name given to a dynamically registered client that sends no `client_name`. Inert when `oauth` is false |
170
+ | `sidekiq_stats_provider` | `nil` | Object answering `counts`, `total_counts`, `queues`, `stats_cleared_at`; a class name String or a Proc is resolved lazily so autoloaded providers can be named in an initializer |
171
+ | `admin_route_namespace` | `:admin` | Namespace record URLs are built from |
172
+ | `admin_url_options` | `{}` | Options passed to `polymorphic_url`; when no `:host` is given, host, protocol and port are taken from `admin_origin` |
173
+ | `skipped_field_classes` | Password | Field class names never serialized |
174
+ | `has_many_field_classes` | HasMany | Field class names treated as expandable collections |
175
+ | `field_serializers` | see above | Field class name to serializer |
176
+
177
+ ## Authorization adapters
178
+
179
+ `Authorization::Permissive` grants every action to every authenticated admin. `Authorization::Pundit`
180
+ runs the policy for the resource exactly as the Administrate UI does: `index?` to list, `show?` to
181
+ read, and the action's own predicate for a write action.
182
+
183
+ Both inherit `authorize_roles!` from `Authorization::Base`: a tool's `requires_roles` (or
184
+ `default_required_roles`) is checked with `admin.can_access?(*roles)`. An admin class that does not
185
+ answer `can_access?` raises `Administrate::MCP::ConfigurationError` rather than letting the call
186
+ through. Clear `default_required_roles`, give the class the method, or override `authorize_roles!`.
187
+
188
+ `report_mcp_improvement` requires no role: reporting that a tool description or result is wrong is
189
+ not a privileged action, so it stays open to any authenticated admin regardless of
190
+ `default_required_roles`.
191
+
192
+ Write your own by answering three methods:
193
+
194
+ ```ruby
195
+ class MyAuthorization < Administrate::MCP::Authorization::Base
196
+ def authorize!(admin, record_or_class, action); end # raise Administrate::MCP::UnauthorizedError
197
+ def authorized?(admin, record_or_class, action); end # true / false
198
+ def authorize_roles!(admin, roles); end # optional, inherited
199
+ end
200
+ ```
201
+
202
+ ## Serializing your own field classes
203
+
204
+ The engine matches field classes by name, walking the field's ancestors, so it never references a
205
+ class you own:
206
+
207
+ ```ruby
208
+ Administrate::MCP.configure do |c|
209
+ c.register_field('MonetaryAmountsField') { |source| source.value&.serialize }
210
+ c.register_field('AnyAttachmentField', as: :attachment)
211
+ c.register_has_many_field('HasManyNonAssociationField')
212
+ c.skip_field('GridField', 'HasOneNonAssociationField')
213
+ end
214
+ ```
215
+
216
+ A field class that answers `mcp_value(record, attr_name)` and is not registered is asked for its own
217
+ value. An unregistered field class with no `mcp_value` falls back to its raw value.
218
+
219
+ `Field::Text` and `Field::Url`, along with every other field class registered as `:scalar`,
220
+ serialize a nil value as `null` rather than an empty string.
@@ -0,0 +1,50 @@
1
+ # Dashboard declarations
2
+
3
+ [Back to README](../README.md)
4
+
5
+ ```ruby
6
+ class OrderDashboard < Administrate::BaseDashboard
7
+ MCP_DESCRIPTION = 'Customer orders, including the cancelled ones the UI hides by default.'
8
+ MCP_BASE_SCOPE = -> { Order.unscope(where: :state) }
9
+
10
+ COLLECTION_FILTERS = {
11
+ unpaid: ->(scope) { scope.where(paid_at: nil) },
12
+ for_country: ->(scope, code) { scope.where(country: code) }
13
+ }.freeze
14
+
15
+ mcp_action :refund,
16
+ description: 'Refund an order.',
17
+ params: { reason: { type: 'string', description: 'Why the order is being refunded.' } } do |record:, admin:, params:|
18
+ Orders::Refund.call!(order: record, admin:, reason: params[:reason])
19
+ end
20
+ end
21
+ ```
22
+
23
+ Every `MCP_` constant and `COLLECTION_FILTERS` is read off the dashboard itself, never inherited, so
24
+ a constant of the same name defined at the top level of your application, or on a shared dashboard
25
+ base class, is not applied to every resource. A subclass that wants one declares it.
26
+
27
+ - `MCP_DESCRIPTION` tells the client what the resource is.
28
+ - `MCP_SKIPPED_ATTRIBUTES = %i[email]` keeps attributes visible in the admin UI but out of MCP
29
+ reads, listings and field selection.
30
+ - `MCP_EXPOSED = false` leaves the dashboard out of the MCP registry entirely.
31
+ - Namespaced models work through the dashboard's own `self.model`, as in Administrate.
32
+ - `MCP_BASE_SCOPE` replaces the model's default scope for MCP reads, mirroring an admin controller's
33
+ `scoped_resource`.
34
+ - `COLLECTION_FILTERS` are published as filters; a one-argument lambda is a boolean filter, a
35
+ two-argument one takes a value. Foreign key columns are filterable without being declared.
36
+ - `mcp_action` publishes one tool per declaration. It requires the OAuth `write` scope, and the
37
+ authorization predicate (`refund?` by default, or `predicate:`) on the loaded record. The block
38
+ receives `record:`, `admin:` and `params:`; a result answering `success? == false` is reported as
39
+ an error.
40
+
41
+ ## Search
42
+
43
+ The `admin_resource_list` tool's `query` argument matches exactly by default, so searching for an id
44
+ or a slug does not also match every row that merely contains it. `*` is the only wildcard. Every
45
+ comparison casts the column to text first, so a search term matched against a uuid `id` column
46
+ returns no rows instead of raising an error.
47
+
48
+ Search is implemented by `FastSearch`, a subclass of `Administrate::Search` that calls methods that
49
+ class treats as internal (`search_attributes`, `searchable_fields`, `query_table_name`,
50
+ `column_to_query`). That is why the gemspec pins `administrate` below 2.0.
@@ -0,0 +1,19 @@
1
+ # Development
2
+
3
+ [Back to README](../README.md)
4
+
5
+ ```sh
6
+ bundle install
7
+ bundle exec rspec
8
+ bundle exec rubocop
9
+ ```
10
+
11
+ You need a reachable PostgreSQL server; the test suite creates its own database on first run (the
12
+ spec helper catches `ActiveRecord::NoDatabaseError` and calls `ActiveRecord::Tasks::DatabaseTasks.create`),
13
+ so no separate `createdb` step is needed. Point the specs at another Postgres with
14
+ `ADMINISTRATE_MCP_DB_HOST`, `_PORT`, `_USER`, `_PASSWORD` and `_NAME`.
15
+
16
+ The dummy application under `spec/dummy` has an `Admin`, a `Widget` (with `MCP_BASE_SCOPE` and
17
+ collection filters) and a `Gadget` (with a `belongs_to` and an `mcp_action`).
18
+
19
+ See [CONTRIBUTING.md](../CONTRIBUTING.md) for how to propose a change.
data/docs/oauth.md ADDED
@@ -0,0 +1,54 @@
1
+ # OAuth
2
+
3
+ [Back to README](../README.md)
4
+
5
+ The engine ships a complete OAuth 2.1 authorization server: Dynamic Client Registration, a consent
6
+ screen, PKCE, refresh tokens, and the two discovery documents clients read to find all of it. That is
7
+ the default, and for most hosts it is the whole story.
8
+
9
+ It does not fit a deployment where something in front of the application already does this. Cloudflare
10
+ Access managed OAuth, for instance, serves both discovery documents at its own edge and points
11
+ clients at `<team>.cloudflareaccess.com` for authorization, token, registration and revocation. The
12
+ engine's endpoints are then unreachable, and advertising them only gives clients a second, broken
13
+ answer. Turn the server off:
14
+
15
+ ```ruby
16
+ Administrate::MCP.configure do |c|
17
+ c.oauth = false
18
+ end
19
+ ```
20
+
21
+ `Routes.draw_mcp_origin` then draws the JSON-RPC endpoint and nothing else: no `/.well-known/*`, no
22
+ `/oauth/register`, no `/oauth/token`, and `Routes.draw_admin_origin` becomes a no-op, so you can
23
+ leave both calls where they are. API keys keep working. An OAuth token presented while the server is
24
+ off is a bearer token the engine does not recognise, and goes to `identity_fallback` like any other.
25
+
26
+ The models and migrations still ship, and the tables are never queried while `oauth` is false, so a
27
+ host that never ran that migration boots and serves normally. `default_client_name` and
28
+ `allow_localhost_redirects` have nothing to act on and are inert.
29
+
30
+ A verifier for Cloudflare Access managed OAuth ships with the gem, for hosts that run authentication
31
+ at the edge instead of through this server: see
32
+ [Identity fallback](authentication.md#identity-fallback) in the authentication guide.
33
+
34
+ ## Dynamic Client Registration
35
+
36
+ Registration accepts `localhost`, `127.0.0.1`, `::1` and any `*.localhost` subdomain as loopback
37
+ hosts, and issues authorization codes to them over plain http rather than requiring https.
38
+ `*.localhost` covers parallel development checkouts running on different subdomains. The match is
39
+ made on the parsed hostname, so a redirect URI on a host such as `localhost.attacker.com` is
40
+ rejected. Set `allow_localhost_redirects = false` to require https for every redirect URI, including
41
+ loopback ones.
42
+
43
+ ## Rate limiting
44
+
45
+ The gem does not depend on rack-attack. If you use it, these are the throttles to add, or call
46
+ `Administrate::MCP::RackAttack.throttles(host: 'admin-mcp')` from your own initializer, which
47
+ registers exactly these:
48
+
49
+ | Name | Request | Limit |
50
+ | --------------------------- | -------------------------------------- | -------------------- |
51
+ | `mcp_oauth/register/ip` | `POST /oauth/register` on the MCP host | 5 per minute per IP |
52
+ | `mcp_oauth/register/ip-day` | `POST /oauth/register` on the MCP host | 50 per day per IP |
53
+ | `mcp_oauth/token/ip` | `POST /oauth/token` on the MCP host | 10 per minute per IP |
54
+ | `mcp_oauth/authorize/ip` | `GET /mcp/oauth/authorize` | 20 per minute per IP |
data/docs/routes.md ADDED
@@ -0,0 +1,30 @@
1
+ # Routes
2
+
3
+ [Back to README](../README.md)
4
+
5
+ The consent screen must be served on the admin origin, where the admin's session lives, while the
6
+ JSON-RPC and metadata endpoints live on a dedicated MCP origin. Draw each set inside your own
7
+ constraints:
8
+
9
+ ```ruby
10
+ Rails.application.routes.draw do
11
+ constraints ->(request) { request.subdomain == 'admin-mcp' } do
12
+ Administrate::MCP::Routes.draw_mcp_origin(self)
13
+ end
14
+
15
+ constraints subdomain: 'admin' do
16
+ Administrate::MCP::Routes.draw_admin_origin(self)
17
+ end
18
+ end
19
+ ```
20
+
21
+ `draw_mcp_origin` adds `/.well-known/oauth-protected-resource`,
22
+ `/.well-known/oauth-authorization-server`, `POST /oauth/register`, `POST /oauth/token` and the
23
+ JSON-RPC endpoint at `/`. `draw_admin_origin` adds `GET` and `POST /mcp/oauth/authorize`; pass
24
+ `path:` to move it. With `config.oauth` false the OAuth routes are left out of both. See
25
+ [OAuth](oauth.md) for what that means.
26
+
27
+ Both sets have to sit at the root of their origin. The metadata documents the engine publishes name
28
+ the token, registration and JSON-RPC endpoints as absolute paths, `/oauth/token`, `/oauth/register`,
29
+ `/`, so mounting the engine under a prefix would advertise paths that do not answer. If you serve
30
+ everything on a single origin, call both helpers on that origin rather than mounting the engine.
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'administrate/mcp/errors'
4
+
5
+ module Administrate
6
+ module MCP
7
+ module Authorization
8
+ # Shared role gating. Roles are a host concept: an admin class that does not answer
9
+ # `can_access?` cannot be gated on them, and saying so beats letting the call through.
10
+ class Base
11
+ def authorize!(admin, record_or_class, action)
12
+ return if authorized?(admin, record_or_class, action)
13
+
14
+ raise UnauthorizedError, "Not authorized to #{action} #{model_name(record_or_class)}"
15
+ end
16
+
17
+ def authorized?(_admin, _record_or_class, _action)
18
+ true
19
+ end
20
+
21
+ def authorize_roles!(admin, roles)
22
+ return if roles.blank?
23
+
24
+ unless admin.respond_to?(:can_access?)
25
+ raise ConfigurationError,
26
+ "#{admin.class.name} does not respond to can_access?, so the required roles " \
27
+ "#{roles.join(', ')} cannot be checked. Give the admin class a can_access?(*roles) " \
28
+ 'method, clear default_required_roles, or override authorize_roles! on the ' \
29
+ 'authorization adapter.'
30
+ end
31
+
32
+ return if admin.can_access?(*roles)
33
+
34
+ raise UnauthorizedError, "Insufficient permissions. Required roles: #{roles.join(', ')}"
35
+ end
36
+
37
+ private
38
+
39
+ def model_name(record_or_class)
40
+ record_or_class.is_a?(Class) ? record_or_class.name : record_or_class.class.name
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'administrate/mcp/authorization/base'
4
+
5
+ module Administrate
6
+ module MCP
7
+ module Authorization
8
+ # Grants every action to every authenticated admin. The default when the host has no policies.
9
+ class Permissive < Base
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'administrate/mcp/authorization/base'
4
+
5
+ module Administrate
6
+ module MCP
7
+ module Authorization
8
+ # Runs the host's Pundit policy for the resource, exactly as the Administrate UI does.
9
+ class Pundit < Base
10
+ def authorize!(admin, record_or_class, action)
11
+ policy = policy_for(admin, record_or_class)
12
+ return if policy.public_send(action)
13
+
14
+ raise UnauthorizedError, "Not authorized to #{action} #{model_name(record_or_class)}"
15
+ end
16
+
17
+ def authorized?(admin, record_or_class, action)
18
+ authorize!(admin, record_or_class, action)
19
+ true
20
+ rescue UnauthorizedError, ::Pundit::NotDefinedError
21
+ false
22
+ end
23
+
24
+ private
25
+
26
+ def policy_for(admin, record_or_class)
27
+ ::Pundit::PolicyFinder.new(record_or_class).policy!.new(admin, record_or_class)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/numeric/time'
4
+ require 'active_support/core_ext/object/blank'
5
+ require 'jwt'
6
+ require 'net/http'
7
+
8
+ module Administrate
9
+ module MCP
10
+ # Verifies the signed assertion Cloudflare Access attaches to every request it lets through.
11
+ #
12
+ # Clients using Access managed OAuth never reach an MCP server's own OAuth flow: Access resolves
13
+ # their token at its own edge and forwards the caller's identity in this header instead. Build
14
+ # one of these and hand it to `config.identity_fallback` — it answers `call(request)`.
15
+ #
16
+ # This ships in `lib` rather than `app` so a host initializer can name the constant: autoloading
17
+ # is not available while initializers run. Nothing outside a method body names a constant the
18
+ # engine autoloads, so `Authentication` still resolves at call time.
19
+ class CloudflareAccess
20
+ ASSERTION_HEADER = 'Cf-Access-Jwt-Assertion'
21
+ ALGORITHM = 'RS256'
22
+ CERTS_PATH = '/cdn-cgi/access/certs'
23
+ KEYS_CACHE_EXPIRY = 1.hour
24
+ AUTH_ERROR_TYPE = 'cloudflare_access'
25
+
26
+ NETWORK_ERRORS = [
27
+ Errno::ECONNREFUSED,
28
+ Errno::ECONNRESET,
29
+ Errno::EHOSTUNREACH,
30
+ Errno::ENETUNREACH,
31
+ IOError,
32
+ Net::OpenTimeout,
33
+ Net::ReadTimeout,
34
+ OpenSSL::SSL::SSLError,
35
+ SocketError,
36
+ Timeout::Error
37
+ ].freeze
38
+
39
+ # `team_domain` and `audience` each take a value or something that answers `call`. Hosts build
40
+ # this in an initializer, before the environment that carries those settings is necessarily
41
+ # readable, so a callable is re-read on every request rather than captured at boot.
42
+ def initialize(team_domain:, audience:, find_admin:, scopes_for: ->(_admin) { [] })
43
+ @team_domain = team_domain
44
+ @audience = audience
45
+ @find_admin = find_admin
46
+ @scopes_for = scopes_for
47
+ end
48
+
49
+ def team_domain
50
+ resolve(@team_domain)
51
+ end
52
+
53
+ def audience
54
+ resolve(@audience)
55
+ end
56
+
57
+ def call(request)
58
+ return nil unless configured?
59
+
60
+ assertion = request.headers[ASSERTION_HEADER]
61
+ return nil if assertion.blank?
62
+
63
+ payload = verify(assertion)
64
+ return nil if payload.nil?
65
+
66
+ admin = find_admin!(payload['email'])
67
+ Authentication::Identity.new(admin:, scopes: @scopes_for.call(admin))
68
+ end
69
+
70
+ def configured?
71
+ team_domain.present? && audience.present?
72
+ end
73
+
74
+ def verify(assertion)
75
+ kid = key_id(assertion)
76
+ return nil if kid.blank?
77
+
78
+ key = find_key(kid) || find_key(kid, refresh: true)
79
+ return nil if key.nil?
80
+
81
+ decode(assertion, key)
82
+ end
83
+
84
+ def decode(assertion, key)
85
+ JWT.decode(
86
+ assertion,
87
+ JWT::JWK.import(key).public_key,
88
+ true,
89
+ algorithms: [ALGORITHM],
90
+ iss: team_domain,
91
+ verify_iss: true,
92
+ aud: audience,
93
+ verify_aud: true
94
+ ).first
95
+ rescue JWT::DecodeError, JWT::JWKError
96
+ nil
97
+ end
98
+
99
+ def key_id(assertion)
100
+ JSON.parse(Base64.urlsafe_decode64(assertion.split('.').first))['kid']
101
+ rescue ArgumentError, JSON::ParserError
102
+ nil
103
+ end
104
+
105
+ def find_key(kid, refresh: false)
106
+ Rails.cache.delete(certs_url) if refresh
107
+ keys = Rails.cache.fetch(certs_url, expires_in: KEYS_CACHE_EXPIRY, skip_nil: true) { fetch_keys }
108
+ keys&.find { |key| key['kid'] == kid }
109
+ end
110
+
111
+ def fetch_keys
112
+ JSON.parse(Net::HTTP.get(URI.parse(certs_url)))['keys']
113
+ rescue JSON::ParserError, *NETWORK_ERRORS
114
+ nil
115
+ end
116
+
117
+ def certs_url
118
+ "#{team_domain}#{CERTS_PATH}"
119
+ end
120
+
121
+ private
122
+
123
+ def resolve(setting)
124
+ value = setting.respond_to?(:call) ? setting.call : setting
125
+ value.presence
126
+ end
127
+
128
+ # The identity provider decides how it capitalises an address; the lookup should not care.
129
+ def find_admin!(email)
130
+ admin = @find_admin.call(email.to_s.downcase)
131
+ return admin if admin
132
+
133
+ raise Authentication::ExternalIdentityError.new(
134
+ "No admin account for #{email}",
135
+ auth_error_type: AUTH_ERROR_TYPE
136
+ )
137
+ end
138
+ end
139
+ end
140
+ end