api_keys 0.2.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/README.md +851 -25
  4. data/SECURITY.md +33 -0
  5. data/app/controllers/api_keys/application_controller.rb +58 -10
  6. data/app/controllers/api_keys/keys_controller.rb +77 -23
  7. data/app/controllers/api_keys/security_controller.rb +8 -0
  8. data/app/views/api_keys/keys/_empty_state.html.erb +9 -0
  9. data/app/views/api_keys/keys/_form.html.erb +33 -4
  10. data/app/views/api_keys/keys/_key_actions.html.erb +20 -0
  11. data/app/views/api_keys/keys/_key_badges.html.erb +17 -0
  12. data/app/views/api_keys/keys/_key_row.html.erb +21 -35
  13. data/app/views/api_keys/keys/_key_status.html.erb +10 -0
  14. data/app/views/api_keys/keys/_keys_table.html.erb +3 -11
  15. data/app/views/api_keys/keys/_publishable_keys.html.erb +40 -0
  16. data/app/views/api_keys/keys/_secret_keys.html.erb +39 -0
  17. data/app/views/api_keys/keys/_show_token.html.erb +10 -47
  18. data/app/views/api_keys/keys/_token_display.html.erb +11 -0
  19. data/app/views/api_keys/keys/index.html.erb +40 -8
  20. data/app/views/api_keys/keys/show.html.erb +2 -2
  21. data/app/views/api_keys/security/best_practices.html.erb +73 -47
  22. data/app/views/layouts/api_keys/application.html.erb +267 -14
  23. data/lib/api_keys/authentication.rb +39 -11
  24. data/lib/api_keys/configuration.rb +444 -17
  25. data/lib/api_keys/engine.rb +5 -20
  26. data/lib/api_keys/errors.rb +73 -0
  27. data/lib/api_keys/form_builder_extensions.rb +168 -0
  28. data/lib/api_keys/helpers/expiration_options.rb +139 -0
  29. data/lib/api_keys/helpers/token_session.rb +203 -0
  30. data/lib/api_keys/helpers/view_helpers.rb +220 -0
  31. data/lib/api_keys/jobs/callbacks_job.rb +10 -17
  32. data/lib/api_keys/jobs/update_stats_job.rb +27 -12
  33. data/lib/api_keys/models/api_key.rb +452 -21
  34. data/lib/api_keys/models/concerns/has_api_keys.rb +269 -26
  35. data/lib/api_keys/services/authenticator.rb +300 -112
  36. data/lib/api_keys/services/digestor.rb +81 -14
  37. data/lib/api_keys/services/token_generator.rb +41 -1
  38. data/lib/api_keys/tenant_resolution.rb +4 -4
  39. data/lib/api_keys/version.rb +1 -1
  40. data/lib/api_keys.rb +12 -0
  41. data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
  42. data/lib/generators/api_keys/add_key_types_generator.rb +68 -0
  43. data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
  44. data/lib/generators/api_keys/templates/add_key_types_to_api_keys.rb.erb +18 -0
  45. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +11 -3
  46. data/lib/generators/api_keys/templates/initializer.rb +261 -120
  47. metadata +29 -63
  48. data/Rakefile +0 -32
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/core_ext/numeric/time"
4
+ require "active_support/core_ext/string/inflections"
4
5
  require "active_support/security_utils"
5
6
 
6
7
  module ApiKeys
@@ -9,51 +10,467 @@ module ApiKeys
9
10
  class Configuration
10
11
  # Default empty callback proc
11
12
  DEFAULT_CALLBACK = ->(_context){}.freeze
13
+ DEFAULT_PARENT_CONTROLLER = "::ApplicationController"
12
14
 
13
15
  # == Accessors ==
14
16
 
15
17
  # Core Authentication
16
- attr_accessor :header, :query_param
18
+ attr_reader :header, :query_param
17
19
 
18
20
  # Token Generation
19
- attr_accessor :token_prefix, :token_length, :token_alphabet
21
+ attr_reader :token_prefix, :token_length, :token_alphabet
20
22
 
21
23
  # Storage & Verification
22
- attr_accessor :hash_strategy, :secure_compare_proc, :key_store_adapter, :policy_provider
24
+ attr_reader :hash_strategy
25
+ attr_reader :secure_compare_proc
26
+ attr_accessor :key_store_adapter, :policy_provider
23
27
 
24
28
  # Engine Configuration
25
- attr_accessor :parent_controller
29
+ attr_reader :parent_controller
26
30
 
27
31
  # Owner Context Configuration
28
- attr_accessor :current_owner_method, :authenticate_owner_method
32
+ attr_reader :current_owner_method, :authenticate_owner_method
29
33
 
30
34
  # Optional Behaviors
31
- attr_accessor :default_max_keys_per_owner, :require_key_name
32
- attr_accessor :expire_after, :default_scopes, :track_requests_count
35
+ attr_reader :default_max_keys_per_owner, :require_key_name
36
+ attr_reader :expire_after, :default_scopes, :track_requests_count
33
37
 
34
38
  # Performance
35
- attr_accessor :cache_ttl
39
+ attr_reader :cache_ttl, :stats_update_interval
36
40
 
37
41
  # Security
38
- attr_accessor :https_only_production, :https_strict_mode
42
+ attr_reader :https_only_production, :https_strict_mode
39
43
 
40
44
  # Tenant Resolution
41
- attr_accessor :tenant_resolver
45
+ attr_reader :tenant_resolver
42
46
 
43
47
  # Callbacks (Placeholders for future extension)
44
- attr_accessor :before_authentication, :after_authentication
48
+ attr_reader :before_authentication, :after_authentication
45
49
 
46
50
  # Background Job Queues
47
- attr_accessor :stats_job_queue, :callbacks_job_queue
51
+ attr_reader :stats_job_queue, :callbacks_job_queue
48
52
 
49
53
  # Global Async Toggle
50
- attr_accessor :enable_async_operations
54
+ attr_reader :enable_async_operations
51
55
 
52
56
  # Engine UI Configuration
53
57
  attr_accessor :return_url, :return_text
54
58
 
55
59
  # Debugging
56
- attr_accessor :debug_logging
60
+ attr_reader :debug_logging
61
+
62
+ # Key Types & Environments (Stripe-style publishable/secret keys)
63
+ #
64
+ # @!attribute [rw] key_types
65
+ # @return [Hash] Key type definitions. Each key type has:
66
+ # - :prefix [String] Token prefix (e.g., "pk" → pk_test_)
67
+ # - :permissions [Array<String>, :all] Scope ceiling for this type
68
+ # - :revocable [Boolean] Whether keys can be revoked (default: true)
69
+ # - :limit [Integer, nil] Max keys per owner per environment (nil = unlimited)
70
+ # - :public [Boolean] If true AND revocable: false, store plaintext token in
71
+ # metadata so it can be viewed again in dashboard. Use ONLY for publishable
72
+ # keys that are designed to be embedded in distributed apps. (default: false)
73
+ # @example
74
+ # config.key_types = {
75
+ # publishable: { prefix: "pk", permissions: %w[read], revocable: false, public: true, limit: 1 },
76
+ # secret: { prefix: "sk", permissions: :all }
77
+ # }
78
+ #
79
+ # @!attribute [rw] environments
80
+ # @return [Hash] Environment definitions. Each environment has:
81
+ # - :prefix_segment [String, nil] Middle part of prefix (e.g., "test" → pk_test_)
82
+ # @example
83
+ # config.environments = { test: { prefix_segment: "test" }, live: { prefix_segment: "live" } }
84
+ #
85
+ # @!attribute [rw] current_environment
86
+ # @return [Proc, Symbol] Lambda or symbol returning current environment
87
+ # @example
88
+ # config.current_environment = -> { Rails.env.production? ? :live : :test }
89
+ #
90
+ # @!attribute [rw] strict_environment_isolation
91
+ # @return [Boolean] If true, keys only work in their matching environment
92
+ #
93
+ # @!attribute [rw] default_key_type
94
+ # @return [Symbol, nil] Default key type when not specified in create_api_key!
95
+ # @example
96
+ # config.default_key_type = :secret
97
+ #
98
+ # @!attribute [rw] dashboard_allow_cross_environment
99
+ # @return [Boolean] If true, dashboard shows keys from all environments.
100
+ # If false (default), dashboard only shows keys matching current_environment.
101
+ # @example
102
+ # config.dashboard_allow_cross_environment = false
103
+ attr_reader :environments, :current_environment, :strict_environment_isolation,
104
+ :default_key_type, :dashboard_allow_cross_environment
105
+
106
+ # Custom writer for key_types that validates prefix uniqueness
107
+ attr_reader :key_types
108
+
109
+ VALID_HASH_STRATEGIES = %i[sha256 bcrypt].freeze
110
+ VALID_TOKEN_ALPHABETS = %i[base58 hex].freeze
111
+ TOKEN_LENGTH_RANGE = (16..64)
112
+ MAX_CONFIGURED_SCOPES = 100
113
+ CONFIG_NAME_PATTERN = /\A[a-zA-Z0-9_-]{1,64}\z/
114
+ HTTP_HEADER_PATTERN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]{1,128}\z/
115
+ QUERY_PARAM_PATTERN = /\A[a-zA-Z0-9_.~-]{1,128}\z/
116
+ METHOD_NAME_PATTERN = /\A[a-zA-Z_]\w*[!?]?\z/
117
+ CONSTANT_NAME_PATTERN = /\A(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*\z/
118
+ BOOLEAN_SETTINGS = %i[
119
+ require_key_name track_requests_count https_only_production https_strict_mode
120
+ enable_async_operations debug_logging strict_environment_isolation
121
+ dashboard_allow_cross_environment
122
+ ].freeze
123
+
124
+ def header=(value)
125
+ unless value.nil? || (value.is_a?(String) && value.match?(HTTP_HEADER_PATTERN))
126
+ raise ArgumentError, "header must be nil or a valid HTTP header name of at most 128 characters"
127
+ end
128
+
129
+ @header = value&.dup&.freeze
130
+ end
131
+
132
+ def query_param=(value)
133
+ unless value.nil? || (value.is_a?(String) && value.match?(QUERY_PARAM_PATTERN))
134
+ raise ArgumentError, "query_param must be nil or a safe parameter name of at most 128 characters"
135
+ end
136
+
137
+ @query_param = value&.dup&.freeze
138
+ end
139
+
140
+ def default_max_keys_per_owner=(value)
141
+ unless value.nil? || (value.is_a?(Integer) && value >= 0)
142
+ raise ArgumentError, "default_max_keys_per_owner must be a non-negative Integer or nil"
143
+ end
144
+
145
+ @default_max_keys_per_owner = value
146
+ end
147
+
148
+ def expire_after=(value)
149
+ unless value.nil?
150
+ seconds = value.to_f if value.respond_to?(:to_f)
151
+ valid = value.respond_to?(:from_now) && seconds&.finite? && seconds.positive?
152
+ raise ArgumentError, "expire_after must be a positive duration or nil" unless valid
153
+ end
154
+
155
+ @expire_after = value
156
+ end
157
+
158
+ def default_scopes=(value)
159
+ unless value.is_a?(Array) && value.length <= MAX_CONFIGURED_SCOPES && value.all? { |scope| valid_scope_name?(scope) }
160
+ raise ArgumentError, "default_scopes must be a bounded Array of safe scope strings"
161
+ end
162
+
163
+ @default_scopes = deep_copy_and_freeze(value.uniq)
164
+ end
165
+
166
+ def cache_ttl=(value)
167
+ unless value.nil?
168
+ seconds = value.to_f if value.respond_to?(:to_f)
169
+ valid = (value.is_a?(Numeric) || value.respond_to?(:from_now)) && seconds&.finite? && !seconds.negative?
170
+ raise ArgumentError, "cache_ttl must be a finite non-negative duration/number or nil" unless valid
171
+ end
172
+
173
+ @cache_ttl = value
174
+ end
175
+
176
+ def stats_update_interval=(value)
177
+ unless value.nil?
178
+ seconds = value.to_f if value.respond_to?(:to_f)
179
+ valid = (value.is_a?(Numeric) || value.respond_to?(:from_now)) && seconds&.finite? && !seconds.negative?
180
+ raise ArgumentError, "stats_update_interval must be a finite non-negative duration/number or nil" unless valid
181
+ end
182
+
183
+ @stats_update_interval = value
184
+ end
185
+
186
+ def parent_controller=(value)
187
+ valid = value.is_a?(Class) || (value.is_a?(String) && value.match?(CONSTANT_NAME_PATTERN))
188
+ raise ArgumentError, "parent_controller must be a controller Class or a valid constant name" unless valid
189
+
190
+ @parent_controller = value.is_a?(String) ? value.dup.freeze : value
191
+ @parent_controller_explicitly_configured = true
192
+ end
193
+
194
+ def parent_controller_class
195
+ candidate = if !@parent_controller_explicitly_configured && defined?(ApiKeys::Engine) &&
196
+ ApiKeys::Engine.config.parent_controller.present?
197
+ ApiKeys::Engine.config.parent_controller
198
+ else
199
+ parent_controller
200
+ end
201
+ candidate.is_a?(Class) ? candidate : candidate.constantize
202
+ end
203
+
204
+ BOOLEAN_SETTINGS.each do |setting|
205
+ define_method("#{setting}=") do |value|
206
+ raise ArgumentError, "#{setting} must be true or false" unless value == true || value == false
207
+
208
+ instance_variable_set("@#{setting}", value)
209
+ end
210
+ end
211
+
212
+ def before_authentication=(value)
213
+ validate_callback!(value, :before_authentication)
214
+ @before_authentication = value
215
+ end
216
+
217
+ def after_authentication=(value)
218
+ validate_callback!(value, :after_authentication)
219
+ @after_authentication = value
220
+ end
221
+
222
+ def tenant_resolver=(value)
223
+ raise ArgumentError, "tenant_resolver must be callable" unless value.respond_to?(:call)
224
+
225
+ @tenant_resolver = value
226
+ end
227
+
228
+ def secure_compare_proc=(value)
229
+ raise ArgumentError, "secure_compare_proc must be callable" unless value.respond_to?(:call)
230
+
231
+ @secure_compare_proc = value
232
+ end
233
+
234
+ def stats_job_queue=(value)
235
+ @stats_job_queue = validate_queue_name!(value, :stats_job_queue)
236
+ end
237
+
238
+ def callbacks_job_queue=(value)
239
+ @callbacks_job_queue = validate_queue_name!(value, :callbacks_job_queue)
240
+ end
241
+
242
+ def current_owner_method=(value)
243
+ @current_owner_method = validate_method_name!(value, :current_owner_method)
244
+ end
245
+
246
+ def authenticate_owner_method=(value)
247
+ @authenticate_owner_method = validate_method_name!(value, :authenticate_owner_method)
248
+ end
249
+
250
+ def current_environment=(value)
251
+ unless value.nil? || value.respond_to?(:call) || valid_config_name?(value)
252
+ raise ArgumentError, "current_environment must be callable, a safe String/Symbol, or nil"
253
+ end
254
+
255
+ @current_environment = value
256
+ end
257
+
258
+ def default_key_type=(value)
259
+ unless value.nil? || valid_config_name?(value)
260
+ raise ArgumentError, "default_key_type must be a safe String/Symbol or nil"
261
+ end
262
+
263
+ @default_key_type = value
264
+ end
265
+
266
+ def token_prefix=(value)
267
+ unless value.is_a?(String) || value.respond_to?(:call)
268
+ raise ArgumentError, "token_prefix must be a String or callable object"
269
+ end
270
+
271
+ validate_resolved_prefix!(value) if value.is_a?(String)
272
+ @token_prefix = value
273
+ end
274
+
275
+ def resolved_token_prefix
276
+ value = @token_prefix.respond_to?(:call) ? @token_prefix.call : @token_prefix
277
+ validate_resolved_prefix!(value)
278
+ value
279
+ end
280
+
281
+ def token_length=(value)
282
+ unless value.is_a?(Integer) && TOKEN_LENGTH_RANGE.cover?(value)
283
+ raise ArgumentError, "token_length must be an Integer between #{TOKEN_LENGTH_RANGE.begin} and #{TOKEN_LENGTH_RANGE.end}"
284
+ end
285
+
286
+ @token_length = value
287
+ end
288
+
289
+ def token_alphabet=(value)
290
+ unless VALID_TOKEN_ALPHABETS.include?(value)
291
+ raise ArgumentError, "token_alphabet must be one of: #{VALID_TOKEN_ALPHABETS.join(', ')}"
292
+ end
293
+
294
+ @token_alphabet = value
295
+ end
296
+
297
+ def hash_strategy=(value)
298
+ unless VALID_HASH_STRATEGIES.include?(value)
299
+ raise ArgumentError, "hash_strategy must be one of: #{VALID_HASH_STRATEGIES.join(', ')}"
300
+ end
301
+
302
+ @hash_strategy = value
303
+ end
304
+
305
+ # Sets the key types configuration with prefix collision validation.
306
+ # @param value [Hash] Key type definitions
307
+ # @raise [ArgumentError] If multiple key types share the same prefix
308
+ def key_types=(value)
309
+ raise ArgumentError, "key_types must be a Hash" unless value.is_a?(Hash)
310
+
311
+ validate_key_types!(value)
312
+ validate_composite_prefixes!(value, @environments || {})
313
+ @key_types = deep_copy_and_freeze(value)
314
+ end
315
+
316
+ def environments=(value)
317
+ raise ArgumentError, "environments must be a Hash" unless value.is_a?(Hash)
318
+
319
+ value.each do |name, environment_config|
320
+ validate_config_name!(name, "environment")
321
+ raise ArgumentError, "Environment '#{name}' configuration must be a Hash" unless environment_config.is_a?(Hash)
322
+
323
+ segment = environment_config[:prefix_segment]
324
+ validate_config_name!(segment, "environment prefix segment") unless segment.nil?
325
+ end
326
+ validate_duplicate_config_names!(value, "environment")
327
+ validate_composite_prefixes!(@key_types || {}, value)
328
+ @environments = deep_copy_and_freeze(value)
329
+ end
330
+
331
+ private
332
+
333
+ def validate_resolved_prefix!(prefix)
334
+ valid = prefix.is_a?(String) && prefix.present? && prefix.valid_encoding? && prefix.bytesize <= 64 &&
335
+ prefix.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
336
+ return if valid
337
+
338
+ raise ArgumentError, "token_prefix must resolve to a non-blank string of at most 64 bytes without whitespace or control characters"
339
+ rescue ArgumentError
340
+ raise ArgumentError, "token_prefix must resolve to a non-blank string of at most 64 bytes without whitespace or control characters"
341
+ end
342
+
343
+ def validate_key_types!(key_types_hash)
344
+ validate_duplicate_config_names!(key_types_hash, "key type")
345
+
346
+ key_types_hash.each do |name, type_config|
347
+ validate_config_name!(name, "key type")
348
+ raise ArgumentError, "Key type '#{name}' configuration must be a Hash" unless type_config.is_a?(Hash)
349
+
350
+ validate_config_name!(type_config[:prefix], "key type prefix")
351
+ permissions = type_config[:permissions]
352
+ unless permissions == :all || permissions.is_a?(Array)
353
+ raise ArgumentError, "Key type '#{name}' permissions must be :all or an Array of strings"
354
+ end
355
+ if permissions.is_a?(Array) && permissions.length > MAX_CONFIGURED_SCOPES
356
+ raise ArgumentError, "Key type '#{name}' permissions cannot contain more than #{MAX_CONFIGURED_SCOPES} entries"
357
+ end
358
+ if permissions.is_a?(Array) && permissions.any? { |permission| !valid_scope_name?(permission) }
359
+ raise ArgumentError, "Key type '#{name}' permissions must contain only non-blank strings of at most 128 bytes"
360
+ end
361
+
362
+ %i[revocable public].each do |setting|
363
+ next unless type_config.key?(setting)
364
+ next if [true, false].include?(type_config[setting])
365
+
366
+ raise ArgumentError, "Key type '#{name}' #{setting} must be true or false"
367
+ end
368
+
369
+ limit = type_config[:limit]
370
+ if !limit.nil? && (!limit.is_a?(Integer) || limit <= 0)
371
+ raise ArgumentError, "Key type '#{name}' limit must be a positive Integer or nil"
372
+ end
373
+
374
+ next unless type_config[:public] == true
375
+
376
+ unless type_config[:revocable] == false
377
+ raise ArgumentError, "Public key type '#{name}' must explicitly set revocable: false"
378
+ end
379
+ unless permissions.is_a?(Array) && permissions.any?
380
+ raise ArgumentError, "Public key type '#{name}' must have a finite, non-empty permissions list"
381
+ end
382
+ end
383
+
384
+ validate_key_type_prefixes!(key_types_hash)
385
+ end
386
+
387
+ def validate_config_name!(value, label)
388
+ return if valid_config_name?(value)
389
+
390
+ raise ArgumentError, "#{label} must contain only letters, numbers, underscores, or hyphens (1-64 characters)"
391
+ end
392
+
393
+ def valid_config_name?(value)
394
+ (value.is_a?(String) || value.is_a?(Symbol)) && value.to_s.match?(CONFIG_NAME_PATTERN)
395
+ end
396
+
397
+ def valid_scope_name?(value)
398
+ value.is_a?(String) && value.present? && value.valid_encoding? && value.bytesize <= 128 &&
399
+ value.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
400
+ rescue ArgumentError
401
+ false
402
+ end
403
+
404
+ # Validates that all key type prefixes are unique to prevent token collision
405
+ def validate_key_type_prefixes!(key_types_hash)
406
+ prefixes = key_types_hash.map { |_type, config| config[:prefix].to_s }.compact
407
+ duplicates = prefixes.group_by(&:itself).select { |_k, v| v.size > 1 }.keys
408
+
409
+ if duplicates.any?
410
+ raise ArgumentError, "Key type prefixes must be unique. Duplicate prefix(es): #{duplicates.join(', ')}"
411
+ end
412
+ end
413
+
414
+ def validate_duplicate_config_names!(configuration, label)
415
+ duplicates = configuration.keys.map(&:to_s).group_by(&:itself).select { |_name, names| names.length > 1 }.keys
416
+ return if duplicates.empty?
417
+
418
+ raise ArgumentError, "#{label} names must be unique after string normalization: #{duplicates.join(', ')}"
419
+ end
420
+
421
+ def validate_composite_prefixes!(key_types, environments)
422
+ return if key_types.empty?
423
+
424
+ environment_entries = environments.empty? ? [[nil, {}]] : environments.to_a
425
+ combinations = key_types.flat_map do |type_name, type_config|
426
+ environment_entries.map do |environment_name, environment_config|
427
+ segment = environment_config[:prefix_segment]
428
+ prefix = segment.nil? ? "#{type_config[:prefix]}_" : "#{type_config[:prefix]}_#{segment}_"
429
+ [prefix, "#{type_name}/#{environment_name || 'default'}"]
430
+ end
431
+ end
432
+ collisions = combinations.group_by(&:first).select { |_prefix, entries| entries.length > 1 }
433
+ return if collisions.empty?
434
+
435
+ details = collisions.map { |prefix, entries| "#{prefix} (#{entries.map(&:last).join(', ')})" }.join("; ")
436
+ raise ArgumentError, "Key type/environment prefixes must be unique: #{details}"
437
+ end
438
+
439
+ def validate_callback!(value, setting)
440
+ raise ArgumentError, "#{setting} must be a Proc" unless value.is_a?(Proc)
441
+ end
442
+
443
+ def validate_queue_name!(value, setting)
444
+ unless (value.is_a?(String) || value.is_a?(Symbol)) && value.to_s.match?(/\A[a-zA-Z0-9_-]{1,128}\z/)
445
+ raise ArgumentError, "#{setting} must be a safe String or Symbol"
446
+ end
447
+
448
+ value
449
+ end
450
+
451
+ def validate_method_name!(value, setting)
452
+ unless value.nil? || ((value.is_a?(String) || value.is_a?(Symbol)) && value.to_s.match?(METHOD_NAME_PATTERN))
453
+ raise ArgumentError, "#{setting} must be a valid method name or nil"
454
+ end
455
+
456
+ value
457
+ end
458
+
459
+ def deep_copy_and_freeze(value)
460
+ copied = case value
461
+ when Hash
462
+ value.to_h { |key, nested_value| [deep_copy_and_freeze(key), deep_copy_and_freeze(nested_value)] }
463
+ when Array
464
+ value.map { |nested_value| deep_copy_and_freeze(nested_value) }
465
+ when String
466
+ value.dup
467
+ else
468
+ value
469
+ end
470
+ copied.freeze
471
+ end
472
+
473
+ public
57
474
 
58
475
  # == Initialization ==
59
476
 
@@ -85,7 +502,8 @@ module ApiKeys
85
502
  @policy_provider = "ApiKeys::BasePolicy" # Default authorization policy class name
86
503
 
87
504
  # Engine Configuration
88
- @parent_controller = '::ApplicationController'
505
+ @parent_controller = DEFAULT_PARENT_CONTROLLER
506
+ @parent_controller_explicitly_configured = false
89
507
 
90
508
  # Owner Context Configuration
91
509
  @current_owner_method = :current_user # Default to current_user for backward compatibility
@@ -95,14 +513,15 @@ module ApiKeys
95
513
  @default_max_keys_per_owner = nil # No global key limit per owner
96
514
  @require_key_name = false # Don't require names for keys globally
97
515
  @expire_after = nil # Keys do not expire by default (e.g., 90.days)
98
- @default_scopes = [] # No default scopes assigned globally
516
+ @default_scopes = [].freeze # No default scopes assigned globally
99
517
 
100
518
  # Performance
101
519
  @cache_ttl = 5.seconds # Good balance: fast revocation, mostly-cached – still allows most repeated requests to benefit from cache
520
+ @stats_update_interval = 1.minute # Debounce last_used_at writes unless exact request counting is enabled
102
521
 
103
522
  # Security
104
523
  @https_only_production = true # Warn if used over HTTP in production
105
- @https_strict_mode = false # Don't raise error, just warn
524
+ @https_strict_mode = true # Fail closed if a production request is not HTTPS
106
525
 
107
526
  # Background Job Queues
108
527
  @stats_job_queue = :default
@@ -127,6 +546,14 @@ module ApiKeys
127
546
 
128
547
  # Tenant Resolution
129
548
  @tenant_resolver = ->(api_key) { api_key.owner if api_key.respond_to?(:owner) }
549
+
550
+ # Key Types & Environments (default to empty/disabled for backwards compatibility)
551
+ @key_types = {}.freeze # Empty = feature disabled, legacy behavior
552
+ @environments = {}.freeze # Empty = no environment-based prefixes
553
+ @current_environment = -> { :default } # Default environment detection
554
+ @strict_environment_isolation = false # Don't enforce environment isolation by default
555
+ @default_key_type = nil # No default key type (must be specified explicitly)
556
+ @dashboard_allow_cross_environment = false # Dashboard shows only current environment's keys
130
557
  end
131
558
  end
132
559
  end
@@ -8,20 +8,11 @@ module ApiKeys
8
8
  class Engine < ::Rails::Engine
9
9
  isolate_namespace ApiKeys
10
10
 
11
- # Allows configuring the parent controller for the engine's controllers
12
- # Defaults to ::ApplicationController, assuming a standard Rails app structure.
13
- config.parent_controller = '::ApplicationController'
14
-
15
- # Ensure our models load first
16
- config.autoload_paths << File.expand_path("../models", __dir__)
17
- config.autoload_paths << File.expand_path("../models/concerns", __dir__)
18
-
19
- # Set up autoloading paths
20
- initializer "api_keys.autoload", before: :set_autoload_paths do |app|
21
- app.config.autoload_paths << root.join("lib")
22
- app.config.autoload_paths << root.join("lib/api_keys/models")
23
- app.config.autoload_paths << root.join("lib/api_keys/models/concerns")
24
- end
11
+ # Backward-compatible fallback for applications that used the old internal
12
+ # Engine config directly. New applications should configure
13
+ # ApiKeys.configuration.parent_controller; an explicit public configuration
14
+ # value takes precedence over this fallback.
15
+ config.parent_controller = nil
25
16
 
26
17
  # Add has_api_keys method to ActiveRecord::Base
27
18
  initializer "api_keys.active_record" do
@@ -38,12 +29,6 @@ module ApiKeys
38
29
  initializer "api_keys.model_attributes" do
39
30
  ActiveSupport.on_load(:active_record) do
40
31
  ApiKeys::ApiKey.class_eval do
41
- # Define JSON attributes for ApiKey model
42
- # Ensure the ApiKey model class is loaded before reopening
43
- # Use require_dependency for development/test, rely on autoloading in production
44
- # Or simply let Zeitwerk handle loading if structure is correct.
45
- require_dependency "api_keys/models/api_key" if defined?(Rails) && !Rails.env.production?
46
-
47
32
  # == JSON Attribute Casting ==
48
33
  # Make the gem work in any database (postgres, sqlite3, mysql...)
49
34
  # Configure the right json-like attributes for the different databases
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiKeys
4
+ module Errors
5
+ # Base error class for all ApiKeys errors
6
+ class BaseError < StandardError; end
7
+
8
+ # Raised when attempting to revoke or destroy a non-revocable key
9
+ class KeyNotRevocableError < BaseError
10
+ def initialize(message = "This API key cannot be revoked or deleted")
11
+ super
12
+ end
13
+ end
14
+
15
+ # Raised when API key environment doesn't match current environment (strict isolation)
16
+ class EnvironmentMismatchError < BaseError
17
+ attr_reader :key_environment, :current_environment
18
+
19
+ def initialize(key_environment:, current_environment:)
20
+ @key_environment = key_environment
21
+ @current_environment = current_environment
22
+ super("API key environment (#{key_environment}) does not match current environment (#{current_environment})")
23
+ end
24
+ end
25
+
26
+ # Raised when an invalid key type is specified
27
+ class InvalidKeyTypeError < BaseError
28
+ attr_reader :key_type, :valid_types
29
+
30
+ def initialize(key_type:, valid_types:)
31
+ @key_type = key_type
32
+ @valid_types = valid_types
33
+ super("Invalid key type '#{key_type}'. Valid types: #{valid_types.join(', ')}")
34
+ end
35
+ end
36
+
37
+ # Raised when an invalid environment is specified
38
+ class InvalidEnvironmentError < BaseError
39
+ attr_reader :environment, :valid_environments
40
+
41
+ def initialize(environment:, valid_environments:)
42
+ @environment = environment
43
+ @valid_environments = valid_environments
44
+ super("Invalid environment '#{environment}'. Valid environments: #{valid_environments.join(', ')}")
45
+ end
46
+ end
47
+
48
+ # Raised when key limit per type/environment is exceeded
49
+ class KeyLimitExceededError < BaseError
50
+ attr_reader :key_type, :environment, :limit
51
+
52
+ def initialize(key_type:, environment:, limit:)
53
+ @key_type = key_type
54
+ @environment = environment
55
+ @limit = limit
56
+ super("Maximum number of #{key_type} keys (#{limit}) reached for #{environment} environment")
57
+ end
58
+ end
59
+
60
+ # Raised when key_types are configured but required database columns are missing
61
+ class MigrationRequiredError < BaseError
62
+ attr_reader :missing_columns
63
+
64
+ def initialize(missing_columns:)
65
+ @missing_columns = missing_columns
66
+ super(
67
+ "Key types are configured but required database columns are missing: #{missing_columns.join(', ')}. " \
68
+ "Run: rails generate api_keys:add_key_types && rails db:migrate"
69
+ )
70
+ end
71
+ end
72
+ end
73
+ end