administrate-mcp 0.2.0 → 0.3.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -1
- data/app/lib/administrate/mcp/actions.rb +9 -1
- data/app/lib/administrate/mcp/admin_dashboard_tool.rb +49 -3
- data/app/lib/administrate/mcp/base_tool.rb +25 -3
- data/app/lib/administrate/mcp/server_builder.rb +5 -1
- data/app/lib/administrate/mcp/tools/admin_resource_list.rb +2 -6
- data/app/lib/administrate/mcp/tools/admin_resource_list_resources.rb +3 -6
- data/app/lib/administrate/mcp/tools/admin_resource_show.rb +3 -6
- data/lib/administrate/mcp/authorization/base.rb +7 -1
- data/lib/administrate/mcp/authorization/pundit.rb +1 -1
- data/lib/administrate/mcp/errors.rb +6 -0
- data/lib/administrate/mcp/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f3f38d54e63d5f8d6b88750c73f284d10d5e0500a0a7f47cae82556be264e8fe
|
|
4
|
+
data.tar.gz: 1b1d5fda201d08915bdddd2c6eb92fb97f9698ece7f17cbff1a1d563f280eea9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bdc5883ed9f4d6cadab89dd8be6aca8f890e25cfc39d0a40ef6b2b7e9fd6b11af6de5e817d4d8c6047a0bcd9d3ad1a6bd05b4af095bd64c256ad6d65198bbf7e
|
|
7
|
+
data.tar.gz: 21bd4c4e668bad2bd5d9050bc6e86abae5800a605be3604d2683687bb3b52bcf4dae42cc2423f5df2fe09713ac14784cc5188a5d76ba28694fe2f41581fa895e
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,56 @@ minor release may change behaviour a host depends on; the entry says so when it
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.3.1] - 2026-10-20
|
|
12
|
+
|
|
13
|
+
### Security
|
|
14
|
+
|
|
15
|
+
- A `tools/call` argument named `admin` no longer replaces the authenticated admin passed to a
|
|
16
|
+
tool's `execute` and to `mcp_action` blocks
|
|
17
|
+
([GHSA-vv7c-hhjc-fhq8](https://github.com/sorare/administrate-mcp/security/advisories/GHSA-vv7c-hhjc-fhq8)).
|
|
18
|
+
The call is now refused with a tool error. For built-in and host-defined tools, the refusal comes
|
|
19
|
+
after the scope and role checks, and the `on_tool_call` hook still reports the call under the
|
|
20
|
+
authenticated admin, with the refused argument in `arguments`. The tools generated from
|
|
21
|
+
`mcp_action` refuse it while validating arguments, before the hook runs.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **Behaviour change for hosts:** a tool that declares an input property named `admin` now raises
|
|
26
|
+
`ArgumentError` when its class is loaded, and an `mcp_action` param named `admin` or `id` raises
|
|
27
|
+
`ArgumentError` when the dashboard is loaded. Rename the property or param.
|
|
28
|
+
- **Behaviour change for clients:** the tools generated from `mcp_action` declarations now reject
|
|
29
|
+
arguments they do not declare, with an `Invalid arguments` tool error, instead of passing them to
|
|
30
|
+
the block in `params`.
|
|
31
|
+
|
|
32
|
+
## [0.3.0] - 2026-10-02
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- **Behaviour change for clients:** two failures of `admin_resource_list_resources`,
|
|
37
|
+
`admin_resource_list` and `admin_resource_show` now return HTTP 200 with a tool error
|
|
38
|
+
(`result.isError: true`) instead of HTTP 403 with `X-Auth-Error: forbidden` and JSON-RPC code
|
|
39
|
+
-32003. Clients such as Claude showed that 403 as an authentication failure and dropped the message:
|
|
40
|
+
- An unknown resource name. The message says the name is not registered and lists up to five
|
|
41
|
+
close registered names, taken only from the resources the caller can read.
|
|
42
|
+
- A registered resource the caller may not read. The message is "Resource `<name>` exists but you
|
|
43
|
+
are not authorized to list it" (or "show it"), followed by the authorization adapter's own
|
|
44
|
+
message when the host wrote one. The old 403 body already told the two cases apart
|
|
45
|
+
(`Unknown resource: …` against `Not authorized to show? <Model>`); the difference is that
|
|
46
|
+
clients now display it.
|
|
47
|
+
|
|
48
|
+
A tool-level refusal, a missing OAuth scope or a required role, is still a 403 for now
|
|
49
|
+
([#11](https://github.com/sorare/administrate-mcp/issues/11)). The per-resource denial is raised
|
|
50
|
+
as `Administrate::MCP::ResourceForbiddenError`, a subclass of `UnauthorizedError`, so host code
|
|
51
|
+
that rescues `UnauthorizedError` still catches it.
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- A `subscriptions/listen` request no longer raises `NoMethodError` in the host's Rack stack. The
|
|
56
|
+
`mcp` gem answers it with a streaming SSE body meant to stay open, which the engine's controller
|
|
57
|
+
cannot serve; the transport is now built with `serve_subscriptions_listen: false`, so the method
|
|
58
|
+
is answered with HTTP 404 and JSON-RPC error `-32601` like any unimplemented method, and
|
|
59
|
+
`server/discover` stops advertising the `listChanged` and `subscribe` capability flags.
|
|
60
|
+
|
|
11
61
|
## [0.2.0] - 2026-09-22
|
|
12
62
|
|
|
13
63
|
### Added
|
|
@@ -88,6 +138,8 @@ minor release may change behaviour a host depends on; the entry says so when it
|
|
|
88
138
|
took the gem from git before 0.1.0 must reinstall its migrations rather than migrate
|
|
89
139
|
incrementally.
|
|
90
140
|
|
|
91
|
-
[Unreleased]: https://github.com/sorare/administrate-mcp/compare/v0.
|
|
141
|
+
[Unreleased]: https://github.com/sorare/administrate-mcp/compare/v0.3.1...HEAD
|
|
142
|
+
[0.3.1]: https://github.com/sorare/administrate-mcp/compare/v0.3.0...v0.3.1
|
|
143
|
+
[0.3.0]: https://github.com/sorare/administrate-mcp/compare/v0.2.0...v0.3.0
|
|
92
144
|
[0.2.0]: https://github.com/sorare/administrate-mcp/compare/v0.1.0...v0.2.0
|
|
93
145
|
[0.1.0]: https://github.com/sorare/administrate-mcp/releases/tag/v0.1.0
|
|
@@ -27,6 +27,8 @@ module Administrate
|
|
|
27
27
|
keyword_init: true
|
|
28
28
|
)
|
|
29
29
|
|
|
30
|
+
RESERVED_PARAMS = %i[admin id].freeze
|
|
31
|
+
|
|
30
32
|
class << self
|
|
31
33
|
# Normalizes the arguments of a `mcp_action` declaration into a spec (everything but the
|
|
32
34
|
# model, which is inferred from the owning dashboard when definitions are built).
|
|
@@ -34,6 +36,7 @@ module Administrate
|
|
|
34
36
|
destructive: true, &invoke)
|
|
35
37
|
raise ArgumentError, "#{name}: a block is required to invoke the action" unless invoke
|
|
36
38
|
|
|
39
|
+
reject_reserved_params!(name, params)
|
|
37
40
|
normalized = normalize_params(params)
|
|
38
41
|
|
|
39
42
|
{
|
|
@@ -97,6 +100,11 @@ module Administrate
|
|
|
97
100
|
|
|
98
101
|
private
|
|
99
102
|
|
|
103
|
+
def reject_reserved_params!(name, params)
|
|
104
|
+
reserved = RESERVED_PARAMS & params.keys.map(&:to_sym)
|
|
105
|
+
raise ArgumentError, "#{name}: reserved param name(s): #{reserved.join(', ')}" if reserved.any?
|
|
106
|
+
end
|
|
107
|
+
|
|
100
108
|
def authorized_for_discovery?(admin, definition)
|
|
101
109
|
Administrate::MCP.config.authorization.authorized?(admin, definition.model_class, definition.predicate)
|
|
102
110
|
rescue StandardError
|
|
@@ -130,7 +138,7 @@ module Administrate
|
|
|
130
138
|
def schema_for(defn, resource)
|
|
131
139
|
properties = { id: { type: 'string', description: "ID or slug of the #{resource}" } }
|
|
132
140
|
defn.params.each { |name, spec| properties[name] = spec }
|
|
133
|
-
{ properties:, required: %w[id] + defn.required.map(&:to_s) }
|
|
141
|
+
{ properties:, required: %w[id] + defn.required.map(&:to_s), additionalProperties: false }
|
|
134
142
|
end
|
|
135
143
|
|
|
136
144
|
# Replaces the role-based default with instance-level authorization on the loaded record.
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'did_you_mean'
|
|
4
|
+
|
|
3
5
|
module Administrate
|
|
4
6
|
module MCP
|
|
5
7
|
# Base class for MCP tools that operate on Administrate dashboard resources.
|
|
6
8
|
class AdminDashboardTool < BaseTool
|
|
7
9
|
LISTED_VALID_VALUES = 40
|
|
10
|
+
MAX_SUGGESTIONS = 5
|
|
11
|
+
ACTION_VERBS = { index?: 'list', show?: 'show' }.freeze
|
|
8
12
|
|
|
9
13
|
class << self
|
|
10
14
|
def policy_action
|
|
@@ -14,7 +18,7 @@ module Administrate
|
|
|
14
18
|
def check_roles!(admin, resource: nil, **)
|
|
15
19
|
return unless resource && policy_action
|
|
16
20
|
|
|
17
|
-
entry = find_dashboard_entry!(resource)
|
|
21
|
+
entry = find_dashboard_entry!(admin, resource)
|
|
18
22
|
authorize_resource!(admin, entry.model_class, policy_action)
|
|
19
23
|
end
|
|
20
24
|
|
|
@@ -22,15 +26,57 @@ module Administrate
|
|
|
22
26
|
Administrate::MCP.config.authorization.authorized?(admin, model_class, action)
|
|
23
27
|
end
|
|
24
28
|
|
|
29
|
+
# The adapter is the host's and raises a plain UnauthorizedError. Re-raised here so a denial on
|
|
30
|
+
# one resource reaches the caller as a tool error instead of a 403 on the whole response. A
|
|
31
|
+
# message the host wrote is kept, since it may say why; the adapters' generic one adds nothing.
|
|
25
32
|
def authorize_resource!(admin, model_class, action)
|
|
26
33
|
Administrate::MCP.config.authorization.authorize!(admin, model_class, action)
|
|
34
|
+
rescue UnauthorizedError => e
|
|
35
|
+
verb = ACTION_VERBS.fetch(action.to_sym) { action.to_s.delete_suffix('?') }
|
|
36
|
+
message = "Resource `#{model_class.name.underscore}` exists but you are not authorized to #{verb} it."
|
|
37
|
+
reason = e.message unless e.message == Authorization::Base.denial_message(action, model_class.name)
|
|
38
|
+
raise ResourceForbiddenError, [message, reason].compact.join(' ')
|
|
27
39
|
end
|
|
28
40
|
|
|
29
|
-
def find_dashboard_entry!(resource_name)
|
|
41
|
+
def find_dashboard_entry!(admin, resource_name)
|
|
30
42
|
entry = DashboardRegistry.find(resource_name)
|
|
31
43
|
return entry if entry
|
|
32
44
|
|
|
33
|
-
raise
|
|
45
|
+
raise InvalidArgumentError, unknown_resource_message(admin, resource_name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def unknown_resource_message(admin, resource_name)
|
|
49
|
+
message = "Unknown resource: #{resource_name}. This name is not registered."
|
|
50
|
+
suggestions = resource_suggestions(admin, resource_name)
|
|
51
|
+
return "#{message} Call admin_resource_list_resources for the catalog." if suggestions.empty?
|
|
52
|
+
|
|
53
|
+
"#{message} Closest registered names: #{suggestions.join(', ')}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Only names the caller can read are suggested, as in the catalog, so a typo does not reveal
|
|
57
|
+
# hidden resources.
|
|
58
|
+
def resource_suggestions(admin, resource_name)
|
|
59
|
+
suggestion_candidates(resource_name).lazy.select { |name| readable?(admin, name) }.first(MAX_SUGGESTIONS)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Matches on the last path segment too, so a bare "order" finds "shop/order".
|
|
63
|
+
def suggestion_candidates(resource_name)
|
|
64
|
+
key = resource_name.to_s.underscore.singularize
|
|
65
|
+
names = DashboardRegistry.resource_names
|
|
66
|
+
by_segment = names.group_by { |name| name.split('/').last }
|
|
67
|
+
segment = key.split('/').last.to_s
|
|
68
|
+
close_segments = DidYouMean::SpellChecker.new(dictionary: by_segment.keys).correct(segment)
|
|
69
|
+
|
|
70
|
+
(
|
|
71
|
+
by_segment.fetch(segment, []) +
|
|
72
|
+
DidYouMean::SpellChecker.new(dictionary: names).correct(key) +
|
|
73
|
+
close_segments.flat_map { |name| by_segment[name] }
|
|
74
|
+
).uniq
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def readable?(admin, resource_name)
|
|
78
|
+
entry = DashboardRegistry.find(resource_name)
|
|
79
|
+
entry && authorized?(admin, entry.model_class, :index?)
|
|
34
80
|
end
|
|
35
81
|
|
|
36
82
|
def reject_unknown!(kind, given, allowed)
|
|
@@ -5,9 +5,17 @@ module Administrate
|
|
|
5
5
|
# Base class for all MCP tools — provides role gating, auditing, and response helpers.
|
|
6
6
|
class BaseTool < ::MCP::Tool
|
|
7
7
|
UnauthorizedError = Administrate::MCP::UnauthorizedError
|
|
8
|
+
ResourceForbiddenError = Administrate::MCP::ResourceForbiddenError
|
|
8
9
|
InvalidArgumentError = Administrate::MCP::InvalidArgumentError
|
|
9
10
|
|
|
11
|
+
RESERVED_ARGUMENTS = %i[admin].freeze
|
|
12
|
+
|
|
10
13
|
class << self
|
|
14
|
+
def input_schema(value = NOT_SET)
|
|
15
|
+
reject_reserved_properties!(value) if value.is_a?(Hash)
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
11
19
|
def requires_roles(*roles)
|
|
12
20
|
@required_roles = roles.flatten
|
|
13
21
|
end
|
|
@@ -26,11 +34,11 @@ module Administrate
|
|
|
26
34
|
|
|
27
35
|
def call(server_context:, **args)
|
|
28
36
|
instrument(server_context) { call_with_context(server_context, **args) }
|
|
37
|
+
rescue ResourceForbiddenError, InvalidArgumentError => e
|
|
38
|
+
error_response(e.message)
|
|
29
39
|
rescue UnauthorizedError => e
|
|
30
40
|
(server_context[:authorization_errors] ||= []).push(e.message)
|
|
31
41
|
error_response(e.message)
|
|
32
|
-
rescue InvalidArgumentError => e
|
|
33
|
-
error_response(e.message)
|
|
34
42
|
rescue StandardError => e
|
|
35
43
|
error_response("Internal error: #{e.message}")
|
|
36
44
|
end
|
|
@@ -46,7 +54,21 @@ module Administrate
|
|
|
46
54
|
check_scopes!(server_context)
|
|
47
55
|
check_roles!(admin, **args)
|
|
48
56
|
audit!(admin, args, server_context)
|
|
49
|
-
|
|
57
|
+
reject_reserved_arguments!(args)
|
|
58
|
+
execute(**args, admin:)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def reject_reserved_arguments!(args)
|
|
62
|
+
reserved = args.keys & RESERVED_ARGUMENTS
|
|
63
|
+
return if reserved.empty?
|
|
64
|
+
|
|
65
|
+
raise InvalidArgumentError, "Reserved argument(s) cannot be passed: #{reserved.join(', ')}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def reject_reserved_properties!(schema)
|
|
69
|
+
properties = (schema[:properties] || schema['properties'] || {}).keys.map(&:to_sym)
|
|
70
|
+
reserved = properties & RESERVED_ARGUMENTS
|
|
71
|
+
raise ArgumentError, "#{name}: reserved input property name(s): #{reserved.join(', ')}" if reserved.any?
|
|
50
72
|
end
|
|
51
73
|
|
|
52
74
|
def check_scopes!(server_context)
|
|
@@ -35,7 +35,11 @@ module Administrate
|
|
|
35
35
|
# The host's own route constraints already restrict which Host values reach the
|
|
36
36
|
# controller, and this is not loopback-bound. Passing the incoming request's own host
|
|
37
37
|
# back as `allowed_hosts:` would just validate the Host header against itself.
|
|
38
|
-
dns_rebinding_protection: false
|
|
38
|
+
dns_rebinding_protection: false,
|
|
39
|
+
# `subscriptions/listen` answers with a streaming SSE body that is held open. The
|
|
40
|
+
# controller buffers the response and builds a stateless transport per request, so it
|
|
41
|
+
# cannot serve that stream; refusing answers the method as unimplemented (-32601).
|
|
42
|
+
serve_subscriptions_listen: false
|
|
39
43
|
)
|
|
40
44
|
server
|
|
41
45
|
end
|
|
@@ -24,10 +24,7 @@ module Administrate
|
|
|
24
24
|
description:
|
|
25
25
|
'Resource type name (e.g., "card", "user", "player"). ' \
|
|
26
26
|
'Use admin_resource_list_resources with no arguments to see available ones. ' \
|
|
27
|
-
'Many resources are namespaced (e.g. "shop/order") and bare names are not aliased.
|
|
28
|
-
'An unrecognised name currently surfaces as an authorization error, not a "not found" ' \
|
|
29
|
-
'error, so treat an auth failure here as a likely wrong resource name and re-check the ' \
|
|
30
|
-
'catalog rather than assuming the server is unavailable.'
|
|
27
|
+
'Many resources are namespaced (e.g. "shop/order") and bare names are not aliased.'
|
|
31
28
|
},
|
|
32
29
|
query: {
|
|
33
30
|
type: 'string',
|
|
@@ -86,7 +83,6 @@ module Administrate
|
|
|
86
83
|
required: %w[resource]
|
|
87
84
|
)
|
|
88
85
|
|
|
89
|
-
# rubocop:disable-next Lint/UnusedMethodArgument
|
|
90
86
|
def self.execute(
|
|
91
87
|
admin:,
|
|
92
88
|
resource:,
|
|
@@ -99,7 +95,7 @@ module Administrate
|
|
|
99
95
|
page: 1,
|
|
100
96
|
per_page: 10
|
|
101
97
|
)
|
|
102
|
-
entry = find_dashboard_entry!(resource)
|
|
98
|
+
entry = find_dashboard_entry!(admin, resource)
|
|
103
99
|
dashboard = entry.dashboard_class.new
|
|
104
100
|
per_page = per_page.to_i.clamp(1, MAX_PER_PAGE)
|
|
105
101
|
page = [page.to_i, 1].max
|
|
@@ -8,7 +8,7 @@ module Administrate
|
|
|
8
8
|
class AdminResourceListResources < AdminDashboardTool
|
|
9
9
|
tool_name 'admin_resource_list_resources'
|
|
10
10
|
description 'Discover available admin resources. ' \
|
|
11
|
-
'Without a resource param: returns
|
|
11
|
+
'Without a resource param: returns the resource names you can read (lightweight catalog). ' \
|
|
12
12
|
'With a resource param: returns fields and filters for that resource.'
|
|
13
13
|
annotations read_only_hint: true, destructive_hint: false, open_world_hint: true
|
|
14
14
|
|
|
@@ -19,10 +19,7 @@ module Administrate
|
|
|
19
19
|
description:
|
|
20
20
|
'Resource name to inspect (optional). When provided, returns fields and filters for that ' \
|
|
21
21
|
'resource. Must be an exact name from the no-argument catalog — many resources are ' \
|
|
22
|
-
'namespaced (e.g. "shop/order") and bare names are not aliased.
|
|
23
|
-
'currently surfaces as an authorization error, not a "not found" error, so treat an auth ' \
|
|
24
|
-
'failure here as a likely wrong resource name and re-check the catalog rather than ' \
|
|
25
|
-
'assuming the server is unavailable.'
|
|
22
|
+
'namespaced (e.g. "shop/order") and bare names are not aliased.'
|
|
26
23
|
}
|
|
27
24
|
}
|
|
28
25
|
)
|
|
@@ -49,7 +46,7 @@ module Administrate
|
|
|
49
46
|
end
|
|
50
47
|
|
|
51
48
|
def self.detail_response(admin, resource_name)
|
|
52
|
-
entry = find_dashboard_entry!(resource_name)
|
|
49
|
+
entry = find_dashboard_entry!(admin, resource_name)
|
|
53
50
|
authorize_resource!(admin, entry.model_class, :index?)
|
|
54
51
|
|
|
55
52
|
json_response(build_detail(resource_name, entry))
|
|
@@ -24,10 +24,7 @@ module Administrate
|
|
|
24
24
|
description:
|
|
25
25
|
'Resource type name (e.g., "card", "user", "player"). ' \
|
|
26
26
|
'Use admin_resource_list_resources with no arguments to see available ones. ' \
|
|
27
|
-
'Many resources are namespaced (e.g. "shop/order") and bare names are not aliased.
|
|
28
|
-
'An unrecognised name currently surfaces as an authorization error, not a "not found" ' \
|
|
29
|
-
'error, so treat an auth failure here as a likely wrong resource name and re-check the ' \
|
|
30
|
-
'catalog rather than assuming the server is unavailable.'
|
|
27
|
+
'Many resources are namespaced (e.g. "shop/order") and bare names are not aliased.'
|
|
31
28
|
},
|
|
32
29
|
id: {
|
|
33
30
|
oneOf: [
|
|
@@ -75,8 +72,8 @@ module Administrate
|
|
|
75
72
|
required: %w[resource id]
|
|
76
73
|
)
|
|
77
74
|
|
|
78
|
-
def self.execute(admin:, resource:, id:, fields: nil, expand: nil)
|
|
79
|
-
entry = find_dashboard_entry!(resource)
|
|
75
|
+
def self.execute(admin:, resource:, id:, fields: nil, expand: nil)
|
|
76
|
+
entry = find_dashboard_entry!(admin, resource)
|
|
80
77
|
dashboard = entry.dashboard_class.new
|
|
81
78
|
attrs, expand_set =
|
|
82
79
|
resolve_attributes_and_expansions(dashboard, fields, expand, :show_page_attributes, MAX_EXPAND)
|
|
@@ -8,10 +8,16 @@ module Administrate
|
|
|
8
8
|
# Shared role gating. Roles are a host concept: an admin class that does not answer
|
|
9
9
|
# `can_access?` cannot be gated on them, and saying so beats letting the call through.
|
|
10
10
|
class Base
|
|
11
|
+
# The message the built-in adapters raise. A caller that reformats a denial compares against
|
|
12
|
+
# it to tell the generic message from one the host wrote.
|
|
13
|
+
def self.denial_message(action, model_name)
|
|
14
|
+
"Not authorized to #{action} #{model_name}"
|
|
15
|
+
end
|
|
16
|
+
|
|
11
17
|
def authorize!(admin, record_or_class, action)
|
|
12
18
|
return if authorized?(admin, record_or_class, action)
|
|
13
19
|
|
|
14
|
-
raise UnauthorizedError,
|
|
20
|
+
raise UnauthorizedError, Base.denial_message(action, model_name(record_or_class))
|
|
15
21
|
end
|
|
16
22
|
|
|
17
23
|
def authorized?(_admin, _record_or_class, _action)
|
|
@@ -11,7 +11,7 @@ module Administrate
|
|
|
11
11
|
policy = policy_for(admin, record_or_class)
|
|
12
12
|
return if policy.public_send(action)
|
|
13
13
|
|
|
14
|
-
raise UnauthorizedError,
|
|
14
|
+
raise UnauthorizedError, Base.denial_message(action, model_name(record_or_class))
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def authorized?(admin, record_or_class, action)
|
|
@@ -9,6 +9,12 @@ module Administrate
|
|
|
9
9
|
class UnauthorizedError < Error
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
# Raised when the caller's role may not read one resource. It is still an authorization failure,
|
|
13
|
+
# but it is returned as a tool error rather than a 403: the credentials work for the tool, only
|
|
14
|
+
# this resource is out of reach, and the caller needs the message to tell the two apart.
|
|
15
|
+
class ResourceForbiddenError < UnauthorizedError
|
|
16
|
+
end
|
|
17
|
+
|
|
12
18
|
# Raised when the host asked for something the configuration cannot deliver.
|
|
13
19
|
class ConfigurationError < Error
|
|
14
20
|
end
|