admin_suite 0.2.8 → 0.3.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -1
  3. data/CONTRIBUTING.md +2 -2
  4. data/Gemfile +3 -0
  5. data/README.md +10 -24
  6. data/app/controllers/admin_suite/application_controller.rb +53 -11
  7. data/app/controllers/admin_suite/resources_controller.rb +63 -9
  8. data/app/views/admin_suite/resources/index.html.erb +3 -1
  9. data/app/views/admin_suite/resources/show.html.erb +2 -2
  10. data/lib/admin/base/filter_builder.rb +2 -1
  11. data/lib/admin/base/resource.rb +14 -2
  12. data/lib/admin_suite/auth/host_hook.rb +25 -0
  13. data/lib/admin_suite/auth/http_basic.rb +46 -0
  14. data/lib/admin_suite/auth/strategy.rb +25 -0
  15. data/lib/admin_suite/auth.rb +33 -0
  16. data/lib/admin_suite/configuration.rb +8 -0
  17. data/lib/admin_suite/ui/show_value_formatter.rb +2 -2
  18. data/lib/admin_suite/version.rb +1 -1
  19. data/lib/admin_suite.rb +16 -0
  20. data/lib/generators/admin_suite/install/templates/admin_suite.rb +24 -7
  21. data/test/controllers/resources_controller_test.rb +80 -0
  22. data/test/dummy/config/initializers/admin_suite_auth.rb +8 -0
  23. data/test/integration/authentication_test.rb +99 -0
  24. data/test/integration/authorization_test.rb +131 -0
  25. data/test/integration/read_only_resource_test.rb +130 -0
  26. data/test/lib/auth_http_basic_test.rb +62 -0
  27. data/test/lib/auth_resolution_test.rb +75 -0
  28. data/test/lib/auth_test.rb +29 -0
  29. data/test/lib/resource_observability_extensions_test.rb +53 -0
  30. data/test/test_helper.rb +43 -0
  31. metadata +15 -14
  32. data/docs/README.md +0 -26
  33. data/docs/actions.md +0 -98
  34. data/docs/configuration.md +0 -284
  35. data/docs/development.md +0 -64
  36. data/docs/docs_viewer.md +0 -79
  37. data/docs/fields.md +0 -188
  38. data/docs/installation.md +0 -80
  39. data/docs/portals.md +0 -140
  40. data/docs/releasing.md +0 -67
  41. data/docs/resources.md +0 -237
  42. data/docs/theming.md +0 -63
  43. data/docs/troubleshooting.md +0 -50
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 663de764696e7dc2696e54b4bb43381fe83ee33b74b9d212269ff6d25433466b
4
- data.tar.gz: 9e9c6f145e86d4c8a0d1087cdc2d45c752da1fbbe9f1d4219d7b8874ebcc5291
3
+ metadata.gz: eadb4dfd6668dd70da1795f1188d17e6ba0d65950c09ff92a858238efa7c606f
4
+ data.tar.gz: 6c99383ce4ba18076897d828faada789b95540760111e6fa7e04194fa66fcb94
5
5
  SHA512:
6
- metadata.gz: b23ab5d74fe3658980016cb8e7df4c1fe09a87601590b98b09dd43f3ece53c3d40f6cbc159aec035daec52525b8dbdbdf0da34ad8846061465ec9de05a270ae4
7
- data.tar.gz: b70e3d18ec3aba0acd78fea6ee74efbec8201546253483abd00f1443e3411e37c9f98b065df6cf04f51ec3df28d9fa5e4ad90738781119f616c3c71db44f6130
6
+ metadata.gz: 97b5c9435b6da6cefa0a427b5c7d09c28917ae7fcd03873fca257be9221bd6bc9c214eea47154e1b51c2f7ed02c8097c08a6a266f5010ee75029ad0548ae3394
7
+ data.tar.gz: dfe2b7c2a326b22a1e4a84620b1f0c322a65af9fb8fd9acd09292ec527a7d3e606380788d6fa3b42f9247beaa96c6608455b90f9d9dd51dd6fde924af1345565
data/CHANGELOG.md CHANGED
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-07-31
11
+
12
+ ### Changed (BREAKING)
13
+ - AdminSuite now **fails closed**: with no authentication configured, every
14
+ engine request responds 403. Configure `config.auth_strategy = :http_basic`
15
+ (or a custom strategy, or the legacy `config.authenticate` lambda). For
16
+ development/test only, `config.allow_unauthenticated = true` restores open
17
+ access (ignored in production).
18
+ - `read_only` resources now also reject the built-in `toggle` endpoint;
19
+ undeclared `execute_action` and `bulk_action` names respond 404 via a
20
+ dedicated bulk-action lookup. Declared member and bulk actions remain
21
+ allowed on `read_only` resources by design.
22
+ - `execute_action` with an unknown action name now responds 404 instead of
23
+ redirecting with an "Action not found." alert.
24
+
25
+ ### Added
26
+ - Pluggable auth strategies: `AdminSuite::Auth.register`, built-in
27
+ `:http_basic` (ENV or `config.auth_options` credentials, constant-time
28
+ comparison, blank credentials deny).
29
+ - `config.authorize` is now enforced for every resource action with the
30
+ contract `->(actor:, action:, resource:, record:, controller:)`,
31
+ action ∈ :read/:create/:update/:destroy/:execute.
32
+ - `config.skip_host_before_actions` (default `[:require_authentication]`)
33
+ replaces the hardcoded host-filter skip.
34
+
35
+ ### Fixed
36
+ - `config.current_actor` is now consulted at most once per request
37
+ (was invoked repeatedly by views; side-effecting lambdas fired multiple times).
38
+ - Requests for resource names with no registered resource definition now
39
+ respond 404 instead of resolving host model classes directly (closes an
40
+ authorization bypass).
41
+
42
+ ## [0.2.9] - 2026-07-14
43
+
44
+ ### Added
45
+
46
+ - Read-only resource support, server-side filter defaults, and filtered-scope statistics.
47
+
10
48
  ## [0.2.8] - 2026-03-23
11
49
 
12
50
  ### Added
@@ -38,4 +76,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
38
76
  - Initial extraction of the AdminSuite Rails engine.
39
77
  - Resource/portal DSL, docs viewer, and theming primitives.
40
78
  - Isolated gem test suite with a dummy Rails app.
41
-
data/CONTRIBUTING.md CHANGED
@@ -12,7 +12,7 @@ Thank you for your interest in contributing to AdminSuite!
12
12
 
13
13
  ## Development
14
14
 
15
- See `docs/development.md` for detailed information on:
15
+ See `../_vault/products/admin_suite/docs/development.md` for detailed information on:
16
16
  - Setting up your development environment
17
17
  - Running tests
18
18
  - Code style guidelines
@@ -36,7 +36,7 @@ The CI workflow runs automatically on every pull request.
36
36
 
37
37
  ## Releasing
38
38
 
39
- See `docs/releasing.md` for information on how releases are managed.
39
+ See `../_vault/products/admin_suite/docs/releasing.md` for information on how releases are managed.
40
40
 
41
41
  Releases are automated via GitHub Actions when changes are merged to `main` with a version bump.
42
42
 
data/Gemfile CHANGED
@@ -10,4 +10,7 @@ end
10
10
 
11
11
  group :test do
12
12
  gem "simplecov", require: false
13
+ # minitest 6.x split Object#stub out of core; needed for Rails.stub in
14
+ # test/integration/authentication_test.rb.
15
+ gem "minitest-mock"
13
16
  end
data/README.md CHANGED
@@ -15,21 +15,9 @@ across other products.
15
15
 
16
16
  ## Documentation
17
17
 
18
- Start here:
19
-
20
- - `docs/README.md` (index)
21
-
22
- Read more:
23
-
24
- - `docs/installation.md`
25
- - `docs/configuration.md`
26
- - `docs/portals.md`
27
- - `docs/resources.md`
28
- - `docs/fields.md`
29
- - `docs/actions.md`
30
- - `docs/theming.md`
31
- - `docs/docs_viewer.md`
32
- - `docs/troubleshooting.md`
18
+ Canonical AdminSuite documentation lives in the TechWright vault at
19
+ `../_vault/products/admin_suite/docs/`. This repo intentionally has no root
20
+ `docs/` tree or docs symlink.
33
21
 
34
22
  ## Quickstart
35
23
 
@@ -67,7 +55,7 @@ AdminSuite.configure do |config|
67
55
  end
68
56
  ```
69
57
 
70
- Read more: `docs/configuration.md`
58
+ Read more: `../_vault/products/admin_suite/docs/configuration.md`
71
59
 
72
60
  ### Add portals (navigation metadata)
73
61
 
@@ -80,7 +68,7 @@ AdminSuite.configure do |config|
80
68
  end
81
69
  ```
82
70
 
83
- Read more: `docs/portals.md`
71
+ Read more: `../_vault/products/admin_suite/docs/portals.md`
84
72
 
85
73
  ### Add a resource
86
74
 
@@ -119,25 +107,23 @@ module Admin
119
107
  end
120
108
  ```
121
109
 
122
- Read more: `docs/resources.md` and `docs/fields.md`
110
+ Read more: `../_vault/products/admin_suite/docs/resources.md` and `../_vault/products/admin_suite/docs/fields.md`
123
111
 
124
112
  ### Add docs (optional)
125
113
 
126
- Create markdown files in your host app:
127
-
128
- - `docs/*.md` (or set `config.docs_path`)
114
+ Set `config.docs_path` to an explicit documentation source. TechWright host apps point this at their canonical `_vault/products/<product>/docs/` directory; they do not create repo `docs/` trees.
129
115
 
130
116
  Then visit:
131
117
 
132
118
  - `/internal/admin/docs`
133
119
 
134
- Read more: `docs/docs_viewer.md`
120
+ Read more: `../_vault/products/admin_suite/docs/docs_viewer.md`
135
121
 
136
122
  ## Contributing
137
123
 
138
124
  See:
139
125
 
140
126
  - `CONTRIBUTING.md`
141
- - `docs/development.md`
142
- - `docs/releasing.md`
127
+ - `../_vault/products/admin_suite/docs/development.md`
128
+ - `../_vault/products/admin_suite/docs/releasing.md`
143
129
 
@@ -5,10 +5,14 @@ module AdminSuite
5
5
  include ActionView::RecordIdentifier
6
6
 
7
7
  # Host apps often include global auth concerns in `ApplicationController`.
8
- # The engine uses `AdminSuite.config.authenticate` instead, so we defensively
9
- # skip any host-level authentication before_actions that would otherwise
10
- # redirect to missing routes (e.g. `new_session_path`).
11
- skip_before_action :require_authentication, raise: false
8
+ # The engine authenticates via its own strategy layer instead, so it skips
9
+ # the host filters named in `config.skip_host_before_actions`
10
+ # (default: [:require_authentication], the Rails 8 authentication
11
+ # generator's filter). Evaluated at class load — changing the config
12
+ # requires a restart.
13
+ Array(AdminSuite.config.skip_host_before_actions).each do |filter|
14
+ skip_before_action filter, raise: false
15
+ end
12
16
 
13
17
  before_action :admin_suite_authenticate!
14
18
  layout "admin_suite/application"
@@ -18,21 +22,59 @@ module AdminSuite
18
22
 
19
23
  private
20
24
 
21
- # Runs the host-app authentication hook (if configured).
25
+ FAIL_CLOSED_MESSAGE =
26
+ "AdminSuite: access denied because no authentication is configured. " \
27
+ "Set config.auth_strategy (e.g. :http_basic) or config.authenticate in " \
28
+ "config/initializers/admin_suite.rb. To run without authentication in " \
29
+ "development/test only, set config.allow_unauthenticated = true."
30
+
31
+ # Fail-closed authentication. An unconfigured engine denies every request.
22
32
  #
23
33
  # @return [void]
24
34
  def admin_suite_authenticate!
25
- hook = AdminSuite.config.authenticate
26
- hook&.call(self)
35
+ strategy = AdminSuite.resolved_auth_strategy
36
+
37
+ if strategy.nil?
38
+ if AdminSuite.config.allow_unauthenticated && !Rails.env.production?
39
+ @admin_suite_actor = nil
40
+ return
41
+ end
42
+ render plain: FAIL_CLOSED_MESSAGE, status: :forbidden
43
+ return
44
+ end
45
+
46
+ actor = strategy.authenticate!(self)
47
+ return if performed?
48
+
49
+ if actor
50
+ @admin_suite_actor = actor
51
+ else
52
+ head :forbidden
53
+ end
27
54
  end
28
55
 
29
- # Returns the configured actor for actions/auditing/authorization.
56
+ # Returns the actor for actions/auditing/authorization.
57
+ #
58
+ # Strategy-provided actor wins; the legacy `config.current_actor` lambda
59
+ # remains the fallback. The HostHook `true` sentinel is never exposed.
30
60
  #
31
61
  # @return [Object, nil]
32
62
  def admin_suite_actor
33
- AdminSuite.config.current_actor&.call(self)
34
- rescue StandardError
35
- nil
63
+ 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
68
+ end
69
+
70
+ return @admin_suite_fallback_actor if defined?(@admin_suite_fallback_actor)
71
+
72
+ @admin_suite_fallback_actor =
73
+ begin
74
+ AdminSuite.config.current_actor&.call(self)
75
+ rescue StandardError
76
+ nil
77
+ end
36
78
  end
37
79
 
38
80
  # Loads resource definition files when needed (runs in all environments).
@@ -5,14 +5,18 @@ module AdminSuite
5
5
  include Pagy::Backend
6
6
  include Pagy::Frontend
7
7
 
8
+ before_action :require_resource_config!
9
+ before_action :enforce_read_only!, only: %i[new create edit update destroy toggle]
8
10
  before_action :set_resource, if: -> { params[:id].present? && !%w[index new create].include?(action_name) }
11
+ before_action :authorize_admin_suite!
9
12
 
10
13
  helper_method :resource_config, :resource_class, :resource, :collection, :current_portal, :resource_name
11
14
 
12
15
  # GET /:portal/:resource_name
13
16
  def index
14
- @stats = calculate_stats if resource_config&.index_config&.stats_list&.any?
15
- @pagy, @collection = paginate_collection(filtered_collection)
17
+ scope = filtered_collection
18
+ @stats = calculate_stats(scope) if resource_config&.index_config&.stats_list&.any?
19
+ @pagy, @collection = paginate_collection(scope)
16
20
  end
17
21
 
18
22
  # GET /:portal/:resource_name/:id
@@ -57,10 +61,7 @@ module AdminSuite
57
61
  def execute_action
58
62
  action = params[:action_name].to_s.to_sym
59
63
  action_def = find_action(action)
60
- unless action_def
61
- redirect_to resource_url(@resource), alert: "Action not found."
62
- return
63
- end
64
+ return head(:not_found) if action_def.nil?
64
65
 
65
66
  executor = Admin::Base::ActionExecutor.new(resource_config, action, admin_suite_actor)
66
67
  result = executor.execute_member(@resource, params.to_unsafe_h)
@@ -76,6 +77,9 @@ module AdminSuite
76
77
  # POST /:portal/:resource_name/bulk_action/:action_name
77
78
  def bulk_action
78
79
  action = params[:action_name].to_s.to_sym
80
+ action_def = find_bulk_action(action)
81
+ return head(:not_found) if action_def.nil?
82
+
79
83
  ids = params[:ids] || []
80
84
  if ids.empty?
81
85
  redirect_to collection_url, alert: "No items selected."
@@ -125,6 +129,44 @@ module AdminSuite
125
129
 
126
130
  private
127
131
 
132
+ # Controller action -> authorization verb.
133
+ AUTHORIZATION_VERBS = {
134
+ "index" => :read, "show" => :read,
135
+ "new" => :create, "create" => :create,
136
+ "edit" => :update, "update" => :update, "toggle" => :update,
137
+ "destroy" => :destroy,
138
+ "execute_action" => :execute, "bulk_action" => :execute
139
+ }.freeze
140
+
141
+ # Enforces the host's `config.authorize` hook. Nil hook = allowed
142
+ # (authentication remains the gate). Falsy return = 403.
143
+ #
144
+ # @return [void]
145
+ def authorize_admin_suite!
146
+ hook = AdminSuite.config.authorize
147
+ return if hook.nil?
148
+
149
+ permitted = hook.call(
150
+ actor: admin_suite_actor,
151
+ action: AUTHORIZATION_VERBS.fetch(action_name),
152
+ resource: resource_config,
153
+ record: (defined?(@resource) ? @resource : nil),
154
+ controller: self
155
+ )
156
+ head :forbidden unless permitted
157
+ end
158
+
159
+ # Guards every action behind a registered resource definition. Undeclared
160
+ # resource names (anything that doesn't map to an
161
+ # `Admin::Resources::*Resource` class) 404 here, before authentication's
162
+ # authorize hook or any model lookup runs — closing off the ability to
163
+ # reach arbitrary host model classes via unregistered resource names.
164
+ #
165
+ # @return [void]
166
+ def require_resource_config!
167
+ head :not_found if resource_config.nil?
168
+ end
169
+
128
170
  def current_portal
129
171
  params[:portal].to_s.presence&.to_sym
130
172
  end
@@ -142,7 +184,7 @@ module AdminSuite
142
184
  end
143
185
 
144
186
  def resource_class
145
- resource_config&.model_class || resource_name.classify.constantize
187
+ resource_config.model_class
146
188
  end
147
189
 
148
190
  def set_resource
@@ -181,11 +223,15 @@ module AdminSuite
181
223
  pagy(scope, items: per_page)
182
224
  end
183
225
 
184
- def calculate_stats
226
+ def calculate_stats(scope)
185
227
  resource_config.index_config.stats_list.map do |stat_def|
186
228
  value =
187
229
  begin
188
- stat_def.calculator.call
230
+ if stat_def.calculator.arity.zero?
231
+ stat_def.calculator.call
232
+ else
233
+ stat_def.calculator.call(scope)
234
+ end
189
235
  rescue StandardError
190
236
  "N/A"
191
237
  end
@@ -193,10 +239,18 @@ module AdminSuite
193
239
  end
194
240
  end
195
241
 
242
+ def enforce_read_only!
243
+ head :not_found if resource_config&.read_only?
244
+ end
245
+
196
246
  def find_action(name)
197
247
  resource_config&.actions_config&.member_actions&.find { |a| a.name == name }
198
248
  end
199
249
 
250
+ def find_bulk_action(name)
251
+ resource_config&.actions_config&.bulk_actions&.find { |a| a.name == name }
252
+ end
253
+
200
254
  def resource_params
201
255
  permitted_fields = []
202
256
  array_fields = []
@@ -1,7 +1,7 @@
1
1
  <% content_for :title, "#{resource_config.human_name_plural} - Admin Suite" %>
2
2
 
3
3
  <%
4
- has_new_route = begin
4
+ has_new_route = !resource_config.read_only? && begin
5
5
  url_for(action: :new)
6
6
  true
7
7
  rescue ActionController::UrlGenerationError
@@ -9,6 +9,8 @@
9
9
  end
10
10
 
11
11
  has_edit_route = ->(record) {
12
+ next false if resource_config.read_only?
13
+
12
14
  begin
13
15
  url_for(action: :edit, id: record.to_param)
14
16
  true
@@ -1,14 +1,14 @@
1
1
  <% content_for :title, "#{resource.respond_to?(:name) ? resource.name : "#{resource_config.human_name} ##{resource.id}"} - Admin Suite" %>
2
2
 
3
3
  <%
4
- has_edit_route = begin
4
+ has_edit_route = !resource_config.read_only? && begin
5
5
  url_for(action: :edit, id: resource.to_param)
6
6
  true
7
7
  rescue ActionController::UrlGenerationError
8
8
  false
9
9
  end
10
10
 
11
- has_destroy_route = begin
11
+ has_destroy_route = !resource_config.read_only? && begin
12
12
  url_for(action: :destroy, id: resource.to_param)
13
13
  true
14
14
  rescue ActionController::UrlGenerationError
@@ -57,7 +57,8 @@ module Admin
57
57
  # They are handled elsewhere (`apply_sort`) and must not be turned into SQL.
58
58
  return scope if %i[sort sort_direction direction page search].include?(filter.name.to_sym)
59
59
 
60
- value = params[filter.name]
60
+ value = params[filter.name].presence
61
+ value = filter.default if value.blank? && filter.respond_to?(:default)
61
62
  return scope if value.blank?
62
63
 
63
64
  if filter.respond_to?(:apply) && filter.apply.present?
@@ -165,6 +165,17 @@ module Admin
165
165
  @export_formats = formats
166
166
  end
167
167
 
168
+ # Declares whether the generic resource routes may mutate records.
169
+ # Read-only resources retain index/show access while the controller
170
+ # rejects direct requests to every built-in mutation endpoint.
171
+ def read_only(value = true)
172
+ @read_only = value
173
+ end
174
+
175
+ def read_only?
176
+ @read_only == true
177
+ end
178
+
168
179
  # Returns the resource name derived from class name
169
180
  #
170
181
  # @return [String]
@@ -328,12 +339,13 @@ module Admin
328
339
  placeholder: options[:placeholder],
329
340
  options: select_options,
330
341
  field: options[:field] || name,
331
- apply: options[:apply]
342
+ apply: options[:apply],
343
+ default: options[:default]
332
344
  )
333
345
  end
334
346
  end
335
347
 
336
- FilterDefinition = Struct.new(:name, :type, :label, :placeholder, :options, :field, :apply, keyword_init: true)
348
+ FilterDefinition = Struct.new(:name, :type, :label, :placeholder, :options, :field, :apply, :default, keyword_init: true)
337
349
 
338
350
  class StatsBuilder
339
351
  attr_reader :stats
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ module Auth
5
+ # Back-compat wrapper for the legacy `config.authenticate` lambda.
6
+ #
7
+ # Legacy lambdas deny by rendering/redirecting on the controller
8
+ # themselves; success is "the lambda returned without halting".
9
+ class HostHook < Strategy
10
+ def authenticate!(controller)
11
+ options[:authenticate].call(controller)
12
+ return nil if controller.performed?
13
+
14
+ actor_hook = options[:current_actor]
15
+ actor =
16
+ begin
17
+ actor_hook&.call(controller)
18
+ rescue StandardError
19
+ nil
20
+ end
21
+ actor || true # authenticated, anonymous actor
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ module Auth
5
+ # Built-in HTTP Basic authentication.
6
+ #
7
+ # Credentials come from options or environment:
8
+ # config.auth_strategy = :http_basic
9
+ # config.auth_options = { username: "...", password: "..." }
10
+ # or ADMIN_SUITE_USERNAME / ADMIN_SUITE_PASSWORD.
11
+ #
12
+ # Blank credentials deny every request — enabling the strategy without
13
+ # configuring credentials must never leave the admin open.
14
+ class HttpBasic < Strategy
15
+ Actor = Struct.new(:username) do
16
+ def name = username
17
+ def to_s = "http-basic:#{username}"
18
+ end
19
+
20
+ def authenticate!(controller)
21
+ username = options[:username].presence || ENV["ADMIN_SUITE_USERNAME"]
22
+ password = options[:password].presence || ENV["ADMIN_SUITE_PASSWORD"]
23
+
24
+ if username.blank? || password.blank?
25
+ controller.render(
26
+ plain: "AdminSuite: HTTP Basic auth is enabled but no credentials are configured. " \
27
+ "Set ADMIN_SUITE_USERNAME and ADMIN_SUITE_PASSWORD (or config.auth_options).",
28
+ status: :forbidden
29
+ )
30
+ return nil
31
+ end
32
+
33
+ authenticated = controller.authenticate_with_http_basic do |given_user, given_pass|
34
+ # Single `&` (not `&&`) so both comparisons always run (constant time).
35
+ ActiveSupport::SecurityUtils.secure_compare(given_user.to_s, username) &
36
+ ActiveSupport::SecurityUtils.secure_compare(given_pass.to_s, password)
37
+ end
38
+
39
+ return Actor.new(username) if authenticated
40
+
41
+ controller.request_http_basic_authentication("AdminSuite")
42
+ nil
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ module Auth
5
+ # Base class for authentication strategies.
6
+ #
7
+ # A strategy authenticates the current request. It must either:
8
+ # - return a truthy actor object (request allowed), or
9
+ # - deny: render/redirect on the controller itself (halts the chain),
10
+ # or return nil/false (the engine responds 403).
11
+ class Strategy
12
+ attr_reader :options
13
+
14
+ def initialize(options = {})
15
+ @options = options
16
+ end
17
+
18
+ # @param controller [ActionController::Base]
19
+ # @return [Object, nil] the authenticated actor, or nil when denied
20
+ def authenticate!(controller)
21
+ raise NotImplementedError, "#{self.class.name} must implement #authenticate!(controller)"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "admin_suite/auth/strategy"
4
+
5
+ module AdminSuite
6
+ # Registry of named authentication strategies.
7
+ module Auth
8
+ class UnknownStrategyError < StandardError; end
9
+
10
+ @registry = {}
11
+
12
+ class << self
13
+ def register(name, klass)
14
+ @registry[name.to_sym] = klass
15
+ end
16
+
17
+ def lookup(name)
18
+ @registry.fetch(name.to_sym) do
19
+ raise UnknownStrategyError,
20
+ "Unknown AdminSuite auth strategy #{name.inspect}. Registered: #{@registry.keys.sort.inspect}"
21
+ end
22
+ end
23
+
24
+ def registered
25
+ @registry.keys
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ require "admin_suite/auth/http_basic"
32
+ require "admin_suite/auth/host_hook"
33
+ AdminSuite::Auth.register(:http_basic, AdminSuite::Auth::HttpBasic)
@@ -6,6 +6,10 @@ module AdminSuite
6
6
  attr_accessor :authenticate,
7
7
  :current_actor,
8
8
  :authorize,
9
+ :auth_strategy,
10
+ :auth_options,
11
+ :allow_unauthenticated,
12
+ :skip_host_before_actions,
9
13
  :logout_path,
10
14
  :logout_method,
11
15
  :logout_label,
@@ -33,6 +37,10 @@ module AdminSuite
33
37
  @authenticate = nil
34
38
  @current_actor = nil
35
39
  @authorize = nil
40
+ @auth_strategy = nil
41
+ @auth_options = {}
42
+ @allow_unauthenticated = false
43
+ @skip_host_before_actions = [ :require_authentication ]
36
44
  @logout_path = nil
37
45
  @logout_method = :delete
38
46
  @logout_label = "Log out"
@@ -54,9 +54,9 @@ module AdminSuite
54
54
  end
55
55
  end
56
56
 
57
- if value.is_a?(ActiveStorage::Attached::One)
57
+ if defined?(ActiveStorage::Attached::One) && value.is_a?(ActiveStorage::Attached::One)
58
58
  return render_attachment_preview(value)
59
- elsif value.is_a?(ActiveStorage::Attached::Many)
59
+ elsif defined?(ActiveStorage::Attached::Many) && value.is_a?(ActiveStorage::Attached::Many)
60
60
  return render_attachments_preview(value)
61
61
  end
62
62
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  module AdminSuite
4
4
  module Version
5
- VERSION = "0.2.8"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
 
8
8
  # Backward-compatible constant.
data/lib/admin_suite.rb CHANGED
@@ -14,6 +14,7 @@ require "admin_suite/markdown_renderer"
14
14
  require "admin_suite/theme_palette"
15
15
  require "admin_suite/portal_registry"
16
16
  require "admin_suite/portal_definition"
17
+ require "admin_suite/auth"
17
18
  require "admin_suite/ui/form_field_renderer"
18
19
  require "admin_suite/ui/show_value_formatter"
19
20
  require "admin_suite/engine"
@@ -32,6 +33,21 @@ module AdminSuite
32
33
  config
33
34
  end
34
35
 
36
+ # Resolves the effective authentication strategy instance.
37
+ #
38
+ # Precedence: explicit `config.auth_strategy` (Symbol name or Class),
39
+ # then the legacy `config.authenticate` lambda (wrapped), then nil.
40
+ #
41
+ # @return [AdminSuite::Auth::Strategy, nil]
42
+ def resolved_auth_strategy
43
+ if config.auth_strategy
44
+ klass = config.auth_strategy.is_a?(Class) ? config.auth_strategy : Auth.lookup(config.auth_strategy)
45
+ klass.new(config.auth_options || {})
46
+ elsif config.authenticate
47
+ Auth::HostHook.new(authenticate: config.authenticate, current_actor: config.current_actor)
48
+ end
49
+ end
50
+
35
51
  # Defines (or updates) a portal using a Ruby DSL.
36
52
  #
37
53
  # Host apps typically place these in: