rodauth-tools 0.3.1 → 0.4.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/.rubocop.yml +4 -0
- data/.rubocop_todo.yml +10 -3
- data/CHANGELOG.md +25 -0
- data/CLAUDE.md +34 -11
- data/Gemfile +5 -5
- data/Gemfile.lock +119 -117
- data/README.md +33 -1
- data/lib/rodauth/features/account_id_obfuscation.rb +317 -0
- data/lib/rodauth/features/hmac_secret_guard.rb +55 -47
- data/lib/rodauth/features/jwt_secret_guard.rb +55 -45
- data/lib/rodauth/features/table_guard.rb +195 -53
- data/lib/rodauth/secret_guard.rb +137 -0
- data/lib/rodauth/sequel_generator.rb +17 -5
- data/lib/rodauth/table_inspector.rb +1 -1
- data/lib/rodauth/tools/account_id_cipher.rb +150 -0
- data/lib/rodauth/tools/migration.rb +3 -4
- data/lib/rodauth/tools/version.rb +1 -1
- data/lib/rodauth/tools.rb +2 -0
- data/package-lock.json +943 -76
- data/package.json +1 -1
- metadata +4 -1
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require_relative '../secret_guard'
|
|
7
|
+
|
|
5
8
|
module Rodauth
|
|
6
9
|
# Automatically sets jwt_secret based on JWT_SECRET and validates it is properly
|
|
7
10
|
# configured before the application starts. This helps prevent deployment
|
|
@@ -9,23 +12,34 @@ module Rodauth
|
|
|
9
12
|
# particularly in production environments.
|
|
10
13
|
#
|
|
11
14
|
# By default, this feature checks during +post_configure+ that +jwt_secret+
|
|
12
|
-
# is set to a non-
|
|
15
|
+
# is set to a non-blank value. In production mode, it raises a
|
|
13
16
|
# ConfigurationError if the secret is missing. In development mode, it logs
|
|
14
17
|
# a warning and uses a fallback development secret.
|
|
15
18
|
#
|
|
19
|
+
# This feature and +hmac_secret_guard+ can be enabled together. Their shared
|
|
20
|
+
# logic lives in +Rodauth::SecretGuard+ and is keyed by secret kind, so each
|
|
21
|
+
# secret is validated independently at boot.
|
|
22
|
+
#
|
|
16
23
|
# @example Basic Configuration
|
|
17
24
|
# plugin :rodauth do
|
|
18
25
|
# enable :jwt_secret_guard
|
|
19
26
|
# end
|
|
20
27
|
#
|
|
21
|
-
# @example Customizing Production Detection
|
|
28
|
+
# @example Customizing Production Detection (fail-safe)
|
|
22
29
|
# plugin :rodauth do
|
|
23
30
|
# enable :jwt_secret_guard
|
|
24
|
-
#
|
|
25
|
-
# #
|
|
31
|
+
# # Treat an unset RACK_ENV as production so a misconfigured deploy fails
|
|
32
|
+
# # closed rather than silently using the development fallback secret:
|
|
33
|
+
# production_env_check proc { ENV.fetch('RACK_ENV', 'production') == 'production' }
|
|
34
|
+
# # Or force it:
|
|
26
35
|
# # production_env_check true
|
|
27
36
|
# end
|
|
28
37
|
#
|
|
38
|
+
# # The default already fails safe: an unset RACK_ENV is treated as
|
|
39
|
+
# # production. Avoid `proc { ENV['RACK_ENV'] == 'production' }` — when the
|
|
40
|
+
# # variable is unset that returns false and silently falls back to the
|
|
41
|
+
# # insecure development secret in what is really a production deploy.
|
|
42
|
+
#
|
|
29
43
|
# @example Customizing Error Messages
|
|
30
44
|
# plugin :rodauth do
|
|
31
45
|
# enable :jwt_secret_guard
|
|
@@ -39,6 +53,16 @@ module Rodauth
|
|
|
39
53
|
# development_jwt_secret_fallback 'my-custom-dev-secret'
|
|
40
54
|
# end
|
|
41
55
|
#
|
|
56
|
+
# # The default fallback is a random per-process value (SecureRandom.hex),
|
|
57
|
+
# # not a constant baked into source. Set an explicit value only if you need
|
|
58
|
+
# # JWTs to remain valid across restarts in development.
|
|
59
|
+
#
|
|
60
|
+
# @example Enforcing a Minimum Secret Length (production only)
|
|
61
|
+
# plugin :rodauth do
|
|
62
|
+
# enable :jwt_secret_guard
|
|
63
|
+
# minimum_secret_length 32 # reject short secrets in production; 0 disables (default)
|
|
64
|
+
# end
|
|
65
|
+
#
|
|
42
66
|
# @example Disabling Validation
|
|
43
67
|
# plugin :rodauth do
|
|
44
68
|
# enable :jwt_secret_guard
|
|
@@ -49,8 +73,10 @@ module Rodauth
|
|
|
49
73
|
auth_value_method :jwt_secret_env_key, 'JWT_SECRET'
|
|
50
74
|
auth_value_method :production_env_check, proc { ENV.fetch('RACK_ENV', 'production') == 'production' }
|
|
51
75
|
auth_value_method :validate_secrets_on_configure?, true
|
|
52
|
-
auth_value_method :
|
|
53
|
-
|
|
76
|
+
auth_value_method :minimum_secret_length, 0
|
|
77
|
+
# Random per-process fallback: never committed to source, and unstable across
|
|
78
|
+
# restarts so it can't be mistaken for a real, persistent secret.
|
|
79
|
+
auth_value_method :development_jwt_secret_fallback, SecureRandom.hex(32)
|
|
54
80
|
|
|
55
81
|
# Make jwt_secret configurable (if not already provided by jwt feature)
|
|
56
82
|
auth_value_method :jwt_secret, nil
|
|
@@ -61,60 +87,44 @@ module Rodauth
|
|
|
61
87
|
def post_configure
|
|
62
88
|
super
|
|
63
89
|
|
|
64
|
-
|
|
65
|
-
if
|
|
66
|
-
env_value = ENV.delete(jwt_secret_env_key)
|
|
67
|
-
self.class.send(:define_method, :jwt_secret) { env_value } if env_value && !env_value.empty?
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
validate_secrets! if validate_secrets_on_configure?
|
|
90
|
+
Rodauth::SecretGuard.load_from_env!(self, :jwt)
|
|
91
|
+
validate_jwt_secret! if validate_secrets_on_configure?
|
|
71
92
|
end
|
|
72
93
|
|
|
73
|
-
auth_methods :validate_secrets!, :production?
|
|
94
|
+
auth_methods :validate_secrets!, :validate_jwt_secret!, :production?
|
|
74
95
|
|
|
75
96
|
# Check if we're running in production environment.
|
|
76
97
|
#
|
|
77
98
|
# @return [Boolean] true if running in production mode based on production_env_check
|
|
78
99
|
def production?
|
|
79
|
-
|
|
80
|
-
when Proc
|
|
81
|
-
instance_exec(&v)
|
|
82
|
-
else
|
|
83
|
-
!!v
|
|
84
|
-
end
|
|
100
|
+
Rodauth::SecretGuard.production?(self)
|
|
85
101
|
end
|
|
86
102
|
|
|
87
|
-
# Validate that JWT secret is properly configured.
|
|
88
|
-
#
|
|
89
|
-
#
|
|
103
|
+
# Validate that the JWT secret is properly configured. Raises
|
|
104
|
+
# ConfigurationError in production if the secret is missing, blank, or (when
|
|
105
|
+
# +minimum_secret_length+ is set) too short. In development it warns and
|
|
106
|
+
# installs a fallback secret.
|
|
107
|
+
#
|
|
108
|
+
# This is the collision-free entry point: prefer it over +validate_secrets!+
|
|
109
|
+
# when both secret guards are enabled.
|
|
90
110
|
#
|
|
91
|
-
# @raise [Rodauth::ConfigurationError] if jwt_secret is
|
|
111
|
+
# @raise [Rodauth::ConfigurationError] if jwt_secret is unusable in production
|
|
92
112
|
# @return [void]
|
|
93
|
-
def
|
|
94
|
-
|
|
95
|
-
current_secret = jwt_secret
|
|
96
|
-
|
|
97
|
-
# Return early if secret is present
|
|
98
|
-
return unless current_secret.nil? || (current_secret.respond_to?(:empty?) && current_secret.empty?)
|
|
99
|
-
raise Rodauth::ConfigurationError, jwt_secret_missing_error if production?
|
|
100
|
-
|
|
101
|
-
# In development, warn and set a fallback
|
|
102
|
-
warn_dev_secret
|
|
103
|
-
self.class.send(:define_method, :jwt_secret) { development_jwt_secret_fallback }
|
|
113
|
+
def validate_jwt_secret!
|
|
114
|
+
Rodauth::SecretGuard.validate!(self, :jwt)
|
|
104
115
|
end
|
|
105
116
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
# Warn about using development secret.
|
|
109
|
-
# Logs to logger if available, otherwise to stderr.
|
|
117
|
+
# Backwards-compatible alias for +validate_jwt_secret!+.
|
|
110
118
|
#
|
|
119
|
+
# Note: +hmac_secret_guard+ defines a +validate_secrets!+ of its own, so when
|
|
120
|
+
# both guards are enabled this name resolves to only one of them. Boot-time
|
|
121
|
+
# validation does not rely on it (see +post_configure+); use
|
|
122
|
+
# +validate_jwt_secret!+ for an unambiguous manual call.
|
|
123
|
+
#
|
|
124
|
+
# @raise [Rodauth::ConfigurationError] if jwt_secret is unusable in production
|
|
111
125
|
# @return [void]
|
|
112
|
-
def
|
|
113
|
-
|
|
114
|
-
logger.warn(jwt_secret_dev_warning)
|
|
115
|
-
else
|
|
116
|
-
warn(jwt_secret_dev_warning)
|
|
117
|
-
end
|
|
126
|
+
def validate_secrets!
|
|
127
|
+
validate_jwt_secret!
|
|
118
128
|
end
|
|
119
129
|
end
|
|
120
130
|
end
|
|
@@ -198,9 +198,14 @@ module Rodauth
|
|
|
198
198
|
def missing_tables
|
|
199
199
|
result = []
|
|
200
200
|
|
|
201
|
+
# Fetch the set of existing table names ONCE for this pass and reuse it
|
|
202
|
+
# across every table check, instead of running a catalog query per table
|
|
203
|
+
# (N+1). See #table_exists? for why the set is not cached on the instance.
|
|
204
|
+
existing = existing_table_names
|
|
205
|
+
|
|
201
206
|
table_configuration.each do |method, info|
|
|
202
207
|
table_name = info[:name]
|
|
203
|
-
next if table_exists?(table_name)
|
|
208
|
+
next if table_exists?(table_name, existing)
|
|
204
209
|
|
|
205
210
|
result << {
|
|
206
211
|
method: method,
|
|
@@ -222,33 +227,61 @@ module Rodauth
|
|
|
222
227
|
|
|
223
228
|
# Check if a table exists in the database
|
|
224
229
|
#
|
|
225
|
-
#
|
|
226
|
-
#
|
|
227
|
-
#
|
|
230
|
+
# For the common case (an unqualified base table in the default schema) this
|
|
231
|
+
# matches against the database's table/view list rather than probing each
|
|
232
|
+
# table with a SELECT. Sequel's db.table_exists? probe logs the "no such
|
|
233
|
+
# table" exception before catching it internally, which an earlier
|
|
234
|
+
# implementation worked around by clearing and restoring the shared
|
|
235
|
+
# db.loggers array around the call. That mutation of shared connection state
|
|
236
|
+
# was not thread-safe: a concurrent query (e.g. when table_status/
|
|
237
|
+
# column_status are called at runtime) could execute while logging was
|
|
238
|
+
# disabled. Matching against the listed names avoids the failed probe
|
|
239
|
+
# entirely, so no logger suppression — and no shared-state mutation — is
|
|
240
|
+
# needed. Views are included (via db.views when the adapter supports it)
|
|
241
|
+
# because a Rodauth table can legitimately be backed by a view, which
|
|
242
|
+
# db.tables alone omits on most adapters.
|
|
243
|
+
#
|
|
244
|
+
# Schema-qualified names (a Symbol like :auth__accounts, a
|
|
245
|
+
# Sequel::SQL::QualifiedIdentifier, or a Sequel.qualify(...) result) are NOT
|
|
246
|
+
# reflected in db.tables (which returns unqualified names from the current
|
|
247
|
+
# search_path), so they take a separate, schema-aware path: we probe with
|
|
248
|
+
# db.table_exists?. That probe can emit Sequel's error-log noise, but it is
|
|
249
|
+
# confined to this rare qualified path and never fires for the common
|
|
250
|
+
# unqualified case that #116 was about.
|
|
251
|
+
#
|
|
252
|
+
# The optional existing_tables argument lets looping callers
|
|
253
|
+
# (missing_tables, table_status) build the existing-name Set ONCE per
|
|
254
|
+
# introspection pass and reuse it, avoiding N catalog queries. When omitted
|
|
255
|
+
# (the single-name public call) a fresh set is fetched — the set is
|
|
256
|
+
# deliberately NOT cached on the instance so runtime introspection does not
|
|
257
|
+
# go stale if tables are created after boot.
|
|
228
258
|
#
|
|
229
|
-
#
|
|
259
|
+
# NOTE: On a genuine error we still fail open (assume the table exists) to
|
|
260
|
+
# preserve current behavior; switching this to fail closed is tracked in the
|
|
261
|
+
# table_guard hardening follow-up (issue #116).
|
|
262
|
+
#
|
|
263
|
+
# @param table_name [String, Symbol, Sequel::SQL::QualifiedIdentifier] Table name
|
|
264
|
+
# @param existing_tables [Set<Symbol>, nil] Pre-fetched existing table names
|
|
230
265
|
# @return [Boolean] True if table exists
|
|
231
|
-
def table_exists?(table_name)
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
266
|
+
def table_exists?(table_name, existing_tables = nil)
|
|
267
|
+
# Symbol/String names may be skipped by configuration. A
|
|
268
|
+
# QualifiedIdentifier does not respond to to_sym, so guard the lookup.
|
|
269
|
+
if table_name.respond_to?(:to_sym) &&
|
|
270
|
+
(table_guard_skip_tables.include?(table_name.to_sym) ||
|
|
271
|
+
table_guard_skip_tables.include?(table_name.to_s))
|
|
272
|
+
return true
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Qualified names live outside the current search_path's unqualified
|
|
276
|
+
# listing, so probe them directly (schema-aware) rather than matching the
|
|
277
|
+
# Set. Rare path — the log noise this can produce does not hit boot.
|
|
278
|
+
return db.table_exists?(table_name) if qualified_table_name?(table_name)
|
|
279
|
+
|
|
280
|
+
existing_tables ||= existing_table_names
|
|
281
|
+
existing_tables.include?(table_name.to_sym)
|
|
243
282
|
rescue StandardError => e
|
|
244
283
|
rodauth_warn("[table_guard] Unable to check table existence for #{table_name}: #{e.message}")
|
|
245
|
-
true # Assume exists to avoid false positives
|
|
246
|
-
ensure
|
|
247
|
-
# Restore original loggers
|
|
248
|
-
if original_logger
|
|
249
|
-
db.loggers.clear
|
|
250
|
-
original_logger.each { |logger| db.loggers << logger }
|
|
251
|
-
end
|
|
284
|
+
true # Assume exists to avoid false positives (see hardening follow-up #116)
|
|
252
285
|
end
|
|
253
286
|
|
|
254
287
|
# List all required table names (sorted)
|
|
@@ -262,12 +295,15 @@ module Rodauth
|
|
|
262
295
|
#
|
|
263
296
|
# @return [Array<Hash>] Status information for each table
|
|
264
297
|
def table_status
|
|
298
|
+
# Build the existing-name set once and reuse it (see missing_tables).
|
|
299
|
+
existing = existing_table_names
|
|
300
|
+
|
|
265
301
|
table_configuration.map do |method, info|
|
|
266
302
|
{
|
|
267
303
|
method: method,
|
|
268
304
|
table: info[:name],
|
|
269
305
|
feature: info[:feature],
|
|
270
|
-
exists: table_exists?(info[:name])
|
|
306
|
+
exists: table_exists?(info[:name], existing)
|
|
271
307
|
}
|
|
272
308
|
end
|
|
273
309
|
end
|
|
@@ -414,6 +450,62 @@ module Rodauth
|
|
|
414
450
|
|
|
415
451
|
private
|
|
416
452
|
|
|
453
|
+
# Build the set of unqualified table names that currently exist, including
|
|
454
|
+
# views (which db.tables omits on most adapters but which can legitimately
|
|
455
|
+
# back a Rodauth table).
|
|
456
|
+
#
|
|
457
|
+
# Fetched fresh on each call — never memoized on the instance — so runtime
|
|
458
|
+
# introspection reflects tables created after boot. Looping callers pass the
|
|
459
|
+
# result into #table_exists? to fetch it only once per pass (see #116 / N+1).
|
|
460
|
+
#
|
|
461
|
+
# @return [Set<Symbol>] Existing base-table and view names
|
|
462
|
+
def existing_table_names
|
|
463
|
+
names = db.tables.map(&:to_sym)
|
|
464
|
+
names.concat(db.views.map(&:to_sym)) if db.respond_to?(:views)
|
|
465
|
+
# Set.new (not names.to_set): referencing the Set constant triggers its
|
|
466
|
+
# autoload on Ruby >= 3.2 (the gem's floor), whereas Enumerable#to_set is
|
|
467
|
+
# only defined once 'set' is already loaded. Using to_set here would rely
|
|
468
|
+
# on a dependency having required 'set' first and could otherwise raise
|
|
469
|
+
# NoMethodError. This also keeps Lint/RedundantRequireStatement satisfied.
|
|
470
|
+
Set.new(names)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Determine whether a table identifier is schema-qualified.
|
|
474
|
+
#
|
|
475
|
+
# Qualified identifiers are not present in db.tables (which lists unqualified
|
|
476
|
+
# names from the current search_path), so #table_exists? routes them to a
|
|
477
|
+
# schema-aware probe instead of the Set match.
|
|
478
|
+
#
|
|
479
|
+
# Recognizes Sequel's qualified forms: a QualifiedIdentifier (from
|
|
480
|
+
# Sequel.qualify) and the implicit-qualification Symbol form :schema__table.
|
|
481
|
+
# A String with underscores is a literal name, not a qualification.
|
|
482
|
+
#
|
|
483
|
+
# @param table_name [Object] Table identifier
|
|
484
|
+
# @return [Boolean] True if schema-qualified
|
|
485
|
+
def qualified_table_name?(table_name)
|
|
486
|
+
return true if defined?(Sequel::SQL::QualifiedIdentifier) &&
|
|
487
|
+
table_name.is_a?(Sequel::SQL::QualifiedIdentifier)
|
|
488
|
+
|
|
489
|
+
table_name.is_a?(Symbol) && table_name.to_s.include?('__')
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# Resolve table_guard_mode to its symbol value, or nil when it is a
|
|
493
|
+
# block/Proc handler.
|
|
494
|
+
#
|
|
495
|
+
# A block handler (arity > 0) cannot be evaluated without arguments, and a
|
|
496
|
+
# 0-arity Proc is a custom handler rather than a mode symbol; in both cases
|
|
497
|
+
# there is no symbol to compare against, so we return nil. This lets callers
|
|
498
|
+
# do `%i[raise halt exit].include?(table_guard_mode_symbol)` safely instead
|
|
499
|
+
# of invoking table_guard_mode with the wrong arity.
|
|
500
|
+
#
|
|
501
|
+
# @return [Symbol, nil] the configured mode symbol, or nil for block/Proc handlers
|
|
502
|
+
def table_guard_mode_symbol
|
|
503
|
+
return nil if method(:table_guard_mode).arity > 0
|
|
504
|
+
|
|
505
|
+
value = table_guard_mode
|
|
506
|
+
value.is_a?(Proc) ? nil : value
|
|
507
|
+
end
|
|
508
|
+
|
|
417
509
|
# Handle column validation based on mode setting
|
|
418
510
|
#
|
|
419
511
|
# @param missing_cols [Array<Hash>] Missing column information
|
|
@@ -608,22 +700,38 @@ module Rodauth
|
|
|
608
700
|
return
|
|
609
701
|
end
|
|
610
702
|
|
|
611
|
-
#
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
#
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
#
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
703
|
+
# Enumerate every table the enabled features' ERB templates create —
|
|
704
|
+
# including "hidden" tables such as account_statuses and
|
|
705
|
+
# account_password_hashes that have no *_table method (RT-09) — and drop
|
|
706
|
+
# them in FK-dependency order via the generator (the same path :sync
|
|
707
|
+
# already uses). The previous code dropped only the discovered *_table
|
|
708
|
+
# names in reversed hash order, so it left the hidden tables in place and
|
|
709
|
+
# the recreate step then failed with "table account_statuses already
|
|
710
|
+
# exists", making :recreate unusable with the default schema.
|
|
711
|
+
features = enabled_template_features
|
|
712
|
+
|
|
713
|
+
rodauth_info("[table_guard] Recreating tables for #{features.size} feature(s) " \
|
|
714
|
+
'(dropping all, creating fresh)...')
|
|
715
|
+
|
|
716
|
+
# Wrap the whole drop+create cycle in one transaction so a failure
|
|
717
|
+
# part-way through cannot leave a partially dropped schema (RT-08).
|
|
718
|
+
# Transactional DDL is a no-op on MySQL (it auto-commits DDL), but it
|
|
719
|
+
# makes PostgreSQL and SQLite atomic, which is exactly where an
|
|
720
|
+
# out-of-order drop would otherwise destroy data and then fail.
|
|
721
|
+
db.transaction do
|
|
722
|
+
generator.execute_drops(db, features: features)
|
|
723
|
+
|
|
724
|
+
# Every required table is now missing, so recreate them all from the
|
|
725
|
+
# templates (base.erb brings back the hidden tables too).
|
|
726
|
+
current_missing = missing_tables
|
|
727
|
+
current_missing_cols = missing_columns
|
|
728
|
+
if current_missing.any? || current_missing_cols.any?
|
|
729
|
+
generator_for_all = Rodauth::SequelGenerator.new(current_missing, self, current_missing_cols)
|
|
730
|
+
generator_for_all.execute_creates(db)
|
|
731
|
+
end
|
|
624
732
|
end
|
|
625
733
|
|
|
626
|
-
rodauth_info("[table_guard] Recreated #{
|
|
734
|
+
rodauth_info("[table_guard] Recreated tables for #{features.size} feature(s)")
|
|
627
735
|
|
|
628
736
|
# Re-validate to show success message
|
|
629
737
|
revalidate_after_creation
|
|
@@ -638,17 +746,24 @@ module Rodauth
|
|
|
638
746
|
return
|
|
639
747
|
end
|
|
640
748
|
|
|
641
|
-
#
|
|
642
|
-
|
|
749
|
+
# Drop every table the enabled features' templates create, hidden
|
|
750
|
+
# tables included (RT-09), in FK-dependency order via the generator.
|
|
751
|
+
features = enabled_template_features
|
|
752
|
+
|
|
753
|
+
rodauth_info("[table_guard] Dropping tables for #{features.size} feature(s)...")
|
|
643
754
|
|
|
644
|
-
#
|
|
645
|
-
|
|
646
|
-
|
|
755
|
+
# One transaction for the whole drop so it is atomic (RT-08).
|
|
756
|
+
db.transaction do
|
|
757
|
+
generator.execute_drops(db, features: features)
|
|
647
758
|
|
|
648
|
-
|
|
649
|
-
|
|
759
|
+
# Drop Sequel migration tracking tables so migrations re-run from
|
|
760
|
+
# scratch. These are independent leaf tables with no ordering
|
|
761
|
+
# constraints, so the simple helper is fine; keeping them in the same
|
|
762
|
+
# transaction makes the whole :drop atomic.
|
|
763
|
+
drop_tables(%i[schema_info schema_migrations])
|
|
764
|
+
end
|
|
650
765
|
|
|
651
|
-
rodauth_info("[table_guard] Dropped #{
|
|
766
|
+
rodauth_info("[table_guard] Dropped tables for #{features.size} feature(s) and migration tracking")
|
|
652
767
|
rodauth_info('[table_guard] Migrations will run from scratch on next execution')
|
|
653
768
|
|
|
654
769
|
else
|
|
@@ -657,7 +772,10 @@ module Rodauth
|
|
|
657
772
|
rescue StandardError => e
|
|
658
773
|
rodauth_error("[table_guard] Sequel generation failed: #{e.class} - #{e.message}")
|
|
659
774
|
rodauth_error(" Location: #{e.backtrace.first}")
|
|
660
|
-
|
|
775
|
+
# Use the resolved symbol mode: calling table_guard_mode directly would
|
|
776
|
+
# raise ArgumentError here when the user configured a block handler
|
|
777
|
+
# (arity > 0), masking the real error `e` we are trying to surface.
|
|
778
|
+
raise if %i[raise halt exit].include?(table_guard_mode_symbol)
|
|
661
779
|
end
|
|
662
780
|
|
|
663
781
|
# Check if the database supports CASCADE on DELETE
|
|
@@ -667,13 +785,37 @@ module Rodauth
|
|
|
667
785
|
%i[postgres mysql].include?(db.database_type)
|
|
668
786
|
end
|
|
669
787
|
|
|
670
|
-
#
|
|
788
|
+
# Feature names (matching ERB template basenames) for every discovered
|
|
789
|
+
# required table, de-duplicated.
|
|
790
|
+
#
|
|
791
|
+
# Used by :recreate/:drop to enumerate the full set of tables to drop from
|
|
792
|
+
# the templates — including hidden tables like account_statuses — rather
|
|
793
|
+
# than only the discovered *_table names. A feature whose template is
|
|
794
|
+
# missing simply contributes no tables (TemplateInspector returns [] for
|
|
795
|
+
# it), which is consistent with the create path, which likewise cannot
|
|
796
|
+
# build a table it has no template for.
|
|
797
|
+
#
|
|
798
|
+
# @return [Array<Symbol>] Enabled feature names that own required tables
|
|
799
|
+
def enabled_template_features
|
|
800
|
+
table_configuration.map { |_, info| info[:feature] }.compact.uniq
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
# Drop a set of independent tables (no inter-table foreign keys), with
|
|
804
|
+
# CASCADE where the adapter supports it.
|
|
805
|
+
#
|
|
806
|
+
# This helper does NOT order for foreign-key dependencies. The destructive
|
|
807
|
+
# sequel modes route their FK-ordered drops through
|
|
808
|
+
# SequelGenerator#execute_drops, which enumerates the templates (hidden
|
|
809
|
+
# tables included) and drops child-before-parent. This helper is now used
|
|
810
|
+
# only for the Sequel migration-tracking tables (:schema_info,
|
|
811
|
+
# :schema_migrations) in :drop mode, which have no ordering constraints. It
|
|
812
|
+
# opens no transaction of its own, so a caller can wrap it (together with
|
|
813
|
+
# execute_drops) in a single transaction for atomicity (RT-08).
|
|
671
814
|
#
|
|
672
|
-
# SQLite doesn't support CASCADE on DROP TABLE, so we
|
|
673
|
-
#
|
|
674
|
-
# ensures dependent objects are properly cleaned up.
|
|
815
|
+
# SQLite doesn't support CASCADE on DROP TABLE, so we detect the database
|
|
816
|
+
# type and avoid it there.
|
|
675
817
|
#
|
|
676
|
-
# @param table_names [Array<String, Symbol>]
|
|
818
|
+
# @param table_names [Array<String, Symbol>] Independent tables to drop
|
|
677
819
|
def drop_tables(table_names)
|
|
678
820
|
table_names.each do |table_name|
|
|
679
821
|
next unless db.table_exists?(table_name)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# lib/rodauth/secret_guard.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
|
|
7
|
+
module Rodauth
|
|
8
|
+
# Shared, secret-kind-parameterized logic behind the +hmac_secret_guard+ and
|
|
9
|
+
# +jwt_secret_guard+ features.
|
|
10
|
+
#
|
|
11
|
+
# The two guard features are nearly identical; the only thing that differs is
|
|
12
|
+
# the "kind" of secret they manage (+:hmac+ or +:jwt+) and the names of the
|
|
13
|
+
# configuration methods that carry that kind as a prefix
|
|
14
|
+
# (+hmac_secret_env_key+ vs +jwt_secret_env_key+, and so on).
|
|
15
|
+
#
|
|
16
|
+
# Keeping this logic in one place — and taking +kind+ as an explicit argument
|
|
17
|
+
# rather than baking it into method names — is what lets both features be
|
|
18
|
+
# enabled at the same time. Each feature's +post_configure+ calls into these
|
|
19
|
+
# helpers with its own +kind+, so both secrets are validated at boot. When the
|
|
20
|
+
# per-feature methods shared a name (the previous design), enabling both
|
|
21
|
+
# guards meant one definition shadowed the other and only a single secret was
|
|
22
|
+
# ever validated.
|
|
23
|
+
#
|
|
24
|
+
# These are plain module functions that take the Rodauth instance explicitly
|
|
25
|
+
# (rather than being mixed in) so there is no method-name surface to collide
|
|
26
|
+
# in the first place.
|
|
27
|
+
module SecretGuard
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Auto-populate +<kind>_secret+ from its environment variable when it has
|
|
31
|
+
# not been configured explicitly.
|
|
32
|
+
#
|
|
33
|
+
# The variable is read with +ENV.delete+ so the raw secret does not linger
|
|
34
|
+
# in the process environment after boot. A blank (nil/empty/whitespace-only)
|
|
35
|
+
# value is treated as absent and leaves the secret unset for +validate!+ to
|
|
36
|
+
# handle.
|
|
37
|
+
#
|
|
38
|
+
# @param rodauth [Rodauth::Auth] the Rodauth instance being configured
|
|
39
|
+
# @param kind [Symbol] the secret kind (+:hmac+ or +:jwt+)
|
|
40
|
+
# @return [void]
|
|
41
|
+
def load_from_env!(rodauth, kind)
|
|
42
|
+
return unless blank?(rodauth.send(:"#{kind}_secret"))
|
|
43
|
+
|
|
44
|
+
raw = ENV.delete(rodauth.send(:"#{kind}_secret_env_key"))
|
|
45
|
+
value = raw&.strip
|
|
46
|
+
return if value.nil? || value.empty?
|
|
47
|
+
|
|
48
|
+
define_secret(rodauth, kind, value)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Validate that +<kind>_secret+ is usable.
|
|
52
|
+
#
|
|
53
|
+
# In production a missing (nil/empty/whitespace-only) or too-short secret
|
|
54
|
+
# raises +Rodauth::ConfigurationError+ — the guard fails closed. Outside
|
|
55
|
+
# production a missing secret logs a warning and falls back to the
|
|
56
|
+
# (ephemeral, per-process) development fallback.
|
|
57
|
+
#
|
|
58
|
+
# @param rodauth [Rodauth::Auth] the Rodauth instance being configured
|
|
59
|
+
# @param kind [Symbol] the secret kind (+:hmac+ or +:jwt+)
|
|
60
|
+
# @raise [Rodauth::ConfigurationError] when the secret is unusable in production
|
|
61
|
+
# @return [void]
|
|
62
|
+
def validate!(rodauth, kind)
|
|
63
|
+
current = rodauth.send(:"#{kind}_secret")
|
|
64
|
+
|
|
65
|
+
unless blank?(current)
|
|
66
|
+
enforce_minimum_length!(rodauth, kind, current)
|
|
67
|
+
return
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
raise Rodauth::ConfigurationError, rodauth.send(:"#{kind}_secret_missing_error") if rodauth.production?
|
|
71
|
+
|
|
72
|
+
warn(rodauth, rodauth.send(:"#{kind}_secret_dev_warning"))
|
|
73
|
+
define_secret(rodauth, kind, rodauth.send(:"development_#{kind}_secret_fallback"))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Resolve the configured production check into a boolean.
|
|
77
|
+
#
|
|
78
|
+
# A Proc is evaluated in the Rodauth instance context (so it can read config
|
|
79
|
+
# methods); anything else is coerced with +!!+.
|
|
80
|
+
#
|
|
81
|
+
# @param rodauth [Rodauth::Auth] the Rodauth instance being configured
|
|
82
|
+
# @return [Boolean]
|
|
83
|
+
def production?(rodauth)
|
|
84
|
+
check = rodauth.send(:production_env_check)
|
|
85
|
+
check.is_a?(Proc) ? rodauth.instance_exec(&check) : !!check
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Enforce +minimum_secret_length+ when it is configured (> 0).
|
|
89
|
+
#
|
|
90
|
+
# Only applied in production so development fallbacks and short test secrets
|
|
91
|
+
# are unaffected. Disabled by default.
|
|
92
|
+
#
|
|
93
|
+
# @return [void]
|
|
94
|
+
def enforce_minimum_length!(rodauth, kind, value)
|
|
95
|
+
minimum = rodauth.send(:minimum_secret_length).to_i
|
|
96
|
+
return if minimum <= 0
|
|
97
|
+
return unless rodauth.production?
|
|
98
|
+
return if value.to_s.strip.length >= minimum
|
|
99
|
+
|
|
100
|
+
# Name the secret generically and cite both configuration avenues: the
|
|
101
|
+
# value may have come from the env var OR from the #{kind}_secret DSL
|
|
102
|
+
# method, so blaming the env key alone would mislead when a short secret
|
|
103
|
+
# was configured directly.
|
|
104
|
+
key = rodauth.send(:"#{kind}_secret_env_key")
|
|
105
|
+
raise Rodauth::ConfigurationError,
|
|
106
|
+
"#{kind.to_s.upcase} secret must be at least #{minimum} characters in production " \
|
|
107
|
+
"(set via #{key} or #{kind}_secret)"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @return [Boolean] true when the value is nil, empty, or whitespace-only
|
|
111
|
+
def blank?(value)
|
|
112
|
+
value.nil? || value.to_s.strip.empty?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Redefine +<kind>_secret+ on the Rodauth subclass to return +value+.
|
|
116
|
+
#
|
|
117
|
+
# This mirrors how Rodauth features memoize resolved config: the auth class
|
|
118
|
+
# is per-configuration and this runs once, single-threaded, at boot.
|
|
119
|
+
#
|
|
120
|
+
# @return [void]
|
|
121
|
+
def define_secret(rodauth, kind, value)
|
|
122
|
+
rodauth.class.send(:define_method, :"#{kind}_secret") { value }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Emit a development warning via the Rodauth logger when present, otherwise
|
|
126
|
+
# to stderr.
|
|
127
|
+
#
|
|
128
|
+
# @return [void]
|
|
129
|
+
def warn(rodauth, message)
|
|
130
|
+
if rodauth.respond_to?(:logger) && rodauth.logger
|
|
131
|
+
rodauth.logger.warn(message)
|
|
132
|
+
else
|
|
133
|
+
Kernel.warn(message)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -263,12 +263,22 @@ module Rodauth
|
|
|
263
263
|
|
|
264
264
|
# Execute DROP TABLE operations directly against the database
|
|
265
265
|
#
|
|
266
|
-
# Uses TemplateInspector to extract ALL tables from ERB templates
|
|
266
|
+
# Uses TemplateInspector to extract ALL tables from ERB templates,
|
|
267
|
+
# including "hidden" tables (account_statuses, account_password_hashes)
|
|
268
|
+
# that have no corresponding *_table method.
|
|
269
|
+
#
|
|
270
|
+
# By default the feature set is derived from missing_tables (the :sync
|
|
271
|
+
# path). Callers that need to drop the full schema regardless of what is
|
|
272
|
+
# currently missing — :recreate and :drop, where nothing may be "missing" —
|
|
273
|
+
# pass an explicit +features+ list so the template enumeration still covers
|
|
274
|
+
# every enabled feature (and therefore the hidden tables).
|
|
267
275
|
#
|
|
268
276
|
# @param db [Sequel::Database] Database connection
|
|
269
|
-
|
|
277
|
+
# @param features [Array<Symbol>, nil] Explicit feature list to enumerate;
|
|
278
|
+
# when nil, features are inferred from missing_tables
|
|
279
|
+
def execute_drops(db, features: nil)
|
|
270
280
|
# Extract all tables from ERB templates
|
|
271
|
-
all_tables = extract_all_tables_from_templates
|
|
281
|
+
all_tables = extract_all_tables_from_templates(features: features)
|
|
272
282
|
|
|
273
283
|
# Drop in reverse order to handle foreign key dependencies
|
|
274
284
|
ordered_tables = order_tables_for_drop(all_tables).reverse
|
|
@@ -336,9 +346,11 @@ module Rodauth
|
|
|
336
346
|
# like account_statuses and account_password_hashes that don't have
|
|
337
347
|
# corresponding *_table methods in Rodauth.
|
|
338
348
|
#
|
|
349
|
+
# @param features [Array<Symbol>, nil] Explicit feature list; when nil,
|
|
350
|
+
# features are inferred from missing_tables (the :sync/codegen default)
|
|
339
351
|
# @return [Array<Symbol>] Array of all table names
|
|
340
|
-
def extract_all_tables_from_templates
|
|
341
|
-
features
|
|
352
|
+
def extract_all_tables_from_templates(features: nil)
|
|
353
|
+
features ||= extract_features_from_missing_tables
|
|
342
354
|
table_prefix = extract_table_prefix
|
|
343
355
|
db_type = extract_db_type
|
|
344
356
|
|
|
@@ -97,7 +97,7 @@ module Rodauth
|
|
|
97
97
|
next unless feature_module
|
|
98
98
|
|
|
99
99
|
# Check if this feature module defines the table method
|
|
100
|
-
return feature_name if feature_module.
|
|
100
|
+
return feature_name if feature_module.method_defined?(method_name, false)
|
|
101
101
|
end
|
|
102
102
|
|
|
103
103
|
# Fallback: try to infer from method name if not found in any feature
|