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.
@@ -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-nil, non-empty value. In production mode, it raises a
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
- # production_env_check proc { ENV['RACK_ENV'] == 'production' }
25
- # # Or use a boolean:
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 :development_jwt_secret_fallback,
53
- 'dev-only-insecure-example-jwt-secret-needs-to-be-changed-in-prod'
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
- # Auto-set jwt_secret if not already set
65
- if jwt_secret.nil? || (jwt_secret.respond_to?(:empty?) && jwt_secret.empty?)
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
- case v = production_env_check
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
- # Raises ConfigurationError in production if secret is missing.
89
- # In development, logs a warning and sets a fallback secret.
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 missing in production
111
+ # @raise [Rodauth::ConfigurationError] if jwt_secret is unusable in production
92
112
  # @return [void]
93
- def validate_secrets!
94
- # Get the current jwt_secret value (may be nil)
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
- private
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 warn_dev_secret
113
- if respond_to?(:logger) && logger
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
- # Temporarily suppresses Sequel's logger to avoid confusing error logs
226
- # when checking non-existent tables (Sequel logs SQLite exceptions before
227
- # catching them internally).
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
- # @param table_name [String, Symbol] Table name
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
- return true if table_guard_skip_tables.include?(table_name.to_sym) ||
233
- table_guard_skip_tables.include?(table_name.to_s)
234
-
235
- # Temporarily suppress Sequel's logger to prevent confusing error logs
236
- # during table existence checks. Sequel's table_exists? implementation
237
- # attempts a SELECT query and logs the exception if table doesn't exist,
238
- # even though it catches the error internally.
239
- original_logger = db.loggers.dup
240
- db.loggers.clear
241
-
242
- db.table_exists?(table_name)
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
- # Get all required tables from configuration
612
- all_tables = table_configuration.map { |_, info| info[:name] }.uniq
613
-
614
- # Drop all existing tables in reverse dependency order
615
- rodauth_info("[table_guard] Recreating #{all_tables.size} table(s) (dropping all, creating fresh)...")
616
- drop_tables(all_tables.reverse)
617
-
618
- # Create all tables fresh (uses missing_tables which should now be all of them)
619
- current_missing = missing_tables
620
- current_missing_cols = missing_columns
621
- if current_missing.any? || current_missing_cols.any?
622
- generator_for_all = Rodauth::SequelGenerator.new(current_missing, self, current_missing_cols)
623
- generator_for_all.execute_creates(db)
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 #{all_tables.size} table(s)")
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
- # Get all required tables from configuration
642
- all_tables = table_configuration.map { |_, info| info[:name] }.uniq
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
- # Drop all existing tables in reverse dependency order
645
- rodauth_info("[table_guard] Dropping #{all_tables.size} table(s)...")
646
- drop_tables(all_tables.reverse)
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
- # Drop Sequel migration tracking tables so migrations re-run from scratch
649
- drop_tables(%i[schema_info schema_migrations])
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 #{all_tables.size} table(s) and migration tracking")
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
- raise if %i[raise halt exit].include?(table_guard_mode)
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
- # Drop tables with proper CASCADE handling for non-SQLite databases
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 need to detect
673
- # the database type and avoid using it. For other databases, CASCADE
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>] Tables to drop
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
- def execute_drops(db)
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 = extract_features_from_missing_tables
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.instance_methods(false).include?(method_name)
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