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 +4 -4
- data/CHANGELOG.md +32 -0
- data/Gemfile +3 -0
- data/app/controllers/admin_suite/application_controller.rb +53 -11
- data/app/controllers/admin_suite/resources_controller.rb +50 -6
- data/lib/admin_suite/auth/host_hook.rb +25 -0
- data/lib/admin_suite/auth/http_basic.rb +46 -0
- data/lib/admin_suite/auth/strategy.rb +25 -0
- data/lib/admin_suite/auth.rb +33 -0
- data/lib/admin_suite/configuration.rb +8 -0
- data/lib/admin_suite/ui/show_value_formatter.rb +2 -2
- data/lib/admin_suite/version.rb +1 -1
- data/lib/admin_suite.rb +16 -0
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +24 -7
- data/test/dummy/config/initializers/admin_suite_auth.rb +8 -0
- data/test/integration/authentication_test.rb +99 -0
- data/test/integration/authorization_test.rb +131 -0
- data/test/integration/read_only_resource_test.rb +28 -41
- data/test/lib/auth_http_basic_test.rb +62 -0
- data/test/lib/auth_resolution_test.rb +75 -0
- data/test/lib/auth_test.rb +29 -0
- data/test/test_helper.rb +43 -0
- metadata +12 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eadb4dfd6668dd70da1795f1188d17e6ba0d65950c09ff92a858238efa7c606f
|
|
4
|
+
data.tar.gz: 6c99383ce4ba18076897d828faada789b95540760111e6fa7e04194fa66fcb94
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
|
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
|
|
data/lib/admin_suite/version.rb
CHANGED
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
|
-
#
|
|
6
|
-
#
|
|
7
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
14
|
-
#
|
|
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.
|
|
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-
|
|
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
|