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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +7 -0
  3. data/CHANGELOG.md +69 -11
  4. data/CONTRIBUTING.md +9 -4
  5. data/README.md +21 -5
  6. data/app/controllers/admin_suite/application_controller.rb +12 -11
  7. data/app/controllers/admin_suite/mcp_controller.rb +36 -0
  8. data/app/controllers/admin_suite/resources_controller.rb +7 -78
  9. data/app/views/admin_suite/resources/index.html.erb +1 -0
  10. data/config/routes.rb +5 -0
  11. data/lib/admin/base/resource.rb +7 -29
  12. data/lib/admin_suite/auth/host_user.rb +42 -0
  13. data/lib/admin_suite/auth/strategy.rb +1 -1
  14. data/lib/admin_suite/auth.rb +15 -0
  15. data/lib/admin_suite/authorization_context.rb +28 -0
  16. data/lib/admin_suite/configuration.rb +56 -2
  17. data/lib/admin_suite/mcp/authorization.rb +68 -0
  18. data/lib/admin_suite/mcp/serializer.rb +138 -0
  19. data/lib/admin_suite/mcp/tools/aggregate.rb +55 -0
  20. data/lib/admin_suite/mcp/tools/describe_resources.rb +63 -0
  21. data/lib/admin_suite/mcp/tools/get_record.rb +41 -0
  22. data/lib/admin_suite/mcp/tools/list_records.rb +67 -0
  23. data/lib/admin_suite/mcp.rb +53 -0
  24. data/lib/admin_suite/query.rb +71 -0
  25. data/lib/admin_suite/ui/show_value_formatter.rb +2 -2
  26. data/lib/admin_suite/version.rb +1 -1
  27. data/lib/admin_suite.rb +14 -9
  28. data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -2
  29. data/test/controllers/resources_controller_test.rb +19 -13
  30. data/test/integration/authentication_test.rb +10 -0
  31. data/test/integration/authorization_test.rb +20 -3
  32. data/test/integration/index_query_characterization_test.rb +229 -0
  33. data/test/integration/mcp_aggregate_test.rb +44 -0
  34. data/test/integration/mcp_authorization_test.rb +102 -0
  35. data/test/integration/mcp_endpoint_test.rb +89 -0
  36. data/test/integration/mcp_get_record_test.rb +62 -0
  37. data/test/integration/mcp_instrumentation_test.rb +107 -0
  38. data/test/integration/mcp_list_records_test.rb +75 -0
  39. data/test/integration/mcp_parity_test.rb +155 -0
  40. data/test/integration/mcp_release_test.rb +131 -0
  41. data/test/integration/read_only_resource_test.rb +128 -2
  42. data/test/integration/searchable_select_search_test.rb +5 -4
  43. data/test/lib/auth_host_user_test.rb +52 -0
  44. data/test/lib/auth_http_basic_test.rb +10 -0
  45. data/test/lib/auth_test.rb +20 -3
  46. data/test/lib/authorization_context_test.rb +127 -0
  47. data/test/lib/engine_defaults_test.rb +1 -2
  48. data/test/lib/mcp_serializer_test.rb +107 -0
  49. data/test/lib/query_test.rb +91 -0
  50. data/test/lib/removed_deprecations_test.rb +16 -0
  51. data/test/publish_workflow_test.rb +63 -0
  52. data/test/test_helper.rb +26 -0
  53. metadata +42 -5
  54. data/lib/admin_suite/renderers/legacy_gleania.rb +0 -232
  55. data/test/lib/legacy_renderer_deprecation_test.rb +0 -42
  56. 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: 178c32d1ca93e95a88492d6d88e74459f38323d120e9c7e32fbf7ce36cbc5d72
4
- data.tar.gz: c414f0961487233cf53a60b832f82dfc39b86f9b17b6e4a31b1ca0341adbaf00
3
+ metadata.gz: 964267328a54fb0d55bfb035fab32ba694b620b0f5cbdb45194e770a3138885f
4
+ data.tar.gz: eec49bd217460a272eb6ce1add0fd9ac487764a37e304bdcde105044f7eed80a
5
5
  SHA512:
6
- metadata.gz: d31c8dba83601ca1365fd9964ca7f561e6af39d4367d805a1e813125c359a0bd50eee961fbc5b78b3ca6d7fbafa444d704f8892b96d7108012e31c9fcc02925b
7
- data.tar.gz: 5a20da0b8fb24e08daa380c7752cd9ba6614728c830c1a89212372ccaa77f81a3d951d24a2cebb44dbccab8fc1e411d80abc6a36cfa8ea63fe14ce29dadfe25f
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." alert.
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 and 3.3
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
- ### Required Secrets for Maintainers
43
+ ### Publishing Setup for Maintainers
44
44
 
45
- The repository requires the following secrets to be configured:
46
- - **`RUBYGEMS_API_KEY`**: Required for automated gem publishing to RubyGems
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
- Set `config.authenticate` so only authorized users can access AdminSuite:
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.authenticate = ->(controller) do
52
- user = controller.respond_to?(:current_user) ? controller.current_user : nil
53
- controller.head(:forbidden) unless user&.admin?
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 at class load changing the config
12
- # requires a restart.
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
- # HostHook's `true` sentinel means it already consulted current_actor
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
- begin
74
- AdminSuite.config.current_actor&.call(self)
75
- rescue StandardError
76
- nil
77
- end
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
- scope = filtered_collection
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 = paginate_collection(scope)
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
 
@@ -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
- # Deprecated no-op; removal targeted at 0.6.0 (moved from the
176
- # originally-planned 0.5.0 -- pending the gleania/trust_growth host
177
- # migrations).
178
- #
179
- # `exportable` was write-only in every prior release -- it never had
180
- # a reader and never drove any export behavior -- but hosts still
181
- # call it from resource-definition bodies (gleania: 30 files;
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
@@ -12,7 +12,7 @@ module AdminSuite
12
12
  attr_reader :options
13
13
 
14
14
  def initialize(options = {})
15
- @options = options
15
+ @options = options.symbolize_keys
16
16
  end
17
17
 
18
18
  # @param controller [ActionController::Base]
@@ -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