admin_suite 0.2.9 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f50f5a24977b7bc8284ee051fb3d9d1746b49a1ab8d9ade00d917bf0a8ff412b
4
- data.tar.gz: 6e32362f8fe763980b83fa34910b21020d7bdca7918afb5d63084aae7e80d72e
3
+ metadata.gz: eadb4dfd6668dd70da1795f1188d17e6ba0d65950c09ff92a858238efa7c606f
4
+ data.tar.gz: 6c99383ce4ba18076897d828faada789b95540760111e6fa7e04194fa66fcb94
5
5
  SHA512:
6
- metadata.gz: b980579e952dc4ec9cd1cd56dab61219f9320e810df0697a97ad42031e08fa59793c893adfdcd00e7d0ab7449468ae7d02a886f2dd6a3406c65bf5c750a28c3f
7
- data.tar.gz: 603fdf2522093c9aba2427286e6573515a75d3016367dde07d3a74661bc7e15ffe95b1b388258c3398cc4d6583b85ef7c9ace18cc55e118b116344835802dfdc
6
+ metadata.gz: 97b5c9435b6da6cefa0a427b5c7d09c28917ae7fcd03873fca257be9221bd6bc9c214eea47154e1b51c2f7ed02c8097c08a6a266f5010ee75029ad0548ae3394
7
+ data.tar.gz: dfe2b7c2a326b22a1e4a84620b1f0c322a65af9fb8fd9acd09292ec527a7d3e606380788d6fa3b42f9247beaa96c6608455b90f9d9dd51dd6fde924af1345565
data/CHANGELOG.md CHANGED
@@ -7,6 +7,38 @@ 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
+
10
42
  ## [0.2.9] - 2026-07-14
11
43
 
12
44
  ### Added
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
@@ -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,8 +5,10 @@ module AdminSuite
5
5
  include Pagy::Backend
6
6
  include Pagy::Frontend
7
7
 
8
- before_action :enforce_read_only!, only: %i[new create edit update destroy]
8
+ before_action :require_resource_config!
9
+ before_action :enforce_read_only!, only: %i[new create edit update destroy toggle]
9
10
  before_action :set_resource, if: -> { params[:id].present? && !%w[index new create].include?(action_name) }
11
+ before_action :authorize_admin_suite!
10
12
 
11
13
  helper_method :resource_config, :resource_class, :resource, :collection, :current_portal, :resource_name
12
14
 
@@ -59,10 +61,7 @@ module AdminSuite
59
61
  def execute_action
60
62
  action = params[:action_name].to_s.to_sym
61
63
  action_def = find_action(action)
62
- unless action_def
63
- redirect_to resource_url(@resource), alert: "Action not found."
64
- return
65
- end
64
+ return head(:not_found) if action_def.nil?
66
65
 
67
66
  executor = Admin::Base::ActionExecutor.new(resource_config, action, admin_suite_actor)
68
67
  result = executor.execute_member(@resource, params.to_unsafe_h)
@@ -78,6 +77,9 @@ module AdminSuite
78
77
  # POST /:portal/:resource_name/bulk_action/:action_name
79
78
  def bulk_action
80
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
+
81
83
  ids = params[:ids] || []
82
84
  if ids.empty?
83
85
  redirect_to collection_url, alert: "No items selected."
@@ -127,6 +129,44 @@ module AdminSuite
127
129
 
128
130
  private
129
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
+
130
170
  def current_portal
131
171
  params[:portal].to_s.presence&.to_sym
132
172
  end
@@ -144,7 +184,7 @@ module AdminSuite
144
184
  end
145
185
 
146
186
  def resource_class
147
- resource_config&.model_class || resource_name.classify.constantize
187
+ resource_config.model_class
148
188
  end
149
189
 
150
190
  def set_resource
@@ -207,6 +247,10 @@ module AdminSuite
207
247
  resource_config&.actions_config&.member_actions&.find { |a| a.name == name }
208
248
  end
209
249
 
250
+ def find_bulk_action(name)
251
+ resource_config&.actions_config&.bulk_actions&.find { |a| a.name == name }
252
+ end
253
+
210
254
  def resource_params
211
255
  permitted_fields = []
212
256
  array_fields = []
@@ -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.9"
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:
@@ -2,16 +2,33 @@
2
2
 
3
3
  # AdminSuite configuration (host app adapter layer).
4
4
  AdminSuite.configure do |config|
5
- # Hook called as a before_action inside the engine.
6
- # config.authenticate = ->(controller) { ... }
7
- config.authenticate = nil
5
+ # --- Authentication (REQUIRED AdminSuite fails closed) ---
6
+ #
7
+ # Built-in HTTP Basic (reads ADMIN_SUITE_USERNAME / ADMIN_SUITE_PASSWORD):
8
+ config.auth_strategy = :http_basic
9
+ # config.auth_options = { username: ENV["ADMIN_SUITE_USERNAME"], password: ENV["ADMIN_SUITE_PASSWORD"] }
10
+ #
11
+ # Or a custom strategy (e.g. your SSO). Subclass AdminSuite::Auth::Strategy,
12
+ # return an actor from #authenticate!(controller), and register it:
13
+ # AdminSuite::Auth.register(:my_sso, MySsoStrategy)
14
+ # config.auth_strategy = :my_sso
15
+ #
16
+ # Or the legacy lambda (still supported):
17
+ # config.authenticate = ->(controller) { ... render/redirect to deny ... }
18
+ #
19
+ # Development/test only — run without authentication (ignored in production):
20
+ # config.allow_unauthenticated = true
21
+
22
+ # Host before_actions the engine skips (it authenticates itself):
23
+ # config.skip_host_before_actions = [ :require_authentication ]
8
24
 
9
- # Actor used for actions/auditing/authorization.
10
- # config.current_actor = ->(controller) { ... }
25
+ # Actor used for actions/auditing/authorization when your strategy does not
26
+ # provide one (legacy fallback):
11
27
  config.current_actor = ->(controller) { controller.respond_to?(:current_user) ? controller.current_user : nil }
12
28
 
13
- # Optional authorization hook (Pundit/CanCan/ActionPolicy/custom).
14
- # config.authorize = ->(actor, action:, subject:, resource:, controller:) { true }
29
+ # Authorization hook — called for every resource request:
30
+ # action is one of :read, :create, :update, :destroy, :execute.
31
+ # config.authorize = ->(actor:, action:, resource:, record:, controller:) { true }
15
32
  config.authorize = nil
16
33
 
17
34
  # Optional sign-out action in the topbar.
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The dummy app's pre-auth integration tests exercise pages without
4
+ # authentication. Fail-closed is covered explicitly in
5
+ # test/integration/authentication_test.rb by flipping this off.
6
+ AdminSuite.configure do |config|
7
+ config.allow_unauthenticated = true
8
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "minitest/mock"
5
+
6
+ module AdminSuite
7
+ class AuthenticationTest < ActionDispatch::IntegrationTest
8
+ ROOT = "/internal/admin_suite"
9
+
10
+ def with_config(**overrides)
11
+ saved = overrides.keys.index_with { |k| AdminSuite.config.public_send(k) }
12
+ overrides.each { |k, v| AdminSuite.config.public_send("#{k}=", v) }
13
+ yield
14
+ ensure
15
+ saved.each { |k, v| AdminSuite.config.public_send("#{k}=", v) }
16
+ end
17
+
18
+ test "unconfigured auth fails closed with 403" do
19
+ with_config(allow_unauthenticated: false, auth_strategy: nil, authenticate: nil) do
20
+ get ROOT
21
+ assert_response :forbidden
22
+ assert_includes response.body, "no authentication is configured"
23
+ end
24
+ end
25
+
26
+ test "allow_unauthenticated opens access outside production" do
27
+ with_config(allow_unauthenticated: true, auth_strategy: nil, authenticate: nil) do
28
+ get ROOT
29
+ assert_response :success
30
+ end
31
+ end
32
+
33
+ test "http_basic strategy denies without credentials and allows with them" do
34
+ with_config(allow_unauthenticated: false, auth_strategy: :http_basic,
35
+ auth_options: { username: "ravi", password: "s3cret" }) do
36
+ get ROOT
37
+ assert_response :unauthorized # Basic challenge
38
+
39
+ get ROOT, headers: {
40
+ "Authorization" => ActionController::HttpAuthentication::Basic.encode_credentials("ravi", "s3cret")
41
+ }
42
+ assert_response :success
43
+ end
44
+ end
45
+
46
+ test "legacy authenticate lambda still works" do
47
+ denials = ->(controller) { controller.head :forbidden }
48
+ with_config(allow_unauthenticated: false, auth_strategy: nil, authenticate: denials) do
49
+ get ROOT
50
+ assert_response :forbidden
51
+ end
52
+ end
53
+
54
+ test "allow_unauthenticated is ignored in production" do
55
+ with_config(allow_unauthenticated: true, auth_strategy: nil, authenticate: nil) do
56
+ Rails.stub(:env, ActiveSupport::StringInquirer.new("production")) do
57
+ get ROOT
58
+ assert_response :forbidden
59
+ assert_includes response.body, "no authentication is configured"
60
+ end
61
+ end
62
+ end
63
+
64
+ test "current_actor is consulted at most once per request on the legacy path" do
65
+ calls = 0
66
+ passes = ->(_controller) {}
67
+ counting_actor = lambda { |_controller|
68
+ calls += 1
69
+ nil
70
+ }
71
+
72
+ with_config(allow_unauthenticated: false, auth_strategy: nil, authenticate: passes,
73
+ current_actor: counting_actor) do
74
+ get ROOT
75
+ assert_response :success
76
+ assert_equal 1, calls
77
+ end
78
+ end
79
+
80
+ test "legacy authenticate lambda passes but a raising current_actor lambda is rescued to nil" do
81
+ passes = ->(_controller) {}
82
+ raising_actor = ->(_controller) { raise "boom" }
83
+
84
+ with_config(allow_unauthenticated: false, auth_strategy: nil, authenticate: passes,
85
+ current_actor: raising_actor) do
86
+ get ROOT
87
+ assert_response :success
88
+ end
89
+ end
90
+
91
+ test "host filter skip list is config-driven with require_authentication default" do
92
+ assert_equal [ :require_authentication ], AdminSuite.config.skip_host_before_actions
93
+ # The controller must consume config rather than hardcode the name:
94
+ source = AdminSuite::Engine.root.join("app/controllers/admin_suite/application_controller.rb").read
95
+ assert_includes source, "skip_host_before_actions"
96
+ refute_match(/skip_before_action :require_authentication\b/, source)
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Reuses the in-memory fixture pattern from read_only_resource_test.rb.
6
+ module AuthzFixtures
7
+ class Gadget
8
+ extend ActiveModel::Naming
9
+
10
+ attr_reader :id, :name
11
+
12
+ def initialize(id: 1, name: "Gadget one")
13
+ @id = id
14
+ @name = name
15
+ end
16
+
17
+ def self.all = ReadOnlyResourceFixtures::Relation.new([ new ])
18
+ def self.column_names = %w[id name]
19
+ def self.primary_key = "id"
20
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
21
+
22
+ def self.find(id)
23
+ raise ActiveRecord::RecordNotFound unless id.to_s == "1"
24
+ new
25
+ end
26
+
27
+ def to_param = id.to_s
28
+ def attributes = { "id" => id, "name" => name }
29
+ end
30
+ end
31
+
32
+ module Admin
33
+ module Resources
34
+ class AuthzGadgetResource < Admin::Base::Resource
35
+ model AuthzFixtures::Gadget
36
+ portal :ops
37
+ section :observability
38
+
39
+ index do
40
+ columns { column :name }
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ module AdminSuite
47
+ class AuthorizationTest < ActionDispatch::IntegrationTest
48
+ BASE = "/internal/admin_suite/ops/authz_gadgets"
49
+
50
+ def with_authorize(hook)
51
+ saved = AdminSuite.config.authorize
52
+ AdminSuite.config.authorize = hook
53
+ yield
54
+ ensure
55
+ AdminSuite.config.authorize = saved
56
+ end
57
+
58
+ test "nil authorize hook allows requests" do
59
+ with_authorize(nil) do
60
+ get BASE
61
+ assert_response :success
62
+ end
63
+ end
64
+
65
+ test "falsy authorize denies with 403" do
66
+ with_authorize(->(**) { false }) do
67
+ get BASE
68
+ assert_response :forbidden
69
+ end
70
+ end
71
+
72
+ test "authorize receives actor, mapped action, resource, record, controller" do
73
+ captured = nil
74
+ hook = lambda do |actor:, action:, resource:, record:, controller:|
75
+ captured = { action: action, resource: resource, record: record }
76
+ true
77
+ end
78
+
79
+ with_authorize(hook) do
80
+ get "#{BASE}/1"
81
+ end
82
+
83
+ assert_equal :read, captured[:action]
84
+ assert_equal Admin::Resources::AuthzGadgetResource, captured[:resource]
85
+ assert_instance_of AuthzFixtures::Gadget, captured[:record]
86
+ end
87
+
88
+ test "destroy maps to :destroy" do
89
+ captured_action = nil
90
+ with_authorize(->(action:, **) { captured_action = action; false }) do
91
+ delete "#{BASE}/1"
92
+ end
93
+ assert_equal :destroy, captured_action
94
+ end
95
+
96
+ test "execute_action reaches the authorize hook with :execute before the action-name 404" do
97
+ captured_action = nil
98
+ with_authorize(->(action:, **) { captured_action = action; false }) do
99
+ post "#{BASE}/1/execute_action/anything"
100
+ end
101
+ assert_equal :execute, captured_action
102
+ end
103
+
104
+ test "bulk_action reaches the authorize hook with :execute before the action-name 404" do
105
+ captured_action = nil
106
+ with_authorize(->(action:, **) { captured_action = action; false }) do
107
+ post "#{BASE}/bulk_action/anything", params: { ids: [ "1" ] }
108
+ end
109
+ assert_equal :execute, captured_action
110
+ end
111
+
112
+ test "unregistered resource name 404s instead of constantizing a host model" do
113
+ get "/internal/admin_suite/ops/strings"
114
+ assert_response :not_found
115
+ end
116
+
117
+ test "unregistered resource name 404s on mutating verbs instead of reaching the model" do
118
+ delete "/internal/admin_suite/ops/strings/1"
119
+ assert_response :not_found
120
+ end
121
+
122
+ test "unregistered resource name 404s before the authorize hook ever runs" do
123
+ hook_called = false
124
+ with_authorize(->(**) { hook_called = true; false }) do
125
+ get "/internal/admin_suite/ops/strings"
126
+ assert_response :not_found
127
+ end
128
+ refute hook_called, "authorize hook must not run for an unregistered resource name"
129
+ end
130
+ end
131
+ end
@@ -2,48 +2,7 @@
2
2
 
3
3
  require "test_helper"
4
4
 
5
- # The dummy app is intentionally database-free, while the generic controller
6
- # supports Active Record hosts. Supply only the exception type its lookup path
7
- # rescues so show-page behavior can be exercised with an in-memory fixture.
8
- unless defined?(ActiveRecord::RecordNotFound)
9
- module ActiveRecord
10
- class RecordNotFound < StandardError; end
11
- end
12
- end
13
-
14
- module TurboFrameTestHelper
15
- def turbo_frame_tag(name, **options, &block)
16
- content_tag(:turbo_frame, capture(&block), id: name, **options)
17
- end
18
- end
19
-
20
- ActionView::Base.include(TurboFrameTestHelper)
21
-
22
5
  module ReadOnlyResourceFixtures
23
- class Relation
24
- include Enumerable
25
-
26
- def initialize(records)
27
- @records = records
28
- end
29
-
30
- def each(&block)
31
- @records.each(&block)
32
- end
33
-
34
- def count(*)
35
- @records.count
36
- end
37
-
38
- def offset(*)
39
- self
40
- end
41
-
42
- def limit(*)
43
- self
44
- end
45
- end
46
-
47
6
  class Widget
48
7
  extend ActiveModel::Naming
49
8
 
@@ -83,6 +42,10 @@ module ReadOnlyResourceFixtures
83
42
  def attributes
84
43
  { "id" => id, "name" => name }
85
44
  end
45
+
46
+ def ping
47
+ true
48
+ end
86
49
  end
87
50
  end
88
51
 
@@ -99,6 +62,10 @@ module Admin
99
62
  column :name
100
63
  end
101
64
  end
65
+
66
+ actions do
67
+ action :ping
68
+ end
102
69
  end
103
70
  end
104
71
  end
@@ -139,5 +106,25 @@ module AdminSuite
139
106
  assert_includes template, "has_edit_route = !resource_config.read_only?"
140
107
  assert_includes template, "has_destroy_route = !resource_config.read_only?"
141
108
  end
109
+
110
+ test "toggle endpoint is rejected on read_only resources" do
111
+ post "#{BASE_PATH}/1/toggle", params: { field: "name" }
112
+ assert_response :not_found
113
+ end
114
+
115
+ test "undeclared execute_action names respond 404" do
116
+ post "#{BASE_PATH}/1/execute_action/nonexistent_action"
117
+ assert_response :not_found
118
+ end
119
+
120
+ test "undeclared bulk_action names respond 404" do
121
+ post "#{BASE_PATH}/bulk_action/nonexistent_bulk", params: { ids: ["1"] }
122
+ assert_response :not_found
123
+ end
124
+
125
+ test "declared member actions remain allowed on read_only resources" do
126
+ post "#{BASE_PATH}/1/execute_action/ping"
127
+ refute_equal 404, response.status
128
+ end
142
129
  end
143
130
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ raise "unset ADMIN_SUITE_USERNAME in test env" if ENV["ADMIN_SUITE_USERNAME"]
4
+
5
+ require "test_helper"
6
+
7
+ module AdminSuite
8
+ class AuthHttpBasicTest < ActiveSupport::TestCase
9
+ # Minimal stand-in for the parts of ActionController the strategy touches.
10
+ class ControllerDouble
11
+ attr_reader :rendered_status, :challenged
12
+
13
+ def initialize(given_username: nil, given_password: nil)
14
+ @given_username = given_username
15
+ @given_password = given_password
16
+ end
17
+
18
+ def authenticate_with_http_basic
19
+ return false if @given_username.nil?
20
+ yield(@given_username, @given_password)
21
+ end
22
+
23
+ def render(plain:, status:)
24
+ @rendered_status = status
25
+ end
26
+
27
+ def request_http_basic_authentication(_realm)
28
+ @challenged = true
29
+ end
30
+ end
31
+
32
+ test "returns an actor for correct credentials" do
33
+ strategy = Auth::HttpBasic.new(username: "ravi", password: "s3cret")
34
+ controller = ControllerDouble.new(given_username: "ravi", given_password: "s3cret")
35
+
36
+ actor = strategy.authenticate!(controller)
37
+
38
+ assert_equal "ravi", actor.username
39
+ assert_equal "http-basic:ravi", actor.to_s
40
+ end
41
+
42
+ test "challenges on wrong credentials and returns nil" do
43
+ strategy = Auth::HttpBasic.new(username: "ravi", password: "s3cret")
44
+ controller = ControllerDouble.new(given_username: "ravi", given_password: "wrong")
45
+
46
+ assert_nil strategy.authenticate!(controller)
47
+ assert controller.challenged
48
+ end
49
+
50
+ test "denies with 403 when credentials are not configured" do
51
+ strategy = Auth::HttpBasic.new(username: nil, password: nil)
52
+ controller = ControllerDouble.new(given_username: "any", given_password: "any")
53
+
54
+ assert_nil strategy.authenticate!(controller)
55
+ assert_equal :forbidden, controller.rendered_status
56
+ end
57
+
58
+ test "is registered as :http_basic" do
59
+ assert_equal Auth::HttpBasic, Auth.lookup(:http_basic)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module AdminSuite
6
+ class AuthResolutionTest < ActiveSupport::TestCase
7
+ class PerformableDouble
8
+ def initialize(performed:) = @performed = performed
9
+ def performed? = @performed
10
+ end
11
+
12
+ setup do
13
+ @saved = {
14
+ auth_strategy: AdminSuite.config.auth_strategy,
15
+ auth_options: AdminSuite.config.auth_options,
16
+ authenticate: AdminSuite.config.authenticate,
17
+ current_actor: AdminSuite.config.current_actor
18
+ }
19
+ end
20
+
21
+ teardown do
22
+ @saved.each { |k, v| AdminSuite.config.public_send("#{k}=", v) }
23
+ end
24
+
25
+ test "config defaults are fail-closed friendly" do
26
+ fresh = AdminSuite::Configuration.new
27
+ assert_nil fresh.auth_strategy
28
+ assert_equal({}, fresh.auth_options)
29
+ assert_equal false, fresh.allow_unauthenticated
30
+ assert_equal [ :require_authentication ], fresh.skip_host_before_actions
31
+ end
32
+
33
+ test "resolves a symbol strategy through the registry with options" do
34
+ AdminSuite.config.auth_strategy = :http_basic
35
+ AdminSuite.config.auth_options = { username: "u", password: "p" }
36
+
37
+ strategy = AdminSuite.resolved_auth_strategy
38
+
39
+ assert_instance_of Auth::HttpBasic, strategy
40
+ assert_equal "u", strategy.options[:username]
41
+ end
42
+
43
+ test "resolves a class strategy directly" do
44
+ klass = Class.new(Auth::Strategy)
45
+ AdminSuite.config.auth_strategy = klass
46
+ assert_instance_of klass, AdminSuite.resolved_auth_strategy
47
+ end
48
+
49
+ test "wraps legacy authenticate lambda when no strategy is set" do
50
+ AdminSuite.config.auth_strategy = nil
51
+ AdminSuite.config.authenticate = ->(_controller) { :called }
52
+ AdminSuite.config.current_actor = ->(_controller) { :legacy_actor }
53
+
54
+ strategy = AdminSuite.resolved_auth_strategy
55
+ assert_instance_of Auth::HostHook, strategy
56
+
57
+ actor = strategy.authenticate!(PerformableDouble.new(performed: false))
58
+ assert_equal :legacy_actor, actor
59
+ end
60
+
61
+ test "legacy lambda that halts (renders/redirects) denies" do
62
+ AdminSuite.config.auth_strategy = nil
63
+ AdminSuite.config.authenticate = ->(_controller) { :redirected_inside }
64
+
65
+ strategy = AdminSuite.resolved_auth_strategy
66
+ assert_nil strategy.authenticate!(PerformableDouble.new(performed: true))
67
+ end
68
+
69
+ test "returns nil when nothing configured" do
70
+ AdminSuite.config.auth_strategy = nil
71
+ AdminSuite.config.authenticate = nil
72
+ assert_nil AdminSuite.resolved_auth_strategy
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module AdminSuite
6
+ class AuthTest < ActiveSupport::TestCase
7
+ class FakeStrategy < AdminSuite::Auth::Strategy
8
+ def authenticate!(_controller) = :fake_actor
9
+ end
10
+
11
+ test "register and lookup a strategy by name" do
12
+ AdminSuite::Auth.register(:fake, FakeStrategy)
13
+ assert_equal FakeStrategy, AdminSuite::Auth.lookup(:fake)
14
+ assert_equal FakeStrategy, AdminSuite::Auth.lookup("fake")
15
+ end
16
+
17
+ test "lookup of unknown strategy raises UnknownStrategyError" do
18
+ assert_raises(AdminSuite::Auth::UnknownStrategyError) do
19
+ AdminSuite::Auth.lookup(:nope)
20
+ end
21
+ end
22
+
23
+ test "base strategy exposes options and requires authenticate!" do
24
+ strategy = AdminSuite::Auth::Strategy.new(username: "u")
25
+ assert_equal({ username: "u" }, strategy.options)
26
+ assert_raises(NotImplementedError) { strategy.authenticate!(nil) }
27
+ end
28
+ end
29
+ end
data/test/test_helper.rb CHANGED
@@ -22,3 +22,46 @@ require "action_dispatch/testing/integration"
22
22
 
23
23
  # Ensure the engine is loaded (and its initializers run).
24
24
  require "admin_suite"
25
+
26
+ # The dummy app is intentionally database-free, while the generic controller
27
+ # supports Active Record hosts. Supply only the exception type its lookup path
28
+ # rescues so show-page behavior can be exercised with an in-memory fixture.
29
+ unless defined?(ActiveRecord::RecordNotFound)
30
+ module ActiveRecord
31
+ class RecordNotFound < StandardError; end
32
+ end
33
+ end
34
+
35
+ module TurboFrameTestHelper
36
+ def turbo_frame_tag(name, **options, &block)
37
+ content_tag(:turbo_frame, capture(&block), id: name, **options)
38
+ end
39
+ end
40
+
41
+ ActionView::Base.include(TurboFrameTestHelper)
42
+
43
+ module ReadOnlyResourceFixtures
44
+ class Relation
45
+ include Enumerable
46
+
47
+ def initialize(records)
48
+ @records = records
49
+ end
50
+
51
+ def each(&block)
52
+ @records.each(&block)
53
+ end
54
+
55
+ def count(*)
56
+ @records.count
57
+ end
58
+
59
+ def offset(*)
60
+ self
61
+ end
62
+
63
+ def limit(*)
64
+ self
65
+ end
66
+ end
67
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: admin_suite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.9
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - TechWright Labs
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-24 00:00:00.000000000 Z
11
+ date: 2026-08-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -178,6 +178,10 @@ files:
178
178
  - lib/admin/base/filter_builder.rb
179
179
  - lib/admin/base/resource.rb
180
180
  - lib/admin_suite.rb
181
+ - lib/admin_suite/auth.rb
182
+ - lib/admin_suite/auth/host_hook.rb
183
+ - lib/admin_suite/auth/http_basic.rb
184
+ - lib/admin_suite/auth/strategy.rb
181
185
  - lib/admin_suite/configuration.rb
182
186
  - lib/admin_suite/engine.rb
183
187
  - lib/admin_suite/markdown_renderer.rb
@@ -222,6 +226,7 @@ files:
222
226
  - test/dummy/config/environments/development.rb
223
227
  - test/dummy/config/environments/production.rb
224
228
  - test/dummy/config/environments/test.rb
229
+ - test/dummy/config/initializers/admin_suite_auth.rb
225
230
  - test/dummy/config/initializers/assets.rb
226
231
  - test/dummy/config/initializers/content_security_policy.rb
227
232
  - test/dummy/config/initializers/filter_parameter_logging.rb
@@ -240,11 +245,16 @@ files:
240
245
  - test/dummy/public/robots.txt
241
246
  - test/dummy/test/test_helper.rb
242
247
  - test/fixtures/docs/progress/PROGRESS_REPORT.md
248
+ - test/integration/authentication_test.rb
249
+ - test/integration/authorization_test.rb
243
250
  - test/integration/dashboard_test.rb
244
251
  - test/integration/docs_test.rb
245
252
  - test/integration/read_only_resource_test.rb
246
253
  - test/integration/theme_test.rb
247
254
  - test/lib/action_executor_test.rb
255
+ - test/lib/auth_http_basic_test.rb
256
+ - test/lib/auth_resolution_test.rb
257
+ - test/lib/auth_test.rb
248
258
  - test/lib/markdown_renderer_test.rb
249
259
  - test/lib/resource_observability_extensions_test.rb
250
260
  - test/lib/theme_palette_test.rb