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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -1
- data/CONTRIBUTING.md +2 -2
- data/Gemfile +3 -0
- data/README.md +10 -24
- data/app/controllers/admin_suite/application_controller.rb +53 -11
- data/app/controllers/admin_suite/resources_controller.rb +63 -9
- data/app/views/admin_suite/resources/index.html.erb +3 -1
- data/app/views/admin_suite/resources/show.html.erb +2 -2
- data/lib/admin/base/filter_builder.rb +2 -1
- data/lib/admin/base/resource.rb +14 -2
- 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/controllers/resources_controller_test.rb +80 -0
- 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 +130 -0
- 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/lib/resource_observability_extensions_test.rb +53 -0
- data/test/test_helper.rb +43 -0
- metadata +15 -14
- data/docs/README.md +0 -26
- data/docs/actions.md +0 -98
- data/docs/configuration.md +0 -284
- data/docs/development.md +0 -64
- data/docs/docs_viewer.md +0 -79
- data/docs/fields.md +0 -188
- data/docs/installation.md +0 -80
- data/docs/portals.md +0 -140
- data/docs/releasing.md +0 -67
- data/docs/resources.md +0 -237
- data/docs/theming.md +0 -63
- data/docs/troubleshooting.md +0 -50
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,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
|
|
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
|
|
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
data/README.md
CHANGED
|
@@ -15,21 +15,9 @@ across other products.
|
|
|
15
15
|
|
|
16
16
|
## Documentation
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
-
|
|
142
|
-
-
|
|
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
|
|
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,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
|
-
|
|
15
|
-
@
|
|
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
|
-
|
|
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
|
|
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.
|
|
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?
|
data/lib/admin/base/resource.rb
CHANGED
|
@@ -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
|
|
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:
|