admin_suite 0.5.0 → 0.6.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/.gitignore +7 -0
- data/CHANGELOG.md +69 -11
- data/CONTRIBUTING.md +9 -4
- data/README.md +21 -5
- data/app/controllers/admin_suite/application_controller.rb +12 -11
- data/app/controllers/admin_suite/mcp_controller.rb +36 -0
- data/app/controllers/admin_suite/resources_controller.rb +7 -78
- data/app/views/admin_suite/resources/index.html.erb +1 -0
- data/config/routes.rb +5 -0
- data/lib/admin/base/resource.rb +7 -29
- data/lib/admin_suite/auth/host_user.rb +42 -0
- data/lib/admin_suite/auth/strategy.rb +1 -1
- data/lib/admin_suite/auth.rb +15 -0
- data/lib/admin_suite/authorization_context.rb +28 -0
- data/lib/admin_suite/configuration.rb +56 -2
- data/lib/admin_suite/mcp/authorization.rb +68 -0
- data/lib/admin_suite/mcp/serializer.rb +138 -0
- data/lib/admin_suite/mcp/tools/aggregate.rb +55 -0
- data/lib/admin_suite/mcp/tools/describe_resources.rb +63 -0
- data/lib/admin_suite/mcp/tools/get_record.rb +41 -0
- data/lib/admin_suite/mcp/tools/list_records.rb +67 -0
- data/lib/admin_suite/mcp.rb +53 -0
- data/lib/admin_suite/query.rb +71 -0
- data/lib/admin_suite/ui/show_value_formatter.rb +2 -2
- data/lib/admin_suite/version.rb +1 -1
- data/lib/admin_suite.rb +14 -9
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -2
- data/test/controllers/resources_controller_test.rb +19 -13
- data/test/integration/authentication_test.rb +10 -0
- data/test/integration/authorization_test.rb +20 -3
- data/test/integration/index_query_characterization_test.rb +229 -0
- data/test/integration/mcp_aggregate_test.rb +44 -0
- data/test/integration/mcp_authorization_test.rb +102 -0
- data/test/integration/mcp_endpoint_test.rb +89 -0
- data/test/integration/mcp_get_record_test.rb +62 -0
- data/test/integration/mcp_instrumentation_test.rb +107 -0
- data/test/integration/mcp_list_records_test.rb +75 -0
- data/test/integration/mcp_parity_test.rb +155 -0
- data/test/integration/mcp_release_test.rb +131 -0
- data/test/integration/read_only_resource_test.rb +128 -2
- data/test/integration/searchable_select_search_test.rb +5 -4
- data/test/lib/auth_host_user_test.rb +52 -0
- data/test/lib/auth_http_basic_test.rb +10 -0
- data/test/lib/auth_test.rb +20 -3
- data/test/lib/authorization_context_test.rb +127 -0
- data/test/lib/engine_defaults_test.rb +1 -2
- data/test/lib/mcp_serializer_test.rb +107 -0
- data/test/lib/query_test.rb +91 -0
- data/test/lib/removed_deprecations_test.rb +16 -0
- data/test/publish_workflow_test.rb +63 -0
- data/test/test_helper.rb +26 -0
- metadata +42 -5
- data/lib/admin_suite/renderers/legacy_gleania.rb +0 -232
- data/test/lib/legacy_renderer_deprecation_test.rb +0 -42
- data/test/lib/resource_exportable_deprecation_test.rb +0 -47
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 964267328a54fb0d55bfb035fab32ba694b620b0f5cbdb45194e770a3138885f
|
|
4
|
+
data.tar.gz: eec49bd217460a272eb6ce1add0fd9ac487764a37e304bdcde105044f7eed80a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5875a1f28a12afb57b1e43c38db7f4362f6ef896f2b0de7f0af2d949945ac1c60e17f0e24a5b3f967f753fdeac8960470f2076421ef31156f3fb64b03285fbd2
|
|
7
|
+
data.tar.gz: 76e74fce525a385fad34c84511f2affbfeca6df6ea147e81dfdaa393051c3b98915055f1b620040a140c6ce5e336823fc66928d2a0b01ca9017881914d530185
|
data/.gitignore
CHANGED
|
@@ -10,3 +10,10 @@
|
|
|
10
10
|
/test/dummy/storage/
|
|
11
11
|
admin_suite-*.gem
|
|
12
12
|
/.bundle/
|
|
13
|
+
|
|
14
|
+
# Agent scratch: git worktrees created per-phase, and subagent-driven-development
|
|
15
|
+
# ledgers/briefs/review diffs. Never part of the gem — `spec.files` is an
|
|
16
|
+
# allowlist, so these could not ship, but an `add -A` from the repo root would
|
|
17
|
+
# otherwise commit an entire nested worktree.
|
|
18
|
+
/.claude/
|
|
19
|
+
/.superpowers/
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,64 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.6.1] - 2026-09-12
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Automated gem publishing now uses RubyGems Trusted Publishing (GitHub Actions
|
|
12
|
+
OIDC) instead of a long-lived API key that required an interactive MFA code.
|
|
13
|
+
- Publishing is restricted to successful CI for main-branch pushes from this
|
|
14
|
+
repository, or manual dispatch on main, using the protected `release` environment.
|
|
15
|
+
|
|
16
|
+
### Upgrade notes
|
|
17
|
+
- Includes the changes listed under 0.6.0 below, which was not published to
|
|
18
|
+
RubyGems. Upgrading from 0.5.0 requires the 0.6.0 host authentication,
|
|
19
|
+
authorization, and renderer migrations.
|
|
20
|
+
|
|
21
|
+
## [0.6.0] - 2026-09-09
|
|
22
|
+
|
|
23
|
+
### Breaking
|
|
24
|
+
- `read_only` resources now reject declared member and bulk actions as well as
|
|
25
|
+
CRUD and toggle routes (404, no model mutation).
|
|
26
|
+
- Authorization hooks now accept `context:` instead of `controller:`. Use
|
|
27
|
+
`context.surface` (`:web` or `:mcp`), `context.controller` (web only), and
|
|
28
|
+
`context.request`. Old signatures fail at assignment with migration guidance.
|
|
29
|
+
- Removed the deprecated `exportable` no-op and the four built-in Gleania
|
|
30
|
+
renderer implementations. Hosts must remove `exportable` calls and provide
|
|
31
|
+
their own renderer classes before upgrading. Existing registered host keys work.
|
|
32
|
+
- MCP requires a real actor and an explicit `config.authorize` hook. A nil hook
|
|
33
|
+
exposes no tools/data; authenticated web requests retain their existing behavior.
|
|
34
|
+
- Default DSL page sizes above the configured cap are now capped on the web index too.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
- Operator MCP at `<mount>/mcp`, using the official SDK's stateless HTTP transport.
|
|
38
|
+
Four read tools: `describe_resources`, `list_records`, `get_record`, `aggregate`.
|
|
39
|
+
Discovery includes portal, section, declared fields, and declared actions as
|
|
40
|
+
metadata (actions are not callable). Reads serialize declared fields only;
|
|
41
|
+
association panels require explicit columns and return bounded rows. No write
|
|
42
|
+
tools, LLM calls or chat UI in this release.
|
|
43
|
+
- `:host_user` authentication strategy with a host-provided user resolver; shared
|
|
44
|
+
actor normalization across surfaces. Existing HTTP Basic and SSO still work.
|
|
45
|
+
- Resource DSL `mcp false` opt-out and `config.mcp.enabled` / `max_page_size` settings.
|
|
46
|
+
- `admin_suite.mcp.tool_call` notifications with tool, resource, action,
|
|
47
|
+
actor_type, actor_id, request_id, filters, q, allowed, error, result_count and
|
|
48
|
+
duration_ms. Hosts own request-log persistence.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
- MCP index serialization evaluates column content lambdas (matching the web
|
|
52
|
+
index) and coerces times and association records to JSON primitives instead of
|
|
53
|
+
`to_s` heap addresses.
|
|
54
|
+
- MCP/default pagination cannot bypass the configured cap by omitting `per_page`
|
|
55
|
+
or passing invalid/non-positive values. The response states the applied limit.
|
|
56
|
+
- Anonymous MCP calls are rejected even under the development escape hatch;
|
|
57
|
+
`get_record` additionally checks record-level policy before serialization.
|
|
58
|
+
- Foreign browser origins are rejected. Notifications return 202; unsupported
|
|
59
|
+
GET streams and session deletion return 405.
|
|
60
|
+
|
|
61
|
+
### Changed
|
|
62
|
+
- Extracted `AdminSuite::Query`; the web index and MCP share filter/search/sort,
|
|
63
|
+
eager-loading and page-size behavior, covered by parity/characterization tests.
|
|
64
|
+
- Migration guide: `../_vault/products/admin_suite/docs/mcp.md`.
|
|
65
|
+
|
|
8
66
|
## [0.5.0] - 2026-08-01
|
|
9
67
|
|
|
10
68
|
### Added
|
|
@@ -224,6 +282,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
224
282
|
|
|
225
283
|
## [0.3.0] - 2026-07-31
|
|
226
284
|
|
|
285
|
+
### Added
|
|
286
|
+
- Pluggable auth strategies: `AdminSuite::Auth.register`, built-in
|
|
287
|
+
`:http_basic` (ENV or `config.auth_options` credentials, constant-time
|
|
288
|
+
comparison, blank credentials deny).
|
|
289
|
+
- `config.authorize` is now enforced for every resource action with the
|
|
290
|
+
contract `->(actor:, action:, resource:, record:, controller:)`,
|
|
291
|
+
action ∈ :read/:create/:update/:destroy/:execute.
|
|
292
|
+
- `config.skip_host_before_actions` (default `[:require_authentication]`)
|
|
293
|
+
replaces the hardcoded host-filter skip.
|
|
294
|
+
|
|
227
295
|
### Changed (BREAKING)
|
|
228
296
|
- AdminSuite now **fails closed**: with no authentication configured, every
|
|
229
297
|
engine request responds 403. Configure `config.auth_strategy = :http_basic`
|
|
@@ -235,17 +303,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
235
303
|
dedicated bulk-action lookup. Declared member and bulk actions remain
|
|
236
304
|
allowed on `read_only` resources by design.
|
|
237
305
|
- `execute_action` with an unknown action name now responds 404 instead of
|
|
238
|
-
redirecting with an "Action not found
|
|
239
|
-
|
|
240
|
-
### Added
|
|
241
|
-
- Pluggable auth strategies: `AdminSuite::Auth.register`, built-in
|
|
242
|
-
`:http_basic` (ENV or `config.auth_options` credentials, constant-time
|
|
243
|
-
comparison, blank credentials deny).
|
|
244
|
-
- `config.authorize` is now enforced for every resource action with the
|
|
245
|
-
contract `->(actor:, action:, resource:, record:, controller:)`,
|
|
246
|
-
action ∈ :read/:create/:update/:destroy/:execute.
|
|
247
|
-
- `config.skip_host_before_actions` (default `[:require_authentication]`)
|
|
248
|
-
replaces the hardcoded host-filter skip.
|
|
306
|
+
redirecting with an "Action not found" alert.
|
|
249
307
|
|
|
250
308
|
### Fixed
|
|
251
309
|
- `config.current_actor` is now consulted at most once per request
|
data/CONTRIBUTING.md
CHANGED
|
@@ -28,7 +28,7 @@ See `../_vault/products/admin_suite/docs/development.md` for detailed informatio
|
|
|
28
28
|
### CI Checks
|
|
29
29
|
|
|
30
30
|
All pull requests must pass the following checks before merging:
|
|
31
|
-
- **Tests**: Automated test suite runs on Ruby 3.2
|
|
31
|
+
- **Tests**: Automated test suite runs on the latest available patch releases of Ruby 3.2, 3.3, 3.4, and 4.0
|
|
32
32
|
- **Coverage**: Code coverage is automatically generated and uploaded to Codecov
|
|
33
33
|
- **Code Review**: At least one maintainer approval required
|
|
34
34
|
|
|
@@ -40,10 +40,15 @@ See `../_vault/products/admin_suite/docs/releasing.md` for information on how re
|
|
|
40
40
|
|
|
41
41
|
Releases are automated via GitHub Actions when changes are merged to `main` with a version bump.
|
|
42
42
|
|
|
43
|
-
###
|
|
43
|
+
### Publishing Setup for Maintainers
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
RubyGems Trusted Publishing authenticates GitHub Actions with short-lived OIDC
|
|
46
|
+
credentials; no `RUBYGEMS_API_KEY` secret is needed. The gem's trusted publisher
|
|
47
|
+
must match `techwright-lab/admin_suite`, workflow `publish.yml`, and environment
|
|
48
|
+
`release`. Restrict the GitHub `release` environment to the `main` branch.
|
|
49
|
+
See the canonical releasing guide linked above for setup and verification.
|
|
50
|
+
|
|
51
|
+
Optional repository secret:
|
|
47
52
|
- **`CODECOV_TOKEN`**: Optional, for uploading code coverage reports to Codecov (workflow continues without it)
|
|
48
53
|
|
|
49
54
|
## Questions?
|
data/README.md
CHANGED
|
@@ -11,6 +11,7 @@ across other products.
|
|
|
11
11
|
- **Portals**: group resources by portal + section, optional per-portal dashboards
|
|
12
12
|
- **Resources DSL**: index (columns/filters/stats), form fields, show panels/associations, actions
|
|
13
13
|
- **Docs viewer**: renders `*.md` from your host app filesystem at `/docs`
|
|
14
|
+
- **Admin MCP**: four read tools derived from resource definitions, under operator authentication and explicit authorization
|
|
14
15
|
- **UI**: baseline CSS + engine Tailwind build; host overrides optional
|
|
15
16
|
|
|
16
17
|
## Documentation
|
|
@@ -43,20 +44,35 @@ bin/rails g admin_suite:install --mount-path=/internal/admin
|
|
|
43
44
|
|
|
44
45
|
### Secure it (recommended)
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
Resolve the host user and authorize admin access:
|
|
47
48
|
|
|
48
49
|
```ruby
|
|
49
50
|
# config/initializers/admin_suite.rb
|
|
50
51
|
AdminSuite.configure do |config|
|
|
51
|
-
config.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
end
|
|
52
|
+
config.auth_strategy = :host_user
|
|
53
|
+
config.auth_options = { resolve: ->(controller) { controller.current_user } }
|
|
54
|
+
config.authorize = ->(actor:, action:, resource:, record:, context:) { actor&.admin? }
|
|
55
55
|
end
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
Read more: `../_vault/products/admin_suite/docs/configuration.md`
|
|
59
59
|
|
|
60
|
+
Set `config.authorize` to decide *what* an authenticated actor may do:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
config.authorize = ->(actor:, action:, resource:, record:, context:) {
|
|
64
|
+
# action is :read, :create, :update, :destroy, or :execute
|
|
65
|
+
true
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A `false` or `nil` return is `403` (fail closed). Leaving the hook `nil`
|
|
70
|
+
keeps authentication as the only web gate; MCP serves no tools or data. Resources marked `read_only` reject
|
|
71
|
+
CRUD, `toggle`, and named execute/bulk actions regardless of this hook.
|
|
72
|
+
|
|
73
|
+
The MCP endpoint is `<mount>/mcp`. See
|
|
74
|
+
`../_vault/products/admin_suite/docs/mcp.md` for setup, tool contracts and migration.
|
|
75
|
+
|
|
60
76
|
### Add portals (navigation metadata)
|
|
61
77
|
|
|
62
78
|
```ruby
|
|
@@ -8,8 +8,8 @@ module AdminSuite
|
|
|
8
8
|
# The engine authenticates via its own strategy layer instead, so it skips
|
|
9
9
|
# the host filters named in `config.skip_host_before_actions`
|
|
10
10
|
# (default: [:require_authentication], the Rails 8 authentication
|
|
11
|
-
# generator's filter). Evaluated
|
|
12
|
-
#
|
|
11
|
+
# generator's filter). Evaluated when this controller class loads; changes
|
|
12
|
+
# take effect on the next class load (or process boot when class caching).
|
|
13
13
|
Array(AdminSuite.config.skip_host_before_actions).each do |filter|
|
|
14
14
|
skip_before_action filter, raise: false
|
|
15
15
|
end
|
|
@@ -51,6 +51,8 @@ module AdminSuite
|
|
|
51
51
|
else
|
|
52
52
|
head :forbidden
|
|
53
53
|
end
|
|
54
|
+
rescue Auth::UnknownStrategyError => error
|
|
55
|
+
render plain: error.message, status: :forbidden
|
|
54
56
|
end
|
|
55
57
|
|
|
56
58
|
# Returns the actor for actions/auditing/authorization.
|
|
@@ -61,20 +63,19 @@ module AdminSuite
|
|
|
61
63
|
# @return [Object, nil]
|
|
62
64
|
def admin_suite_actor
|
|
63
65
|
if defined?(@admin_suite_actor) && @admin_suite_actor
|
|
64
|
-
|
|
65
|
-
# this request and found nothing — don't consult it again.
|
|
66
|
-
return nil if @admin_suite_actor.equal?(true)
|
|
67
|
-
return @admin_suite_actor
|
|
66
|
+
return AdminSuite::Auth.normalize_actor(@admin_suite_actor)
|
|
68
67
|
end
|
|
69
68
|
|
|
70
69
|
return @admin_suite_fallback_actor if defined?(@admin_suite_fallback_actor)
|
|
71
70
|
|
|
72
71
|
@admin_suite_fallback_actor =
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
72
|
+
AdminSuite::Auth.normalize_actor(
|
|
73
|
+
begin
|
|
74
|
+
AdminSuite.config.current_actor&.call(self)
|
|
75
|
+
rescue StandardError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
)
|
|
78
79
|
end
|
|
79
80
|
|
|
80
81
|
# Loads resource definition files when needed (runs in all environments).
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AdminSuite
|
|
4
|
+
class McpController < ApplicationController
|
|
5
|
+
before_action :validate_mcp_origin!, prepend: true
|
|
6
|
+
# Cross-origin browsers cannot send application/json without a CORS
|
|
7
|
+
# preflight; form-compatible content types still require a CSRF token.
|
|
8
|
+
protect_from_forgery with: :exception, unless: :mcp_json_request?
|
|
9
|
+
|
|
10
|
+
def create
|
|
11
|
+
return head :not_found unless AdminSuite.config.mcp.enabled
|
|
12
|
+
return head :unauthorized unless admin_suite_actor
|
|
13
|
+
return head :method_not_allowed unless request.post?
|
|
14
|
+
|
|
15
|
+
AdminSuite::DefinitionLoader.load!(:resources)
|
|
16
|
+
server = AdminSuite::Mcp.server_for(actor: admin_suite_actor, request: request)
|
|
17
|
+
transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
|
|
18
|
+
server, stateless: true, allowed_hosts: [request.host]
|
|
19
|
+
)
|
|
20
|
+
status, headers, body = transport.handle_request(request)
|
|
21
|
+
self.status = status
|
|
22
|
+
headers.each { |name, value| response.set_header(name, value) }
|
|
23
|
+
self.response_body = body
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def validate_mcp_origin!
|
|
29
|
+
head :forbidden if request.origin && request.origin != request.base_url
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def mcp_json_request?
|
|
33
|
+
request.media_type == "application/json"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -6,7 +6,7 @@ module AdminSuite
|
|
|
6
6
|
include Pagy::Frontend
|
|
7
7
|
|
|
8
8
|
before_action :require_resource_config!
|
|
9
|
-
before_action :enforce_read_only!, only: %i[new create edit update destroy toggle]
|
|
9
|
+
before_action :enforce_read_only!, only: %i[new create edit update destroy toggle execute_action bulk_action]
|
|
10
10
|
# `search` is excluded even though it can receive an `:id`-shaped query
|
|
11
11
|
# param: unlike show/edit/update/destroy, a record has no business
|
|
12
12
|
# participating in this action at all. Without this exclusion, a stray
|
|
@@ -22,9 +22,10 @@ module AdminSuite
|
|
|
22
22
|
|
|
23
23
|
# GET /:portal/:resource_name
|
|
24
24
|
def index
|
|
25
|
-
|
|
25
|
+
query = AdminSuite::Query.new(resource_config: resource_config, params: params)
|
|
26
|
+
scope = query.scope
|
|
26
27
|
@stats = calculate_stats(scope) if resource_config&.index_config&.stats_list&.any?
|
|
27
|
-
@pagy, @collection =
|
|
28
|
+
@pagy, @collection = pagy(scope, limit: query.per_page)
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
# GET /:portal/:resource_name/:id
|
|
@@ -213,7 +214,7 @@ module AdminSuite
|
|
|
213
214
|
action: AUTHORIZATION_VERBS.fetch(action_name),
|
|
214
215
|
resource: resource_config,
|
|
215
216
|
record: (defined?(@resource) ? @resource : nil),
|
|
216
|
-
controller: self
|
|
217
|
+
context: AdminSuite::AuthorizationContext.new(surface: :web, controller: self, request: request)
|
|
217
218
|
)
|
|
218
219
|
head :forbidden unless permitted
|
|
219
220
|
end
|
|
@@ -281,80 +282,6 @@ module AdminSuite
|
|
|
281
282
|
@collection
|
|
282
283
|
end
|
|
283
284
|
|
|
284
|
-
def filtered_collection
|
|
285
|
-
return resource_class.all unless resource_config&.index_config
|
|
286
|
-
|
|
287
|
-
scope = Admin::Base::FilterBuilder.new(resource_config, params).apply(resource_class.all)
|
|
288
|
-
apply_index_includes(scope)
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
# Applies the index's `includes:` DSL option (see
|
|
292
|
-
# `Admin::Base::Resource::IndexConfig#includes`) to the filtered scope.
|
|
293
|
-
# Only when the scope actually responds to `#includes` -- the PORO
|
|
294
|
-
# `Relation` test doubles used throughout this gem's own test suite
|
|
295
|
-
# don't, and a host's own non-AR scope object may not either -- so this
|
|
296
|
-
# skips silently rather than raising. `.includes` itself can raise for
|
|
297
|
-
# a bad/renamed/typo'd association name once the scope is a real AR
|
|
298
|
-
# relation; that must degrade the index to an unoptimized-but-working
|
|
299
|
-
# page, not 500 it, so it's logged and swallowed the same way the
|
|
300
|
-
# chart panel's bad `type:`/`data` values are (see
|
|
301
|
-
# `app/views/admin_suite/panels/_chart.html.erb`).
|
|
302
|
-
#
|
|
303
|
-
# @param scope [Object] the filtered collection
|
|
304
|
-
# @return [Object] the scope, with associations eager-loaded when possible
|
|
305
|
-
def apply_index_includes(scope)
|
|
306
|
-
includes_list = resource_config.index_config.includes_list
|
|
307
|
-
return scope if includes_list.blank?
|
|
308
|
-
return scope unless scope.respond_to?(:includes)
|
|
309
|
-
|
|
310
|
-
scope.includes(*includes_list)
|
|
311
|
-
rescue StandardError => e
|
|
312
|
-
Rails.logger&.warn(
|
|
313
|
-
"AdminSuite: #{resource_class}'s index `includes(#{includes_list.inspect})` raised " \
|
|
314
|
-
"#{e.class}: #{e.message}; rendering the index without eager loading."
|
|
315
|
-
)
|
|
316
|
-
scope
|
|
317
|
-
end
|
|
318
|
-
|
|
319
|
-
# Max a request can push the index's per-page count to, regardless of
|
|
320
|
-
# what `per_page` the query string carries -- `per_page` is user-supplied
|
|
321
|
-
# (a plain query param), so this exists to stop `?per_page=999999` from
|
|
322
|
-
# turning the index into an unbounded query.
|
|
323
|
-
MAX_PER_PAGE = 100
|
|
324
|
-
|
|
325
|
-
def paginate_collection(scope)
|
|
326
|
-
dsl_per_page = resource_config&.index_config&.per_page || 25
|
|
327
|
-
# Pagy 9.x's vars key is `limit:`, not `items:` -- the pre-existing
|
|
328
|
-
# `items:` call silently did nothing (pagy fell through to its own
|
|
329
|
-
# `DEFAULT[:limit]` of 20), so every resource's `paginate(n)` DSL
|
|
330
|
-
# value was already being ignored before this task. Fixed here since
|
|
331
|
-
# this task's clamp is meaningless without it.
|
|
332
|
-
pagy(scope, limit: clamped_per_page(dsl_per_page))
|
|
333
|
-
end
|
|
334
|
-
|
|
335
|
-
# Resolves the effective per-page count for the index from the
|
|
336
|
-
# `per_page` query param, clamped to `MAX_PER_PAGE` and falling back to
|
|
337
|
-
# the DSL's `paginate(n)` value (`dsl_per_page`) whenever the param is
|
|
338
|
-
# absent or not a usable positive integer.
|
|
339
|
-
#
|
|
340
|
-
# `per_page` is the most directly attacker-influenceable input this
|
|
341
|
-
# phase adds, so every shape it can arrive in is handled without
|
|
342
|
-
# raising: missing (nil), non-numeric ("abc"), zero, negative, an
|
|
343
|
-
# array (`per_page[]=1`, which Rails hands back as a plain Array, not
|
|
344
|
-
# a String -- `Integer(Array)` raises `TypeError`), and absurdly large
|
|
345
|
-
# (clamped, never passed through to the query).
|
|
346
|
-
#
|
|
347
|
-
# @param dsl_per_page [Integer] the resource's `paginate(n)` value
|
|
348
|
-
# @return [Integer]
|
|
349
|
-
def clamped_per_page(dsl_per_page)
|
|
350
|
-
value = Integer(params[:per_page])
|
|
351
|
-
return dsl_per_page if value <= 0
|
|
352
|
-
|
|
353
|
-
value.clamp(..MAX_PER_PAGE)
|
|
354
|
-
rescue ArgumentError, TypeError
|
|
355
|
-
dsl_per_page
|
|
356
|
-
end
|
|
357
|
-
|
|
358
285
|
def calculate_stats(scope)
|
|
359
286
|
resource_config.index_config.stats_list.map do |stat_def|
|
|
360
287
|
value =
|
|
@@ -372,6 +299,8 @@ module AdminSuite
|
|
|
372
299
|
end
|
|
373
300
|
|
|
374
301
|
def enforce_read_only!
|
|
302
|
+
# Declared member/bulk actions are mutations; read_only rejects them
|
|
303
|
+
# the same way as CRUD/toggle rather than advertising a write path.
|
|
375
304
|
head :not_found if resource_config&.read_only?
|
|
376
305
|
end
|
|
377
306
|
|
|
@@ -202,6 +202,7 @@
|
|
|
202
202
|
%>
|
|
203
203
|
<%= tag.tr class: "hover:bg-slate-50 cursor-pointer",
|
|
204
204
|
data: {
|
|
205
|
+
record_id: record.to_param,
|
|
205
206
|
controller: "admin-suite--click-actions",
|
|
206
207
|
action: "click->admin-suite--click-actions#navigate",
|
|
207
208
|
"admin-suite--click-actions-url-value": show_path
|
data/config/routes.rb
CHANGED
|
@@ -7,6 +7,11 @@ AdminSuite::Engine.routes.draw do
|
|
|
7
7
|
get "docs(/)", to: "docs#index", as: :docs
|
|
8
8
|
get "docs/*path", to: "docs#show", as: :doc, format: false
|
|
9
9
|
|
|
10
|
+
# Must precede the portal catch-all, which would otherwise treat "mcp" as
|
|
11
|
+
# a portal name.
|
|
12
|
+
post "mcp", to: "mcp#create", as: :mcp
|
|
13
|
+
match "mcp", to: "mcp#create", via: [:get, :delete]
|
|
14
|
+
|
|
10
15
|
# Portal dashboards (e.g. /ops, /email). Accept optional trailing slash.
|
|
11
16
|
get ":portal(/)", to: "portals#show", as: :portal
|
|
12
17
|
|
data/lib/admin/base/resource.rb
CHANGED
|
@@ -41,11 +41,6 @@ module Admin
|
|
|
41
41
|
class Resource
|
|
42
42
|
extend AdminSuite::Deprecation
|
|
43
43
|
|
|
44
|
-
EXPORTABLE_DEPRECATION_MESSAGE_FORMAT =
|
|
45
|
-
"AdminSuite: %<resource>s calls `exportable`, which is a deprecated " \
|
|
46
|
-
"no-op and will be removed in 0.6.0. It never actually implemented " \
|
|
47
|
-
"export in any released version — safe to delete the call."
|
|
48
|
-
|
|
49
44
|
class << self
|
|
50
45
|
# Model configuration
|
|
51
46
|
attr_reader :model_class, :portal_name, :section_name, :nav_label, :nav_icon, :nav_order
|
|
@@ -172,30 +167,13 @@ module Admin
|
|
|
172
167
|
@read_only == true
|
|
173
168
|
end
|
|
174
169
|
|
|
175
|
-
#
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
# trust_growth: 1). A real removal would raise `NoMethodError` at
|
|
183
|
-
# definition-load time, and in production `DefinitionLoader` logs
|
|
184
|
-
# and swallows that, so the resource just silently vanishes from the
|
|
185
|
-
# admin. Kept as a no-op instead, so those files keep loading.
|
|
186
|
-
#
|
|
187
|
-
# Deliberately does not restore `@export_formats` or any reader --
|
|
188
|
-
# only the harmless no-op comes back.
|
|
189
|
-
#
|
|
190
|
-
# @param _formats [Array<Symbol>] ignored
|
|
191
|
-
# @return [void]
|
|
192
|
-
def exportable(*_formats)
|
|
193
|
-
# `warn_once`, `warn_once_sink` and `reset_deprecation_notices!`
|
|
194
|
-
# come from `AdminSuite::Deprecation`, extended above. Keyed on the
|
|
195
|
-
# resource class itself, so each resource warns independently (and
|
|
196
|
-
# only once) rather than one call anywhere silencing every other
|
|
197
|
-
# resource's first call.
|
|
198
|
-
warn_once(name, format(EXPORTABLE_DEPRECATION_MESSAGE_FORMAT, resource: name))
|
|
170
|
+
# Opts this resource out of the model-facing MCP surface.
|
|
171
|
+
def mcp(enabled)
|
|
172
|
+
@mcp_enabled = enabled
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def mcp_enabled?
|
|
176
|
+
@mcp_enabled.nil? ? true : !!@mcp_enabled
|
|
199
177
|
end
|
|
200
178
|
|
|
201
179
|
# Returns the resource name derived from class name
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AdminSuite
|
|
4
|
+
module Auth
|
|
5
|
+
# Authenticates against the host application's own user.
|
|
6
|
+
#
|
|
7
|
+
# This is the standard strategy and the one the docs lead with: most
|
|
8
|
+
# adopters already have a users table with a role, or a separate
|
|
9
|
+
# admin/developer user type. HTTP Basic and any future mechanism are
|
|
10
|
+
# additional.
|
|
11
|
+
#
|
|
12
|
+
# config.auth_strategy = :host_user
|
|
13
|
+
# config.auth_options = { resolve: ->(controller) { controller.current_user } }
|
|
14
|
+
#
|
|
15
|
+
# The resolver returns the host's user object, or nil to deny. Whatever
|
|
16
|
+
# it returns is passed through `Auth.normalize_actor`, so `config.authorize`
|
|
17
|
+
# always receives a real object or nil -- never a bare `true`.
|
|
18
|
+
class HostUser < Strategy
|
|
19
|
+
def authenticate!(controller)
|
|
20
|
+
resolver = options[:resolve]
|
|
21
|
+
|
|
22
|
+
unless resolver.respond_to?(:call)
|
|
23
|
+
Rails.logger&.error(
|
|
24
|
+
"AdminSuite: auth_strategy :host_user requires config.auth_options[:resolve] " \
|
|
25
|
+
"(a callable taking the controller). Denying every request until it is set."
|
|
26
|
+
)
|
|
27
|
+
return nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
actor =
|
|
31
|
+
begin
|
|
32
|
+
resolver.call(controller)
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
Rails.logger&.warn("AdminSuite: :host_user resolver raised #{e.class}: #{e.message}; denying.")
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Auth.normalize_actor(actor)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/admin_suite/auth.rb
CHANGED
|
@@ -24,10 +24,25 @@ module AdminSuite
|
|
|
24
24
|
def registered
|
|
25
25
|
@registry.keys
|
|
26
26
|
end
|
|
27
|
+
|
|
28
|
+
# One definition of "is this a usable actor?", shared by every surface.
|
|
29
|
+
#
|
|
30
|
+
# Legacy `HostHook` returns `true` to mean "authenticated, but I could
|
|
31
|
+
# not name anybody". That is an answer to a different question than
|
|
32
|
+
# authorization asks, so it is not an actor. Lives here rather than in
|
|
33
|
+
# a controller so the MCP surface -- which has no controller -- cannot
|
|
34
|
+
# drift from the web UI's interpretation.
|
|
35
|
+
def normalize_actor(value)
|
|
36
|
+
return nil if value.nil? || value.equal?(true) || value.equal?(false)
|
|
37
|
+
|
|
38
|
+
value
|
|
39
|
+
end
|
|
27
40
|
end
|
|
28
41
|
end
|
|
29
42
|
end
|
|
30
43
|
|
|
31
44
|
require "admin_suite/auth/http_basic"
|
|
32
45
|
require "admin_suite/auth/host_hook"
|
|
46
|
+
require "admin_suite/auth/host_user"
|
|
33
47
|
AdminSuite::Auth.register(:http_basic, AdminSuite::Auth::HttpBasic)
|
|
48
|
+
AdminSuite::Auth.register(:host_user, AdminSuite::Auth::HostUser)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AdminSuite
|
|
4
|
+
# Surface-agnostic context handed to `config.authorize`.
|
|
5
|
+
#
|
|
6
|
+
# Replaces the old `controller:` keyword, which could not be honestly
|
|
7
|
+
# populated for a non-HTTP surface. `surface` lets one hook express
|
|
8
|
+
# different policy per entry point -- e.g. reads from anywhere, writes
|
|
9
|
+
# only from the human UI.
|
|
10
|
+
class AuthorizationContext
|
|
11
|
+
SURFACES = %i[web mcp].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :surface, :controller, :request
|
|
14
|
+
|
|
15
|
+
def initialize(surface:, controller: nil, request: nil)
|
|
16
|
+
unless SURFACES.include?(surface)
|
|
17
|
+
raise ArgumentError, "Unknown AdminSuite authorization surface #{surface.inspect}. Expected one of #{SURFACES.inspect}."
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
@surface = surface
|
|
21
|
+
@controller = controller
|
|
22
|
+
@request = request
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def web? = surface == :web
|
|
26
|
+
def mcp? = surface == :mcp
|
|
27
|
+
end
|
|
28
|
+
end
|